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}