Skip to main content

rustyn64_cart/
lib.rs

1//! `rustyn64-cart` — PI cart interface + PIF/CIC boot + cartridge saves.
2//!
3//! Models the Peripheral Interface (PI) DMA path to the cartridge ROM, the PIF
4//! boot ROM + CIC seed handshake (SI side), and the on-cart save backends
5//! (EEPROM 4k/16k, SRAM, `FlashRAM`, Controller Pak). This is a **skeleton** —
6//! the real PI/SI DMA engines, the CIC challenge/response, and the `FlashRAM`
7//! state machine are roadmap phases left as marked TODOs.
8//!
9//! Part of the one-directional chip-crate graph (see `docs/architecture.md`):
10//! this crate does NOT depend on any other chip crate. The RDP depends on it
11//! ONLY for the shared RDRAM memory-bus trait ([`RdramBus`]) — the N64 analog
12//! shared RDRAM access path that the RDP also borrows.
13//! `#![no_std]` + `alloc`; only the frontend carries `std` + `unsafe`.
14
15#![no_std]
16#![forbid(unsafe_code)]
17#![warn(missing_docs)]
18#![allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
19// Skeleton `tick`/hook methods are deliberately non-`const` (they will drive
20// PI/SI DMA and the FlashRAM state machine).
21#![allow(clippy::missing_const_for_fn)]
22
23extern crate alloc;
24
25/// The PI (Peripheral Interface) DMA engine + BSD domain timing registers.
26pub mod pi;
27/// The PIF RAM + the SI joybus frame executor (controllers, EEPROM, Pak).
28pub mod pif;
29/// The four on-cartridge save backends (SRAM, FlashRAM, EEPROM, Controller Pak).
30pub mod save;
31
32use alloc::vec::Vec;
33
34use serde::{Deserialize, Serialize};
35
36/// The shared RDRAM memory bus, as seen by chips that DMA into/out of main RAM.
37///
38/// The RDP (framebuffer + texture fetches via the RDRAM) and the PI/SI DMA
39/// engines all read and write RDRAM through this narrow trait. `rustyn64-core`
40/// owns the concrete 8 MiB (4 MiB base + 4 MiB Expansion Pak) backing store and
41/// implements this; keeping the trait in `rustyn64-cart` lets `rustyn64-rdp`
42/// depend on exactly one chip crate, preserving the one-directional graph.
43pub trait RdramBus {
44    /// Read a byte from RDRAM at a physical address.
45    fn rdram_read(&self, addr: u32) -> u8;
46    /// Write a byte to RDRAM at a physical address.
47    fn rdram_write(&mut self, addr: u32, val: u8);
48
49    /// Read a big-endian 32-bit word from RDRAM. Default composes four byte
50    /// reads; `rustyn64-core` overrides with a fast slice path.
51    fn rdram_read_u32(&self, addr: u32) -> u32 {
52        u32::from_be_bytes([
53            self.rdram_read(addr),
54            self.rdram_read(addr.wrapping_add(1)),
55            self.rdram_read(addr.wrapping_add(2)),
56            self.rdram_read(addr.wrapping_add(3)),
57        ])
58    }
59
60    /// Read the RDRAM "hidden" bits for the 16-bit halfword at `addr` — the 9th
61    /// bit RDRAM carries per byte, which the RDP Z-buffer uses for the low 2 bits
62    /// of the per-pixel `dz`. Returns the 2-bit value (`0..=3`).
63    ///
64    /// Default returns 0, so an impl that does not model the hidden bits behaves
65    /// as if they read back clear (which is also the power-on state).
66    fn rdram_read_hidden(&self, addr: u32) -> u8 {
67        let _ = addr;
68        0
69    }
70
71    /// Write the RDRAM hidden bits (`0..=3`) for the halfword at `addr`. Default
72    /// no-op for impls that do not model them.
73    fn rdram_write_hidden(&mut self, addr: u32, val: u8) {
74        let _ = (addr, val);
75    }
76}
77
78/// On-cartridge non-volatile save backend type.
79///
80/// Detected from the per-game database (the IPL/ROM has no reliable in-header
81/// save-type field, unlike the iNES mapper byte) — keyed off the cart serial /
82/// CRC. `None` means the title saves only to the Controller Pak (or not at all).
83#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
84pub enum SaveType {
85    /// No on-cart save chip.
86    #[default]
87    None,
88    /// 4 Kbit serial EEPROM (512 bytes).
89    Eeprom4k,
90    /// 16 Kbit serial EEPROM (2 KiB).
91    Eeprom16k,
92    /// 256 Kbit battery-backed SRAM (32 KiB).
93    Sram,
94    /// 1 Mbit `FlashRAM` (128 KiB).
95    FlashRam,
96    /// Removable Controller Pak / Memory Pak (32 KiB, via the SI joybus).
97    ControllerPak,
98}
99
100impl SaveType {
101    /// Backing-store size in bytes (`0` for [`SaveType::None`]).
102    #[must_use]
103    pub const fn size_bytes(self) -> usize {
104        match self {
105            Self::None => 0,
106            Self::Eeprom4k => 512,
107            Self::Eeprom16k => 2 * 1024,
108            Self::Sram | Self::ControllerPak => 32 * 1024,
109            Self::FlashRam => 128 * 1024,
110        }
111    }
112}
113
114/// The CIC (boot-security copy-protection) lockout-chip variant.
115///
116/// The PIF and the CIC exchange a seeded challenge/response at boot; the variant
117/// fixes the seed + checksum the IPL3 expects. Skeleton — the handshake itself
118/// is a roadmap phase.
119#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
120pub enum Cic {
121    /// 6101 (early NTSC: Star Fox 64).
122    Cic6101,
123    /// 6102 / 7101 (the common NTSC/PAL variant).
124    #[default]
125    Cic6102,
126    /// 6103 / 7103.
127    Cic6103,
128    /// 6105 / 7105 (uses the X105 IPL2 ramp).
129    Cic6105,
130    /// 6106 / 7106.
131    Cic6106,
132}
133
134/// The boot secrets a CIC hands the PIF at power-on.
135///
136/// From `n64brew_wiki/markdown/PIF-NUS.md` §checksum table: the two 8-bit seeds
137/// and the 6-byte IPL2 checksum. On the real-PIF path the PIF writes the seeds
138/// into PIF RAM (IPL2 reads them to run its own checksum) and keeps the checksum
139/// to compare against the value IPL2 computes — a mismatch NMI-halts the CPU.
140/// These are documented constants, not the SM5 firmware; the seed bytes
141/// cross-check the per-CIC seed values the HLE boot path already injects.
142#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
143pub struct CicBootSecrets {
144    /// The 8-bit IPL2 seed (used by IPL2's checksum over the cart's IPL3).
145    pub ipl2_seed: u8,
146    /// The 8-bit IPL3 seed (used by IPL3's checksum over the first MiB).
147    pub ipl3_seed: u8,
148    /// The 6-byte IPL2 checksum the PIF expects the CPU to reproduce.
149    pub ipl2_checksum: [u8; 6],
150}
151
152/// Standard CRC-32 (reflected, poly `0xEDB8_8320`) — the fingerprint used to
153/// identify a cartridge's CIC from its IPL3 (cen64 `si/cic.c` `si_crc32`).
154fn crc32(data: &[u8]) -> u32 {
155    let mut crc = 0xFFFF_FFFF_u32;
156    for &byte in data {
157        crc ^= u32::from(byte);
158        for _ in 0..8 {
159            crc = if crc & 1 != 0 {
160                0xEDB8_8320 ^ (crc >> 1)
161            } else {
162                crc >> 1
163            };
164        }
165    }
166    !crc
167}
168
169impl Cic {
170    /// Identify the CIC from the cartridge IPL3 (`rom[0x40..0x1000]`) by its
171    /// CRC-32 — the standard fingerprint (cen64 `si/cic.c`, N64brew *CIC-NUS*).
172    /// The core never consults a per-game database (ADR 0003/0004); this reads
173    /// only the ROM's own boot code. An unknown IPL3 (homebrew, e.g.
174    /// n64-systemtest, which ships its own) falls back to 6102 — the seed only
175    /// feeds the boot handshake, and a custom IPL3 does not depend on the stock
176    /// checksum path (the same fallback cen64 documents).
177    #[must_use]
178    pub fn from_ipl3(rom: &[u8]) -> Self {
179        let Some(ipl3) = rom.get(0x40..0x1000) else {
180            return Self::Cic6102;
181        };
182        // CRC values: cen64 `si/cic.c`. 7102 and the three iQue variants share
183        // the 6101 seed; 8303 (64DD), the common 6102 fingerprint `0x90BB_6CB5`,
184        // and every unknown/homebrew IPL3 all resolve to 6102 (the last arm).
185        match crc32(ipl3) {
186            0x6170_A4A1 | 0x009E_9EA3 | 0xCD19_FEF1 | 0xB98C_ED9A | 0xE71C_2766 => Self::Cic6101,
187            0x0B05_0EE0 => Self::Cic6103,
188            0x98BC_2C86 => Self::Cic6105,
189            0xACC8_580A => Self::Cic6106,
190            // `0x90BB_6CB5` (6102) folds into this default — the same variant.
191            _ => Self::Cic6102,
192        }
193    }
194
195    /// The [`CicBootSecrets`] for this variant. All modeled variants are the
196    /// NTSC members; the 7xxx PAL twins share the same seeds and checksums, so
197    /// region is a separate axis (the PIF SM5 ROM, not the CIC, is region-locked).
198    #[must_use]
199    pub const fn boot_secrets(self) -> CicBootSecrets {
200        let (ipl2_seed, ipl3_seed, ipl2_checksum) = match self {
201            Self::Cic6101 => (0x3F, 0x3F, [0x45, 0xCC, 0x73, 0xEE, 0x31, 0x7A]),
202            Self::Cic6102 => (0x3F, 0x3F, [0xA5, 0x36, 0xC0, 0xF1, 0xD8, 0x59]),
203            Self::Cic6103 => (0x78, 0x78, [0x58, 0x6F, 0xD4, 0x70, 0x98, 0x67]),
204            Self::Cic6105 => (0x91, 0x91, [0x86, 0x18, 0xA4, 0x5B, 0xC2, 0xD3]),
205            Self::Cic6106 => (0x85, 0x85, [0x2B, 0xBA, 0xD4, 0xE6, 0xEB, 0x74]),
206        };
207        CicBootSecrets {
208            ipl2_seed,
209            ipl3_seed,
210            ipl2_checksum,
211        }
212    }
213}
214
215/// ROM image byte order, derived from the magic in the first four bytes.
216///
217/// `.z64` is big-endian (native), `.n64` is little-endian (byte-swapped),
218/// `.v64` is byte-swapped within each 16-bit halfword. The loader normalizes
219/// everything to big-endian internally.
220#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
221pub enum RomFormat {
222    /// `.z64` — big-endian, the canonical internal order.
223    Z64BigEndian,
224    /// `.n64` — little-endian (32-bit word swap).
225    N64LittleEndian,
226    /// `.v64` — byte-swapped halfwords.
227    V64ByteSwapped,
228}
229
230impl RomFormat {
231    /// Detect the format from the leading four magic bytes, or `None` if the
232    /// header is too short / unrecognized.
233    #[must_use]
234    pub fn detect(magic: &[u8]) -> Option<Self> {
235        match magic {
236            [0x80, 0x37, 0x12, 0x40, ..] => Some(Self::Z64BigEndian),
237            [0x40, 0x12, 0x37, 0x80, ..] => Some(Self::N64LittleEndian),
238            [0x37, 0x80, 0x40, 0x12, ..] => Some(Self::V64ByteSwapped),
239            _ => None,
240        }
241    }
242}
243
244/// Parsed cartridge header (the first 0x40 bytes of the ROM image).
245#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
246pub struct RomHeader {
247    /// Internal game title (0x20..0x34, space-padded ASCII).
248    pub title: [u8; 20],
249    /// Cartridge serial / game code (0x3B..0x3F, e.g. `NSME`).
250    pub game_code: [u8; 4],
251    /// Detected save backend (resolved via the per-game DB, not the header).
252    pub save_type: SaveType,
253    /// CIC lockout-chip variant (resolved from the IPL3 checksum / DB).
254    pub cic: Cic,
255}
256
257impl RomHeader {
258    /// Parse a normalized big-endian header. Skeleton: only the title / game
259    /// code are extracted; `save_type` + `cic` are DB-resolved elsewhere.
260    ///
261    /// # Errors
262    /// Returns [`CartError::ShortHeader`] if `rom` is shorter than 0x40 bytes.
263    pub fn parse(rom: &[u8]) -> Result<Self, CartError> {
264        if rom.len() < 0x40 {
265            return Err(CartError::ShortHeader);
266        }
267        let mut title = [0u8; 20];
268        title.copy_from_slice(&rom[0x20..0x34]);
269        let mut game_code = [0u8; 4];
270        game_code.copy_from_slice(&rom[0x3B..0x3F]);
271        // The CIC is fingerprinted from the ROM's own IPL3 (not a per-game DB).
272        // TODO(T-CART-02): resolve save_type from the per-game DB by serial/CRC.
273        Ok(Self {
274            title,
275            game_code,
276            save_type: SaveType::None,
277            cic: Cic::from_ipl3(rom),
278        })
279    }
280}
281
282/// Error type for cartridge loading / parsing.
283#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
284pub enum CartError {
285    /// The ROM image is shorter than a 0x40-byte header.
286    ShortHeader,
287    /// The leading magic bytes matched no known `.z64`/`.n64`/`.v64` order.
288    UnknownFormat,
289}
290
291/// The cartridge trait. One cart model, parameterized by save type, CIC, and
292/// region (ADR 0003) — the N64 has no mapper equivalent.
293///
294/// Board-specific behavior — PI/SI-mediated reads and writes, save backing,
295/// optional bootstrap — lives behind it, not in the CPU. All hooks default to
296/// no-op so a board implements only what it uses.
297pub trait Cartridge {
298    /// PI-side read from the cartridge address space (`$1000_0000..`).
299    fn pi_read(&mut self, addr: u32) -> u8 {
300        let _ = addr;
301        0
302    }
303    /// PI-side write into the cartridge address space (save SRAM/FlashRAM, regs).
304    fn pi_write(&mut self, addr: u32, val: u8) {
305        let _ = (addr, val);
306    }
307    /// SI-side joybus exchange (controllers + Controller Pak). Default no-op.
308    fn si_exchange(&mut self, _channel: u8, _tx: &[u8], _rx: &mut [u8]) {}
309    /// Per-CPU-cycle hook (counter-driven cart hardware). Default no-op.
310    fn notify_cpu_cycle(&mut self) {}
311    /// The active save backend for this cartridge.
312    fn save_type(&self) -> SaveType {
313        SaveType::None
314    }
315}
316
317/// PI cart + PIF/CIC + save state — the concrete board.
318#[derive(Debug, Default, Serialize, Deserialize)]
319pub struct Cart {
320    /// The normalized (big-endian) ROM image.
321    ///
322    /// **Excluded from save-states** (`#[serde(skip)]`): the ROM is immutable and
323    /// up to 64 MiB, so serializing it in every snapshot would make the rewind
324    /// ring enormous. A restore deserializes an empty ROM; the frontend re-attaches
325    /// the currently-loaded image via [`Cart::reattach_rom`]. A save-state is thus
326    /// only valid alongside the same ROM — the normal emulator contract.
327    #[serde(skip)]
328    rom: Vec<u8>,
329    /// Parsed header (title / game code / save+CIC selection).
330    header: RomHeader,
331    /// The active save backend (PI-bus SRAM/FlashRAM or joybus EEPROM/Pak).
332    save: save::SaveDevice,
333    /// The PIF: its 64-byte RAM + the joybus executor for controllers/accessories.
334    pif: pif::Pif,
335}
336
337/// PI domain-1 base (the cartridge ROM window).
338const ROM_PI_BASE: u32 = 0x1000_0000;
339
340impl Cart {
341    /// Construct an empty cart at power-on.
342    #[must_use]
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Load + normalize a raw ROM image of any supported byte order.
348    ///
349    /// # Errors
350    /// [`CartError::UnknownFormat`] for an unrecognized magic, or
351    /// [`CartError::ShortHeader`] for a truncated header.
352    pub fn load(raw: &[u8]) -> Result<Self, CartError> {
353        let format = RomFormat::detect(raw).ok_or(CartError::UnknownFormat)?;
354        let rom = normalize_to_big_endian(raw, format);
355        let header = RomHeader::parse(&rom)?;
356        let save = save::SaveDevice::new(header.save_type);
357        let mut pif = pif::Pif::new();
358        // A Controller-Pak cart advertises its pak on port 0 so the game's
359        // accessory probe (`0x00` info → status 1) finds it.
360        pif.set_pak_present(0, header.save_type == SaveType::ControllerPak);
361        Ok(Self {
362            rom,
363            header,
364            save,
365            pif,
366        })
367    }
368
369    /// Re-attach a ROM image after a save-state restore (which deserializes an
370    /// empty ROM — the ROM field is `#[serde(skip)]`'d). The `rom` bytes are the
371    /// normalized big-endian image (what `Cart::load` stores, i.e. what
372    /// `rom_image` returns); the frontend keeps the loaded image and re-inserts it
373    /// here so cart/PI reads resolve again. All other cart state (save, PIF,
374    /// header) came from the snapshot and is left untouched.
375    pub fn reattach_rom(&mut self, rom: alloc::vec::Vec<u8>) {
376        self.rom = rom;
377    }
378
379    /// The normalized (big-endian) ROM image, for the frontend to stash so it can
380    /// [`Cart::reattach_rom`] after a restore.
381    #[must_use]
382    pub fn rom_image(&self) -> &[u8] {
383        &self.rom
384    }
385
386    /// The PIF's 64-byte RAM (the SI DMA copies it to/from RDRAM).
387    #[must_use]
388    pub const fn pif_ram(&self) -> &[u8; pif::PIF_RAM_LEN] {
389        self.pif.ram()
390    }
391
392    /// Load the whole PIF RAM (an SI 64-byte write from RDRAM).
393    pub fn pif_load(&mut self, bytes: &[u8; pif::PIF_RAM_LEN]) {
394        self.pif.load(bytes);
395    }
396
397    /// A CPU direct read/write byte of PIF RAM.
398    #[must_use]
399    pub fn pif_read(&self, off: usize) -> u8 {
400        self.pif.read(off)
401    }
402
403    /// Write a CPU direct byte of PIF RAM.
404    pub const fn pif_write(&mut self, off: usize, val: u8) {
405        self.pif.write(off, val);
406    }
407
408    /// Execute the pending joybus frame (on an SI read), using the four packed
409    /// controller port words and the cart's save backend.
410    pub fn pif_execute(&mut self, controllers: &[u32; 4]) {
411        self.pif.execute(controllers, &mut self.save);
412    }
413
414    /// Install the PIF boot ROM (IPL1/IPL2) for the real-PIF boot path.
415    pub fn pif_load_boot_rom(&mut self, bytes: &[u8]) {
416        self.pif.load_boot_rom(bytes);
417    }
418
419    /// Read a byte of the PIF boot ROM (`0x1FC0_0000 + off`); 0 when absent (HLE).
420    #[must_use]
421    pub fn pif_boot_rom_read(&self, off: usize) -> u8 {
422        self.pif.boot_rom_read(off)
423    }
424
425    /// Is a real PIF boot ROM installed (real-PIF boot path active)?
426    #[must_use]
427    pub const fn pif_has_boot_rom(&self) -> bool {
428        self.pif.has_boot_rom()
429    }
430
431    /// Register the CIC's IPL2 checksum for the real-PIF boot verify.
432    pub const fn pif_set_boot_checksum(&mut self, checksum: [u8; 6]) {
433        self.pif.set_boot_checksum(checksum);
434    }
435
436    /// Process a reset-mode PIF command-byte write (real-PIF boot); returns `true`
437    /// if the checksum verify failed and the CPU must be NMI-halted.
438    pub fn pif_boot_command(&mut self) -> bool {
439        self.pif.boot_command()
440    }
441
442    /// Warm-reset the transient PIF boot state (unlock the ROM, drop the latched
443    /// checksum) so a reset can re-run IPL1→IPL2.
444    pub const fn pif_reset_boot(&mut self) {
445        self.pif.reset_boot();
446    }
447
448    /// The parsed cartridge header.
449    #[must_use]
450    pub const fn header(&self) -> &RomHeader {
451        &self.header
452    }
453
454    /// The persistable save backing bytes (empty for [`SaveType::None`]).
455    #[must_use]
456    pub fn save(&self) -> &[u8] {
457        self.save.backing()
458    }
459
460    /// Mutable access to the save device (the joybus module drives EEPROM/Pak).
461    #[must_use]
462    pub fn save_device_mut(&mut self) -> &mut save::SaveDevice {
463        &mut self.save
464    }
465
466    /// A whole-word PI write (direct-I/O or DMA). Routes the `FlashRAM` Command
467    /// Internal Register (`0x0801_0000`) as a 32-bit command; other DOM2-window
468    /// writes fall to the byte path. ROM-window writes are ignored (read-only).
469    pub fn pi_write_word(&mut self, addr: u32, word: u32) {
470        if addr & !3 == save::FLASH_CIR {
471            self.save.flash_cir(word);
472        } else {
473            for (i, b) in word.to_be_bytes().into_iter().enumerate() {
474                self.pi_write(addr.wrapping_add(i as u32), b);
475            }
476        }
477    }
478
479    /// Advance one unit of cart time (PI/SI DMA progress).
480    pub fn tick(&mut self) {
481        // TODO(T-CART-01): step any in-flight PI/SI DMA transfer.
482    }
483}
484
485impl Cartridge for Cart {
486    fn pi_read(&mut self, addr: u32) -> u8 {
487        // The DOM2 save window (SRAM / FlashRAM) takes priority, but **only**
488        // within that window — otherwise a SRAM/FlashRAM cart's `pi_read` would
489        // answer for the ROM window too (the save's flat store returns `0` past
490        // its end), and the game could never read its own ROM through PI DMA.
491        if (save::SAVE_PI_BASE..ROM_PI_BASE).contains(&addr)
492            && let Some(b) = self.save.pi_read(addr)
493        {
494            return b;
495        }
496        let off = (addr as usize).wrapping_sub(ROM_PI_BASE as usize);
497        self.rom.get(off).copied().unwrap_or(0)
498    }
499
500    fn pi_write(&mut self, addr: u32, val: u8) {
501        // Byte writes reach SRAM and the FlashRAM page buffer; the ROM is
502        // read-only (writes ignored). The FlashRAM CIR is word-only — see
503        // `pi_write_word`.
504        if (save::SAVE_PI_BASE..ROM_PI_BASE).contains(&addr) && addr & !3 != save::FLASH_CIR {
505            self.save.pi_write(addr, val);
506        }
507    }
508
509    fn save_type(&self) -> SaveType {
510        self.header.save_type
511    }
512}
513
514/// Normalize a raw ROM image to internal big-endian (`.z64`) order.
515fn normalize_to_big_endian(raw: &[u8], format: RomFormat) -> Vec<u8> {
516    match format {
517        RomFormat::Z64BigEndian => raw.to_vec(),
518        RomFormat::V64ByteSwapped => {
519            let mut out = raw.to_vec();
520            for pair in out.chunks_exact_mut(2) {
521                pair.swap(0, 1);
522            }
523            out
524        }
525        RomFormat::N64LittleEndian => {
526            let mut out = raw.to_vec();
527            for word in out.chunks_exact_mut(4) {
528                word.swap(0, 3);
529                word.swap(1, 2);
530            }
531            out
532        }
533    }
534}
535
536/// Returns the crate version string.
537#[must_use]
538pub const fn version() -> &'static str {
539    env!("CARGO_PKG_VERSION")
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn detects_rom_formats() {
548        assert_eq!(
549            RomFormat::detect(&[0x80, 0x37, 0x12, 0x40]),
550            Some(RomFormat::Z64BigEndian)
551        );
552        assert_eq!(
553            RomFormat::detect(&[0x40, 0x12, 0x37, 0x80]),
554            Some(RomFormat::N64LittleEndian)
555        );
556        assert_eq!(
557            RomFormat::detect(&[0x37, 0x80, 0x40, 0x12]),
558            Some(RomFormat::V64ByteSwapped)
559        );
560        assert_eq!(RomFormat::detect(&[0, 0, 0, 0]), None);
561    }
562
563    #[test]
564    fn save_sizes() {
565        assert_eq!(SaveType::Eeprom4k.size_bytes(), 512);
566        assert_eq!(SaveType::FlashRam.size_bytes(), 128 * 1024);
567        assert_eq!(SaveType::None.size_bytes(), 0);
568    }
569
570    #[test]
571    fn short_header_errors() {
572        assert_eq!(RomHeader::parse(&[0u8; 8]), Err(CartError::ShortHeader));
573    }
574
575    #[test]
576    fn constructs() {
577        let cart = Cart::new();
578        assert_eq!(cart.save_type(), SaveType::None);
579    }
580
581    #[test]
582    fn version_is_non_empty() {
583        assert!(!version().is_empty());
584    }
585
586    #[test]
587    fn crc32_matches_the_standard_check_vector() {
588        // The canonical CRC-32 check value for the ASCII string "123456789".
589        assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
590    }
591
592    #[test]
593    fn an_unknown_ipl3_falls_back_to_6102() {
594        // An all-zero IPL3 is not in the fingerprint table — homebrew ships its
595        // own boot code, so 6102 is the documented fallback (cen64).
596        let rom = alloc::vec![0u8; 0x1000];
597        assert_eq!(Cic::from_ipl3(&rom), Cic::Cic6102);
598        // Too short to hold an IPL3 → same fallback, no panic.
599        assert_eq!(Cic::from_ipl3(&[0u8; 0x40]), Cic::Cic6102);
600    }
601
602    #[test]
603    fn boot_secrets_match_the_pif_nus_table() {
604        // N64brew PIF-NUS §checksum table — the IPL2 seeds differ per CIC (the
605        // real IPL2 consumes them), and the 6-byte checksum is the authenticator.
606        assert_eq!(
607            Cic::Cic6102.boot_secrets().ipl2_checksum,
608            [0xA5, 0x36, 0xC0, 0xF1, 0xD8, 0x59]
609        );
610        assert_eq!(Cic::Cic6102.boot_secrets().ipl2_seed, 0x3F);
611        assert_eq!(Cic::Cic6103.boot_secrets().ipl2_seed, 0x78);
612        assert_eq!(Cic::Cic6105.boot_secrets().ipl2_seed, 0x91);
613        assert_eq!(Cic::Cic6106.boot_secrets().ipl2_seed, 0x85);
614        // In every known CIC the IPL2 and IPL3 seeds coincide.
615        for cic in [
616            Cic::Cic6101,
617            Cic::Cic6102,
618            Cic::Cic6103,
619            Cic::Cic6105,
620            Cic::Cic6106,
621        ] {
622            let s = cic.boot_secrets();
623            assert_eq!(s.ipl2_seed, s.ipl3_seed, "seeds coincide for {cic:?}");
624        }
625    }
626}