Skip to main content

rustysnes_cart/
board.rs

1//! The [`Board`] trait — the SNES analogue of RustyNES's `Mapper`.
2//!
3//! A "board" is one cartridge PCB family: a base address-mapping mode (LoROM / HiROM /
4//! ExHiROM) plus any on-cart coprocessor. The 65C816 bus calls [`Board::read24`] /
5//! [`Board::write24`] with a full 24-bit `(bank << 16) | addr`; the board decodes its own
6//! mapping. Coprocessor boards additionally implement the default-no-op hooks
7//! ([`Board::coprocessor_tick`], the `notify_*` family) — exactly the RustyNES pattern where
8//! per-board IRQ/cycle quirks live INSIDE the board, called via default-no-op trait hooks.
9//!
10//! See `docs/cart.md` for the per-board / per-coprocessor table and the decode formulas.
11
12use alloc::boxed::Box;
13use alloc::vec;
14
15use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
16
17use crate::header::{Coprocessor as CoproId, Header, MapMode};
18
19/// The result of a board's address decode: where a 24-bit CPU address lands.
20///
21/// The bus uses this to route a read/write to the right backing store, and the default
22/// [`Board::read24`] / [`Board::write24`] consume it directly over the board's own storage.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MappedAddr {
25    /// Maps into ROM at the given linear byte offset (already mirror-folded to `rom_size`).
26    Rom(u32),
27    /// Maps into cartridge SRAM at the given offset (already wrapped to `sram_size`).
28    Sram(u32),
29    /// Maps into a coprocessor register window (the board handles it internally).
30    Coprocessor,
31    /// Open bus / unmapped (returns the last bus value).
32    Open,
33}
34
35/// Identifies which coprocessor (if any) a board carries.
36///
37/// Mirrors the header's [`CoproId`] but is re-exported here so downstream callers can match on
38/// a board's coprocessor without importing the header module.
39pub type Coprocessor = CoproId;
40
41/// A cartridge board: its address mapping + any coprocessor behavior.
42///
43/// The default-no-op hooks are the load-bearing port of RustyNES's `Mapper::notify_*`:
44/// the CPU/PPU/scheduler call them unconditionally, and only coprocessor boards override
45/// them. Keep every board-specific quirk INSIDE its `impl Board` — never special-case a
46/// board from the bus or the PPU.
47pub trait Board: Send {
48    /// Human-readable board name (for the debugger + logs), e.g. `"LoROM"`, `"HiROM+DSP-1"`.
49    fn name(&self) -> &'static str;
50
51    /// Which coprocessor this board carries (or [`Coprocessor::None`]).
52    fn coprocessor(&self) -> Coprocessor {
53        Coprocessor::None
54    }
55
56    /// Decode a 24-bit CPU address `(bank << 16) | addr` to its backing store. The returned
57    /// [`MappedAddr::Rom`] / [`MappedAddr::Sram`] offsets are already folded into range.
58    fn map(&self, addr24: u32) -> MappedAddr;
59
60    /// Read a byte at a 24-bit CPU address. Default routes through [`Self::map`] over the
61    /// board's own storage; coprocessor boards override to intercept their register windows.
62    fn read24(&mut self, addr24: u32) -> u8 {
63        match self.map(addr24) {
64            MappedAddr::Rom(off) => self.rom().get(off as usize).copied().unwrap_or(0),
65            MappedAddr::Sram(off) => self.sram().get(off as usize).copied().unwrap_or(0),
66            MappedAddr::Coprocessor | MappedAddr::Open => 0,
67        }
68    }
69
70    /// Write a byte at a 24-bit CPU address (SRAM, coprocessor registers, bank latches).
71    /// Default writes through [`Self::map`] to SRAM only — ROM and open bus are read-only.
72    fn write24(&mut self, addr24: u32, val: u8) {
73        if let MappedAddr::Sram(off) = self.map(addr24)
74            && let Some(slot) = self.sram_mut().get_mut(off as usize)
75        {
76            *slot = val;
77        }
78    }
79
80    /// The board's ROM backing store (for save-states / debugging). Read-only.
81    fn rom(&self) -> &[u8];
82
83    /// The board's SRAM backing store (for battery saves / save-states). Read-only.
84    fn sram(&self) -> &[u8];
85
86    /// The board's SRAM backing store, mutable (for battery restore / save-state load).
87    fn sram_mut(&mut self) -> &mut [u8];
88
89    // --- Default-no-op coprocessor / IRQ hooks (the `notify_a12`-equivalents). ---
90
91    /// Advance the on-cart coprocessor by one master clock. The Bus calls this from inside its
92    /// own per-master-tick loop (`advance_master`, alongside the PPU dot and the APU's
93    /// SMP-cycle release) — every single tick, unconditionally, on the coprocessor's divisor —
94    /// so a host-driven coprocessor (Super FX/GSU) runs genuinely concurrently with the CPU's
95    /// own subsequent instructions instead of draining an entire `Go` burst to completion
96    /// "atomically" inside the one bus write that armed it. This mirrors ares's `SuperFX :
97    /// Thread` cothread, which the scheduler interleaves with the main CPU at native
98    /// master-clock granularity (`sfc/coprocessor/superfx/superfx.cpp`'s `Thread::create` +
99    /// `timing.cpp`'s `Thread::synchronize` after every access) — the CPU can do unrelated
100    /// work, or even service a *second* `Go` burst, in between two ticks of the first one,
101    /// instead of only ever observing the coprocessor's result after it fully finishes
102    /// (`Gsu::tick` doc has the detail on what is, and isn't, deferred). Default no-op (base
103    /// LoROM/HiROM/ExHiROM have no coprocessor; DSP-n stays RQM-polled, not tick-driven).
104    fn coprocessor_tick(&mut self) {}
105
106    /// Notify the board that the PPU is starting a new scanline. Default no-op. (Reserved for
107    /// boards whose coprocessor or IRQ counter is scanline-aligned.)
108    fn notify_scanline(&mut self) {}
109
110    /// Notify the board of one elapsed CPU cycle. Default no-op. Coprocessors with a
111    /// CPU-cycle-driven IRQ/refresh counter override this.
112    fn notify_cpu_cycle(&mut self) {}
113
114    /// Notify the board that DMA channel `channel`'s `$43n2-$43n6` source-address/byte-count
115    /// registers were just written, reporting the channel's CURRENT full 24-bit source address
116    /// and 16-bit count. Default no-op. The `$4300-$437F` DMA register file lives in
117    /// `rustysnes-core::Bus` (not routed through `Board::read24`/`write24` at all under normal
118    /// SNES addressing), so a board that needs to observe DMA setup — S-DD1's decompression-
119    /// during-DMA hook, which snoops these exact registers on real hardware (ares
120    /// `sfc/coprocessor/sdd1/sdd1.cpp` `dmaWrite`) — has no other way to see it; this hook is
121    /// `rustysnes-core`'s side of that snoop, called after every relevant register write
122    /// regardless of board (cheap no-op for the other 99% of carts).
123    fn notify_dma_channel(&mut self, channel: usize, address: u32, count: u16) {
124        let _ = (channel, address, count);
125    }
126
127    /// Whether the board is currently asserting its IRQ line (SA-1, Super FX, SPC7110 RTC).
128    /// Default `false`. The bus ORs this with the other IRQ sources.
129    fn irq_pending(&self) -> bool {
130        false
131    }
132
133    /// The GSU register file (R0-R15 + SFR + PBR), for a Super FX board's debugger Cart panel.
134    ///
135    /// Default `None` — only [`crate::coproc::superfx::SuperFxBoard`] overrides this. A
136    /// read-only debug accessor, not a control surface (`docs/frontend.md` §Debugger overlay).
137    fn debug_gsu_state(&self) -> Option<([u16; 16], u16, u8)> {
138        None
139    }
140
141    /// Supply a coprocessor firmware dump (e.g. the DSP-1 `dsp1.rom`). Default `false` — a base
142    /// board has no firmware to load. A chip-ROM-dump coprocessor returns `true` once the dump is
143    /// accepted; without it the board is non-functional, never silently degraded (`docs/adr/0003`).
144    fn load_firmware(&mut self, _bytes: &[u8]) -> bool {
145        false
146    }
147
148    /// The specific firmware file name this board expects (e.g. `"dsp2.rom"`), if the board knows
149    /// exactly which chip dump it needs. Default `None` — most chip-ROM-dump coprocessors accept
150    /// any same-family, same-size dump (DSP-1 accepts either `dsp1.rom` or `dsp1b.rom`), so callers
151    /// searching a firmware directory should try this exact name FIRST when present: several NEC
152    /// DSP chips share an identical firmware byte size (`docs/cart.md` §"the shared NEC core"), so
153    /// trying a same-sized-but-wrong-chip's dump would silently load the wrong lookup tables/
154    /// program into the engine — this hint is what stops that ambiguity for the single-game
155    /// variants (DSP-2/4, ST010) that DO need one exact file, not a same-family candidate list.
156    fn firmware_hint(&self) -> Option<&'static str> {
157        None
158    }
159
160    /// Count of host accesses to the coprocessor's data ports since power-on (debugger /
161    /// diagnostics). Default `0` — base boards have no coprocessor.
162    fn coprocessor_host_accesses(&self) -> u64 {
163        0
164    }
165
166    // --- Save-state hooks (`docs/adr/0006`). --------------------------------------------------
167    //
168    // ROM and SRAM are NOT written here — `System::save_state` captures SRAM separately (it's
169    // also the battery-save path, `Board::sram`/`sram_mut`) and never captures ROM at all (it's
170    // loaded fresh from the user's own file on restore, never embedded in a save-state — the same
171    // "never commit/carry a ROM byte" posture `docs/adr/0003` already applies to firmware dumps).
172    // These hooks cover everything else a board's coprocessor carries: register files, cursors,
173    // decompressor/engine state.
174
175    /// Write this board's coprocessor state (registers, cursors, sub-engine state — NOT ROM/SRAM,
176    /// see above). Default no-op: the base LoROM/HiROM/ExHiROM boards and any coprocessor board
177    /// that hasn't opted in yet carry no extra state beyond what `System::save_state` already
178    /// captures directly, so writing nothing is correct, not merely convenient — restoring such a
179    /// board's post-load state is already exact.
180    fn save_state(&self, w: &mut SaveWriter) {
181        let _ = w;
182    }
183
184    /// The inverse of [`Self::save_state`] — restore state a matching `save_state` call wrote.
185    /// Default no-op, matching that default. A board overriding one MUST override the other; an
186    /// asymmetric pair would silently desync a restored coprocessor from its own register file,
187    /// which is exactly the honesty-gate failure mode `docs/adr/0003`/`docs/adr/0006` forbid.
188    ///
189    /// # Errors
190    /// A board rejects malformed/truncated bytes via [`SaveStateError`] rather than partially
191    /// applying them — never a panic on untrusted (user-supplied, possibly hand-edited or
192    /// corrupted) save-state data.
193    fn load_state(&mut self, r: &mut SaveReader) -> Result<(), SaveStateError> {
194        let _ = r;
195        Ok(())
196    }
197
198    // --- Second-CPU hooks (SA-1). -----------------------------------------------------------
199    //
200    // The one-directional crate graph forbids `rustysnes-cart` from depending on `rustysnes-cpu`,
201    // so a board that carries a *second* 65C816 (the SA-1) keeps the entire coprocessor SYSTEM
202    // state here and exposes the second CPU's memory view + control lines through these default-
203    // no-op hooks. `rustysnes-core` owns the second `rustysnes_cpu::Cpu` instance and drives it
204    // through these. See `docs/scheduler.md` §SA-1 and [`crate::coproc::sa1`].
205
206    /// Whether this board carries a second CPU that core must instantiate + step. Default `false`.
207    fn has_second_cpu(&self) -> bool {
208        false
209    }
210
211    /// Read a byte through the second CPU's memory view (its own address decode). Default open bus.
212    fn second_cpu_read(&mut self, addr24: u32) -> u8 {
213        let _ = addr24;
214        0
215    }
216
217    /// Write a byte through the second CPU's memory view. Default no-op.
218    fn second_cpu_write(&mut self, addr24: u32, val: u8) {
219        let _ = (addr24, val);
220    }
221
222    /// Whether the second CPU is currently allowed to execute (not held in reset / sleep). Default
223    /// `false`.
224    fn second_cpu_running(&self) -> bool {
225        false
226    }
227
228    /// Take a pending second-CPU reset edge (e.g. SA-1 RESB 1→0). Returns `true` exactly once per
229    /// edge; core then resets the second CPU. Default `false`.
230    fn second_cpu_take_reset(&mut self) -> bool {
231        false
232    }
233
234    /// Edge-triggered NMI to the second CPU (acknowledges on a `true` return). Default `false`.
235    fn second_cpu_poll_nmi(&mut self) -> bool {
236        false
237    }
238
239    /// Level-sensitive IRQ to the second CPU (honored when its `I` flag is clear). Default `false`.
240    fn second_cpu_poll_irq(&self) -> bool {
241        false
242    }
243
244    /// Advance the second CPU's internal timer/counters by `clocks` of its own master clock.
245    /// Default no-op.
246    fn second_cpu_tick(&mut self, clocks: u32) {
247        let _ = clocks;
248    }
249}
250
251/// Fold a linear ROM offset into a `size`-byte image with hardware-accurate mirroring.
252///
253/// Power-of-two sizes are a plain `& (size - 1)`. Non-power-of-two images (e.g. 6 MiB =
254/// 4 MiB + 2 MiB) mirror the way real address decoding does: the largest power-of-two block
255/// addresses linearly and the remainder mirrors within itself. This matches ares' `Bus::mirror`
256/// (clean-room re-implementation — the algorithm is hardware fact).
257#[must_use]
258const fn mirror(mut address: u32, size: u32) -> u32 {
259    if size == 0 {
260        return 0;
261    }
262    let mut base = 0u32;
263    let mut mask = 1u32 << 23;
264    let mut size = size;
265    while address >= size {
266        while address & mask == 0 {
267            mask >>= 1;
268        }
269        address -= mask;
270        if size > mask {
271            size -= mask;
272            base += mask;
273        }
274        mask >>= 1;
275    }
276    base + address
277}
278
279/// Select the concrete board for a parsed header, allocating ROM + zeroed SRAM.
280///
281/// `rom` must be the copier-prefix-stripped image (see [`Header::detect`]). The base map mode
282/// picks the base board; a detected coprocessor wraps it (DSP-1 → [`crate::coproc::Dsp1Board`]).
283/// Coprocessor boards that depend on a chip-ROM dump are inert until the firmware is supplied via
284/// [`Board::load_firmware`] (`docs/adr/0003`).
285#[must_use]
286pub fn select(header: &Header, rom: &[u8]) -> Box<dyn Board> {
287    let rom_len = rom.len();
288    // Extracted before `rom` is boxed below: the 21-byte internal title, used only to disambiguate
289    // the single-game NEC DSP variants (DSP-2/4, ST010) from plain DSP-1 — see `necdsp_variant`'s
290    // module doc for why the header's coprocessor byte alone can't tell them apart.
291    let title_upper = rom
292        .get(header.offset..header.offset + 21)
293        .and_then(|b| core::str::from_utf8(b).ok())
294        .map(str::to_uppercase);
295    let rom: Box<[u8]> = Box::from(rom);
296
297    // Super FX / GSU owns its own ROM/RAM mapping (no base-board delegation): the GSU program
298    // lives in the cart ROM, so the board is functional with no chip-ROM dump. It re-decodes the
299    // LoROM Super FX map itself, so it never builds a base board.
300    if header.coprocessor == CoproId::SuperFx {
301        return Box::new(crate::coproc::superfx::select(
302            header.map_mode,
303            rom,
304            header.sram_size,
305        ));
306    }
307
308    // SA-1 owns its own Super-MMC ROM/BW-RAM/I-RAM mapping (no base-board delegation); the SA-1
309    // program lives in the cart ROM, so the board is functional the moment the cart loads. The
310    // second 65C816 is instantiated + stepped by `rustysnes-core` via the second-CPU hooks.
311    if header.coprocessor == CoproId::Sa1 {
312        return Box::new(crate::coproc::sa1::select(
313            rom,
314            header.sram_size,
315            header.region,
316        ));
317    }
318
319    // S-DD1 owns its own ROM mapping (no base-board delegation): its bank-fold formula and
320    // DMA-decompression hook need the full ROM read path uninterrupted — see `coproc::sdd1`'s
321    // module doc. No chip-ROM dump; the algorithm runs against the cart's own compressed data.
322    if header.coprocessor == CoproId::SDd1 {
323        return Box::new(crate::coproc::sdd1::select(
324            header.map_mode,
325            rom,
326            header.sram_size,
327        ));
328    }
329
330    // SPC7110 owns its own PROM/DROM/RAM mapping (no base-board delegation): its unified linear
331    // data-ROM fold and the register window's whole-bank $50/$58 mirrors need the full ROM read
332    // path uninterrupted — see `coproc::spc7110`'s module doc.
333    if header.coprocessor == CoproId::Spc7110 {
334        return Box::new(crate::coproc::spc7110::select(
335            header.map_mode,
336            rom,
337            header.sram_size,
338        ));
339    }
340
341    let sram = vec![0u8; header.sram_size].into_boxed_slice();
342    let base: Box<dyn Board> = match header.map_mode {
343        MapMode::LoRom => Box::new(LoRom::new(rom, sram)),
344        MapMode::HiRom => Box::new(HiRom::new(rom, sram)),
345        MapMode::ExHiRom => Box::new(ExHiRom::new(rom, sram)),
346        MapMode::ExLoRom => Box::new(ExLoRom::new(rom, sram)),
347    };
348    match header.coprocessor {
349        CoproId::Dsp => {
350            let variant = title_upper
351                .as_deref()
352                .and_then(crate::coproc::NecDspVariant::detect);
353            match variant {
354                Some(v) => Box::new(crate::coproc::NecDspVariantBoard::new(base, v)),
355                None => Box::new(crate::coproc::Dsp1Board::new(
356                    base,
357                    header.map_mode,
358                    rom_len,
359                )),
360            }
361        }
362        CoproId::Obc1 => Box::new(crate::coproc::Obc1Board::new(base)),
363        CoproId::Cx4 => Box::new(crate::coproc::Cx4Board::new(base)),
364        CoproId::Srtc => Box::new(crate::coproc::SharpRtcBoard::new(base)),
365        CoproId::St018 => Box::new(crate::coproc::St018Board::new(base)),
366        // Other coprocessor families land in later sprints; until then the cart runs as its base
367        // board (the coprocessor window is simply unmapped, never silently faked).
368        _ => base,
369    }
370}
371
372/// LoROM (mode `$20`): 32 KiB ROM windows in `$8000–$FFFF` of each bank.
373#[derive(Debug, Clone)]
374pub struct LoRom {
375    rom: Box<[u8]>,
376    sram: Box<[u8]>,
377}
378
379impl LoRom {
380    /// Construct a LoROM board from owned ROM + (zeroed, header-sized) SRAM.
381    #[must_use]
382    pub const fn new(rom: Box<[u8]>, sram: Box<[u8]>) -> Self {
383        Self { rom, sram }
384    }
385}
386
387impl Board for LoRom {
388    fn name(&self) -> &'static str {
389        "LoROM"
390    }
391
392    fn map(&self, addr24: u32) -> MappedAddr {
393        let bank = (addr24 >> 16) & 0xFF;
394        let addr = addr24 & 0xFFFF;
395
396        // SRAM: banks $70–$7D and $F0–$FF, $0000–$7FFF (when present).
397        if !self.sram.is_empty() {
398            let lo = bank & 0x7F;
399            if (0x70..=0x7D).contains(&lo) && addr < 0x8000 {
400                let idx = (lo - 0x70) * 0x8000 + addr;
401                #[allow(clippy::cast_possible_truncation)]
402                let len = self.sram.len() as u32;
403                return MappedAddr::Sram(idx % len);
404            }
405        }
406
407        // ROM: $8000–$FFFF of every bank; offset = ((bank & 0x7F) << 15) | (addr & 0x7FFF).
408        if addr >= 0x8000 {
409            let off = ((bank & 0x7F) << 15) | (addr & 0x7FFF);
410            #[allow(clippy::cast_possible_truncation)]
411            let size = self.rom.len() as u32;
412            return MappedAddr::Rom(mirror(off, size));
413        }
414
415        MappedAddr::Open
416    }
417
418    fn rom(&self) -> &[u8] {
419        &self.rom
420    }
421    fn sram(&self) -> &[u8] {
422        &self.sram
423    }
424    fn sram_mut(&mut self) -> &mut [u8] {
425        &mut self.sram
426    }
427}
428
429/// HiROM (mode `$21`): 64 KiB linear ROM banks; full ROM at `$C0–$FF`.
430#[derive(Debug, Clone)]
431pub struct HiRom {
432    rom: Box<[u8]>,
433    sram: Box<[u8]>,
434}
435
436impl HiRom {
437    /// Construct a HiROM board from owned ROM + (zeroed, header-sized) SRAM.
438    #[must_use]
439    pub const fn new(rom: Box<[u8]>, sram: Box<[u8]>) -> Self {
440        Self { rom, sram }
441    }
442}
443
444impl Board for HiRom {
445    fn name(&self) -> &'static str {
446        "HiROM"
447    }
448
449    fn map(&self, addr24: u32) -> MappedAddr {
450        let bank = (addr24 >> 16) & 0xFF;
451        let addr = addr24 & 0xFFFF;
452
453        // SRAM: banks $20–$3F and $A0–$BF, $6000–$7FFF (when present).
454        if !self.sram.is_empty() {
455            let lo = bank & 0x7F;
456            if (0x20..=0x3F).contains(&lo) && (0x6000..0x8000).contains(&addr) {
457                let idx = (lo - 0x20) * 0x2000 + (addr - 0x6000);
458                #[allow(clippy::cast_possible_truncation)]
459                let len = self.sram.len() as u32;
460                return MappedAddr::Sram(idx % len);
461            }
462        }
463
464        #[allow(clippy::cast_possible_truncation)]
465        let size = self.rom.len() as u32;
466
467        // Linear ROM: banks $40–$7D and $C0–$FF, full 64 KiB → offset = (bank & 0x3F) << 16 | addr.
468        let lo = bank & 0x7F;
469        if (0x40..=0x7D).contains(&lo) || bank >= 0xC0 {
470            let off = ((bank & 0x3F) << 16) | addr;
471            return MappedAddr::Rom(mirror(off, size));
472        }
473
474        // Windowed ROM: banks $00–$3F and $80–$BF, $8000–$FFFF → same linear offset.
475        if (lo < 0x40) && addr >= 0x8000 {
476            let off = ((bank & 0x3F) << 16) | addr;
477            return MappedAddr::Rom(mirror(off, size));
478        }
479
480        MappedAddr::Open
481    }
482
483    fn rom(&self) -> &[u8] {
484        &self.rom
485    }
486    fn sram(&self) -> &[u8] {
487        &self.sram
488    }
489    fn sram_mut(&mut self) -> &mut [u8] {
490        &mut self.sram
491    }
492}
493
494/// ExHiROM (mode `$25`): the extended HiROM layout for >4 MiB titles.
495///
496/// Banks `$80–$FF` address the first 4 MiB; banks `$00–$7D` address the extra (high) 4 MiB.
497/// The ROM offset's bit 22 is the inverse of address bit 23, so the high banks select the
498/// upper image half. See `docs/cart.md` §ExHiROM.
499#[derive(Debug, Clone)]
500pub struct ExHiRom {
501    rom: Box<[u8]>,
502    sram: Box<[u8]>,
503}
504
505impl ExHiRom {
506    /// Construct an ExHiROM board from owned ROM + (zeroed, header-sized) SRAM.
507    #[must_use]
508    pub const fn new(rom: Box<[u8]>, sram: Box<[u8]>) -> Self {
509        Self { rom, sram }
510    }
511}
512
513impl Board for ExHiRom {
514    fn name(&self) -> &'static str {
515        "ExHiROM"
516    }
517
518    fn map(&self, addr24: u32) -> MappedAddr {
519        let bank = (addr24 >> 16) & 0xFF;
520        let addr = addr24 & 0xFFFF;
521
522        // SRAM: banks $20–$3F / $A0–$BF, $6000–$7FFF (HiROM-style), present only on $00–$3F
523        // half where ROM isn't already mapped at $6000.
524        if !self.sram.is_empty() && (0x20..=0x3F).contains(&(bank & 0x7F)) {
525            // On ExHiROM the SRAM appears only when the bank's $8000 window isn't ROM; here we
526            // expose it at $6000–$7FFF of banks $20–$3F / $A0–$BF, matching HiROM SRAM windows.
527            if (0x6000..0x8000).contains(&addr) && bank < 0x80 {
528                let idx = ((bank & 0x3F) - 0x20) * 0x2000 + (addr - 0x6000);
529                #[allow(clippy::cast_possible_truncation)]
530                let len = self.sram.len() as u32;
531                return MappedAddr::Sram(idx % len);
532            }
533        }
534
535        #[allow(clippy::cast_possible_truncation)]
536        let size = self.rom.len() as u32;
537        let lo = bank & 0x7F;
538
539        // bit 22 of the ROM offset = inverse of A23 (i.e. of bank bit 7). Banks $80–$FF (A23=1)
540        // → high bit 0 → first 4 MiB; banks $00–$7D (A23=0) → high bit 1 → extra 4 MiB.
541        let high = if bank & 0x80 != 0 { 0 } else { 1u32 << 22 };
542
543        // Linear ROM region: banks $40–$7D / $C0–$FF, full 64 KiB.
544        if (0x40..=0x7D).contains(&lo) || bank >= 0xC0 {
545            let off = high | ((bank & 0x3F) << 16) | addr;
546            return MappedAddr::Rom(mirror(off, size));
547        }
548
549        // Windowed ROM: banks $00–$3F / $80–$BF, $8000–$FFFF.
550        if lo < 0x40 && addr >= 0x8000 {
551            let off = high | ((bank & 0x3F) << 16) | addr;
552            return MappedAddr::Rom(mirror(off, size));
553        }
554
555        MappedAddr::Open
556    }
557
558    fn rom(&self) -> &[u8] {
559        &self.rom
560    }
561    fn sram(&self) -> &[u8] {
562        &self.sram
563    }
564    fn sram_mut(&mut self) -> &mut [u8] {
565        &mut self.sram
566    }
567}
568
569/// ExLoROM (unofficial — no dedicated `$xFD5` mode value): the extended LoROM layout for
570/// titles over 4 MiB that keep LoROM's 32 KiB bank windowing instead of switching to HiROM's
571/// linear banks.
572///
573/// Banks `$80–$FF` address the first 4 MiB; banks `$00–$7D` address the extra (high) 4 MiB —
574/// the same A23-inverted high/low bank split [`ExHiRom`] uses, applied to LoROM's `$8000`
575/// window instead of HiROM's full bank.
576///
577/// No real ExLoROM ROM (commercial or homebrew) exists in this project's local corpus, so this
578/// board has no golden-framebuffer validation — see `docs/adr/0003`. The formula is sourced
579/// directly from bsnes's own runtime board database (`board: EXLOROM` / `EXLOROM-RAM`,
580/// `target-bsnes/resource/system/boards.bml`: `map address=00-7d:8000-ffff mask=0x808000
581/// base=0x400000` / `map address=80-ff:8000-ffff mask=0x808000 base=0x000000`), decoded against
582/// `Bus::reduce`'s bit-packing algorithm (`sfc/memory/memory.cpp`) rather than guessed — not
583/// from the header-detection heuristic alone. See `docs/cart.md` §ExLoROM.
584#[derive(Debug, Clone)]
585pub struct ExLoRom {
586    rom: Box<[u8]>,
587    sram: Box<[u8]>,
588}
589
590impl ExLoRom {
591    /// Construct an ExLoROM board from owned ROM + (zeroed, header-sized) SRAM.
592    #[must_use]
593    pub const fn new(rom: Box<[u8]>, sram: Box<[u8]>) -> Self {
594        Self { rom, sram }
595    }
596}
597
598impl Board for ExLoRom {
599    fn name(&self) -> &'static str {
600        "ExLoROM"
601    }
602
603    fn map(&self, addr24: u32) -> MappedAddr {
604        let bank = (addr24 >> 16) & 0xFF;
605        let addr = addr24 & 0xFFFF;
606        let lo = bank & 0x7F;
607
608        // SRAM: banks $70–$7D and $F0–$FF, $0000–$7FFF (LoROM-style window; when present). The
609        // two ranges are checked against the RAW bank (not `lo = bank & 0x7F`), since folding
610        // $F0–$FF through `lo` would wrongly exclude $FE/$FF (`lo` = $7E/$7F, outside $70–$7D).
611        // The two ranges use independent 0-based slot numbers; `idx % len` washes out any slot
612        // difference for a power-of-two SRAM size (the only kind `Header::parse` ever produces).
613        if !self.sram.is_empty() && ((0x70..=0x7D).contains(&bank) || bank >= 0xF0) && addr < 0x8000
614        {
615            let slot = if bank < 0x80 {
616                bank - 0x70
617            } else {
618                bank - 0xF0
619            };
620            let idx = slot * 0x8000 + addr;
621            #[allow(clippy::cast_possible_truncation)]
622            let len = self.sram.len() as u32;
623            return MappedAddr::Sram(idx % len);
624        }
625
626        // ROM: $8000–$FFFF of every bank EXCEPT $7E–$7F (reserved for console WRAM, matching
627        // ExHiROM's `lo < 0x40` window boundary already excluding them). Bit 22 of the offset is
628        // the inverse of A23 (bank bit 7) — banks $80–$FF select the first 4 MiB, banks $00–$7D
629        // select the extra 4 MiB (same inversion as ExHiROM's `high`, just added to the LoROM
630        // packed offset instead of the HiROM one).
631        if addr >= 0x8000 && (bank & 0x80 != 0 || lo <= 0x7D) {
632            let high = if bank & 0x80 != 0 { 0 } else { 1u32 << 22 };
633            let off = high | (lo << 15) | (addr & 0x7FFF);
634            #[allow(clippy::cast_possible_truncation)]
635            let size = self.rom.len() as u32;
636            return MappedAddr::Rom(mirror(off, size));
637        }
638
639        MappedAddr::Open
640    }
641
642    fn rom(&self) -> &[u8] {
643        &self.rom
644    }
645    fn sram(&self) -> &[u8] {
646        &self.sram
647    }
648    fn sram_mut(&mut self) -> &mut [u8] {
649        &mut self.sram
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    fn boxed(v: alloc::vec::Vec<u8>) -> Box<[u8]> {
658        v.into_boxed_slice()
659    }
660
661    #[test]
662    fn base_boards_default_no_coprocessor() {
663        let lo = LoRom::new(boxed(vec![0; 0x8000]), boxed(vec![]));
664        assert_eq!(lo.coprocessor(), Coprocessor::None);
665        let hi = HiRom::new(boxed(vec![0; 0x8000]), boxed(vec![]));
666        assert!(!hi.irq_pending());
667        let mut ex = ExHiRom::new(boxed(vec![0; 0x8000]), boxed(vec![]));
668        ex.coprocessor_tick();
669        ex.notify_scanline();
670        ex.notify_cpu_cycle();
671    }
672
673    #[test]
674    fn default_no_op_save_state_round_trips_on_a_rom_only_board() {
675        // T-52-002's stated acceptance criterion: the default no-op impl is exercised for at
676        // least one ROM-only board (no coprocessor state beyond what System::save_state already
677        // captures via Board::sram directly).
678        let mut b = LoRom::new(boxed(vec![0; 0x8000]), boxed(vec![0; 0x2000]));
679        let mut w = SaveWriter::new();
680        b.save_state(&mut w);
681        let bytes = w.into_bytes();
682        assert!(bytes.is_empty(), "the default no-op must write nothing");
683        let mut r = SaveReader::new(&bytes);
684        b.load_state(&mut r).unwrap();
685        assert_eq!(r.remaining(), 0);
686    }
687
688    #[test]
689    fn lorom_decode_and_windowing() {
690        // 64 KiB ROM = two 32 KiB banks. Mark distinctive bytes.
691        let mut rom = vec![0u8; 0x1_0000];
692        rom[0x0000] = 0xAA; // bank $00:$8000
693        rom[0x7FFF] = 0xBB; // bank $00:$FFFF
694        rom[0x8000] = 0xCC; // bank $01:$8000
695        let mut b = LoRom::new(boxed(rom), boxed(vec![]));
696
697        assert_eq!(b.read24(0x00_8000), 0xAA);
698        assert_eq!(b.read24(0x00_FFFF), 0xBB);
699        assert_eq!(b.read24(0x01_8000), 0xCC);
700        // $80 mirror of $00.
701        assert_eq!(b.read24(0x80_8000), 0xAA);
702        // $0000–$7FFF of a ROM bank is open bus (no SRAM here).
703        assert_eq!(b.map(0x00_0000), MappedAddr::Open);
704    }
705
706    #[test]
707    fn lorom_sram_roundtrip() {
708        let rom = vec![0u8; 0x1_0000];
709        let sram = vec![0u8; 0x2000]; // 8 KiB
710        let mut b = LoRom::new(boxed(rom), boxed(sram));
711        // bank $70:$0000.
712        b.write24(0x70_0000, 0x42);
713        assert_eq!(b.read24(0x70_0000), 0x42);
714        // mirror at $F0.
715        assert_eq!(b.read24(0xF0_0000), 0x42);
716        assert_eq!(b.sram()[0], 0x42);
717    }
718
719    #[test]
720    fn hirom_decode_linear_and_window() {
721        // 64 KiB ROM. $C0:$0000 should be ROM offset 0; $00:$8000 should be ROM offset $8000.
722        let mut rom = vec![0u8; 0x1_0000];
723        rom[0x0000] = 0x11; // C0:0000
724        rom[0x8000] = 0x22; // C0:8000 and 00:8000
725        let mut b = HiRom::new(boxed(rom), boxed(vec![]));
726
727        assert_eq!(b.read24(0xC0_0000), 0x11);
728        assert_eq!(b.read24(0xC0_8000), 0x22);
729        // $00:$8000 windows the same linear offset $8000.
730        assert_eq!(b.read24(0x00_8000), 0x22);
731        // $00:$0000 is not ROM (no SRAM) → open.
732        assert_eq!(b.map(0x00_0000), MappedAddr::Open);
733    }
734
735    #[test]
736    fn hirom_sram_roundtrip() {
737        let rom = vec![0u8; 0x1_0000];
738        let sram = vec![0u8; 0x2000];
739        let mut b = HiRom::new(boxed(rom), boxed(sram));
740        b.write24(0x20_6000, 0x99);
741        assert_eq!(b.read24(0x20_6000), 0x99);
742        assert_eq!(b.read24(0xA0_6000), 0x99); // mirror
743    }
744
745    #[test]
746    fn non_power_of_two_mirroring() {
747        // 6 MiB image (4 MiB + 2 MiB). Address 5 MiB (0x500000) folds to 4 MiB + 1 MiB = 0x500000
748        // (in range). Address 6 MiB (0x600000) wraps into the 2 MiB tail: -> 0x400000.
749        let size = 0x60_0000;
750        assert_eq!(mirror(0x10_0000, size), 0x10_0000); // in range, identity
751        assert_eq!(mirror(0x60_0000, size), 0x40_0000); // first past end mirrors into tail
752        // pure power-of-two behaves like a mask.
753        assert_eq!(mirror(0x1_0001, 0x1_0000), 0x0001);
754    }
755
756    #[test]
757    fn exhirom_high_and_low_banks() {
758        // 8 MiB ROM. $C0:$0000 → first 4 MiB offset 0. $40:$0000 → extra 4 MiB offset 0x400000.
759        let mut rom = vec![0u8; 0x80_0000];
760        rom[0x0000] = 0x55; // first half base
761        rom[0x40_0000] = 0x66; // extra half base
762        let mut b = ExHiRom::new(boxed(rom), boxed(vec![]));
763        assert_eq!(b.read24(0xC0_0000), 0x55);
764        assert_eq!(b.read24(0x40_0000), 0x66);
765    }
766
767    #[test]
768    fn exlorom_high_and_low_banks() {
769        // 8 MiB ROM. Bank $80:$8000 -> first 4 MiB (offset 0). Bank $00:$8000 -> extra 4 MiB
770        // (offset 0x400000). Bank $01:$8000 -> extra 4 MiB + one 32 KiB window (0x400000+0x8000).
771        let mut rom = vec![0u8; 0x80_0000];
772        rom[0x00_0000] = 0x55; // first half base
773        rom[0x40_0000] = 0x66; // extra half base
774        rom[0x40_8000] = 0x77; // extra half, bank $01's window
775        let mut b = ExLoRom::new(boxed(rom), boxed(vec![]));
776        assert_eq!(b.read24(0x80_8000), 0x55);
777        assert_eq!(b.read24(0x00_8000), 0x66);
778        assert_eq!(b.read24(0x01_8000), 0x77);
779        // $0000-$7FFF of a bank is open bus (no SRAM here).
780        assert_eq!(b.map(0x80_0000), MappedAddr::Open);
781        // Banks $7E/$7F are reserved for console WRAM and are never cart ROM.
782        assert_eq!(b.map(0x7E_8000), MappedAddr::Open);
783        assert_eq!(b.map(0x7F_8000), MappedAddr::Open);
784    }
785
786    #[test]
787    fn exlorom_sram_roundtrip() {
788        let rom = vec![0u8; 0x80_0000];
789        let sram = vec![0u8; 0x2000];
790        let mut b = ExLoRom::new(boxed(rom), boxed(sram));
791        b.write24(0x70_0000, 0x42);
792        assert_eq!(b.read24(0x70_0000), 0x42);
793        assert_eq!(b.read24(0xF0_0000), 0x42); // mirror
794        assert_eq!(b.sram()[0], 0x42);
795    }
796
797    #[test]
798    fn exlorom_sram_covers_fe_and_ff() {
799        // $F0-$FF (16 banks) is asymmetric with $70-$7D (14 banks) — $FE/$FF have no low-half
800        // counterpart, so `lo = bank & 0x7F` (which folds them to $7E/$7F) must not be used to
801        // gate the SRAM window, or these two banks silently lose their SRAM mapping.
802        let rom = vec![0u8; 0x80_0000];
803        let sram = vec![0u8; 0x2000];
804        let mut b = ExLoRom::new(boxed(rom), boxed(sram));
805        b.write24(0xFE_0000, 0x11);
806        assert_eq!(b.read24(0xFE_0000), 0x11);
807        b.write24(0xFF_0000, 0x22);
808        assert_eq!(b.read24(0xFF_0000), 0x22);
809    }
810}