Skip to main content

rustynes_mappers/
mapper.rs

1//! `Mapper` trait + supporting `MapperError` type.
2//!
3//! Concrete mappers live in their own modules (e.g. `nrom`). The trait
4//! interface follows `docs/mappers.md` §Interfaces; the rationale for
5//! mapper-resident IRQ logic is in `docs/mappers.md` §IRQ counter mechanisms.
6
7use alloc::{string::String, vec::Vec};
8use thiserror::Error;
9
10use crate::cartridge::Mirroring;
11
12/// Per-tile extended-attribute / extended-bank info supplied by mappers that
13/// implement an extended attribute mode (currently MMC5 in `$5104` mode 01).
14///
15/// The PPU consults this at the nametable-byte fetch boundary of each BG
16/// tile. When `Some`, the PPU:
17///
18/// - Replaces the standard 2-bit attribute-derived palette with [`Self::palette`].
19/// - Routes the BG pattern fetch for that tile through [`Self::chr_bank`]
20///   (a 12-bit physical 4 KiB bank index — see MMC5 `ExGrafix` mode docs).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct ExAttribute {
23    /// 2-bit palette select (replaces the AT-byte palette).
24    pub palette: u8,
25    /// 12-bit physical CHR bank for this tile (in 4 KiB units). Combined with
26    /// the fine-Y / column offset by the mapper at fetch time.
27    pub chr_bank: u16,
28}
29
30/// Per-tile override produced by a mapper that implements a vertical
31/// split-screen mode (currently MMC5 via `$5200`-`$5202`).
32///
33/// MMC5's split allows a vertical band of the screen to render from an
34/// independently scrolled "alt region" backed by ExRAM-as-nametable and a
35/// dedicated 4 KiB CHR bank. The PPU consults
36/// [`Mapper::bg_split_state`] once per 8-dot BG fetch group (at the NT-byte
37/// fetch boundary). When `Some(...)` is returned, the PPU uses the supplied
38/// nametable address, attribute address, and fine-Y in place of its own
39/// loopy-v derivation for that tile, and the mapper internally latches the
40/// CHR bank for the following BG pattern fetches.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct BgSplitState {
43    /// Synthesized nametable byte address in `$2000-$3EFF`, computed from
44    /// the alt-region scroll and the current coarse-X.
45    pub nt_addr: u16,
46    /// Synthesized attribute byte address in the same nametable.
47    pub at_addr: u16,
48    /// Fine-Y (0..=7) within the alt-region's logical row.
49    pub fine_y: u8,
50    /// 4 KiB CHR bank index (from `$5202` for MMC5) for the alt region's
51    /// BG pattern fetches.
52    pub chr_bank: u8,
53}
54
55/// Read-only debug snapshot of mapper-internal state for the UI.
56///
57/// Mappers override [`Mapper::debug_info`] to surface their banking
58/// registers and any IRQ-counter state. Fields are pre-formatted strings
59/// so the UI doesn't have to know the protocol of each mapper.
60#[derive(Debug, Default, Clone)]
61pub struct MapperDebugInfo {
62    /// Mapper id (e.g. `4` for MMC3).
63    pub mapper_id: u16,
64    /// Human-readable mapper name (e.g. `"MMC3 (Sharp)"`).
65    pub name: String,
66    /// Current mirroring layout name (`"Horizontal"`, `"Vertical"`,
67    /// `"SingleScreen"`, `"FourScreen"`).
68    pub mirroring: &'static str,
69    /// PRG bank registers — one (label, value) entry per register the
70    /// mapper exposes. Values are hex-formatted by the mapper.
71    pub prg_banks: Vec<(String, String)>,
72    /// CHR bank registers — same shape as `prg_banks`.
73    pub chr_banks: Vec<(String, String)>,
74    /// IRQ counter state — `(label, value)` pairs, e.g.
75    /// `[("counter", "0x12"), ("reload", "0x80"), ("enabled", "true")]`.
76    pub irq_state: Vec<(String, String)>,
77    /// Free-form extra status (envelope shape, sub-mapper flags, ...).
78    pub extra: Vec<(String, String)>,
79    // v1.5.0 "Lens" Workstream I8 — cartridge-level metadata, populated by the
80    // bus (it owns the `Cartridge`) when it builds the debug view, NOT by each
81    // mapper. All default to empty / 0 / None so a mapper's own `debug_info()`
82    // (which leaves these untouched) is unchanged, and the headless / no-pack
83    // path is byte-identical (this is an output-only inspection struct).
84    /// NES 2.0 submapper id (0 for iNES 1.0).
85    pub submapper: u8,
86    /// Accuracy-evidence tier name (`"Core"` / `"Curated"` / `"BestEffort"`),
87    /// empty if the id isn't classified.
88    pub tier: &'static str,
89    /// PRG-ROM size in bytes.
90    pub prg_rom_size: usize,
91    /// CHR-ROM size in bytes (0 when the board uses CHR-RAM).
92    pub chr_rom_size: usize,
93    /// Requested PRG-RAM size in bytes.
94    pub prg_ram_size: usize,
95    /// Requested CHR-RAM size in bytes (0 when the board ships CHR-ROM).
96    pub chr_ram_size: usize,
97    /// True when PRG-RAM is battery-backed (save RAM / NVRAM present).
98    pub has_battery: bool,
99    /// The IRQ mechanism, when the board has one (e.g. `"PPU A12 (MMC3)"`,
100    /// `"PPU scanline (MMC5)"`, `"CPU cycle (VRC/FME-7/N163)"`). Empty if the
101    /// board has no IRQ source.
102    pub irq_kind: &'static str,
103    /// On-cart expansion-audio chip name, if any (e.g. `"VRC6"`).
104    pub expansion_audio: Option<&'static str>,
105}
106
107/// APU frame-counter event mask fanned out to the mapper, used by on-cart
108/// audio extensions (MMC5, FDS, …) whose internal envelopes / length counters
109/// share the CPU's frame-counter clocks.
110///
111/// The bus calls [`Mapper::notify_frame_event`] once per CPU cycle, passing
112/// the events the APU's frame counter fired on the same cycle. For mappers
113/// without on-cart audio (or with audio that doesn't use envelopes / length
114/// counters, like VRC6) the default no-op impl is used.
115///
116/// The struct shape matches `rustynes_apu::frame_counter::FrameEvents` 1:1; the
117/// duplication is deliberate — `rustynes-mappers` does not depend on `rustynes-apu`,
118/// and the bus is responsible for translating between the two types.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
120pub struct MapperFrameEvents {
121    /// Quarter-frame: clock envelopes.
122    pub quarter: bool,
123    /// Half-frame: clock length counters (and sweeps, for 2A03 — but MMC5
124    /// pulses have no sweep unit).
125    pub half: bool,
126}
127
128/// Errors produced by mapper save-state load.
129#[derive(Debug, Error)]
130#[non_exhaustive]
131pub enum MapperError {
132    /// Save-state blob is not the length this mapper's state has: shorter
133    /// or longer. (Until v2.9.9 the message said "truncated" for a long blob
134    /// too; NC-14. Until v3.0.0 the variant itself was named `Truncated`,
135    /// which said the opposite of half its uses; T-API-EXTENSIBLE.)
136    #[error("mapper save state has the wrong length: expected {expected} bytes, got {got}")]
137    WrongLength {
138        /// Expected byte count.
139        expected: usize,
140        /// Actual byte count.
141        got: usize,
142    },
143
144    /// Save-state blob carries an unknown version tag.
145    #[error("mapper save state has unsupported version {0}")]
146    UnsupportedVersion(u8),
147
148    /// Save-state blob would put the mapper into an inconsistent state.
149    #[error("mapper save state invalid: {0}")]
150    Invalid(String),
151}
152
153/// v2.8.0 Phase 4 — per-CPU-cycle mapper capability flags (see
154/// [`Mapper::caps`]). Each flag corresponds to one of the four hooks the
155/// bus would otherwise virtually dispatch every CPU cycle.
156// Four INDEPENDENT capability bits, one per skippable hook — a bitflags
157// type would obscure the 1:1 hook mapping for zero gain.
158#[allow(clippy::struct_excessive_bools)]
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub struct MapperCaps {
161    /// The mapper overrides [`Mapper::notify_cpu_cycle`] (CPU-clocked IRQ
162    /// counters: VRC4/6/7, FME-7, Namco 163, MMC3's A12 filter clock, …).
163    pub cpu_cycle_hook: bool,
164    /// The mapper overrides [`Mapper::mix_audio`] (on-cart expansion audio;
165    /// only meaningful when the `mapper-audio` feature is compiled in).
166    pub audio: bool,
167    /// The mapper overrides [`Mapper::notify_frame_event`] (MMC5 audio's
168    /// frame-counter-cadenced envelope/length clocks).
169    pub frame_event_hook: bool,
170    /// The mapper overrides [`Mapper::irq_pending`] (it can assert IRQs).
171    pub irq_source: bool,
172}
173
174impl MapperCaps {
175    /// Every hook dispatched — the safe default for unannotated mappers.
176    pub const ALL: Self = Self {
177        cpu_cycle_hook: true,
178        audio: true,
179        frame_event_hook: true,
180        irq_source: true,
181    };
182    /// No hooks — discrete boards (NROM/UxROM/CNROM/AxROM/…) with no IRQ,
183    /// no audio, and no per-cycle state.
184    pub const NONE: Self = Self {
185        cpu_cycle_hook: false,
186        audio: false,
187        frame_event_hook: false,
188        irq_source: false,
189    };
190    /// CPU-cycle hook + IRQ source (the common IRQ-mapper shape).
191    pub const CYCLE_IRQ: Self = Self {
192        cpu_cycle_hook: true,
193        audio: false,
194        frame_event_hook: false,
195        irq_source: true,
196    };
197}
198
199/// Trait implemented by every cartridge mapper.
200///
201/// Visible read/write addresses follow `docs/architecture.md`
202/// §Per-memory-access fanout. The PPU bus call covers the whole
203/// `$0000-$3FFF` address space because nametable mirroring is mapper-controlled
204/// (see `docs/mappers.md` §Mirroring).
205///
206/// IRQ-emitting mappers signal pending IRQs via [`Mapper::irq_pending`]; the
207/// CPU bus polls this on the same cycle it polls APU/external IRQs.
208///
209/// All trait methods are `&mut self` because every mapper has at least some
210/// internal state — open-bus latch, bank registers, IRQ counters, etc. —
211/// even on an apparent read.
212pub trait Mapper: Send {
213    /// Read a byte from the CPU address space `$4020-$FFFF`.
214    fn cpu_read(&mut self, addr: u16) -> u8;
215
216    /// Write a byte to the CPU address space `$4020-$FFFF`.
217    fn cpu_write(&mut self, addr: u16, value: u8);
218
219    /// Returns `true` when `addr` is **not** wired to mapper-resident
220    /// memory — i.e. when `cpu_read(addr)` returns junk and the bus
221    /// should fall through to the open-bus latch instead of overwriting
222    /// it. The CPU databus is left floating in this case, so the most
223    /// recently driven byte stays visible to the next read.
224    ///
225    /// Default impl covers stock NROM-class boards: the entire
226    /// `$4020-$5FFF` window is unmapped (no PRG-RAM, no mapper
227    /// registers), and `$6000-$7FFF` is unmapped exactly when the board has
228    /// no save RAM to put there ([`Self::sram`] is empty). `$8000-$FFFF` is
229    /// PRG-ROM and always mapped. Mappers that DO map any subset of
230    /// `$4020-$5FFF` (MMC5 audio + `ExRAM`, FME-7 IRQ control, VRC family
231    /// register banks, etc.) must override this to return `false` for their
232    /// mapped sub-ranges so the bus uses the real value; a board with ROM or
233    /// readable registers at `$6000-$7FFF` but no save RAM must override it
234    /// for that window.
235    ///
236    /// The `$6000-$7FFF` rule is v2.7.2 (core audit §5.5). Before it, every
237    /// board without RAM there read a made-up `$00` instead of open bus; a
238    /// sweep over every mapper number found 205 board variants doing so
239    /// (`tests/prg_ram_window_open_bus.rs`). It leans on v2.7.1's contract that
240    /// every board holding save RAM exposes it through `sram()`
241    /// (`tests/battery_sram_exposed.rs`), and the same test fails any board
242    /// the rule would float while it actually drives data there.
243    ///
244    /// This is the canonical hardware oracle for `AccuracyCoin`'s
245    /// `CPU Behavior :: Open Bus` Test 1 (`LDA $5000` should read
246    /// `$50`, not `$00`).
247    fn cpu_read_unmapped(&self, addr: u16) -> bool {
248        match addr {
249            0x4020..=0x5FFF => true,
250            0x6000..=0x7FFF => self.sram().is_empty(),
251            _ => false,
252        }
253    }
254
255    /// A CPU read the mapper declined ([`Self::cpu_read_unmapped`]) has just
256    /// completed, and `value` is what floated on the bus.
257    ///
258    /// Only GTROM (mapper 111) uses it: its register latches on any CPU access
259    /// to its window, so "reading from the register effectively writes the
260    /// value of open bus" (`nesdev_wiki/output/GTROM.md`). Called only on the
261    /// unmapped path, so PRG fetches never pay for it. Default: nothing.
262    /// Added in v2.9.6.
263    fn notify_floating_read(&mut self, _addr: u16, _value: u8) {}
264
265    /// The CPU wrote `value` to the PPU register window (`$2000-$3FFF`), at
266    /// the undecoded address `addr`.
267    ///
268    /// Only MMC5 (mapper 5) uses it: the chip is "known to listen to the same address
269    /// as the PPU to find out when to enable the 8x16 sprite mode", decoding
270    /// `$2000` and `$2001` fully, so a write to a mirror such as `$2008` is
271    /// not seen (`nesdev_wiki/output/MMC5.md`). The address is passed
272    /// undecoded for that reason. Called once per PPU register write, never
273    /// per cycle. Default: nothing. Added in v2.9.9.
274    fn notify_ppu_register_write(&mut self, _addr: u16, _value: u8) {}
275
276    /// Whether PPU `$3000-$3EFF` is independent RAM on this cartridge rather
277    /// than a mirror of `$2000-$2EFF`.
278    ///
279    /// When `true`, [`Self::nametable_fetch`] and [`Self::nametable_write`]
280    /// receive `$3000-$3EFF` unfolded. Two boards need it: GTROM's "bonus RAM"
281    /// (`GTROM.md`) and UNROM 512's four-screen board (`UNROM_512.md`), whose
282    /// nametable RAM covers the whole `$2000-$3EFF` window. Only `$2007`
283    /// accesses reach `$3xxx` (rendering fetches never do), and the PPU asks
284    /// only for those, so rendering pays nothing. Default: `false`, the
285    /// console's own mirroring. Added in v2.9.6.
286    fn nametable_unfolded(&self) -> bool {
287        false
288    }
289
290    /// Which data bits a mapped read in the register window (`$4020-$5FFF`)
291    /// actually drives; the rest float and keep the bus's open-bus value.
292    ///
293    /// A register that drives only part of the byte is common on cheap ASICs:
294    /// the Sachen SA-020A (mappers 150 / 243) returns its 3-bit registers on
295    /// D2-D0 and leaves D7-D3 floating (`nesdev_wiki/INES_Mapper_150.xhtml`).
296    /// Returning the undriven bits as 0 from [`Self::cpu_read`] would let the
297    /// bus latch those zeros; this mask lets it keep what was floating there
298    /// instead (core audit §4.5).
299    ///
300    /// Consulted only for `$4020-$5FFF`, so it costs nothing on the
301    /// `$6000-$FFFF` fetches that dominate CPU time. Default: all 8 bits.
302    fn cpu_read_driven_mask(&self, _addr: u16) -> u8 {
303        0xFF
304    }
305
306    /// Read a byte from the PPU address space `$0000-$3FFF` (pattern table
307    /// + nametable mirror window). Used as the BG-side / generic fetch path.
308    fn ppu_read(&mut self, addr: u16) -> u8;
309
310    /// Read a byte from the PPU pattern-table window (`$0000-$1FFF`) on
311    /// behalf of a *sprite* tile fetch. MMC5 in 8x16 sprite mode uses a
312    /// separate set of CHR bank registers (`$5120-$5127`) for sprite
313    /// fetches; other mappers default to forwarding to [`Mapper::ppu_read`].
314    fn ppu_read_sprite(&mut self, addr: u16) -> u8 {
315        self.ppu_read(addr)
316    }
317
318    /// HD-pack tile identity: the ABSOLUTE post-banking offset into CHR-ROM for a
319    /// pattern-space address `$0000-$1FFF` (`Some(offset)`), or `None` for CHR-RAM
320    /// (content-hashed instead). `tile_index = offset / 16` is the key Mesen uses
321    /// for CHR-ROM `<tile>` replacements. Default `None` so an unported mapper
322    /// falls back to the content-hash path (no worse than before); the common
323    /// CHR-ROM mappers override it by exposing their internal CHR mapping.
324    fn chr_phys(&self, _addr: u16) -> Option<u32> {
325        None
326    }
327
328    /// v3.1.0 (`T-SPRITE-LIMIT`) — whether [`Self::ppu_read`] and
329    /// [`Self::ppu_read_sprite`] on `$0000-$1FFF` change nothing but return a
330    /// byte. `true` (the default) lets the PPU's "disable sprite limit" option
331    /// make extra, display-only pattern reads on this board.
332    ///
333    /// Override to `false` for any board whose CHR read has an effect: a latch
334    /// that switches banks on a tile (MMC2, MMC4), an IRQ counter clocked by
335    /// reads (the J.Y. ASIC), or address bits latched from the read (Bandai
336    /// 96, Nanjing 163). `every_board_that_claims_pure_chr_reads_has_them`
337    /// checks the claim against `save_state` for every mapper id, so a new
338    /// impure board that keeps the default fails a test rather than letting
339    /// the option change emulation.
340    fn chr_reads_are_pure(&self) -> bool {
341        true
342    }
343
344    /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`, ACC-13) — force an MMC3's IRQ revision
345    /// (`Some`), or return to the one its header selected (`None`). Returns
346    /// whether this board is an MMC3 that applied it; every other board
347    /// ignores it (the default). Lets an iNES 1.0 dump, which cannot name its
348    /// MMC3 revision, run under the alternate (`Nec`) behaviour.
349    fn set_mmc3_revision_override(&mut self, _revision: Option<crate::Mmc3Revision>) -> bool {
350        false
351    }
352
353    /// Write a byte to the PPU address space `$0000-$3FFF`.
354    fn ppu_write(&mut self, addr: u16, value: u8);
355
356    /// Optionally synthesize a nametable byte for `addr` ($2000-$3EFF).
357    ///
358    /// When the mapper returns `Some(v)`, the PPU uses `v` directly and
359    /// skips its CIRAM read. MMC5 uses this for "fill mode" (`$5105`
360    /// per-1KiB selector value 0b11), where every nametable-byte fetch
361    /// returns the fill tile (`$5106`) and every attribute-byte fetch
362    /// returns a 2-bit attribute (`$5107`) replicated 4 ways.
363    ///
364    /// Default returns `None` (mapper does not synthesize; PPU reads CIRAM).
365    fn nametable_fetch(&mut self, _addr: u16) -> Option<u8> {
366        None
367    }
368
369    /// Optionally absorb a nametable write for `addr` ($2000-$3EFF) directly
370    /// into mapper-resident storage.
371    ///
372    /// Returns `true` if the mapper consumed the write (PPU should NOT also
373    /// write its CIRAM). MMC5 uses this for ExRAM-mapped nametables and for
374    /// fill mode (where writes are silently dropped).
375    ///
376    /// Default returns `false` (PPU continues with the CIRAM write path).
377    fn nametable_write(&mut self, _addr: u16, _value: u8) -> bool {
378        false
379    }
380
381    /// Optionally provide per-tile extended attribute + CHR-bank override for
382    /// the BG tile currently being fetched.
383    ///
384    /// `v` is the PPU's loopy-v register value at NT-byte fetch time
385    /// (`fetch_nt`); the lower 12 bits encode the 32x30 tile coordinate.
386    /// MMC5 in `$5104` mode 01 returns `Some` here. The PPU uses the
387    /// returned palette to override the AT byte. The mapper itself caches
388    /// the `chr_bank` internally so that the subsequent BG pattern fetches
389    /// (`ppu_read` calls during the same 8-dot fetch group) consult it
390    /// instead of the standard BG bank registers.
391    ///
392    /// Default returns `None` (no extended attribute mode active).
393    fn peek_ex_attribute(&mut self, _v: u16) -> Option<ExAttribute> {
394        None
395    }
396
397    /// Optionally redirect a BG fetch group into a vertical split-screen
398    /// "alt region".
399    ///
400    /// Called by the PPU at the NT-byte fetch boundary of each 8-dot BG
401    /// fetch group, once per tile column (32 times per visible scanline).
402    /// `scanline_y` is the current visible-scanline index in 0..=239 (the
403    /// pre-render line passes 0 here to keep the path branchless). `coarse_x`
404    /// is the loopy-v coarse-X (0..=31) for the tile about to be fetched.
405    ///
406    /// Returning `Some(state)` instructs the PPU to use the supplied
407    /// `nt_addr` / `at_addr` / `fine_y` instead of those derived from `v`,
408    /// and instructs the mapper to internally latch the CHR bank for the
409    /// pattern fetches that immediately follow.
410    ///
411    /// Default returns `None`.
412    fn bg_split_state(&mut self, _scanline_y: u16, _coarse_x: u16) -> Option<BgSplitState> {
413        None
414    }
415
416    /// Resolve a nametable address in `$2000-$3EFF` to a CIRAM offset in
417    /// `0..0x800`. The PPU owns the 2 KiB CIRAM and uses this hook to apply
418    /// per-mapper mirroring without giving the mapper direct access to the
419    /// console-side VRAM.
420    ///
421    /// Default impl applies the mirroring reported by [`Mapper::current_mirroring`]
422    /// via [`crate::Mirroring::physical_bank`]. Mappers with on-cart 4-screen
423    /// VRAM (Gauntlet, Rad Racer II) can override this; the lockstep bus
424    /// will trampoline the read/write back through the mapper for any offset
425    /// outside `0..0x800` if needed (Phase 4).
426    #[allow(clippy::cast_possible_truncation)]
427    fn nametable_address(&self, addr: u16) -> u16 {
428        const NAMETABLE_SIZE: u16 = 0x0400;
429        let table = ((addr.wrapping_sub(0x2000)) / NAMETABLE_SIZE) & 0x03;
430        let local = addr & (NAMETABLE_SIZE - 1);
431        // `physical_bank` always returns 0 or 1; the truncation is a no-op.
432        let physical = self.current_mirroring().physical_bank(table as u8) as u16;
433        physical * NAMETABLE_SIZE + local
434    }
435
436    /// Notify of a PPU A12 line transition. Default no-op; MMC3 / MMC5
437    /// override this for IRQ counter clocking.
438    fn notify_a12(&mut self, _level: bool) {}
439
440    /// Notify of a PPU A12 line transition with the current sub-dot of
441    /// the host CPU cycle (0 / 1 = M2-low half; 2 = M2-high half).
442    /// Default impl falls through to [`Self::notify_a12`] so existing
443    /// mappers compile unchanged; MMC3 overrides this for the
444    /// M2-phase-aware IRQ-output propagation delay required by
445    /// `mmc3_test_2/4-scanline_timing` sub-test #3 (C1 step B4 successor).
446    fn notify_a12_at_sub_dot(&mut self, level: bool, _sub_dot: u8) {
447        self.notify_a12(level);
448    }
449
450    /// Notify of a CPU cycle. Default no-op; VRC2/4/6, FME-7, Namco 163
451    /// override this for IRQ counter clocking.
452    fn notify_cpu_cycle(&mut self) {}
453
454    /// The console's RESET button (a soft reset, not a power cycle).
455    ///
456    /// A cartridge sees reset only on the boards that wire the CIC's reset
457    /// line, or /RESET itself, into their logic, so the default does nothing
458    /// and almost every board keeps its registers across a reset, exactly as
459    /// on a console. The boards that clear something are documented per page:
460    /// mapper 37's outer latch (`INES_Mapper_037.md`), mapper 45's outer
461    /// registers, NES-EVENT's PRG lock (mapper 105), the 76-in-1 BMC's two
462    /// registers (mapper 226, since v2.9.8), and Action 52's register
463    /// (mapper 228). Added in v2.9.6; a power cycle rebuilds the mapper
464    /// instead and never calls this.
465    fn reset(&mut self) {}
466
467    /// Notify the mapper of the APU frame-counter events fired on the
468    /// current CPU cycle (quarter-frame envelope clock, half-frame length
469    /// clock). Only on-cart audio extensions that re-use the 2A03 frame
470    /// counter cadence need to handle this (MMC5 audio's two pulse
471    /// channels). Default no-op.
472    fn notify_frame_event(&mut self, _events: MapperFrameEvents) {}
473
474    /// Notify the mapper that the PPU is starting a new rendered scanline.
475    ///
476    /// Called by the PPU at the start of each visible scanline (and the
477    /// pre-render line) before any tile fetches happen. MMC5 uses this to
478    /// drive its scanline IRQ counter (which clocks at PPU cycle 4 of each
479    /// rendered line — different from MMC3's A12-edge-driven counter).
480    ///
481    /// Default is a no-op; only mappers with scanline-counter IRQs override.
482    fn notify_scanline_start(&mut self) {}
483
484    /// Notify the mapper that the PPU has entered vertical blank.
485    ///
486    /// MMC5 uses this to clear its "in-frame" flag (bit 6 of `$5204`).
487    /// Default no-op.
488    fn notify_vblank(&mut self) {}
489
490    /// v2.0.0 beta.5 (Vs. `DualSystem`): provision the board's shared 2 KiB
491    /// work RAM at `$6000-$7FFF` (mirrored across the 8 KiB window — MAME
492    /// `vsnes.cpp`: `map(0x6000, 0x67ff).mirror(0x1800).ram()`). Only the
493    /// Vs. System board (mapper 99) implements this — the `DualSystem`
494    /// cabinets carry a 2 KiB RAM shared between the two consoles, absent
495    /// on `UniSystem` carts (whose `$6000` window stays open bus,
496    /// byte-identically). Called by the `VsDualSystem` wrapper at
497    /// construction on both consoles: each console holds its own COPY, and
498    /// the wrapper converges the copies by draining the write log (below)
499    /// after every stepped instruction — MAME's fully-shared
500    /// `.share("nvram")` model at soft-lockstep granularity. Default no-op.
501    fn enable_vs_dual_wram(&mut self) {}
502
503    /// v2.0.0 beta.5 (Vs. `DualSystem`): mark this mapper instance as the
504    /// cabinet's SUB console — it banks the second 32 KiB PRG half and the
505    /// upper CHR pages (the two CPUs run DIFFERENT programs; MAME
506    /// `balonfgt` loads distinct `sub`-region ROMs, Mesen2 uses
507    /// `prgOuter = IsVsMainConsole() ? 0 : 4`). Applied by the
508    /// `VsDualSystem` wrapper at construction, like the bus's sub
509    /// identity. Default no-op.
510    fn set_vs_dual_sub(&mut self) {}
511
512    /// v2.0.0 beta.5 (Vs. `DualSystem`): drain this console's shared-WRAM
513    /// write log — every `(offset, value)` the CPU wrote to the window
514    /// since the last drain, in order — by APPENDING into `dst` (never
515    /// clearing it first). The wrapper replays them into the partner's
516    /// copy ([`Self::apply_vs_dual_wram_write`]), which is what makes the
517    /// RAM behave as ONE simultaneously-shared memory (MAME's model;
518    /// nesdev also documents a `$4016`-bit-1 access mux, but MAME — where
519    /// the four `DualSystem` games verifiably run — shares the RAM
520    /// unconditionally, and Balloon Fight's boot handshake requires the
521    /// partner to see writes made while the mux would deny it access).
522    ///
523    /// Implementations MUST drain their internal log via `Vec::drain`
524    /// (or equivalent) rather than replacing it, so the log's own
525    /// allocated capacity is retained across calls — `pump_comms` calls
526    /// this after EVERY stepped instruction on a `DualSystem` cart, so a
527    /// reallocating drain here is a real hot-path allocation, not a
528    /// theoretical one. Default: no-op (boards without the dual WRAM).
529    fn drain_vs_dual_wram_writes(&mut self, dst: &mut Vec<(u16, u8)>) {
530        let _ = dst;
531    }
532
533    /// Convenience wrapper around [`Self::drain_vs_dual_wram_writes`] for
534    /// callers that don't already hold a reusable buffer (diagnostics,
535    /// tests) — NOT used by the hot `pump_comms` path, which owns and
536    /// reuses its own scratch buffer instead.
537    fn take_vs_dual_wram_writes(&mut self) -> Vec<(u16, u8)> {
538        let mut writes = Vec::new();
539        self.drain_vs_dual_wram_writes(&mut writes);
540        writes
541    }
542
543    /// v2.0.0 beta.5 (Vs. `DualSystem`): replay one partner-console write
544    /// into this console's copy of the shared WRAM (see
545    /// [`Self::take_vs_dual_wram_writes`]). Does NOT re-log the write (no
546    /// echo loop). Default no-op.
547    fn apply_vs_dual_wram_write(&mut self, offset: u16, value: u8) {
548        let _ = (offset, value);
549    }
550
551    /// v2.0.0 beta.5 (Vs. `DualSystem`): take the console's shared-WRAM copy
552    /// (used by the wrapper's snapshot-restore normalization — the two
553    /// copies are re-converged from one buffer after a restore). Returns
554    /// `None` on boards without the dual WRAM. Default `None`.
555    fn take_vs_dual_wram(&mut self) -> Option<alloc::boxed::Box<[u8]>> {
556        None
557    }
558
559    /// v2.0.0 beta.5 (Vs. `DualSystem`): install a shared-WRAM copy (the
560    /// other half of the restore normalization). Default no-op.
561    // The `Box` is the interface contract (ownership of the one buffer
562    // moves between the two consoles' mappers without copying) — the
563    // default impl merely drops it, which trips `boxed_local` spuriously.
564    #[allow(clippy::boxed_local)]
565    fn set_vs_dual_wram(&mut self, wram: alloc::boxed::Box<[u8]>) {
566        let _ = wram;
567    }
568
569    /// Returns `true` if the mapper is currently asserting an IRQ.
570    fn irq_pending(&self) -> bool {
571        false
572    }
573
574    /// Acknowledge a pending IRQ. Default no-op; mappers that latch IRQ state
575    /// override this.
576    fn irq_acknowledge(&mut self) {}
577
578    /// Return one signed audio sample for mappers with on-cart audio
579    /// (VRC6/7, MMC5, Sunsoft 5B, Namco 163, FDS). Default returns silence.
580    ///
581    /// **`i32`, widened from `i16` in v2.2.3 (A1).** The bus scales this by
582    /// `/ 65536.0` into roughly the same `[-0.5, 0.5]` range as the APU mixer's
583    /// own output, so the old `i16` return capped a chip's representable level
584    /// at `32767 / 65536 ≈ 0.5`. That was fine for every board except the
585    /// Sunsoft 5B, whose logarithmic DAC needs ~3.6x the 2A03 pulse at full
586    /// volume — and three simultaneous full-volume tones (Gimmick!, Hebereke)
587    /// several times more. The 5B's absolute level was therefore a documented,
588    /// deliberately un-calibrated gap purely because the return type could not
589    /// hold it. Widening the type is what unblocks it; see
590    /// `docs/accuracy-ledger.md` §Expansion-audio levels and `SUNSOFT5B_MIX_SCALE`.
591    ///
592    /// Boards other than the 5B return exactly the values they always did —
593    /// the widening is representational only and changes no mixed output.
594    fn mix_audio(&mut self) -> i32 {
595        0
596    }
597
598    /// v2.8.0 Phase 4 — the mapper's per-CPU-cycle capability flags.
599    ///
600    /// The bus fans four virtual calls out to the mapper EVERY CPU cycle
601    /// (~30 k/frame each): [`Self::notify_cpu_cycle`], [`Self::mix_audio`],
602    /// [`Self::notify_frame_event`], and [`Self::irq_pending`]. For most
603    /// boards all four are the default no-ops, so the bus caches these
604    /// flags at construction and skips the dispatch entirely.
605    ///
606    /// The contract is mechanical: a flag may be `false` ONLY when the
607    /// mapper does not override the corresponding default method (skipping
608    /// a default no-op is provably byte-identical). The default returns
609    /// [`MapperCaps::ALL`], so an unannotated mapper keeps every dispatch —
610    /// always correct, just slower.
611    fn caps(&self) -> MapperCaps {
612        MapperCaps::ALL
613    }
614
615    /// Returns the mapper's current effective mirroring layout.
616    ///
617    /// Most mappers report the static mirroring set in the cartridge header;
618    /// mappers with runtime mirroring control (MMC1, MMC3, `AxROM`, ...) report
619    /// the live state.
620    fn current_mirroring(&self) -> Mirroring;
621
622    /// Whether this mapper's nametable mirroring is **hardwired by the
623    /// cartridge** (solder pads / the iNES header bit) rather than controlled
624    /// by the mapper's own registers at runtime.
625    ///
626    /// This gates whether an *external* mirroring correction (e.g. a per-game
627    /// database entry, applied via `Nes::set_mirroring_override`) may
628    /// be honored. A static override is only meaningful for a hardwired board
629    /// whose header bit can be *wrong*; forcing a static mirroring onto a mapper
630    /// that switches mirroring itself (MMC1/3/5, `AxROM`, VRC, Sunsoft FME-7,
631    /// Namco 163, …) **corrupts** its rendering — e.g. `AxROM`'s mid-frame
632    /// single-screen A↔B flip that draws Wizards & Warriors' status bar, whose
633    /// GoodNES-derived game-DB row lists a spurious `Horizontal` that, when
634    /// force-applied, blanks the bottom of the screen and hangs the game.
635    ///
636    /// Default: `false` (assume the mapper controls its own mirroring). This is
637    /// the **safe** default — a mapper that omits an annotation merely declines
638    /// a rarely-needed, cosmetic header correction; it can never break a working
639    /// game. Only the classic fixed-mirroring discrete boards (`NROM`, `UxROM`,
640    /// `CNROM`, `GxROM`, …) override this to `true`.
641    fn has_hardwired_mirroring(&self) -> bool {
642        false
643    }
644
645    // --- Optional Famicom Disk System (FDS) disk interface ---
646    //
647    // Only the FDS device (`fds::Fds`) overrides these; every other mapper uses
648    // the default no-op / empty impls. The bus and `Nes` surface them so a
649    // frontend can drive side-swap and persist a modified disk without
650    // downcasting the `Box<dyn Mapper>`.
651
652    /// Number of disk sides in the inserted image (0 for non-FDS mappers).
653    fn disk_side_count(&self) -> usize {
654        0
655    }
656
657    /// The currently inserted disk side index, or `None` when ejected (or for
658    /// non-FDS mappers).
659    fn inserted_disk_side(&self) -> Option<usize> {
660        None
661    }
662
663    /// Insert disk side `i` (`Some`) or eject the disk (`None`). No-op for
664    /// non-FDS mappers; an out-of-range index is ignored by the FDS device.
665    fn set_disk_side(&mut self, _side: Option<usize>) {}
666
667    /// Returns a reference to the mapper's internal SRAM/PRG-RAM.
668    /// By default, returns an empty slice if unsupported.
669    fn sram(&self) -> &[u8] {
670        &[]
671    }
672
673    /// Returns a mutable reference to the mapper's internal SRAM/PRG-RAM.
674    /// By default, returns an empty mutable slice if unsupported.
675    fn sram_mut(&mut self) -> &mut [u8] {
676        &mut []
677    }
678
679    /// The cartridge's non-volatile data: what a battery save persists and a
680    /// power cycle keeps. For almost every board that is its battery-backed
681    /// RAM, [`Self::sram`], and the default says so.
682    ///
683    /// Self-flashable boards differ (v2.9.6). GTROM and a flashable UNROM 512
684    /// save by rewriting their own PRG flash, so their save is the flash image,
685    /// and they have no RAM at `$6000` at all. Keeping the two apart is the
686    /// point. `sram()` goes on meaning "the RAM in the `$6000` window", which
687    /// the open-bus rule, the libretro memory map and `RetroAchievements` all
688    /// rely on. Only the save paths read this.
689    fn save_data(&self) -> &[u8] {
690        self.sram()
691    }
692
693    /// Mutable [`Self::save_data`], for loading a save.
694    fn save_data_mut(&mut self) -> &mut [u8] {
695        self.sram_mut()
696    }
697
698    /// Return the save data to the state of a cartridge that has never been
699    /// saved to. That is zeroed RAM by default. On a flash board it is the PRG
700    /// image as loaded, since a zero-filled flash would be a ROM with no
701    /// program in it. A power-on movie calls this (`power_on_for_movie`).
702    fn clear_save_data(&mut self) {
703        self.save_data_mut().fill(0);
704    }
705
706    /// Start recording the diagnostic FDS read-stream trace (off by default;
707    /// observation-only). No-op for non-FDS mappers. See [`crate::FdsTraceRec`].
708    fn enable_fds_trace(&mut self) {}
709
710    /// Drain the accumulated FDS read-stream trace records (empty for non-FDS
711    /// mappers / when tracing was never enabled).
712    fn take_fds_trace(&mut self) -> Vec<crate::FdsTraceRec> {
713        Vec::new()
714    }
715
716    /// Re-serialize the (possibly-modified) disk image to its byte layout for
717    /// host persistence. Returns an empty vector for non-FDS mappers.
718    fn disk_image_bytes(&self) -> Vec<u8> {
719        Vec::new()
720    }
721
722    /// Whether the disk image has unsaved writes. Always `false` for non-FDS
723    /// mappers.
724    fn disk_is_dirty(&self) -> bool {
725        false
726    }
727
728    /// Clear the disk dirty flag (a host calls this after persisting). No-op
729    /// for non-FDS mappers.
730    fn clear_disk_dirty(&mut self) {}
731
732    /// Mark the inserted disk read-only (`true`) or writable (`false`). No-op
733    /// for non-FDS mappers.
734    fn set_disk_write_protected(&mut self, _protected: bool) {}
735
736    // --- Optional NSF music-player interface ---
737    //
738    // Only the NSF player (`nsf::NsfMapper`) overrides these; every other mapper
739    // uses the default 0 / no-op impls. The bus and `Nes` surface them so a
740    // frontend can drive track selection without downcasting the boxed mapper.
741
742    /// Number of selectable songs (0 for a non-NSF mapper).
743    fn nsf_song_count(&self) -> u8 {
744        0
745    }
746
747    /// The currently-selected 0-based song (0 for a non-NSF mapper).
748    fn nsf_current_song(&self) -> u8 {
749        0
750    }
751
752    /// Select a 0-based song. Returns `true` if this is an NSF mapper (so the
753    /// caller knows to re-run the reset that re-vectors into the driver's
754    /// `init`). Default no-op returning `false`.
755    fn nsf_set_song(&mut self, _song: u8) -> bool {
756        false
757    }
758
759    /// Encode the mapper's mutable state into a tagged save-state blob.
760    fn save_state(&self) -> Vec<u8>;
761
762    /// Decode a previously [`Mapper::save_state`] blob back into the mapper.
763    ///
764    /// # Errors
765    ///
766    /// Returns [`MapperError`] when the blob is truncated, has the wrong
767    /// version tag, or otherwise fails internal consistency checks.
768    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError>;
769
770    /// Surface read-only debug info for the UI. Override per mapper to
771    /// expose bank registers, IRQ counters, etc. Default returns a
772    /// minimal entry naming the mapper id.
773    fn debug_info(&self) -> MapperDebugInfo {
774        // The bus fills the cartridge-level metadata fields (submapper, tier,
775        // sizes, battery, irq_kind, expansion_audio) — see
776        // `Bus::mapper_debug_info` — so they default here (v1.5.0 I8).
777        MapperDebugInfo {
778            mapper_id: 0,
779            name: "(unknown)".into(),
780            mirroring: mirroring_name(self.current_mirroring()),
781            prg_banks: Vec::new(),
782            chr_banks: Vec::new(),
783            irq_state: Vec::new(),
784            extra: Vec::new(),
785            ..Default::default()
786        }
787    }
788}
789
790/// Helper for mapper `debug_info` overrides.
791#[must_use]
792pub const fn mirroring_name(m: Mirroring) -> &'static str {
793    match m {
794        Mirroring::Horizontal => "Horizontal",
795        Mirroring::Vertical => "Vertical",
796        Mirroring::SingleScreenA => "SingleScreen (A)",
797        Mirroring::SingleScreenB => "SingleScreen (B)",
798        Mirroring::FourScreen => "FourScreen",
799        Mirroring::MapperControlled => "MapperControlled",
800    }
801}
802
803#[cfg(test)]
804mod caps_tests {
805    use super::MapperCaps;
806    use crate::{Mapper, parse};
807    use alloc::{boxed::Box, vec, vec::Vec};
808
809    /// Build a minimal iNES image for `mapper_id` (32 KiB PRG + 8 KiB CHR —
810    /// 32 KiB satisfies every family probed below, incl. `AxROM`'s
811    /// 32-KiB-multiple requirement).
812    fn synth_rom(mapper_id: u8) -> Vec<u8> {
813        let mut rom = vec![
814            b'N',
815            b'E',
816            b'S',
817            0x1A,
818            2, // 32 KiB PRG
819            1, // 8 KiB CHR
820            (mapper_id << 4),
821            (mapper_id & 0xF0),
822        ];
823        rom.resize(16, 0);
824        rom.resize(16 + 32 * 1024 + 8 * 1024, 0);
825        rom
826    }
827
828    fn caps_of(mapper_id: u8) -> MapperCaps {
829        let (_cart, mapper): (_, Box<dyn Mapper>) =
830            parse(&synth_rom(mapper_id)).expect("synth rom parses");
831        mapper.caps()
832    }
833
834    /// v3.1.0 (`T-SPRITE-LIMIT`): every board that reports
835    /// `chr_reads_are_pure` really has pure CHR reads. The PPU's "disable
836    /// sprite limit" option makes extra pattern reads exactly where this is
837    /// `true`, so a wrong `true` would let a display option change emulation.
838    ///
839    /// For every mapper id the parser builds from a synthetic ROM (iNES ids
840    /// 0-255 and NES 2.0 ids 256-4095), read all of CHR through both entry
841    /// points and require `save_state` to be unchanged. The five boards that
842    /// report `false` were found by a scan of every `ppu_read` body for writes
843    /// to `self` (v3.1.0); this test is what keeps a sixth from keeping the
844    /// default. It cannot see state a board leaves out of `save_state`, which
845    /// would already be a save-state defect of its own.
846    #[test]
847    fn every_board_that_claims_pure_chr_reads_has_them() {
848        fn nes2_rom(id: u16) -> Vec<u8> {
849            let [lo, hi] = id.to_le_bytes();
850            let mut rom = synth_rom(lo);
851            rom[7] = (rom[7] & 0xF0) | 0x08; // NES 2.0 identifier
852            rom[8] = hi & 0x0F; // mapper bits 8-11
853            rom
854        }
855        let mut checked = 0usize;
856        let mut impure = Vec::new();
857        for id in 0u16..4096 {
858            let rom = u8::try_from(id).map_or_else(|_| nes2_rom(id), synth_rom);
859            let Ok((_cart, mut mapper)) = parse(&rom) else {
860                continue;
861            };
862            if !mapper.chr_reads_are_pure() {
863                impure.push(id);
864                continue;
865            }
866            let before = mapper.save_state();
867            for addr in 0..0x2000u16 {
868                let _ = mapper.ppu_read(addr);
869                let _ = mapper.ppu_read_sprite(addr);
870            }
871            assert_eq!(
872                before,
873                mapper.save_state(),
874                "mapper {id} reports pure CHR reads, but reading CHR changed its state"
875            );
876            checked += 1;
877        }
878        assert!(checked > 150, "only {checked} boards were checked");
879        assert_eq!(
880            impure,
881            vec![9, 10, 35, 90, 96, 163, 209, 211],
882            "the impure set moved"
883        );
884    }
885
886    /// v2.8.0 Phase 4 — the capability-flag contract for the key families.
887    /// A flag may be `false` ONLY when the mapper does not override the
888    /// corresponding default no-op; these spot checks pin the mechanical
889    /// derivation for the highest-population boards.
890    #[test]
891    fn caps_match_overridden_hooks_for_key_mappers() {
892        // NROM / UxROM / CNROM / AxROM / GxROM: no hooks at all.
893        for id in [0u8, 2, 3, 7, 66] {
894            assert_eq!(caps_of(id), MapperCaps::NONE, "mapper {id}");
895        }
896        // MMC1: overrides ONLY notify_cpu_cycle (write throttle) — no IRQ.
897        let m1 = caps_of(1);
898        assert!(m1.cpu_cycle_hook && !m1.irq_source && !m1.audio && !m1.frame_event_hook);
899        // MMC3: cycle hook (A12 filter clock) + IRQ source.
900        assert_eq!(caps_of(4), MapperCaps::CYCLE_IRQ);
901        // MMC5: cycle + IRQ + frame-event hook (+ audio when compiled in).
902        let m5 = caps_of(5);
903        assert!(m5.cpu_cycle_hook && m5.irq_source && m5.frame_event_hook);
904        assert_eq!(m5.audio, cfg!(feature = "mapper-audio"));
905        // VRC6a: cycle + IRQ + audio-when-compiled.
906        let m24 = caps_of(24);
907        assert!(m24.cpu_cycle_hook && m24.irq_source && !m24.frame_event_hook);
908        assert_eq!(m24.audio, cfg!(feature = "mapper-audio"));
909    }
910
911    fn hardwired_of(mapper_id: u8) -> bool {
912        let (_cart, mapper): (_, Box<dyn Mapper>) =
913            parse(&synth_rom(mapper_id)).expect("synth rom parses");
914        mapper.has_hardwired_mirroring()
915    }
916
917    /// Regression guard for the Wizards & Warriors freeze (game-DB `Horizontal`
918    /// force-applied onto `AxROM`'s mapper-controlled single-screen split): a
919    /// mapper that controls its OWN mirroring MUST report
920    /// `has_hardwired_mirroring() == false` so the frontend declines an external
921    /// mirroring override; a fixed-mirroring discrete board MUST report `true`.
922    #[test]
923    fn hardwired_mirroring_gate_matches_board_type() {
924        // Fixed solder-pad mirroring — an external correction is valid.
925        for id in [0u8, 2, 3, 66] {
926            assert!(hardwired_of(id), "mapper {id} must be hardwired-mirroring");
927        }
928        // Mapper-controlled mirroring — an external override would corrupt them.
929        // 7 = AxROM (the W&W bug), 1 = MMC1, 4 = MMC3, 5 = MMC5, 9 = MMC2.
930        for id in [7u8, 1, 4, 5, 9] {
931            assert!(
932                !hardwired_of(id),
933                "mapper {id} controls its own mirroring — must NOT be hardwired"
934            );
935        }
936    }
937}