Skip to main content

rustynes_core/
genie.rs

1//! Game Genie cheat-code decoding.
2//!
3//! The Game Genie is a pass-through cartridge adapter that substitutes bytes
4//! the console reads from PRG-ROM (`$8000-$FFFF`). A 6-character code is an
5//! unconditional `(address, data)` substitution; an 8-character code adds a
6//! `compare` byte so the substitution only fires when the original byte
7//! matches (this lets one code target a specific bank in a mirrored address).
8//!
9//! Codes are a runtime overlay applied on the CPU read path in
10//! [`crate::LockstepBus`]; they are **not** part of emulation state (not
11//! serialized into save states), so with no codes active every read is
12//! byte-identical to a build without this feature — the determinism contract
13//! is preserved. The frontend persists the user's code strings per-ROM.
14//!
15//! The 6/8-character decode is the canonical NES Game Genie algorithm
16//! (nesdev wiki "Game Genie"). This is a clean-room reimplementation
17//! cross-checked against the reference codes `YYKPOYZZ` (The Legend of Zelda
18//! — `$9F41`, data `$77`, compare `$22`) and `SXIOPO` (Super Mario Bros.
19//! infinite lives — `$91D9`, data `$AD`, no compare), and against the
20//! structure of `TetaNES` `tetarustynes-core/src/genie.rs` (MIT).
21
22use alloc::string::String;
23use core::fmt;
24
25/// Error returned when a Game Genie code string cannot be decoded.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum GenieError {
28    /// The code was not 6 or 8 characters long.
29    InvalidLength(usize),
30    /// The code contained a letter outside the 16-letter Game Genie alphabet
31    /// (`A P Z L G I T Y E O X U K S V N`, case-insensitive).
32    InvalidCharacter(char),
33}
34
35impl fmt::Display for GenieError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::InvalidLength(n) => {
39                write!(f, "Game Genie code must be 6 or 8 characters, found {n}")
40            }
41            Self::InvalidCharacter(c) => write!(f, "invalid Game Genie character '{c}'"),
42        }
43    }
44}
45
46impl core::error::Error for GenieError {}
47
48/// A decoded Game Genie code: an address in `$8000-$FFFF`, a substitute data
49/// byte, and an optional compare byte (present only for 8-character codes).
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct GenieCode {
52    code: String,
53    addr: u16,
54    data: u8,
55    compare: Option<u8>,
56}
57
58impl GenieCode {
59    /// Decode a 6- or 8-character Game Genie code.
60    ///
61    /// The input is case-insensitive; [`code`](Self::code) returns the
62    /// canonical upper-case form.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`GenieError::InvalidLength`] if the code is not 6 or 8
67    /// characters, or [`GenieError::InvalidCharacter`] if it contains a
68    /// letter outside the Game Genie alphabet.
69    pub fn new(code: &str) -> Result<Self, GenieError> {
70        let len = code.chars().count();
71        if len != 6 && len != 8 {
72            return Err(GenieError::InvalidLength(len));
73        }
74        let mut hex = [0u8; 8];
75        for (i, c) in code.chars().enumerate() {
76            hex[i] = letter_to_nibble(c).ok_or(GenieError::InvalidCharacter(c))?;
77        }
78
79        // Address bits are shuffled identically for 6- and 8-character codes.
80        let addr = 0x8000
81            + (((u16::from(hex[3]) & 7) << 12)
82                | ((u16::from(hex[5]) & 7) << 8)
83                | ((u16::from(hex[4]) & 8) << 8)
84                | ((u16::from(hex[2]) & 7) << 4)
85                | ((u16::from(hex[1]) & 8) << 4)
86                | (u16::from(hex[4]) & 7)
87                | (u16::from(hex[3]) & 8));
88
89        // The data byte's high "bank" bit comes from hex[5] in a 6-char code
90        // and hex[7] in an 8-char code; an 8-char code also carries a compare.
91        let (data, compare) = if len == 6 {
92            let data = ((hex[1] & 7) << 4) | ((hex[0] & 8) << 4) | (hex[0] & 7) | (hex[5] & 8);
93            (data, None)
94        } else {
95            let data = ((hex[1] & 7) << 4) | ((hex[0] & 8) << 4) | (hex[0] & 7) | (hex[7] & 8);
96            let compare = ((hex[7] & 7) << 4) | ((hex[6] & 8) << 4) | (hex[6] & 7) | (hex[5] & 8);
97            (data, Some(compare))
98        };
99
100        Ok(Self {
101            code: code.to_ascii_uppercase(),
102            addr,
103            data,
104            compare,
105        })
106    }
107
108    /// The canonical upper-case code string.
109    #[must_use]
110    // clippy::missing_const_for_fn is a false positive here: `&self.code`
111    // requires a non-const `String -> str` deref coercion (E0015).
112    #[allow(clippy::missing_const_for_fn)]
113    pub fn code(&self) -> &str {
114        &self.code
115    }
116
117    /// The PRG address (`$8000-$FFFF`) this code substitutes.
118    #[must_use]
119    pub const fn addr(&self) -> u16 {
120        self.addr
121    }
122
123    /// The substitute data byte.
124    #[must_use]
125    pub const fn data(&self) -> u8 {
126        self.data
127    }
128
129    /// The compare byte (8-character codes only).
130    #[must_use]
131    pub const fn compare(&self) -> Option<u8> {
132        self.compare
133    }
134
135    /// Apply this code to a byte read from [`addr`](Self::addr).
136    ///
137    /// For a 6-character code the substitute [`data`](Self::data) always
138    /// replaces `original`. For an 8-character code the substitution only
139    /// fires when `original` equals the compare byte; otherwise the original
140    /// byte passes through (so the code targets one specific bank).
141    #[must_use]
142    pub const fn read(&self, original: u8) -> u8 {
143        match self.compare {
144            Some(c) if original != c => original,
145            _ => self.data,
146        }
147    }
148}
149
150impl fmt::Display for GenieCode {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        f.write_str(&self.code)
153    }
154}
155
156/// Map a Game Genie letter to its 4-bit nibble (case-insensitive).
157const fn letter_to_nibble(c: char) -> Option<u8> {
158    Some(match c.to_ascii_uppercase() {
159        'A' => 0x0,
160        'P' => 0x1,
161        'Z' => 0x2,
162        'L' => 0x3,
163        'G' => 0x4,
164        'I' => 0x5,
165        'T' => 0x6,
166        'Y' => 0x7,
167        'E' => 0x8,
168        'O' => 0x9,
169        'X' => 0xA,
170        'U' => 0xB,
171        'K' => 0xC,
172        'S' => 0xD,
173        'V' => 0xE,
174        'N' => 0xF,
175        _ => return None,
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn letter_table_matches_known_bit_string() {
185        // SXIOPO decodes to bits 1101 1010 0101 1001 0001 1001 (nesdev /
186        // widely-published SMB example), i.e. nibbles D A 5 9 1 9.
187        let nibbles: alloc::vec::Vec<u8> = "SXIOPO"
188            .chars()
189            .map(|c| letter_to_nibble(c).unwrap())
190            .collect();
191        assert_eq!(nibbles, alloc::vec![0xD, 0xA, 0x5, 0x9, 0x1, 0x9]);
192    }
193
194    #[test]
195    fn decodes_eight_char_code_with_compare() {
196        // The Legend of Zelda "8 hearts" code (TetaNES reference).
197        let gc = GenieCode::new("YYKPOYZZ").unwrap();
198        assert_eq!(gc.addr(), 0x9F41);
199        assert_eq!(gc.data(), 0x77);
200        assert_eq!(gc.compare(), Some(0x22));
201        // Substitution fires only when the original matches the compare byte.
202        assert_eq!(gc.read(0x22), 0x77);
203        assert_eq!(gc.read(0x00), 0x00);
204    }
205
206    #[test]
207    fn decodes_six_char_code_without_compare() {
208        // Super Mario Bros. infinite lives.
209        let gc = GenieCode::new("SXIOPO").unwrap();
210        assert_eq!(gc.addr(), 0x91D9);
211        assert_eq!(gc.data(), 0xAD);
212        assert_eq!(gc.compare(), None);
213        // No compare: always substitutes.
214        assert_eq!(gc.read(0x00), 0xAD);
215        assert_eq!(gc.read(0xFF), 0xAD);
216    }
217
218    #[test]
219    fn case_insensitive_and_canonicalized() {
220        let gc = GenieCode::new("sxiopo").unwrap();
221        assert_eq!(gc.code(), "SXIOPO");
222        assert_eq!(gc.addr(), 0x91D9);
223    }
224
225    #[test]
226    fn rejects_bad_length_and_characters() {
227        assert_eq!(
228            GenieCode::new("ABC").unwrap_err(),
229            GenieError::InvalidLength(3)
230        );
231        assert_eq!(
232            GenieCode::new("ABCDEFG").unwrap_err(),
233            GenieError::InvalidLength(7)
234        );
235        // 'W' is not in the Game Genie alphabet.
236        assert_eq!(
237            GenieCode::new("WXIOPO").unwrap_err(),
238            GenieError::InvalidCharacter('W')
239        );
240    }
241
242    #[test]
243    fn all_addresses_land_in_prg_window() {
244        // Every decoded code must address $8000-$FFFF.
245        for code in [
246            "AAAAAA", "NNNNNN", "AAAAAAAA", "NNNNNNNN", "SXIOPO", "YYKPOYZZ",
247        ] {
248            assert!(GenieCode::new(code).unwrap().addr() >= 0x8000);
249        }
250    }
251}