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