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}