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}