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}