Skip to main content

rustysnes_core/
cheat.rs

1//! SNES cheat-code decoding: Game Genie and Pro Action Replay (`v0.8.0 "Instrumentation"`,
2//! T-81-003).
3//!
4//! Ported from bsnes's `CheatEditor::decodeSNES` (`ref-proj/bsnes/bsnes/target-bsnes/tools/
5//! cheat-editor.cpp`) and cross-checked bit-for-bit against Mesen2's independent
6//! `CheatManager::ConvertFromSnesGameGenie`/`ConvertFromSnesProActionReplay`
7//! (`ref-proj/Mesen2/Core/Shared/CheatManager.cpp`) — both codebases compute an identical 24-bit
8//! address and value byte for any given code string. Test vectors below are real commercial
9//! codes drawn from Mesen2's shipped cheat database (`ref-proj/Mesen2/UI/Dependencies/Internal/
10//! CheatDb.Snes.json`), decoded independently by hand against the bit formula as a third check.
11//!
12//! Both formats decode to a plain 24-bit CPU-bus address (`$bank:offset`) plus an 8-bit
13//! substitute value — no LoROM/HiROM bank translation happens here (that is the Bus's normal
14//! memory-map job, same as [`crate::Bus::poke_wram`]/[`crate::Bus::peek_wram`]). Neither SNES
15//! format supports a compare byte (unlike NES's 8-character Game Genie) — a decoded cheat is
16//! always an unconditional address/value substitution.
17//!
18//! A cheat is host-applied external input, not emulated hardware behavior (`docs/adr/0004`) — it
19//! is not part of any save state and is not evaluated unless the frontend's `cheats` feature is
20//! on and a patch is actually applied, so the determinism contract is untouched when no cheat is
21//! active.
22
23/// The SNES Game Genie's 16-character alphabet; a character's position in this string is its
24/// decoded nibble value (`D` = 0, `F` = 1, ... `E` = 15).
25const GENIE_ALPHABET: &[u8; 16] = b"DF4709156BC8A23E";
26
27/// Error decoding a cheat-code string.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
29pub enum CheatError {
30    /// Not a recognized Game Genie (`XXXX-XXXX`, 9 characters) or Pro Action Replay (8 hex
31    /// characters) shape.
32    #[error("not a recognized SNES Game Genie or Pro Action Replay code")]
33    UnrecognizedFormat,
34    /// Contained a character outside the expected alphabet for its detected format.
35    #[error("invalid cheat-code character '{0}'")]
36    InvalidCharacter(char),
37}
38
39/// A decoded cheat patch: substitute `value` at CPU-bus `address` (`$bank:offset`, 24-bit).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct CheatPatch {
42    /// The 24-bit CPU-bus address (`$bank:offset`) this patch targets.
43    pub address: u32,
44    /// The substitute byte value.
45    pub value: u8,
46}
47
48/// Map a Game Genie character to its 4-bit nibble (case-insensitive).
49///
50/// Rejects non-ASCII input outright rather than truncating it to a `u8` — a truncating cast
51/// (`c as u8`) could alias an unrelated non-ASCII codepoint onto a valid alphabet byte (e.g.
52/// `'\u{0144}'` truncates to `0x44`, `'D'`) and falsely "succeed" decoding garbage input.
53fn genie_nibble(c: char) -> Option<u8> {
54    if !c.is_ascii() {
55        return None;
56    }
57    let upper = c.to_ascii_uppercase() as u8;
58    u8::try_from(GENIE_ALPHABET.iter().position(|&a| a == upper)?).ok()
59}
60
61/// Decode a Game Genie code: `XXXX-XXXX` (case-insensitive, a dash at index 4, 9 characters
62/// total).
63///
64/// # Errors
65/// Returns [`CheatError::UnrecognizedFormat`] if `code` isn't 9 characters with a dash at index
66/// 4, or [`CheatError::InvalidCharacter`] if a non-dash character is outside the Game Genie
67/// alphabet.
68pub fn decode_game_genie(code: &str) -> Result<CheatPatch, CheatError> {
69    // Determine the shape (length + dash position) BEFORE validating any character content —
70    // `decode`'s fallback to Pro Action Replay depends on `UnrecognizedFormat` meaning "this
71    // wasn't shaped like a Game Genie code at all," not "the first bad character happened to be
72    // found before an eventual length mismatch would have been noticed." Two passes over the
73    // iterator, but no heap allocation (an earlier `Vec<char>` collect was flagged for exactly
74    // that unnecessary no_std allocation).
75    if code.chars().count() != 9 || code.chars().nth(4) != Some('-') {
76        return Err(CheatError::UnrecognizedFormat);
77    }
78    let mut raw: u32 = 0;
79    for (i, c) in code.chars().enumerate() {
80        if i == 4 {
81            continue;
82        }
83        let nibble = genie_nibble(c).ok_or(CheatError::InvalidCharacter(c))?;
84        raw = (raw << 4) | u32::from(nibble);
85    }
86
87    // bsnes `CheatEditor::decodeSNES`'s bit-scramble, verbatim (each destination address bit's
88    // source mask/shift in `raw`, low 24 bits only — the top byte of `raw` is the value below).
89    let bit = |mask: u32| u32::from(raw & mask != 0);
90    let address = (bit(0x00_2000) << 23)
91        | (bit(0x00_1000) << 22)
92        | (bit(0x00_0800) << 21)
93        | (bit(0x00_0400) << 20)
94        | (bit(0x00_0020) << 19)
95        | (bit(0x00_0010) << 18)
96        | (bit(0x00_0008) << 17)
97        | (bit(0x00_0004) << 16)
98        | (bit(0x80_0000) << 15)
99        | (bit(0x40_0000) << 14)
100        | (bit(0x20_0000) << 13)
101        | (bit(0x10_0000) << 12)
102        | (bit(0x00_0002) << 11)
103        | (bit(0x00_0001) << 10)
104        | (bit(0x00_8000) << 9)
105        | (bit(0x00_4000) << 8)
106        | (bit(0x08_0000) << 7)
107        | (bit(0x04_0000) << 6)
108        | (bit(0x02_0000) << 5)
109        | (bit(0x01_0000) << 4)
110        | (bit(0x00_0200) << 3)
111        | (bit(0x00_0100) << 2)
112        | (bit(0x00_0080) << 1)
113        | bit(0x00_0040);
114    // `raw >> 24` is always in 0..=255 (`raw` is packed from exactly 8 nibbles).
115    #[allow(clippy::cast_possible_truncation)]
116    let value = (raw >> 24) as u8;
117
118    Ok(CheatPatch { address, value })
119}
120
121/// Decode a Pro Action Replay code: 8 hex digits (case-insensitive), no scrambling —
122/// `AAAAAADD` (6 hex-digit address, high; 2 hex-digit value, low).
123///
124/// # Errors
125/// Returns [`CheatError::UnrecognizedFormat`] if `code` isn't 8 characters, or
126/// [`CheatError::InvalidCharacter`] if a character isn't a hex digit.
127pub fn decode_pro_action_replay(code: &str) -> Result<CheatPatch, CheatError> {
128    // Shape (length) first, content second — see `decode_game_genie`'s doc comment for why.
129    if code.chars().count() != 8 {
130        return Err(CheatError::UnrecognizedFormat);
131    }
132    let mut raw: u32 = 0;
133    for c in code.chars() {
134        let nibble = c.to_digit(16).ok_or(CheatError::InvalidCharacter(c))?;
135        raw = (raw << 4) | nibble;
136    }
137    // `raw & 0xFF` is always in 0..=255 by construction.
138    #[allow(clippy::cast_possible_truncation)]
139    let value = (raw & 0xFF) as u8;
140    Ok(CheatPatch {
141        address: raw >> 8,
142        value,
143    })
144}
145
146/// Decode `code` as a Game Genie code, falling back to Pro Action Replay only when `code`
147/// doesn't match the Game Genie shape at all.
148///
149/// The two formats' valid shapes never overlap (9 characters with a dash vs. exactly 8 hex
150/// digits), so this dispatch is unambiguous. Only [`CheatError::UnrecognizedFormat`] falls
151/// through to the Pro Action Replay decoder — a Game Genie–shaped code with a genuinely invalid
152/// character (e.g. `C282-070G`) returns that specific [`CheatError::InvalidCharacter`] instead
153/// of a misleading "wrong format" from a decoder that was never going to match its shape either.
154///
155/// # Errors
156/// Returns the [`CheatError`] from whichever format `code`'s length suggests; if neither format
157/// recognizes the shape at all, returns [`CheatError::UnrecognizedFormat`].
158pub fn decode(code: &str) -> Result<CheatPatch, CheatError> {
159    match decode_game_genie(code) {
160        Err(CheatError::UnrecognizedFormat) => decode_pro_action_replay(code),
161        other => other,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn genie_alphabet_matches_bsnes_and_mesen2_source() {
171        // "DF4709156BC8A23E", position = value — confirmed identical in both reference decoders.
172        let expected: [(char, u8); 16] = [
173            ('D', 0),
174            ('F', 1),
175            ('4', 2),
176            ('7', 3),
177            ('0', 4),
178            ('9', 5),
179            ('1', 6),
180            ('5', 7),
181            ('6', 8),
182            ('B', 9),
183            ('C', 10),
184            ('8', 11),
185            ('A', 12),
186            ('2', 13),
187            ('3', 14),
188            ('E', 15),
189        ];
190        for (c, v) in expected {
191            assert_eq!(genie_nibble(c), Some(v));
192            assert_eq!(genie_nibble(c.to_ascii_lowercase()), Some(v));
193        }
194    }
195
196    // Real commercial Game Genie codes from Mesen2's shipped `CheatDb.Snes.json`, decoded by
197    // hand against the bit-scramble formula as an independent third check (see the module doc).
198    #[test]
199    fn decodes_real_game_genie_codes() {
200        let gc = decode_game_genie("C282-0706").expect("valid code");
201        assert_eq!(gc.address, 0x02_B1DD);
202        assert_eq!(gc.value, 0xAD);
203
204        let gc = decode_game_genie("DBB7-0704").expect("valid code");
205        assert_eq!(gc.address, 0x00_993D);
206        assert_eq!(gc.value, 0x09);
207    }
208
209    #[test]
210    fn game_genie_is_case_insensitive() {
211        let upper = decode_game_genie("C282-0706").unwrap();
212        let lower = decode_game_genie("c282-0706").unwrap();
213        assert_eq!(upper, lower);
214    }
215
216    #[test]
217    fn game_genie_rejects_bad_shape() {
218        assert_eq!(
219            decode_game_genie("C2820706"),
220            Err(CheatError::UnrecognizedFormat)
221        );
222        assert_eq!(
223            decode_game_genie("C282_0706"),
224            Err(CheatError::UnrecognizedFormat)
225        );
226        assert_eq!(
227            decode_game_genie("C282-070"),
228            Err(CheatError::UnrecognizedFormat)
229        );
230        assert_eq!(
231            decode_game_genie("W282-0706"),
232            Err(CheatError::InvalidCharacter('W'))
233        );
234    }
235
236    // Real commercial Pro Action Replay / raw-hex codes from the same database — all land in
237    // WRAM ($7E0000-$7FFFFF), matching the module doc's WRAM-cheat framing.
238    #[test]
239    fn decodes_real_pro_action_replay_codes() {
240        let pc = decode_pro_action_replay("7E0A2A06").expect("valid code");
241        assert_eq!(pc.address, 0x7E_0A2A);
242        assert_eq!(pc.value, 0x06);
243
244        let pc = decode_pro_action_replay("7E1E6B14").expect("valid code");
245        assert_eq!(pc.address, 0x7E_1E6B);
246        assert_eq!(pc.value, 0x14);
247    }
248
249    #[test]
250    fn pro_action_replay_is_case_insensitive() {
251        let upper = decode_pro_action_replay("7E0A2A06").unwrap();
252        let lower = decode_pro_action_replay("7e0a2a06").unwrap();
253        assert_eq!(upper, lower);
254    }
255
256    #[test]
257    fn pro_action_replay_rejects_bad_shape() {
258        assert_eq!(
259            decode_pro_action_replay("7E0A2A0"),
260            Err(CheatError::UnrecognizedFormat)
261        );
262        assert_eq!(
263            decode_pro_action_replay("7E0A2A0G"),
264            Err(CheatError::InvalidCharacter('G'))
265        );
266    }
267
268    #[test]
269    fn unified_decode_dispatches_to_the_matching_format() {
270        assert_eq!(
271            decode("C282-0706").unwrap(),
272            decode_game_genie("C282-0706").unwrap()
273        );
274        assert_eq!(
275            decode("7E0A2A06").unwrap(),
276            decode_pro_action_replay("7E0A2A06").unwrap()
277        );
278        assert_eq!(decode("not a code"), Err(CheatError::UnrecognizedFormat));
279    }
280
281    #[test]
282    fn unified_decode_does_not_mask_a_genuine_game_genie_character_error() {
283        // "C282-070G" is Game-Genie-shaped (9 chars, dash at index 4) but 'G' isn't in the
284        // alphabet — `decode` must surface that specific error, not silently fall through to
285        // Pro Action Replay (which would also fail, but with a less useful "wrong format").
286        assert_eq!(decode("C282-070G"), Err(CheatError::InvalidCharacter('G')));
287    }
288
289    #[test]
290    fn genie_nibble_rejects_non_ascii_rather_than_truncating() {
291        // '\u{0144}' truncates to 0x44 ('D') under a lossy `as u8` cast — must be rejected, not
292        // silently aliased onto a valid alphabet character.
293        assert_eq!(genie_nibble('\u{0144}'), None);
294    }
295}