rustysnes_cart/header.rs
1//! SNES internal-header detection.
2//!
3//! Unlike the NES iNES header (a clean 16-byte prefix), the SNES header lives INSIDE the ROM
4//! at a map-mode-dependent offset: LoROM `$7FC0`, HiROM `$FFC0`, ExHiROM `$40FFC0` (plus an
5//! optional 512-byte copier prefix to skip). Detection scores each candidate offset by
6//! checksum / reset-vector / printable-title / map-mode plausibility and picks the best.
7//!
8//! See `docs/cartridge-format.md` for the authoritative header-byte layout (`$xFC0`–`$xFDF`)
9//! and the score heuristic.
10
11use thiserror::Error;
12
13/// Errors from header detection.
14#[derive(Debug, Error, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum HeaderError {
17 /// The ROM image is too small to contain any candidate header.
18 #[error("rom too small: {0} bytes (need at least one 64 KiB bank)")]
19 TooSmall(usize),
20 /// No candidate offset scored as a valid SNES header.
21 #[error("no valid SNES header found at any candidate offset")]
22 NoValidHeader,
23}
24
25/// The cartridge map mode (header byte `$xFD5`, low bits). Determines the base board.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MapMode {
28 /// LoROM (`$20`).
29 LoRom,
30 /// HiROM (`$21`).
31 HiRom,
32 /// ExHiROM (`$25`).
33 ExHiRom,
34 /// ExLoROM (unofficial — no dedicated `$xFD5` value; homebrew/flashcart >4 MiB titles that
35 /// keep LoROM's 32 KiB bank windowing instead of switching to HiROM's linear banks).
36 ExLoRom,
37}
38
39/// Console region, derived from the destination-code header byte (`$xFD9`).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Region {
42 /// NTSC (60 Hz) — Japan / North America destination codes.
43 Ntsc,
44 /// PAL (50 Hz) — Europe / Australia destination codes.
45 Pal,
46}
47
48/// On-cart coprocessor, derived from the chipset header byte (`$xFD6`).
49///
50/// Tier-annotated in [`crate::tier`]. Base carts are always [`Coprocessor::None`] in Phase 2;
51/// the concrete coprocessor boards land in Phase 4.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum Coprocessor {
55 /// No coprocessor — plain LoROM/HiROM/ExHiROM ROM (+ optional SRAM).
56 None,
57 /// NEC uPD77C25 DSP-1..4 (Pilotwings, Super Mario Kart, ...).
58 Dsp,
59 /// Super FX / GSU-1/2 (Star Fox, Yoshi's Island).
60 SuperFx,
61 /// SA-1 (Super Mario RPG, Kirby Super Star).
62 Sa1,
63 /// S-DD1 decompression (Star Ocean, Street Fighter Alpha 2).
64 SDd1,
65 /// SPC7110 decompression + RTC (Far East of Eden Zero).
66 Spc7110,
67 /// Capcom CX4 (Mega Man X2/X3).
68 Cx4,
69 /// OBC1 (Metal Combat).
70 Obc1,
71 /// Sharp RTC-4513 standalone real-time clock (Daikaijuu Monogatari II).
72 Srtc,
73 /// ST018 — a full `ARMv3` (ARM6) CPU coprocessor (Hayazashi Nidan Morita Shogi 2).
74 St018,
75}
76
77/// A parsed SNES internal header.
78///
79/// Detection scores every candidate offset (`$7FC0` / `$FFC0` / `$40FFC0`, after copier-prefix
80/// skip) and keeps the highest-scoring one. See [`Header::detect`].
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Header {
83 /// The byte offset the header was found at, relative to the *copier-prefix-stripped* ROM
84 /// (LoROM `$7FC0` / HiROM `$FFC0` / ExHiROM `$40FFC0`).
85 pub offset: usize,
86 /// The number of leading bytes that are a copier prefix (0 or 512); the board ROM is the
87 /// image with these bytes stripped.
88 pub copier_prefix: usize,
89 /// The base map mode.
90 pub map_mode: MapMode,
91 /// Whether the cart runs the FastROM (3.58 MHz) access window (`$xFD5` bit 4).
92 pub fast_rom: bool,
93 /// The console region.
94 pub region: Region,
95 /// The on-cart coprocessor (or [`Coprocessor::None`]).
96 pub coprocessor: Coprocessor,
97 /// ROM size in bytes (the actual image size after copier strip, not the header claim).
98 pub rom_size: usize,
99 /// SRAM (battery save-RAM) size in bytes (0 if none).
100 pub sram_size: usize,
101 /// Whether the cart is battery-backed (has persistent SRAM / RTC).
102 pub has_battery: bool,
103}
104
105/// Header field offsets relative to the header base (`$xFC0`).
106mod field {
107 /// 21-byte ASCII title at `$xFC0`.
108 pub const TITLE: usize = 0x00;
109 /// Title length in bytes.
110 pub const TITLE_LEN: usize = 21;
111 /// Map-mode + speed byte at `$xFD5`.
112 pub const MAP_MODE: usize = 0x15;
113 /// Chipset (cartridge type) byte at `$xFD6`.
114 pub const CHIPSET: usize = 0x16;
115 /// RAM-size byte at `$xFD8`.
116 pub const RAM_SIZE: usize = 0x18;
117 /// Destination (region) code at `$xFD9`.
118 pub const REGION: usize = 0x19;
119 /// Checksum complement at `$xFDC` (little-endian u16).
120 pub const COMPLEMENT: usize = 0x1C;
121 /// Checksum at `$xFDE` (little-endian u16).
122 pub const CHECKSUM: usize = 0x1E;
123 /// Emulation-mode reset vector at `$xFFC` (little-endian u16); `$xFC0 + 0x3C`.
124 pub const RESET_VECTOR: usize = 0x3C;
125}
126
127impl Header {
128 /// The candidate header *base* offsets (`$xFC0`) before any copier-prefix adjustment,
129 /// paired with the map mode each location implies.
130 //
131 // The extended (>4 MiB) candidates are listed FIRST: `Self::detect`'s tie-break keeps
132 // whichever candidate is found first when two offsets score identically (only strictly
133 // higher scores replace `best`), and a >4 MiB image's real header can, in principle, tie a
134 // spurious high-scoring match at the smaller offset — ordering extended modes first means a
135 // tie favors the model that actually explains the full image, not the truncated one.
136 const CANDIDATES: [(usize, MapMode); 4] = [
137 (0x40_FFC0, MapMode::ExHiRom),
138 (0x40_7FC0, MapMode::ExLoRom),
139 (0x7FC0, MapMode::LoRom),
140 (0xFFC0, MapMode::HiRom),
141 ];
142
143 /// Detect the internal header in a raw ROM image.
144 ///
145 /// Skips a 512-byte copier prefix when `len % 0x8000 == 0x200`, then scores each candidate
146 /// offset and keeps the highest. A non-zero score is required, so an all-zero / garbage
147 /// image yields [`HeaderError::NoValidHeader`].
148 ///
149 /// # Errors
150 /// [`HeaderError::TooSmall`] if the (de-prefixed) image can't hold a single 32 KiB bank, or
151 /// [`HeaderError::NoValidHeader`] if no candidate offset scores above zero.
152 pub fn detect(rom: &[u8]) -> Result<Self, HeaderError> {
153 let copier_prefix = if rom.len() % 0x8000 == 0x200 {
154 0x200
155 } else {
156 0
157 };
158 let image = &rom[copier_prefix..];
159 if image.len() < 0x8000 {
160 return Err(HeaderError::TooSmall(rom.len()));
161 }
162
163 let mut best: Option<(u32, usize, MapMode)> = None;
164 for (offset, map_mode) in Self::CANDIDATES {
165 // The 64-byte header region `offset..offset+0x40` must fit in the de-prefixed image.
166 if image.len() < offset + 0x40 {
167 continue;
168 }
169 let score = score_candidate(image, offset, map_mode);
170 if score > 0 && best.is_none_or(|(b, ..)| score > b) {
171 best = Some((score, offset, map_mode));
172 }
173 }
174
175 let (_, offset, map_mode) = best.ok_or(HeaderError::NoValidHeader)?;
176 Ok(Self::parse(image, offset, map_mode, copier_prefix))
177 }
178
179 /// Parse the concrete header fields at a known-good `(offset, map_mode)`.
180 fn parse(image: &[u8], offset: usize, map_mode: MapMode, copier_prefix: usize) -> Self {
181 let h = &image[offset..];
182 let chipset = h[field::CHIPSET];
183 let region = region_from_code(h[field::REGION]);
184
185 let raw_sram = h[field::RAM_SIZE];
186 let fast_rom = h[field::MAP_MODE] & 0x10 != 0;
187
188 let title_bytes = &image[offset + field::TITLE..offset + field::TITLE + field::TITLE_LEN];
189 let title_upper = core::str::from_utf8(title_bytes)
190 .unwrap_or("")
191 .to_uppercase();
192 let coprocessor = coprocessor_from_chipset(chipset, &title_upper);
193
194 // `$xFD6` low nibble: 2 / 5 / 6 imply battery-backed RAM (RAM+battery, RAM+battery+RTC).
195 let has_battery = matches!(chipset & 0x0F, 0x2 | 0x5 | 0x6);
196
197 let mut sram_size = match raw_sram {
198 0 => 0,
199 n => 1024 << n,
200 };
201
202 // GSU games often declare 0 SRAM size but have 32 KiB or 64 KiB of on-cart RAM for the plot buffer.
203 if coprocessor == Coprocessor::SuperFx && sram_size == 0 {
204 if title_upper.contains("DOOM")
205 || title_upper.contains("WINTER GOLD")
206 || title_upper.contains("STARFOX2")
207 || title_upper.contains("STAR FOX 2")
208 {
209 sram_size = 0x1_0000; // 64 KiB
210 } else {
211 sram_size = 0x8000; // 32 KiB
212 }
213 }
214
215 Self {
216 offset,
217 copier_prefix,
218 map_mode,
219 fast_rom,
220 region,
221 coprocessor,
222 rom_size: image.len(),
223 sram_size,
224 has_battery,
225 }
226 }
227}
228
229/// Derive the on-cart coprocessor from the chipset byte (`$xFD6`) plus (for the ambiguous `$F`
230/// "custom" nibble only) the cart's title.
231///
232/// The low nibble is the cartridge type: `0`/`1`/`2` are plain ROM(+RAM(+battery)); `3`–`6` flag
233/// that a coprocessor is present. The high nibble then names it: `0`=DSP (`µPD77C25` family,
234/// including the single-game ST010/ST011 variants — see below), `1`=Super FX/GSU, `2`=OBC1,
235/// `3`=SA-1, `4`=S-DD1, `F`=custom (SPC7110/CX4/S-RTC/ST018).
236///
237/// `$F` is genuinely ambiguous from header bytes alone — real emulators disambiguate it (and, for
238/// that matter, DSP-1 vs DSP-2/3/4/ST010/011 under the SAME `$0` nibble) via a bundled cartridge
239/// database keyed on title/checksum, not header parsing; even ares' own header path never reads
240/// an `$xFBF` "subtype" byte (confirmed against `sfc/cartridge/`, and empirically against a real
241/// Mega Man X2 dump, whose `$7FBF` is `$10` — not any documented subtype value). Lacking a
242/// database, title-match the two known CX4 games here, the same single-game-chip approach
243/// [`crate::coproc::necdsp_variant::Variant::detect`] uses for the DSP family's singles.
244fn coprocessor_from_chipset(chipset: u8, title_upper: &str) -> Coprocessor {
245 // No coprocessor unless the type nibble marks one present. The `$F` custom category doesn't
246 // follow the same RAM/battery low-nibble convention as `$0-$4` (e.g. SPC7110+RTC is `$F9`,
247 // outside `0x3..=0x6` — confirmed against a real Far East of Eden Zero dump's `$FFD6`), so it
248 // skips this gate and relies entirely on the title match below (already safe: no match falls
249 // through to `Coprocessor::None`).
250 if chipset >> 4 != 0xF && !matches!(chipset & 0x0F, 0x3..=0x6) {
251 return Coprocessor::None;
252 }
253 match chipset >> 4 {
254 0x0 => Coprocessor::Dsp,
255 0x1 => Coprocessor::SuperFx,
256 0x2 => Coprocessor::Obc1,
257 0x3 => Coprocessor::Sa1,
258 0x4 => Coprocessor::SDd1,
259 0xF => {
260 if title_upper.contains("MEGA MAN X2")
261 || title_upper.contains("MEGAMAN X2")
262 || title_upper.contains("ROCKMAN X2")
263 || title_upper.contains("MEGA MAN X3")
264 || title_upper.contains("MEGAMAN X3")
265 || title_upper.contains("ROCKMAN X3")
266 {
267 Coprocessor::Cx4
268 } else if title_upper.contains("TENGAI MAKYO")
269 || title_upper.contains("FAR EAST OF EDEN")
270 {
271 Coprocessor::Spc7110
272 } else if title_upper.contains("DAIKAIJUU MONOGATARI")
273 || title_upper.contains("DAIKAIJU MONOGATARI")
274 {
275 Coprocessor::Srtc
276 } else if title_upper.contains("MORITASHOGI2") || title_upper.contains("MORITA SHOGI2")
277 {
278 // ST018 (Hayazashi Nidan Morita Shogi 2) — internal title `NIDAN MORITASHOGI2`
279 // per an independent database (superfamicom.org), not yet verified against a
280 // real dump (no commercial copy exists in this project's local corpus, the same
281 // honesty gap already carried openly for the other title-matched `$F` customs).
282 // Real emulators (Mesen2, ares) disambiguate this one specific chip via the
283 // extended-header `CartridgeType`/`cartridgeSubType` byte at `$xFBF` instead of a
284 // title match — this project deliberately does NOT read that byte for the OTHER
285 // `$F`-nibble customs above (found unreliable against a real Mega Man X2 dump),
286 // so this mirrors that same established title-match convention rather than
287 // introducing a new, differently-verified header field just for this one chip.
288 Coprocessor::St018
289 } else {
290 // Other undetected `$F` customs stay BestEffort/not-started: the cart runs as
291 // its base board (unmapped coprocessor window) rather than guessing.
292 Coprocessor::None
293 }
294 }
295 _ => Coprocessor::None,
296 }
297}
298
299/// Map a destination (region) code (`$xFD9`) to NTSC/PAL.
300///
301/// Codes 0 (Japan), 1 (North America) and 13 (South Korea) are 60 Hz NTSC; codes 2–12 are the
302/// 50 Hz PAL territories (Europe, Scandinavia, France, ...). Anything else defaults NTSC.
303const fn region_from_code(code: u8) -> Region {
304 match code {
305 0x02..=0x0C => Region::Pal,
306 _ => Region::Ntsc,
307 }
308}
309
310/// The map-mode low nibble each candidate location expects (`$xFD5 & 0x0F`).
311///
312/// ExLoROM has no dedicated value — it's unofficial (confirmed against ares/bsnes: neither
313/// assigns it a distinct `$xFD5` nibble) — so real ExLoROM carts fall back to reporting plain
314/// LoROM's `$0` nibble here; this candidate is still disambiguated from true LoROM purely by
315/// its header *offset* (`$40_7FC0`, only reachable by images >4 MiB).
316const fn expected_mode_nibble(map_mode: MapMode) -> u8 {
317 match map_mode {
318 MapMode::LoRom | MapMode::ExLoRom => 0x0,
319 MapMode::HiRom => 0x1,
320 MapMode::ExHiRom => 0x5,
321 }
322}
323
324/// Score a candidate header at `offset` for the map mode that location implies. Higher is
325/// better; `0` means "definitely not a header here". See `docs/cartridge-format.md`.
326fn score_candidate(image: &[u8], offset: usize, map_mode: MapMode) -> u32 {
327 let h = &image[offset..offset + 0x40];
328 let mut score = 0u32;
329
330 // 1. Checksum + complement sum to $FFFF — the strongest signal.
331 let complement = u16::from_le_bytes([h[field::COMPLEMENT], h[field::COMPLEMENT + 1]]);
332 let checksum = u16::from_le_bytes([h[field::CHECKSUM], h[field::CHECKSUM + 1]]);
333 if checksum != 0 && complement != 0 && checksum ^ complement == 0xFFFF {
334 score += 8;
335 }
336
337 // 2. Map-mode byte matches its location (`$20`/`$21`/`$25`, ignoring the fast bit).
338 let mode = h[field::MAP_MODE];
339 if mode & 0xE0 == 0x20 && mode & 0x0F == expected_mode_nibble(map_mode) {
340 score += 4;
341 }
342
343 // 3. Plausible reset vector: points into $8000–$FFFF.
344 let reset = u16::from_le_bytes([h[field::RESET_VECTOR], h[field::RESET_VECTOR + 1]]);
345 if reset >= 0x8000 {
346 score += 2;
347 }
348
349 // 4. Printable 21-byte title (ASCII space..~), space-padded.
350 if h[field::TITLE..field::TITLE + field::TITLE_LEN]
351 .iter()
352 .all(|&b| (0x20..=0x7E).contains(&b))
353 {
354 score += 1;
355 }
356
357 score
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use alloc::vec;
364 use alloc::vec::Vec;
365
366 /// Build a 64 KiB image with a valid header at `base` (`$xFC0`) for `mode_nibble`.
367 fn synth_image(base: usize, mode_nibble: u8, ram_byte: u8, region: u8) -> Vec<u8> {
368 let mut rom = vec![0u8; 0x1_0000];
369 let h = base;
370 // Printable title (21 bytes).
371 for (i, b) in b"RUSTYSNES TEST ROM ".iter().enumerate() {
372 rom[h + field::TITLE + i] = *b;
373 }
374 rom[h + field::MAP_MODE] = 0x20 | mode_nibble; // slow + mode nibble
375 rom[h + field::CHIPSET] = if ram_byte == 0 { 0x00 } else { 0x02 }; // RAM+battery
376 rom[h + field::RAM_SIZE] = ram_byte;
377 rom[h + field::REGION] = region;
378 // checksum/complement sum to 0xFFFF
379 let checksum: u16 = 0x1234;
380 let complement = !checksum;
381 rom[h + field::COMPLEMENT..h + field::COMPLEMENT + 2]
382 .copy_from_slice(&complement.to_le_bytes());
383 rom[h + field::CHECKSUM..h + field::CHECKSUM + 2].copy_from_slice(&checksum.to_le_bytes());
384 // reset vector -> $8000
385 rom[h + field::RESET_VECTOR..h + field::RESET_VECTOR + 2]
386 .copy_from_slice(&0x8000u16.to_le_bytes());
387 rom
388 }
389
390 #[test]
391 fn too_small_rejected() {
392 assert_eq!(Header::detect(&[]), Err(HeaderError::TooSmall(0)));
393 }
394
395 #[test]
396 fn detects_lorom() {
397 let rom = synth_image(0x7FC0, 0x0, 0x03, 0x01); // 8 KiB SRAM, NTSC
398 let h = Header::detect(&rom).expect("lorom header should detect");
399 assert_eq!(h.map_mode, MapMode::LoRom);
400 assert_eq!(h.offset, 0x7FC0);
401 assert_eq!(h.region, Region::Ntsc);
402 assert_eq!(h.sram_size, 0x2000);
403 assert!(h.has_battery);
404 assert_eq!(h.coprocessor, Coprocessor::None);
405 }
406
407 #[test]
408 fn detects_hirom() {
409 let rom = synth_image(0xFFC0, 0x1, 0x00, 0x00); // no SRAM, Japan/NTSC
410 let h = Header::detect(&rom).expect("hirom header should detect");
411 assert_eq!(h.map_mode, MapMode::HiRom);
412 assert_eq!(h.offset, 0xFFC0);
413 assert_eq!(h.region, Region::Ntsc);
414 assert_eq!(h.sram_size, 0);
415 // chipset 0x00 -> no battery
416 assert!(!h.has_battery);
417 }
418
419 #[test]
420 fn pal_region_detected() {
421 let rom = synth_image(0x7FC0, 0x0, 0x00, 0x02); // Europe
422 let h = Header::detect(&rom).expect("detect");
423 assert_eq!(h.region, Region::Pal);
424 }
425
426 #[test]
427 fn hirom_outscores_lorom_when_both_offsets_present() {
428 // A valid HiROM header at $FFC0; the $7FC0 window is zeroed (no checksum match), so
429 // HiROM must win the score even though LoROM is the first candidate.
430 let rom = synth_image(0xFFC0, 0x1, 0x00, 0x00);
431 let h = Header::detect(&rom).expect("detect");
432 assert_eq!(h.map_mode, MapMode::HiRom);
433 }
434
435 #[test]
436 fn copier_prefix_stripped() {
437 let core = synth_image(0x7FC0, 0x0, 0x00, 0x01);
438 let mut rom = vec![0xAAu8; 0x200];
439 rom.extend_from_slice(&core);
440 assert_eq!(rom.len() % 0x8000, 0x200);
441 let h = Header::detect(&rom).expect("detect after prefix strip");
442 assert_eq!(h.copier_prefix, 0x200);
443 assert_eq!(h.map_mode, MapMode::LoRom);
444 assert_eq!(h.rom_size, 0x1_0000);
445 }
446
447 #[test]
448 fn garbage_rejected() {
449 let rom = vec![0u8; 0x1_0000];
450 assert_eq!(Header::detect(&rom), Err(HeaderError::NoValidHeader));
451 }
452
453 #[test]
454 fn detects_exlorom() {
455 // ExLoROM has no dedicated $xFD5 nibble, so the header reports plain LoROM's ($0) —
456 // disambiguated purely by the $40_7FC0 offset, only reachable by images >4 MiB.
457 let mut rom = vec![0u8; 0x40_8000];
458 let synth = synth_image(0x7FC0, 0x0, 0x00, 0x01);
459 rom[0x40_0000..0x40_8000].copy_from_slice(&synth[0..0x8000]);
460 let h = Header::detect(&rom).expect("exlorom header should detect");
461 assert_eq!(h.map_mode, MapMode::ExLoRom);
462 assert_eq!(h.offset, 0x40_7FC0);
463 }
464
465 #[test]
466 fn sram_size_formula() {
467 // byte 5 -> 0x400 << 5 = 32 KiB
468 let rom = synth_image(0x7FC0, 0x0, 0x05, 0x01);
469 let h = Header::detect(&rom).expect("detect");
470 assert_eq!(h.sram_size, 0x8000);
471 }
472}