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}