Skip to main content

rustysnes_cart/
lib.rs

1//! `rustysnes-cart` — the SNES cartridge / memory-map / coprocessor model (cart).
2//!
3//! Owns the LoROM / HiROM / ExHiROM address mapping and every on-cart coprocessor
4//! (DSP-1..4 / Super FX (GSU) / SA-1 / S-DD1 / SPC7110 / CX4 / OBC1). All board-specific
5//! behavior — bank switching, the coprocessor clock, IRQ/refresh hooks — lives behind the
6//! [`Board`] trait, not in the PPU or CPU (the RustyNES "mapper logic lives in the mapper"
7//! rule, ported). The video chip depends ONLY on this crate for its VRAM/CHR bus.
8//!
9//! Part of the one-directional chip-crate graph (see `docs/architecture.md`): this crate
10//! does NOT depend on the other chip crates. `#![no_std]` + alloc so it cross-compiles to a
11//! bare-metal target; only the frontend carries `std` + `unsafe`.
12
13#![no_std]
14#![forbid(unsafe_code)]
15extern crate alloc;
16
17pub mod board;
18pub mod coproc;
19pub mod header;
20pub mod tier;
21
22pub use board::{Board, Coprocessor, ExHiRom, HiRom, LoRom, MappedAddr};
23pub use coproc::{Dsp1Board, Gsu, Revision, Sa1Board, SuperFxBoard, Upd77c25};
24pub use header::{Header, HeaderError, MapMode, Region};
25pub use tier::{BoardTier, board_tier};
26
27use alloc::boxed::Box;
28
29/// A loaded cartridge: a parsed [`Header`] + the [`Board`] that implements its mapping and
30/// any coprocessor. The [`crate::board`] module decodes the header into the right board.
31///
32/// Replace the stub internals with the real ROM/SRAM storage; pin behavior against the test
33/// ROMs FIRST (test-ROM-is-spec), then implement until they pass.
34pub struct Cart {
35    /// The parsed cartridge header (map mode, region, coprocessor id, sizes).
36    pub header: Header,
37    /// The active memory-map board (LoROM / HiROM / ExHiROM + coprocessor hooks).
38    pub board: Box<dyn Board>,
39}
40
41impl Cart {
42    /// Decode a raw ROM image into a [`Cart`] (header detection + board selection).
43    ///
44    /// Strips a 512-byte copier prefix when present, detects the internal header, and builds
45    /// the matching board backed by the real ROM bytes + a zeroed, header-sized SRAM.
46    ///
47    /// # Errors
48    /// Returns [`HeaderError`] if the image is too small or no internal header scores a valid
49    /// map mode at any candidate offset (LoROM `$7FC0` / HiROM `$FFC0` / ExHiROM `$40FFC0`).
50    pub fn load(rom: &[u8]) -> Result<Self, HeaderError> {
51        let header = Header::detect(rom)?;
52        // `Header::detect` records the copier-prefix length; build the board from the stripped
53        // image so ROM offset 0 is the first real cartridge byte.
54        let stripped = &rom[header.copier_prefix..];
55        let board = board::select(&header, stripped);
56        Ok(Self { header, board })
57    }
58
59    /// Decode a raw ROM image into a [`Cart`]. Alias of [`Cart::load`].
60    ///
61    /// # Errors
62    /// See [`Cart::load`].
63    pub fn from_rom(rom: &[u8]) -> Result<Self, HeaderError> {
64        Self::load(rom)
65    }
66
67    /// Read a byte at a 24-bit CPU address `(bank << 16) | addr` via the active board.
68    ///
69    /// `open_bus` is the caller's current open-bus latch (the CPU's MDR — the last value
70    /// actually driven on the data bus), echoed back verbatim when the board's own
71    /// [`MappedAddr::Open`] decode says this address is genuinely unmapped. Ported from ares'
72    /// `Bus::read(address, data)` (`sfc/memory/inline.hpp`), which threads the same fallback
73    /// value through for exactly this reason: [`Board::read24`]'s own default (and every
74    /// coprocessor board's override) has no access to the shared bus latch and would otherwise
75    /// have to invent a placeholder value — every board here returns a bare `0` for that case,
76    /// which is wrong for real open bus (a `0` opcode fetch is `BRK`, so a wild jump into
77    /// unmapped cart space reliably BRK-storms in this emulator even on titles where real
78    /// hardware's open bus echoes back a harmless, non-zero value). See
79    /// `docs/audit/spc7110-boot-crash-2026-07-08.md` for the investigation that found this.
80    pub fn read24(&mut self, addr24: u32, open_bus: u8) -> u8 {
81        if matches!(self.board.map(addr24), MappedAddr::Open) {
82            return open_bus;
83        }
84        self.board.read24(addr24)
85    }
86
87    /// Write a byte at a 24-bit CPU address `(bank << 16) | addr` via the active board.
88    pub fn write24(&mut self, addr24: u32, val: u8) {
89        self.board.write24(addr24, val);
90    }
91
92    /// Borrow the current SRAM contents (for a battery save). Empty if the cart has no SRAM.
93    #[must_use]
94    pub fn save_sram(&self) -> &[u8] {
95        self.board.sram()
96    }
97
98    /// Restore SRAM contents (from a battery save). Copies up to the board's SRAM length; a
99    /// shorter slice leaves the tail zeroed, a longer one is truncated.
100    pub fn load_sram(&mut self, data: &[u8]) {
101        let dst = self.board.sram_mut();
102        let n = data.len().min(dst.len());
103        dst[..n].copy_from_slice(&data[..n]);
104    }
105
106    /// The mutable counterpart to [`Self::save_sram`] — for a host embedder that needs a raw
107    /// memory-map pointer (e.g. a libretro core's `RETRO_MEMORY_SAVE_RAM`, which some frontends
108    /// write through directly rather than only calling [`Self::load_sram`]).
109    pub fn sram_mut(&mut self) -> &mut [u8] {
110        self.board.sram_mut()
111    }
112
113    /// Advance any on-cart coprocessor by one of its clock units. Default boards no-op.
114    pub fn coprocessor_tick(&mut self) {
115        self.board.coprocessor_tick();
116    }
117
118    /// Supply a coprocessor firmware dump (the user-provided chip ROM, e.g. DSP-1 `dsp1.rom`).
119    ///
120    /// Returns `true` if this cart's board carried a chip-ROM-dump coprocessor that accepted the
121    /// image. A cart without such a coprocessor (or a dump of the wrong size) returns `false` and
122    /// is unchanged — the honesty posture of `docs/adr/0003`: absent the dump the coprocessor is
123    /// non-functional, never silently degraded.
124    pub fn install_coprocessor_firmware(&mut self, bytes: &[u8]) -> bool {
125        self.board.load_firmware(bytes)
126    }
127
128    /// The specific firmware file name this cart's board expects, if it knows exactly which chip
129    /// dump it needs (see [`crate::board::Board::firmware_hint`]). `None` for boards that accept
130    /// any same-family dump (or carry no firmware-dependent coprocessor at all).
131    #[must_use]
132    pub fn firmware_hint(&self) -> Option<&'static str> {
133        self.board.firmware_hint()
134    }
135
136    /// Count of host accesses to the coprocessor's data ports since power-on (debugger/diag).
137    #[must_use]
138    pub fn coprocessor_host_accesses(&self) -> u64 {
139        self.board.coprocessor_host_accesses()
140    }
141}
142
143impl core::fmt::Debug for Cart {
144    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
145        f.debug_struct("Cart")
146            .field("header", &self.header)
147            .field("board", &self.board.name())
148            .finish()
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn empty_rom_is_rejected() {
158        assert!(Cart::from_rom(&[]).is_err());
159        assert!(Cart::load(&[]).is_err());
160    }
161
162    #[test]
163    fn load_lorom_and_sram_roundtrip() {
164        use alloc::vec;
165        use alloc::vec::Vec;
166        // Minimal valid LoROM image (64 KiB) with 8 KiB battery SRAM.
167        let mut rom: Vec<u8> = vec![0u8; 0x1_0000];
168        let base = 0x7FC0;
169        for (i, b) in b"RUSTYSNES LOROM TEST ".iter().enumerate() {
170            rom[base + i] = *b;
171        }
172        rom[base + 0x15] = 0x20; // LoROM, slow
173        rom[base + 0x16] = 0x02; // ROM+RAM+battery
174        rom[base + 0x18] = 0x03; // 8 KiB SRAM
175        rom[base + 0x19] = 0x01; // North America / NTSC
176        let checksum: u16 = 0x4321;
177        rom[base + 0x1C..base + 0x1E].copy_from_slice(&(!checksum).to_le_bytes());
178        rom[base + 0x1E..base + 0x20].copy_from_slice(&checksum.to_le_bytes());
179        rom[base + 0x3C..base + 0x3E].copy_from_slice(&0x8000u16.to_le_bytes());
180        rom[0x1234] = 0x5A; // a ROM byte to read back
181
182        let mut cart = Cart::load(&rom).expect("valid lorom");
183        assert_eq!(cart.header.map_mode, MapMode::LoRom);
184        assert_eq!(cart.header.sram_size, 0x2000);
185        // ROM offset 0x1234 lives at bank $00:$9234 ($8000 + 0x1234).
186        assert_eq!(cart.read24(0x00_9234, 0x00), 0x5A);
187        // SRAM round-trip.
188        cart.write24(0x70_0010, 0x77);
189        assert_eq!(cart.read24(0x70_0010, 0x00), 0x77);
190        assert_eq!(cart.save_sram()[0x10], 0x77);
191        // load_sram restores.
192        let mut snap = vec![0u8; 0x2000];
193        snap[0x10] = 0xEE;
194        cart.load_sram(&snap);
195        assert_eq!(cart.read24(0x70_0010, 0x00), 0xEE);
196        // sram_mut() is the same backing storage save_sram()/load_sram() operate on.
197        cart.sram_mut()[0x20] = 0x11;
198        assert_eq!(cart.read24(0x70_0020, 0x00), 0x11);
199    }
200}