Skip to main content

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}