Skip to content

Mappers and cartridge

References: ref-docs/research-report.md §Cartridge format and mappers; ref-docs/nesdev-wiki-technical-report.md §Common Mapper Families; Nesdev Mapper, Bus conflict, MMC1, MMC3, and MMC5.

Purpose

Implement the cartridge subsystem in crates/rustynes-mappers: a Mapper trait, a Cartridge struct that owns the ROM/RAM banks and a boxed dyn Mapper, and concrete implementations of the top ~25 mappers (covering >95% of the licensed library).

Interfaces

pub trait Mapper: Send {
    fn cpu_read(&mut self, addr: u16) -> u8;          // $4020-$FFFF
    fn cpu_write(&mut self, addr: u16, value: u8);
    fn ppu_read(&mut self, addr: u16) -> u8;          // $0000-$3FFF (CHR + nametable)
    fn ppu_write(&mut self, addr: u16, value: u8);

    fn notify_a12(&mut self, level: bool) {}          // for MMC3/MMC5
    fn notify_cpu_cycle(&mut self) {}                 // for CPU-cycle IRQ counters (VRC, FME-7)
    fn irq_pending(&self) -> bool { false }
    fn irq_acknowledge(&mut self) {}

    fn mix_audio(&mut self) -> i16 { 0 }              // VRC6/7, MMC5, Sunsoft 5B, Namco 163, FDS

    fn save_state(&self) -> Vec<u8>;
    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError>;

    // v3.1.0 (`T-SPRITE-LIMIT`): `false` when a CHR read changes the board.
    fn chr_reads_are_pure(&self) -> bool { true }
}

// `#[non_exhaustive]` since v3.0.0: outside `rustynes-mappers`, build one with
// `rustynes_mappers::parse` or `Cartridge::synthetic`, never a struct literal.
#[non_exhaustive]
pub struct Cartridge {
    pub prg_rom: Box<[u8]>,
    pub chr_rom: Box<[u8]>,          // empty if cart uses CHR-RAM
    pub mapper_id: u16,
    pub submapper: u8,
    pub mirroring: Mirroring,
    pub region: Region,
    pub console_type: ConsoleType,
    pub vs_ppu_type: VsPpuType,
    pub vs_dual_system: bool,
    pub prg_ram_size: u32,
    pub chr_ram_size: u32,
    pub has_battery: bool,
    pub has_trainer: bool,
    pub is_nes2: bool,
    pub nametable_wiring_bits: u8,   // the header's raw nametable bits (v2.9.9)
}

pub enum Mirroring { Horizontal, Vertical, SingleScreenA, SingleScreenB, FourScreen, MapperControlled }

chr_reads_are_pure (v3.1.0). The PPU's "disable sprite limit" option makes extra, display-only pattern reads, and only on boards that report true. Five report false because a CHR read changes them: MMC2 (9) and MMC4 (10) switch a CHR latch on tiles $FD / $FE, the J.Y. ASIC (35, 90, 209, 211) clocks an IRQ counter on PPU reads and mapper 209 latches CHR, Bandai 96 follows the last PPU address for its inner CHR bank, and Nanjing 163 latches PPU A13. A new board whose ppu_read writes self must override it. every_board_that_claims_pure_chr_reads_has_them (in mapper.rs) reads all of CHR on every constructible mapper id that claims purity and fails if save_state moved; it also pins the impure set, so the list here and the code cannot drift apart. The set came from a scan of every ppu_read body for writes to self.

rustynes_mappers::parse(&[u8]) -> Result<(Cartridge, Box<dyn Mapper>), RomError> parses an iNES or NES 2.0 file (see cartridge-format.md), constructs the appropriate concrete mapper, and returns the metadata header together with the boxed mapper as a tuple. The tuple shape is the pragmatic one: the Cartridge is cheap-to-clone metadata (region, mirroring, mapper id, ROM slices) that the bus, save-state path, and debugger all want to inspect without disturbing the mapper, while the Box<dyn Mapper> is the live mutable state. Keeping them as separate ownership roots lets the bus own the mapper exclusively while metadata can be passed by & or cheaply cloned for diagnostics.

State

The Cartridge owns immutable PRG-ROM and CHR-ROM banks plus mutable PRG-RAM and CHR-RAM. The mapper holds:

  • Bank-select registers (one per banking dimension).
  • Mirroring control state (if mapper-controlled).
  • IRQ counter state (latch, counter, enable, pending).
  • For audio mappers: extra channel timers, DAC values, frame counter sub-state.

The mapper does not directly own the ROM bytes — it receives a reference to the cart-owned arrays via constructor. This avoids duplication and keeps the dyn Mapper boxed type small.

Battery saves go through sram() / sram_mut(), and the default is empty. Nes::sram() is the one place a host can read battery memory from, and the libretro core is what persists it today: it registers sram_mut() as RETRO_MEMORY_SAVE_RAM, which RetroArch writes to the game's .srm file (crates/rustynes-libretro/src/lib.rs). A board that holds save memory anywhere else — its own wram field, an on-chip RAM, a serial EEPROM, a wrapped inner mapper — must override the pair, or RetroArch saves an empty file. v2.7.1 found six boards that did not (Bandai FCG's EEPROM, Taito X1-005's 128 bytes, TxSROM and TQROM not forwarding to their MMC3, Multicart 15, and BMC-FK23C).

Every host persists it now. Libretro hands sram() to RetroArch (.srm); the desktop keeps <data_dir>/battery/<rom_sha256>.sav since v2.7.3 (FE-01); Android and iOS keep a .sav per ROM hash since v2.7.4 (MOB-05 / AND-09), through the bridge's battery_ram / load_battery_ram. All of them persist only a cartridge whose header sets the battery bit (Nes::has_battery), since NROM, MMC1 and MMC3 expose work RAM whatever the header says. Before those releases a game's in-cartridge save on the desktop and the phones survived only inside a save state (the v2.7.1 finding).

Since v2.9.6 the hosts persist save_data(), which is sram() except on a self-flashable board. GTROM (111) and a flashable UNROM 512 (30) save by rewriting their PRG flash, have no RAM at $6000, and return the flash image from Mapper::save_data. Every host moved to it: the desktop .sav, the mobile bridge, libretro's SAVE_RAM, a power cycle (which keeps it), and a power-on movie, which calls clear_save_data, the ROM as loaded on a flash board. sram() stays the $6000 RAM: libretro's memory map, which RetroAchievements reads, and the open-bus rule both depend on that meaning, so it was not stretched to cover a 512 KiB ROM. parse raises the battery flag for these two boards, because GTROM headers do not set it.

crates/rustynes-mappers/tests/battery_sram_exposed.rs builds every NES 2.0 mapper number (0-4095) with a battery and 8 KiB of PRG-NVRAM, both with CHR-ROM and with CHR-RAM, and fails any board whose $6000-$7FFF holds CPU writes while sram() stays empty or unchanged — so a new mapper that forgets the override fails by default. Boards whose save memory has no CPU window (the FCG EEPROM) or needs an unlock sequence (X1-005) have their own tests in the same file. The loop can only check boards whose RAM a blind write sweep reaches — 43 of 296 images at v2.7.1 — and prints the rest; RAM gated behind a board-specific enable is not checked by it.

A save state carries cartridge RAM only through the mapper's own blob. The .rns container has no SRAM section: the MAP section is the mapper's save_state bytes and nothing else, so a board that leaves its PRG-RAM or CHR-RAM out of that blob loses it on every save-state load, rewind step, run-ahead frame and netplay rollback — the restored machine keeps the RAM the running game held. Until v2.9.2 the Konami VRC2, VRC4, VRC6 and VRC7 boards (mappers 21-26 and 85) did exactly that (core audit v2.9.2 AUD-02; VRC7's own version comment claimed its v1 blob carried PRG-RAM, when it carried only the enable bit). They now append the 8 KiB PRG-RAM, then the 8 KiB CHR-RAM on a board without CHR-ROM, after every older field: VRC2 and VRC4 write section v2, VRC6 v3 (after its audio tail), and VRC7 v3 without mapper-audio / v4 with it (after the synthesizer tail, so the version still says whether that tail is present). Every older version loaded and left the RAM as it was until v2.9.8, which refuses them (see "Save-state versions" below).

A sweep of every mapper id with the same shape of test, run while triaging AUD-02, found the same omission on boards beyond the VRC2/4/6/7 set it had fixed, VRC1 among them: PRG-RAM on 10 (MMC4), and CHR-RAM on 9, 10, 11, 19, 34, 69, 75 and 151. v2.9.2 fixes them the same way, a versioned tail after every older field: MMC2 (9), MMC4 (10), Color Dreams (11), mapper 34 and VRC1 (75, and 151, which forwards its section to the VRC1 core) write section v2, FME-7 (69) v3 (after its 5B audio tail) and Namco 163 (19) v4 (after its v3 chr_ram_disable / ciram_owned bytes). Every older version loaded and left the RAM as it was until v2.9.8, which refuses them; a blob of the wrong length is refused before any field is written.

The sweep is now a standing test, every_board_snapshot_carries_cartridge_ram in crates/rustynes-core/src/nes.rs, which replaces the two VRC-only pins. It walks every mapper id 0-4095 that parse builds (NES 2.0 under all 16 submappers, and iNES 1.0 for 0-255), each with and without CHR-ROM, snapshots a whole Nes, scribbles the RAM with its complement and restores. PRG-RAM is checked through sram() on the whole buffer, or through the $6000-$7FFF window when a board keeps RAM there without exposing it; CHR-RAM through PPU $0000-$1FFF. It also asserts the boards above stay among those checked, so a regression in the probes cannot pass by checking nothing. Two limits: RAM behind a board-specific enable that sram() does not expose is not reached, and CHR-RAM beyond the 8 KiB mapped at power-on is checked only on its visible part.

A board with nothing at $6000-$7FFF floats there (v2.7.2). The CPU bus keeps an open-bus latch, and a mapper reports an undriven address through cpu_read_unmapped. The trait default now treats $6000-$7FFF as unmapped exactly when sram() is empty. Before v2.7.2 the default treated all of $6000-$FFFF as mapped, and 205 board variants read a made-up $00 there. A board with ROM or readable registers in that window but no save RAM must override the hook for it: mappers 40, 42, 50, 212, 238, 305 and 306 do. crates/rustynes-mappers/tests/prg_ram_window_open_bus.rs checks both directions for every mapper number. Comparing the commercial-ROM suite before and after this rule found five boards whose documented RAM the model lacked (156, 177, 227's FW-01 variant, 241, 245); v2.7.2 gave them their 8 KiB (tests/documented_wram.rs). A read that drives only some data bits, like Sachen's 3-bit registers, reports the rest through cpu_read_driven_mask, and the bus keeps its floating value on them.

Save-state versions

Since v2.9.8 every mapper's load_state reads its current layout only (ADR 0042). Before then most boards that grew their blob kept reading the older layouts: a lower version byte, or, where the version did not move, a shorter length, loaded with the new fields at a default or the RAM left as it was. All of those readers are gone. An older version byte is MapperError::UnsupportedVersion, a short or long blob MapperError::WrongLength. The boards that had them: MMC1, MMC3, MMC5, MMC2, MMC4, Color Dreams, Bandai FCG, Namco 163, VRC2, VRC4, VRC6, VRC7, VRC1, mapper 34, FME-7, Namco 118, Vs. System (layout 1), FK23C, COOLBOY, Sachen 9602, the MMC3 clones, mappers 156, 177 and 241, the mapper-15 and FW-01 multicarts, FDS and NSF (a v1 blob on an expansion-audio NSF). Two layouts per board survive where both are current: VRC7 writes v3 without mapper-audio and v4 with it, mapper 99 writes v2 with the DualSystem shared RAM and v3 without, and an NSF writes v1 or v2 by its expansion chips. A .rns file from v2.9.7 or earlier never reaches these readers: its container is refused at the header.

Behavior

Banking pattern

Every mapper resolves a CPU/PPU address into a (bank_index, offset) pair on every access, then indexes into the cart-owned ROM/RAM. This is hot code — inline aggressively.

IRQ counter mechanisms (the four families)

  1. None — NROM, UxROM, AxROM, MMC1, CNROM, MMC2/4, BNROM, GxROM, Color Dreams, CPROM. No IRQs.
  2. PPU A12 edge counter — MMC3 (and clones, e.g., Namco 108 derivatives). Notify on every A12 transition; filter is internal to the mapper. Per ref-docs/research-report.md §MMC3, the filter requires A12 to remain low for 3 falling edges of M2 before the next rising edge counts.
  3. PPU scanline count — MMC5. Detects scanline by observing PPU attribute-table fetches; IRQ at PPU cycle 4 of the target scanline.
  4. CPU cycle counter — VRC2/4/6/7, Sunsoft FME-7, Namco 163, plus the BestEffort M2-counter pirate boards (NTDEC 2722 / mapper 40, Nitra / mapper 250). Tick on every CPU cycle (notify_cpu_cycle).

Mirroring

Most mappers expose a register that selects horizontal, vertical, single-screen A, or single-screen B. The PPU's nametable fetch consults cart.ppu_read(addr) for $2000-$3FFF; the mapper applies mirroring. Four-screen mode requires extra cart-side VRAM (typically 2 KB) on top of the console's 2 KB.

Per-game mirroring override (v1.1.0 beta.1, T-110-B4). The bus carries an optional nt_mirroring_override: Option<Mirroring> (set via Nes::set_mirroring_override). When Some, the bus's $2000-$3EFF nametable translation uses it instead of the mapper's nametable_address — a load-time correction for ROMs with a wrong iNES mirroring flag, supplied by the frontend's CRC32-keyed game database (the rustynes_frontend::game_db module). It does not affect mapper-supplied VRAM (nametable_fetch, e.g. four-screen), is None by default (byte-identical; the core test suites never set it), and is persisted in the save-state. The database itself (its format, CRC keys and the load-time rules for when an entry may apply) is documented in the rustynes-gamedb crate docs (crates/rustynes-gamedb/src/lib.rs); the CHANGELOG has the rollout.

Bus conflicts

Some early mappers (CNROM, AxROM, GxROM, Color Dreams) do not buffer writes to the bank-select range, so the value written collides with the value being read from PRG at the same address. Implementations should AND the written value with the PRG byte at that address (per NESdev wiki bus-conflict rules). Affects rare but real test cases.

Bus conflict behavior is board-specific. ASIC mappers usually disable PRG ROM outputs during writes; many discrete mappers do not. NES 2.0 submappers can distinguish some conflict-free homebrew or modified boards from original conflict-prone boards. The mapper implementation should therefore decide conflicts from mapper/submapper/board metadata, not only from mapper number.

FDS medium model (Mapper 20, v2.2.0 "Capstone")

The Famicom Disk System RAM adapter (crates/rustynes-mappers/src/fds.rs) models the disk medium as a synthesized byte-stream wire image, not just the raw .fds payload:

  • Wire image: each inserted side is expanded into the hardware wire format the RP2C33 controller scans — a lead-in gap ($00 run), a $80 start mark, the block bytes, and a CRC-16/KERMIT (reflected poly 0x8408) per block, with inter-block gaps between them. Reads stream this wire image; the controller's gap-skip hides the gap + start mark from the CPU (first byte delivered is the block's first real byte).
  • Per-block CRC-16 on write: when the BIOS writes a block payload, the write path mirrors the byte into the raw side and re-emits that block's CRC-16 over the updated payload (resynth_block_crc), modelling the controller's continuous CRC generator so the medium stays self-consistent (a stricter loader checking $4030.D4, and the synthetic oracle below, see a valid block).
  • Continuous analog head-seek model (opt-in, default-off — set_analog_head_seek): the belt-driven head's motor-restart rewind time is proportional to the distance it had travelled from the disk-start gap (a constant belt velocity, HEAD_SEEK_BYTES_PER_CYCLE, plus a fixed HEAD_SEEK_SETTLE_CYCLES settle), clamped to a cold MOTOR_SPIN_UP_CYCLES spin-up. This replaces the flat HEAD_RESEEK_CYCLES not-ready window with a position-dependent seek. With the model disabled (the default) a non-writing .fds run is byte-identical to prior releases; the model state round-trips the v4 save-state tail.
  • Synthetic write-verify oracle (medium_write_verify): a BIOS-free walk of the wire image asserting every block's CRC-16 and gap/mark framing round-trips. This is the CI-verifiable half of the medium model; the real-BIOS write-CRC path needs a copyright FDS BIOS and is exercised only from a local, gitignored dump. See docs/accuracy-ledger.md for the CI-verifiable vs local-only split.

All FDS timing is deterministic cycle arithmetic (no wall-clock / analog jitter), so the determinism contract and save-state round-trip hold.

The save-state disk tail sizes itself with checked arithmetic (v2.9.2). The v3/v4 tail carries its own side count as a u32, and the loader validates the blob's exact length as count * 65500 plus the fixed fields. On a 64-bit host that product cannot overflow, so a hostile count only fails the length check. On the 32-bit targets RustyNES ships (wasm32, armv7, i686) it wrapped: a count of 2^30 wraps the side region to zero bytes, so a crafted blob with no side data passed the check and the restore loop sliced past its end, a panic in every profile (core audit v2.9.2 AUD-01). The product and sum are now checked and a count that overflows usize is rejected; no separate cap is needed, because a count that fits must still match the blob's own length exactly. Pinned by fds::tests::load_state_rejects_a_side_count_whose_length_wraps_on_32_bit, which is red only when the mapper tests run on a 32-bit target — the host suite and the host-run save_state fuzz target could not see this.

Mapper coverage matrix (Phase 4 status)

Sorted by number of commercial titles using each mapper.

iNES Submapper Name Phase Audio IRQ Status Notes
0 — NROM 1 — — landed (Phase 1) 247 titles. Trivial; no banking.
1 1-5 MMC1 (SUROM, SXROM, etc.) 2 — — landed (Phase 2) Serial 5-write protocol; consecutive-write bug. On boards with at most 8 KiB of CHR (v2.7.2, from nesdev_wiki/MMC1.xhtml), the CHR bank register's bit 4 selects the 256 KiB PRG half for the whole window, fixed bank included (SUROM / SXROM), and bits 3-2 select the 8 KiB PRG-RAM bank (SOROM: bit 3; SXROM: bit 3 = A14, bit 2 = A13). In 4 KiB CHR mode the driving register is the one the last CHR fetch selected. SNROM's bit-4 RAM enable applies only to <= 256 KiB PRG with <= 8 KiB RAM. holy_mapperel M1_P512K_CR8K_S8K / _S32K pass 0000. SZROM is not modelled.
2 0-2 UxROM 2 — — landed (Phase 2) UNROM, UOROM, etc. CHR-RAM only.
3 0-2 CNROM 2 — — landed (Phase 2) Bus conflict required.
4 0-3 MMC3 (and MMC6, sub 1) 4 — A12 landed (Phase 4 / S1) Sharp vs NEC IRQ revision; default Sharp. v3.1.0: Nes::set_mmc3_revision_override selects the alternate (NEC) revision for any mapper-4 ROM, and its $C001 reload to 0 now asserts as documented, so mmc3_test_2/6-MMC3_alt passes under it. The IRQ line is raised at the first per-cycle hook after the A12 rise (v2.9.9, pitfall 2). mmc3_test/5-MMC3 and mmc3_test_2/5-MMC3 pass; both 4-scanline_timing ROMs pass all 13 sub-tests since T-MMC3-BG-A12, a PPU change (docs/ppu-2c02.md pitfall 4); they failed at sub-test 9 from v2.9.9.
5 — MMC5 4 yes (landed) scanline v0+v1 landed (Phase 4 / S4) Banking + scanline IRQ + ExRAM modes 10/11 + multiplier (v0). Fill mode ($5106/$5107), dual sprite/BG CHR registers used for sprite tile fetches, ExGrafix per-tile attribute + CHR override (mode 01) (v1). Vertical split-screen ($5200-$5202) via bg_split_state (Castlevania III J status bar) and the MMC5 audio extension (two pulse + 7-bit PCM, $5000-$5015, behind the default-on mapper-audio feature) landed. PRG-RAM banking (v2.7.2, nesdev_wiki/MMC5.xhtml §"PRG-RAM configurations"): $5113 and RAM-mode $5114-$5116 page the RAM by the bank value's low three bits over the wiki's 64 KiB "compatible superset for all games", because PRG-RAM sizes in headers are unreliable (L'Empereur's NES 2.0 dump under-declares its ETROM board); a 16 KiB window takes A13 from the CPU. sram() is the battery-backed part of the header's declared RAM: all of it, except ETROM's 16 KiB, where only the first chip is saved.
7 0-2 AxROM 2 — — landed (Phase 2) Single-screen mirroring control.
9 — MMC2 4 — — landed (Phase 4 / S2) Punch-Out; latched CHR per fetch ($FD/$FE).
10 — MMC4 4 — — landed (Phase 4 / S2) Like MMC2 with full PRG banking.
11 — Color Dreams 4 — — landed (Phase 4 / S2) Unlicensed; bus conflict.
13 — CPROM 4 — — landed (Phase 4 / S2) Videomation.
19 — Namco 163 4 yes (landed) CPU banking+IRQ+audio landed (Phase 4 / S3 + Track C2 / Phase 2.2) Mappy-Land, King of Kings, Final Lap, Rolling Thunder, Megami Tensei II. 1-8 wavetable channels playing 4-bit wavetables from 128 B mapper-internal sound RAM. Address-port at $F800-$FFFF (bit 7 = auto-increment, bits 6-0 = 7-bit RAM address) + data-port at $4800-$4FFF; per-channel registers at the top of internal RAM (channel 8 at $78-$7F, channel 1 at $40-$47); 18-bit frequency + 24-bit phase + 6-bit wave-length + nibble-addressed wave start address + 4-bit volume per channel; $E000 bit 6 = audio-disable. Gated behind the mapper-audio cargo feature. Nametables and CIRAM-as-CHR (v2.7.2, nesdev_wiki/INES_Mapper_019.xhtml): $C000/$C800/$D000/$D800 select each nametable quadrant (< $E0 a read-only 1 KiB CHR-ROM page, >= $E0 CIRAM A/B by the low bit), powering on as the header's layout; CHR values >= $E0 map CIRAM as CHR-RAM unless $E800 bit 6 ($0000-$0FFF) or bit 7 ($1000-$1FFF) disables it. Save state v3 carries the $E800 bits; an older blob loads as both halves disabled, the pre-v2.7.2 behaviour. IRQ: a 15-bit up-counter at $5000 (low) / $5800 (EHHH HHHH, bit 7 = enable), clocked every CPU cycle while enabled, firing and stopping at $7FFF; a $5800 write with bit 7 clear disables it, and the enable reads back in bit 7 (v2.9.8 -- before then every $5800 write enabled the counter, which broke Megami Tensei II's raster bands).
21 1, 2 VRC4a / VRC4c 4 — CPU landed (Phase 4 / S3) Konami; Wai Wai World.
22 — VRC2a 4 — — landed (Phase 4 / S3) Konami.
23 1-3 VRC4e / VRC4f / VRC2b 4 — CPU landed (Phase 4 / S3) Konami.
24 — VRC6a 4 yes (landed) CPU banking+IRQ+audio landed (Phase 4 / S3 + Track C2) Akumajou Densetsu. 3 extra audio channels (2 pulse + 1 sawtooth) gated behind the mapper-audio cargo feature. Power-on CHR = identity (ASSUMPTION, v2.9.8): the eight 1 KiB CHR registers ($D000-$E003) start at 0-7. NESdev documents no VRC6 power-on state; Pulsewave Invite (PD) never writes them and draws its postcard only under this layout (all-zero, the old value, scattered its tiles). The other ten staged mapper 24/26 dumps boot to byte-identical frames either way. Power-on only: Mapper::reset keeps the registers, as a soft reset does on almost every board. PRG registers still start at 0 (no documentation or evidence for another value). Applies to mapper 26 as well.
25 1-3 VRC4b / VRC4d / VRC2c 4 — CPU landed (Phase 4 / S3) Konami.
26 — VRC6b 4 yes (landed) CPU banking+IRQ+audio landed (Phase 4 / S3 + Track C2) Madara, Esper Dream 2. Same channels as VRC6a; A0/A1 swap. Same identity power-on CHR assumption as mapper 24.
34 0-2 BNROM / NINA-001 4 — — landed (Phase 4 / S2) Submapper 1 selects NINA-001.
66 — GxROM 2 — — landed (Phase 2) Bus conflict.
69 — Sunsoft FME-7 4 yes (5B, landed) CPU banking+IRQ+audio landed (Phase 4 / S3 + Track C2 / Phase 2.1) Gimmick! Sunsoft 5B = YM2149F clone: 3 squares + 32-step envelope generator + 17-bit LFSR noise. Two-write protocol via $C000-$DFFF (address latch) and $E000-$FFFF (data); audio gated behind the mapper-audio cargo feature.
71 — Camerica BF9093 4 — — landed (Phase 4 / S2) Codemasters titles.
75 — VRC1 4 — — landed (Phase 4 / S2) Konami.
85 — VRC7 5 yes (FM, landed) CPU banking+IRQ+OPLL FM audio landed Lagrange Point (JP). Banking + CPU-cycle IRQ identical to VRC6's. OPLL FM via the clean-room emu2413 port (crates/rustynes-apu/src/opll.rs, MIT; ADR-0006 supersedes ADR-0004).

First long-tail batch (14 families, 25 → 39)

Spec-implemented from the nesdev wiki with register/IRQ/nametable unit tests + boot-smoke (no redistributable behavioral fixtures exist for these boards).

iNES Submapper Name Audio IRQ Notes
16 / 159 0,4,5 Bandai FCG — CPU DBZ, Famicom Jump II, Datach. +minimal I2C EEPROM (24C02/24C01).
18 — Jaleco SS88006 — (ADPCM decoded-not-emulated) CPU Goemon Gaiden, Doropie. Nibble-paired banking; selectable-width IRQ.
64 — Tengen RAMBO-1 — A12 + CPU Klax, Skull & Crossbones. Dual-mode IRQ (reuses MMC3 A12 filter). The zero test follows a reload as well as a decrement, so a latch of 0 asserts on every clock (v2.9.8; Skull & Crossbones depends on it). The reload "+1 kick" and the IRQ delay are not modelled.
65 — Irem H3001 — CPU Daiku no Gen-san, Spartan X 2. 16-bit reload-latch down-counter.
67 — Sunsoft-3 — CPU Fantasy Zone 2. 16-bit write-twice-latch IRQ.
68 — Sunsoft-4 — — After Burner, Maharaja. CHR-ROM-as-nametable.
70 — Bandai discrete — — Kamen Rider Club, Family Trainer. UxROM-like.
73 — Konami VRC3 — CPU Salamander. Simplest VRC; 8K CHR-RAM.
78 1,3 Holy Diver / Cosmo Carrier — — Submapper-selected mirroring: 3 = Holy Diver (H/V), 1 = Cosmo Carrier (1scA/1scB). Without one (iNES 1.0, or NES 2.0 submapper 0) the header's alternative-nametables bit decides, per INES_Mapper_078: set = Holy Diver, clear = Cosmo Carrier (v2.9.8; it was always Holy Diver). The GoodNES Uchuusen - Cosmo Carrier (J) [!] sets the bit, so its header names Holy Diver and it stalls on a blue screen; the NES 2.0 image of the same PRG/CHR (submapper 1) plays.
88 / 206 — Namco 118 / DxROM — — Dragon Spirit, Quinty, Family Circuit. MMC3 banking subset.
118 — TxSROM / TLSROM — A12 Armadillo, NES Play Action Football. MMC3 + per-slot NT mirroring.
119 — TQROM — A12 Pin*Bot, High Speed. MMC3 + mixed CHR (64K CHR-ROM + 8K CHR-RAM; bank bit 6 = RAM select).
210 1,2 Namco 175 / 340 — — Famista variants. Submapper-split banking/mirroring; no IRQ.

Second long-tail batch (4 families, 39 → 43)

Spec-implemented from the nesdev wiki with register/bank unit tests. Mapper 99 is the headline: it is the robust, mapper-driven Vs. System signal (no licensed home game uses it), so detecting it forces ConsoleType::VsSystem + the 2C03 RGB PPU at parse time — finally enabling in-game RGB-PPU verification of the RGB device.

iNES Submapper Name Audio IRQ Notes
33 — Taito TC0190 / TC0350 — — Don Doko Don, Power Blazer. 2x8K PRG + 2x2K + 4x1K CHR; software mirroring. (mapper 48 = TC0690, the +A12-IRQ variant.)
93 — Sunsoft-3R — — Shanghai, Fantasy Zone. UxROM-like: PRG bits 4-6 + CHR-RAM-enable bit 0; 8K CHR-RAM.
99 — Nintendo Vs. System — — Vs. Excitebike, Vs. Clu Clu Land. Fixed PRG (8/16/32K) + 8K CHR bank from $4016 bit 2. Forces Vs. System + 2C03 RGB PPU (mapper-driven, immune to the byte-7 trap). Since v2.9.8 a UniSystem cart also has the board's 2 KiB RAM at $6000-$7FFF (mirrored), which the CPU sees while the last $4016 write had bit 1 (OUT1) set and as open bus otherwise (nesdev "Vs. System", $4016 write; save-state layout v3). Vs. Super Mario Bros. keeps its state there and never left its first frame without it. The DualSystem path keeps its shared copy and does not gate on OUT1. The circulating VS Super Mario Bros.nes dump is headed mapper 3 with no Vs. flag, so it runs as CNROM and still cannot boot: its code banks CHR through $4016 bit 2 and writes the $4020 coin counter, so its board is mapper 99 (recorded, not forced).
152 — Bandai 74161/161 (1-screen) — — Arkanoid II, Pocket Zaurus. UxROM-like (PRG bits 4-6, CHR bits 0-3) + bit-7 software 1-screen select.

Third long-tail batch (5 families, 43 → 48)

Five more spec-implemented licensed boards (nesdev wiki), each with register/bank unit tests and a commercial-ROM visual survey via coverage_smoke. Mapper 48 is the headline of the batch: an MMC3-style A12 scanline IRQ grafted onto the TC0190 banking shape.

iNES Submapper Name Audio IRQ Notes
32 1 = Major League Irem G-101 — — Image Fight, Major League, Kaiketsu Yancha Maru 2, Magical Pop's. 2x8K PRG with a $9000 swap-mode bit + 8x1K CHR + software H/V mirroring. Submapper 1 (Major League) hard-wires single-screen A and ignores the $9000 mirroring bit.
48 — Taito TC0690 — A12 scanline Don Doko Don 2, Flintstones 2, Jetsons, Bakushou!! Jinsei Gekijou 3. TC0190 banking + an MMC3-style A12 IRQ ($C000 latch = value ^ 0xFF, $C001 reload, $C002/$C003 enable/disable) + $E000 bit-6 mirroring. The TC0690 has a 1-CPU-cycle IRQ-assert delay vs MMC3 that is not modelled exactly (the MMC3 A12 counter is used as-is — close enough for every licensed game).
87 — Jaleco/Konami CNROM-style — — Argus, Choplifter, The Goonies, City Connection. Fixed PRG + one bit-swapped 8K CHR-bank register in the $6000-$7FFF window: bank = ((v >> 1) & 1) \| ((v << 1) & 2).
89 — Sunsoft-2 (Sunsoft-3 board) — — Tenka no Goikenban: Mito Koumon. One $8000-$FFFF register: PRG bits 4-6, CHR ((v>>3)&1)<<3 \| (v&7) (bit 3 = A16), bit 7 = one-screen A/B select; last 16K PRG fixed. Note: Mito Koumon (the only iNES mapper-89 dump on hand) renders blank — see docs/compatibility.md.
184 — Sunsoft-1 — — Atlantis no Nazo, The Wing of Madoola, Kid Niki. Fixed PRG + two 4K CHR banks from one $6000-$7FFF register: bits 0-2 @ $0000, bits 4-6 @ $1000.

Fourth long-tail batch — Taito X1 + arcade RGB (3 families, 48 → 51)

Three more spec-implemented licensed boards (nesdev wiki), each with register/bank unit tests and a commercial-ROM visual survey via coverage_smoke. This batch pairs with the clean-byte arcade detection in rustynes-mappers::parse (see docs/compatibility.md §PlayChoice-10): a clean iNES-1.0 dump whose byte 7 is exactly 0x01 (Vs.) or 0x02 (PC10) is routed through the 2C03 RGB PPU, and mapper 151 joins mapper 99 as a mapper-driven Vs. signal.

iNES Submapper Name Audio IRQ Notes
80 — Taito X1-005 — — Kyonshiizu 2, Kyoto Ryuu no Tera Satsujin Jiken. A $7EF0-$7EFF register window: two 2K CHR banks (value & 0xFE, each driving a pair of adjacent 1K slots) + four 1K CHR banks, three switchable 8K PRG banks ($7EFA→$8000, $7EFC→$A000, $7EFE→$C000; only $E000 is fixed to the last bank), $7EF6 bit-0 mirroring (0 = Horizontal, 1 = Vertical), plus an on-cart 128-byte battery RAM at $7F00-$7FFF enabled only after writing $A3 to both $7EF8 and $7EF9. No IRQ. Kyonshiizu 2 renders its title screen (visually verified) — the earlier blank boot was a missing $7EFE $C000 PRG register that stranded the reset bank (also: the $7EF6 polarity was inverted).
82 — Taito X1-017 — (decoded, unused) Kyuukyoku Harikiri Koushien / Stadium III. Like the X1-005 plus a CHR A12-inversion mode bit ($7EF6 bit 1 swaps the 2K/1K CHR halves between $0000-$0FFF and $1000-$1FFF — the non-linear X1-017 quirk), value-shifted registers (2K CHR banks value >> 1; PRG banks $7EFA-$7EFC value >> 2, ≤128K addressable), and three independently-protected 8K PRG-RAM sub-regions ($7EF7=$CA, $7EF8=$69, $7EF9=$84). The IRQ surface ($7EFD-$7EFF) is decoded but never clocked (the licensed games do not use it). $7EF6 bit 0: 0 = Horizontal, 1 = Vertical.
151 — Konami VS (VRC1 on Vs.) — — Vs. Gradius, Vs. The Goonies. Konami VRC1 silicon (banking byte-identical to mapper 75: three 8K PRG banks $8000/$A000/$C000 + fixed last; two 4K CHR windows with $9000-driven MSB bits; $9000 bit 0 = H/V) on a Vs. board. Like mapper 99 it forces ConsoleType::VsSystem + the 2C03 RGB PPU (mapper-driven, immune to the byte-7 trap). Verified in-game via Vs. Gradius / Vs. The Goonies (both mapper 151). The unpatched Goonies dump (Goonies, The (VS).nes) gained its own vs_db row in v2.9.8 (RP2C04-0003, DSW0 $80, as the hack row); before it drew its title on the 2C03 palette. Mislabelled, recorded not forced: the circulating Vs. T.K.O. Boxing dumps (GVS VS. TKO Boxing.nes and VS. TKO Boxing (VS) [!].nes, byte-identical, SHA-256 a6332035…) carry a mapper-151 header, but their code drives a Namco 108: the reset handler writes $8000 = 6, $8001 = 8, $8000 = 7, $8001 = 9, and nothing writes $9000-$F000. VRC1 decodes $8001 as another PRG write, so the boot goes nowhere (a grey frame). NESdev "Vs. System" lists the Namco 108 (mapper 206) with an extra protection IC for three third-party Vs. games; NES 2.0 encodes T.K.O. Boxing's as Vs. hardware type 2, which RustyNES does not model. The patched VS TKO Boxing Hack.nes (mapper 4 header) plays.

Mapper coverage was staged across Phases 1-4 (the matrix above) and extended across the engine lineage in the long-tail batches above (all shipping in RustyNES v1.0.0); see to-dos/ROADMAP.md. FDS audio shipped as the last expansion-audio integration.

Expansion-audio levels (v2.1.6 "Expansion Audio")

The expansion synth cores (VRC6/VRC7/MMC5/N163/5B, above) are correct in shape; v2.1.6 calibrated their absolute level vs the 2A03 pulse to the bbbradsmith db_* decibel-comparison ROMs / Mesen2 mixer weights, verified by the crates/rustynes-test-harness/tests/audio_expansion.rs level_db_* oracle. Corrected scales (mix_audio): VRC6 256 → 979 (VRC6_MIX_SCALE, ≈1.51×), MMC5 256/16 → 650/40 (≈1.0×, "equivalent to APU"), Namco 163 64 → 261 (NAMCO163_MIX_SCALE, ≈6.0× for 1-channel mode — no reference attenuates N163, ours was ~12 dB too quiet). The N163 change is bit-shared with the NSF path (nsf_expansion.rs). Two levels remain documented gaps: the Sunsoft 5B absolute level (log DAC shape is exact, but the full vol-15 / 3-tone range overflows the i16 mix_audio contract — needs a wider mix path) and the VRC7 FM level (OPLL implemented + patch ROM verified canonical, but the pseudo-sine level is patch-dependent, not oracle-pinned). Full detail + targets: docs/apu-2a03.md §Expansion-audio levels and docs/accuracy-ledger.md.

Fifth long-tail batch — v1.2.0 curated (9 families, 51 → 60)

Discrete-logic boards, each with register-decode unit tests. They now live in per-board modules (m038_bitcorp38.rs, m041_caltron41.rs, ave_nina.rs, jaleco_discrete.rs, m232_camerica_bf9096.rs, cne240.rs, m241_bxrom241.rs). All are Tier-1 Curated (see "Mapper accuracy tiering" below).

iNES Name Audio IRQ Notes
38 Bit Corp UNL-PCI556 — — Crime Busters. PRG/CHR latch at $7000-$7FFF.
41 Caltron 6-in-1 — — Outer register $6000-$67FF (PRG/mirroring/CHR-hi); inner CHR-low at $8000-$FFFF with a bus conflict, gated by the outer enable bit.
79 AVE NINA-03/06 — — PRG+CHR bank via $4100-$5FFF (the $4100/$5000 address mask).
86 Jaleco JF-13 — — PRG/CHR latch in the $6000-$7FFF window.
113 NINA-006 / MB-91 — — Like 79 plus a register-controlled mirroring bit (no header mirroring).
140 Jaleco JF-11/14 — — PRG/CHR latch in the $6000-$7FFF window.
232 Camerica Quattro (BF9096) — — Two-level (outer block + inner) 16 KiB PRG banking.
240 C&E multicart — — PRG/CHR via $4020-$5FFF (write-only).
241 BxROM-like (pirate) — — 32 KiB PRG bank via $8000-$FFFF; CHR-RAM.

Sixth long-tail batch — v1.2.0 best-effort sweep (27 families, 60 → 87)

The aggressive Tier-2 sweep, ported from the GeraNES / Mesen2 references (14 + 13 boards). Mostly multicart / Sachen / discrete boards with no redistributable test fixture; register-decode unit-tested only and not accuracy-gated (see the tiering note below).

first wave second wave
15 (K-1029 multicart), 36 (TXC 01-22000), 39 (Subor BNROM-like), 61, 62 (multicart), 72 / 92 (Jaleco JF-17/19), 77 (Irem, 4-screen CHR-RAM), 96 (Bandai Oeka Kids, PPU-bus CHR latch), 97 (Irem TAM-S1), 132 (TXC 22211), 133 / 145 / 146 (Sachen) 147 (Sachen 3018), 148 / 149 (Sachen), 150 (Sachen SA-015, readable protection + custom mirroring), 180 (Nichibutsu UNROM-inverted), 185 (CNROM CHR-disable protection), 200 / 201 / 202 / 203 / 212 / 213 / 214 (multicart)

Mapper 212, 999-in-1 (v2.9.8 survey): not a board defect. Its menu shows 0 glyphs around the list because the nametable is never cleared. The reset handler waits for one vblank, then sets PPUADDR and writes 960 $24 tiles; that $2006 write lands about 27,400 CPU cycles after power-on, inside the documented NTSC warm-up in which PPUCTRL/PPUMASK/PPUSCROLL/PPUADDR writes are ignored (~29,658 cycles, NESdev "PPU power up state"), so the fill goes to pattern space and the nametable keeps tile $00, the font's 0. The same page says a Famicom's PPU leaves reset about one frame before its CPU, which such pirate carts assume; with the warm-up removed in a scratch run the menu draws on a clean background. RustyNES emulates the front-loader here; whether to model a Famicom power-on is a console-model decision, not a mapper fix. The board itself matches INES_Mapper_212.

Seventh long-tail batch — v1.3.0 "Bedrock" best-effort sweep (14 families, 87 → 101)

The v1.3.0 Workstream D1 Tier-2 sweep, ported from the GeraNES reference. Simple discrete / homebrew / multicart boards with no IRQ, no on-cart audio, and no per-cycle / A12 hook (MapperCaps::NONE); register-decode unit-tested only and not accuracy-gated (see the tiering note below).

iNES Submapper Name Audio IRQ Status Notes
29 — Sealie RET-CUFROM — — landed (v1.3.0 / S8) Homebrew. 16K PRG (data bits 4-2) + 8K CHR-RAM bank (data bits 1-0); fixed last PRG bank at $C000.
31 — INL NSF-style (2A03 Puritans) — — landed (v1.3.0 / S8) Eight 4K PRG slots latched at $5FF8-$5FFF; CHR-RAM; power-on fixes the $F000 slot to the last bank.
58 — Multicart — — landed (v1.3.0 / S8) Address-decoded PRG (16/32K mode) + CHR + mirroring bit; data byte ignored.
60 — Reset-based 4-in-1 multicart — — landed (v1.3.0 / S8) Power-on bank only modelled (reset-latch game selection is host-driven, not exercised in the no_std core).
94 — UN1ROM (Senjou no Ookami) — — landed (v1.3.0 / S8) 16K PRG bank (data bits 4-2, bus conflict) + fixed last bank at $C000; CHR-RAM.
101 — Jaleco JF-10 CHR latch — — landed (v1.3.0 / S8) Fixed 32K PRG; 8K CHR bank latched via a write to the $6000-$7FFF window.
107 — Magic Dragon — — landed (v1.3.0 / S8) One $8000-$FFFF latch: 32K PRG = data>>1, 8K CHR = data.
111 — GTROM / Cheapocabra — — landed (v1.3.0 / S8); rewritten and promoted to Curated v2.9.6 Homebrew. 32K PRG + 16K CHR-RAM (two 8K banks) + two 8 KiB nametable pages, four-screen. See the v2.9.6 section below for the register window, bonus RAM and self-flashing. Since v2.9.0 (re-audit NC-02) a save state whose PRG, CHR or nametable bank is one the register cannot produce is refused; before, it loaded and the next fetch panicked.
143 — Sachen TCA01 — — landed (v1.3.0 / S8) NROM-128 (mirrored) + a simple protection read at $4020-$5FFF returning (~addr & 0x3F) \| 0x40.
177 — Hengedianzi — — landed (v1.3.0 / S8) 32K PRG + mirroring bit (bit 5) from one $8000-$FFFF latch; CHR-RAM.
179 — Hengedianzi variant — — landed (v1.3.0 / S8) 32K PRG via $5000-$5FFF (data>>1) + mirroring bit (bit 0) via $8000-$FFFF; CHR-RAM.
218 — Magic Floor — — landed (v1.3.0 / S8); single-screen wirings fixed v2.9.8 No PRG/CHR-ROM banking; the pattern tables and nametables are both the console's 2 KiB CIRAM. The header wires CIRAM A10 to one PPU address line (NESdev INES_Mapper_218): flags 6 $A1 = A10, $A0 = A11, $A8 = A12 (1 KiB per pattern table, one screen in bank 0), $A9 = A13 (all pattern space bank 0, the nametable bank 1). parse re-reads raw bit 0 when bit 3 is set, because the generic parser folds it into FourScreen; before v2.9.8 both single-screen wirings fell back to A10, and Magic Floor ($A9) drew its nametable over its own tiles.
231 — 20-in-1 multicart — — landed (v1.3.0 / S8) Address-decoded dual 16K PRG banks + a mirroring bit; CHR-RAM.
234 — Maxi 15 / BNROM-like multicart — — landed (v1.3.0 / S8) Two latch regs ($FF80-$FF9F / $FFE8-$FFF8) selecting 32K PRG + 8K CHR in NINA-style or CNROM-style sub-mode.

Eighth long-tail batch — v1.4.0 "Fidelity" best-effort sweep (12 families, 101 → 113)

The v1.4.0 Workstream G Tier-2 sweep, ported from the concretely-documented nesdev decode tables (and the Mesen2 / GeraNES reference implementations). Simple discrete / homebrew / multicart boards with no IRQ, no on-cart audio, and no per-cycle / A12 hook (MapperCaps::NONE); register-decode unit-tested only and not accuracy-gated (see the tiering note below).

iNES Submapper Name Audio IRQ Status Notes
28 — Action 53 homebrew multicart — — landed (v1.4.0 / S9); rewritten v2.9.3 $5000-$5FFF selects one of four registers ($00 CHR bank, $01 inner PRG, $80 mode, $81 outer PRG), written through $8000-$FFFF with no bus conflicts. PRG follows the wiki's 12-row mode × outer-size table (32 KiB, UNROM #180, UNROM #2; 32-256 KiB outer), the fixed UNROM half resolved as 32 KiB. 32 KiB CHR-RAM in four 8 KiB banks; D4 of a $00/$01 write selects the 1-screen page. Powers on with the last 16 KiB at $C000.
30 — UNROM-512 — — landed (v1.4.0 / S9) Homebrew. Latch [N CC P PPPP]: 16K PRG (bits 0-4) + 8K CHR-RAM/ROM (bits 5-6) + nametable bit (bit 7); fixed last bank at $C000. Bus-conflict / flash wiring keyed off submapper + battery (sub 0 w/o battery or sub 2 = bus conflicts on $8000-$FFFF; sub 0 w/ battery or sub 1/3/4 = no conflicts, latch only on $C000-$FFFF, $8000-$BFFF = flash window). Since v2.9.6 the flash window reaches a modelled SST39SF040 and the four-screen board maps the last 8 KiB of CHR-RAM over $2000-$3EFF (see the v2.9.6 section below).
63 — NTDEC 0324 (Powerful 250-in-1) — — landed (v1.4.0 / S9) Address-decoded multicart: 16/32K PRG bank + mirroring bit; CHR-RAM.
76 — NAMCOT-3446 (Namco 109) — — landed (v1.4.0 / S9) MMC3-style $8000/$8001 register pairs select two 8K PRG banks (fixed last two) + four 2K CHR banks; header-fixed mirroring.
174 — NTDEC 5-in-1 — — landed (v1.4.0 / S9) Address-decoded 16/32K PRG bank + 8K CHR bank + mirroring bit.
225 — ColorDreams 72-in-1 — — landed (v1.4.0 / S9) Address-decoded A~[.HMO PPPP PPCC CCCC]: CHR A0-A5, PRG A6-A11, mode A12 (16/32K), mirror A13, high bit A14; plus a $5800-$5FFF 4-nibble scratch-RAM block.
226 — 76-in-1 BMC — — landed (v1.4.0 / S9) Two $8000-$FFFF regs (even/odd): reg0 [PMOP PPPP] (bits 4-0 = PRG bits 4-0, bit 5 mode 0=32K/1=16K, bit 6 mirror 0=H/1=V, bit 7 = PRG bit 5), reg1 bit0 = PRG bit 6; CHR-RAM; RESET clears both registers. Before v2.9.8 bits 5-7 were read as PRG bit 5 / mode / mirroring, so 76-in-1 drew one repeated tile and Super 42-in-1 opened on its second page under the wrong mirroring.
227 — 1200-in-1 BMC — — landed (v1.4.0 / S9) Address-decoded 16/32K PRG + fixed-high-bank mode + mirroring bit; CHR-RAM.
229 — 31-in-1 BMC — — landed (v1.4.0 / S9) Address-decoded: low bits zero = fixed NROM-32 menu bank, else a 16K bank pair + 8K CHR + mirroring bit.
233 — 42-in-1 reset-based BMC — — landed (v1.4.0 / S9) DATA-driven [MMOP PPPP] (4-bit page, bit5 mode 0=16K/1=32K, bits6-7 mirroring); the reset-selected outer block is host-driven (fixed power-on 0); CHR-RAM. Not implementable from documentation (v2.9.8 survey): Unknown Multi Cart w-Galaxian [p1].nes (512 KiB PRG + 256 KiB CHR) is solid blue at every capture point. It is the "Unknown Multicart 1" that INES_Mapper_233 itself says "does not follow the description in this doc at all": 32 NROM-128 games, no menu, each game's CHR page eight below its PRG page, mirroring varying per game, "might even be assigned the wrong mapper number". No documented board fits it, so none is guessed.
242 — Waixing 43-in-1 (Wai Xing Zhan Shi) — — landed (v1.4.0 / S9) $8000-$FFFF address-decoded 32K PRG (inner = A2-A4, outer = A5-A6) + mirror bit (A1); 8K work-RAM at $6000-$7FFF; CHR-RAM.
246 — Fong Shen Bang / G0151-1 — — landed (v1.4.0 / S9) Four $6000-$6003 PRG (8K) + four $6004-$6007 CHR (2K) banking regs; 2K PRG-RAM at $6800-$6FFF; $6003 powers on to $FF and $FFE4-$FFFF-family reads force PRG A17 high; CHR-ROM, header-fixed mirroring.

These were boot-smoked against real unlicensed / pirate / multicart dumps (10 of the 12 families have a library dump; 28 + 174 do not and are register-decode + save-state tested only).

Mapper 28 was wrong until v2.9.3. It shifted the outer bank instead of masking it, masked the inner bank to one bit, fixed the wrong half in both UNROM modes, ignored the CHR bank register and the D4 mirroring write, and powered on with bank 1 at $C000 (a review thread on #97 reported the banking half). It is now pinned by a unit test that checks every mode value, outer and inner bank against the wiki table transcribed row for row, and by tests/roms/nes-test-roms/other/test28.nes: the board passes that ROM's power-on check, which it used to fail, and then stops at its check 2 exactly where Mesen and Nestopia do (compared black-box, screen output only). The boot-smoke caught a shared cpu_read_unmapped inversion (it had also been latent in the pre-existing m132 + m143) that open-bused the whole PRG window so the board never booted, plus several decode errors (m225/m226/m233/m242/m246) — all corrected. 7 of the 10 staged dumps now render a real screen headless; the other 3 (30/63/233) boot + run real menu code but are input-/reset-gated. See screenshots/besteffort/README.md for the full matrix and the per-mapper fix log.

Ninth long-tail batch — v1.5.0 "Lens" best-effort sweep (10 families, 113 → 123)

The v1.5.0 Workstream F Tier-2 sweep, ported from the concretely-documented nesdev decode tables (and the Mesen2 / GeraNES / puNES reference implementations). Small pirate / unlicensed / multicart boards; eight are hook-free (MapperCaps::NONE) and two carried a simple CPU-cycle (M2) IRQ (MapperCaps::CYCLE_IRQ, m40 + m250 — no A12 hook). m250's was corrected in v2.9.7 to the MMC3 scanline counter on A12 (its row below); m40 keeps the M2 counter its page describes. Register-decode + save-state unit-tested only and not accuracy-gated (see the tiering note below).

iNES Submapper Name Audio IRQ Status Notes
40 — NTDEC 2722 (SMB2J pirate) — M2 cycle landed (v1.5.0 / S10) Fixed PRG layout ($8000/$A000/$E000 = banks 4/5/7) with one switchable 8K window at $C000, selected by an $E000 write (bits 0-2); 12-bit M2 IRQ that arms on $A000, asserts at 4096, and disables/acks on $8000; CHR-RAM.
81 — NTDEC Super Gun — — landed (v1.5.0 / S10) CNROM-like single $8000-$FFFF register: 16K PRG (bits 2-3, $C000 half fixed last) + 8K CHR (bits 0-1); header mirroring.
95 — NAMCOT-3425 (Dragon Buster) — — landed (v1.5.0 / S10) MMC3-subset $8000/$8001 register port (no A12 IRQ); CHR reg-0 bit 5 drives one-screen mirroring select; CHR-ROM.
112 — NTDEC ASDER / Huang-1 — — landed (v1.5.0 / S10) Indexed $8000(idx)/$A000(data) port (no A12 IRQ) for two 8K PRG + 2K/1K CHR slots; $E000 bit 0 = mirroring; $C000/$E000 PRG fixed last two.
137 — Sachen 8259D — — landed (v1.5.0 / S10) $4100(cmd)/$4101(data) protection board: 32K fixed PRG select (cmd 5) + four 2K CHR banks (cmds 0-3) + CHR outer (cmd 4) + mirroring (cmd 7).
156 — DIS23C01 DAOU (Open Corp) — — landed (v1.5.0 / S10; decode corrected in the coverage pass) CHR-nibble registers $C000-$C00F decode the 1K slot as (addr&0x03)+(addr>=0xC008?4:0) with bit 2 selecting the high/low nibble array; $C010 = 16K PRG; $C014 = H/V mirroring from a single-screen-A power-on (Mesen2 DaouInfosys).
162 — Waixing FS304 (San Guo Zhi II) — — landed (v1.5.0 / S10; decode corrected in the coverage pass) PRG bank composed from individual A15-A20 bits across $5000/$5100/$5200 with a $5300 mode selector (NESdev table; reset boots 32K bank #2); 8K battery PRG-RAM at $6000-$7FFF; 8K CHR-RAM; header mirroring.
178 — Waixing educational series (FS305) — — landed (v1.5.0 / S10; decode corrected in the coverage pass) $4800 bit 0 = mirroring, bits 1-2 = PRG mode (NROM-256/BNROM, UNROM, NROM-128, UNROM-variant); 16K bank = (reg2<<3)\|(reg1&0x07); 8K work-RAM at $6000; CHR-RAM.
244 — Decathlon (Mega Soft) — — landed (v1.5.0 / S10; decode corrected in the coverage pass) Data-decoded multicart: the written DATA byte selects through two scramble LUTs with bit 3 choosing CHR (LUT_CHR[(v>>4)&7][v&7]) vs PRG (LUT_PRG[(v>>4)&3][v&3]); CHR-ROM, header mirroring (Mesen2/puNES).
250 — Nitra (Time Diver Avenger) — PPU A12 (MMC3) landed (v1.5.0 / S10; decode corrected in the coverage pass; IRQ corrected v2.9.7) MMC3-register-compatible, but the register data is carried in address bits A0-A7 and the even/odd line in A10 (addr & 0x0400, Mesen2 MMC3_250); MMC3 banking subset + the MMC3 scanline IRQ counter (A12 through MMC3's filter). Until v2.9.7 the IRQ was an M2-clocked 8-bit reload counter, which the page does not support ("a regular MMC3 chip connected in [a] different way"); Time Diver Avenger's splits landed at arbitrary points and its playfield drew from the wrong CHR banks (T-COMMERCIAL-GARBLE); CHR-ROM.

These are register-decode + save-state unit-tested only (no redistributable fixture is committed), and structurally excluded from the AccuracyCoin / oracle gate by the BestEffort tier classifier.

Per-mapper screenshot-coverage decode pass

A boot-coverage pass (the auto-discovering external_coverage harness + per-mapper commercial dumps) surfaced a cluster of BestEffort boards that booted to a blank/few-colour frame. Cross-checking each against puNES / Mesen2 / the NESdev wiki corrected real decode bugs in m143, m147, m150, m156, m162, m177, m178, m185, m227, m233, m244, m250 (details in CHANGELOG.md); each now renders a real screen for the staged dumps. All are BestEffort (off the AccuracyCoin oracle), so AccuracyCoin holds 139/141 (the two newest upstream PPU tests are known gaps) and the mapper_tier_honesty gate stays green. A handful of titles remain blank and are documented follow-ups: 4 of the 5 m162 FS304 RPGs need the $5000.7 CHR auto-switch (the core decode is proven by The Mummy rendering); m036 TXC needs a proper TXC- chip port ($4000-$4FFF & 0x200 register window) rather than the flat decode; m040 / m063 / m111 / m202; and 2 m227 pirate hacks need the m227-hack $6000 WRAM. Vs. System DualSystem games (Balloon Fight / Mahjong / Tennis / Wrecking Crew) stay blank by design on this single-system core.

A later boot-coverage pass cleared three more blanks. m030 UNROM-512 (Wampus, PROTO DERE .NES) booted blank because the board unconditionally applied bus conflicts; the self-flashing carts set the iNES battery bit, which on submapper 0 means no bus conflicts (and the banking latch responds only to $C000-$FFFF, with $8000-$BFFF the flash window) — both now render gameplay. m080 Taito X1-005 (Kyonshiizu 2) was missing the $7EFE $C000 PRG register (only two of three switchable PRG banks were modelled), stranding the reset bank; with all three banks it renders its title screen. m185 Seicross (CRC 0F05FF0A) is a CHR-disable copy-protection title that loops forever unless CHR reads back as disabled for its protection latch ($21); its GoodNES dump is iNES-1.0 mapper 185 submapper 0, but it is really submapper 4 (enabled iff the latch low bits are 0). The fix is a frontend per-game DB submapper correction (game_database.txt, applied by apply_header_overrides, which now promotes an iNES-1.0 header to NES 2.0 when a non-zero submapper override is set) — the mapper's existing submapper-4 rule already matches FCEUX Sync181 / BizHawk's Seicross special-case, so the core is untouched. The external_coverage boot-smoke used to feed raw bytes to Nes::from_rom and bypass the frontend DB, so Seicross captured blank there — a harness limitation, not a decode bug. As of v2.3.4 the harness applies the database (extracted into the rustynes-gamedb crate so both consumers share one copy), and Seicross renders.

The three converted Waixing (Wxn) dumps staged under mapper-030- are Waixing FS005 (iNES mapper 176 submapper 2) misdetected as mapper 30 by GoodNES. As of v2.3.4 they are routed by the loader, on a rule that needs no database entry because the header refutes itself: UNROM-512 is a CHR-RAM-only board, so a mapper-30 image declaring CHR-ROM cannot be describing the board it claims. Two of the three boot; Chu Liu Xiang renders no tiles and remains open.

Mapper 15 — K-1029 / K-1030P multicart

Two deliberate departures from the bare board, both v2.3.4, because the staged corpus is dominated by single-game hacks rather than the multicarts the board shipped as:

behaviour why
CPU $6000-$7FFF 8 KiB PRG-RAM The K-1029 has none. The mapper-15 hacks of Doraemon, Dragon Ball, Z Gundam and others are conversions of games that expect work RAM there, and several set the battery flag. Without it they read open bus and hang. Decoded before the bank latch, so it cannot shadow a register.
PPU $0000-$1FFF CHR-RAM writable in every mode The board is CHR-RAM-only; write-protecting it in some banking modes was modelling a restriction the hardware does not have, and it blanked four ROMs.

The PRG-RAM is serialized, so it round-trips a save-state. Its arrival lengthened the state blob without a version bump, so until v2.9.8 load_state accepted both lengths and cleared the RAM on the shorter one. Since v2.9.8 (ADR 0042) the shorter, pre-v2.3.4 length is MapperError::WrongLength (named Truncated before v3.0.0).

Mapper 154 — NAMCOT-3453

Mapper 88 plus a single one-screen nametable bit, and nothing else. Implemented as a third Namco118Board variant rather than a new type, because that is what the hardware is: the CHR A16/A12 split, the bank registers, and the fixed PRG banks are all mapper 88's.

$8000-$FFFF bit 6 nametable select — 0 = one-screen page A, 1 = page B
everything else identical to mapper 88, including the CHR A16 ↔ PPU A12 split

The bit is decoded across the whole $8000-$FFFF range, which is the one thing easy to get wrong: it shares a byte with the bank-select register, but that register is only decoded at $8000-$9FFF on the associated Namco 108. So the nametable bit is read on every write in the range — odd addresses carrying bank data included — and a test pins each of $8001, $9FFF, $A000, $C001, $FFFF.

This makes mirroring mutable state on a board family where it had been constant, so SAVE_STATE_VERSION moves to 2 to carry it. A v1 blob never held a mirroring byte and would have restored the wrong CIRAM page; it is refused since v2.9.8 (ADR 0042).

Used by exactly one game, Devil Man, whose dump is headered mapper 88 and corrected to 154 by the per-game database.

Mapper 243 — Sachen SA-020A

The same eight-register ASIC as mapper 150, on the PCB it was designed for. NESdev records this under mapper 243's Errata: the SA-150 board "connects the same ASIC differently, changing the meaning of the CHR-bank-related register bits". So this is a Sa020aBoard variant of the existing type, not a new one — the $4100/$4101 index/data protocol, the three-bit register file, the readback, and the four mirroring modes are all shared, and only the bank decode branches.

register SA-020A (243) SA-150 (150)
PRG 32 KiB bank R5[1:0] (A16..A15) R5 \| R2[0]
CHR 8 KiB bank R2[0] (A13) ∥ R4[0] (A14) ∥ R6[1:0] (A16..A15) R2[0]<<3 \| R4[0]<<2 \| R6[1:0]

Note that both boards use the same three registers — R2 is the CHR LSB on the SA-020A and the MSB on the SA-150. That inversion is the whole reason the two need separate mapper numbers, and a test asserts one register setting lands on different banks for the two boards.

Mirroring comes from R7[2:1], including selector 0's S0-S0-S0-S1 layout (three nametables sharing CIRAM page 0, only the fourth distinct) which the existing resolve_nametable already implemented for mapper 150.

Used by exactly one game, 美女拳 Honey Peach (SA-006), headered mapper 150 and corrected to 243 by the per-game database. Both maxima are reachable: 128 KiB PRG (4 × 32 KiB from two bits) and 128 KiB CHR (16 × 8 KiB from four bits).

Mapper 176 submapper 2 — WAIXING-FS005/FS006

Mapper 176 is the 8025 enhanced-MMC3 ASIC, whose variants are incompatible and split by NES 2.0 submapper. m176_bmc_fk23c.rs covers submapper 0/1 (BMC-FK23C) and, since v2.3.4, submapper 2:

area FS005 behaviour
$A001 RAM Configuration Register once bit 5 is set; behaves as MMC3 $A001 until then
$A001.0-1 8 KiB WRAM bank at $6000-$7FFF (the board carries 32 KiB, not 8)
$A001.2 first 8 KiB of CHR space becomes CHR-RAM (mapper-195-like mixed memory)
$A001.6 clear ⇒ $5000-$5FFF is not a register window; it reads/writes the second 4 KiB of WRAM bank 2
$A000 two mirroring bits; single-screen A/B only while the RAM Configuration Register is enabled
$8000 bank-select values $46/$47 swap registers 6 and 7 — but $06/$07 do not
$5xx0.3/7, $5xx2.5/6/7 PRG A21, A22, A25, A23, A24
decode mask $E003, not the stock MMC3 $E001, so $9FFF does not alias $8001

The register-window disable is the whole mechanism behind the documented Waixing copy-protection sequence: the game parks $5000-$5FFF over WRAM, stashes three bytes there, re-enables the registers, and reads the same bytes back through $6000-$7FFF with WRAM bank 2 selected.

Implemented from the NESdev wiki INES_Mapper_176 page. Unlike the FK23C banking transforms in the same file — a disclosed Mesen2 derivation, see NOTICE and docs/originality-and-provenance.md §1 — no reference-emulator source was consulted for any of the FS005 code.

v2.9.8 survey: staged dumps that are not the board their header names. Recorded rather than forced, because the board code is right for the board the header names and no per-game override is added:

  • 21-in-1 [p1][!] (labelled 133, Sachen SA-72008). 128 KiB PRG and 64 KiB CHR, more than the 72008's $4100 latch can address (one PRG bit, two CHR bits, INES_Mapper_133), and it never writes $4100: its first writes go to $8001, $F000, $F020 and $F001, an address-encoded latch. Re-headed in scratch as mapper 225 (ET-4310 / K-1010, INES_Mapper_225: banks and mirroring in the address of a $8000-$FFFF write) it boots to its "21 GAME" menu; as 133 it is tile noise.
  • Zhan Guo Si Chuan Sheng (C&E) (Unl) (labelled 132, TXC 22211). The INES_Mapper_132 page names this exact image: GoodNES sets it to 132, but it is a mapper hack with the CHR banks rearranged for some emulators' mapper 132, "not on the above implementation based on studying the circuit board"; the correct board is mapper 173. Here its title and map screen render, and in-game screens draw from the wrong CHR bank. Mapper 173 is not implemented.
  • Uchuusen - Cosmo Carrier (J) [!] (mapper 78): see the row in the first long-tail table; its header's nametable bit names Holy Diver.

BB Car (Asia) (En) (Unl) (mapper 152) is not a defect: it never writes a mapper register, and the "0123456789" screen it shows is its own. Its controller loop reads the Start bit and discards it, so the harness's START taps never leave that screen; an A press starts the race.

Tenth batch — v2.9.6 "Roster" (17 families, 174 → 191)

Every family here is written from its vendored NESdev page, named in the table; no reference-emulator source was read. The MMC3-based boards live in mmc3_boards.rs, a new module that wraps the project's own Mmc3 as a register file and IRQ counter. They are kept out of mmc3_clones.rs on purpose: that file carries a Mesen2 derivation record for its MMC3 variants, and mixing independently written boards into it would blur which regions the record covers.

Tiers follow the maintainer's rule for this release (2026-09-30). A family is Curated when its page gives exact register masks: ADR 0011's "precise decode spec", backed by register-decode unit tests and a synthetic CC0 boot fixture in crates/rustynes-test-harness/tests/roster_boards.rs. It is BestEffort when the page gives only Disch's notes, or masks marked "probably".

iNES Sub Board (page) Tier IRQ Notes
12 0 Gouder SL-5020B (INES_Mapper_012) Curated MMC3A (the alternate / NEC behaviour) $4100 mask $E100: CHR A18 per pattern table (bit 0 for PPU A12=0, bit 4 for A12=1), outside the ASIC so unaffected by $8000 bit 7. The Dragon Ball Z 5 language bit read at the same address returns 0 on D0: the page says every known copy is hard-wired to Chinese but not which level that is, so this is an assumption. Submapper 1 (the Magic Card 4M extraction) is a different device and is not supported.
37 — SMB + Tetris + NWC (INES_Mapper_037) Curated MMC3 The 74HC161 at $6000-$7FFF, written only while the MMC3's $A001 allows a PRG-RAM write; PRG A16 = Q0·Q1 + Q2·M16, A17 = CHR A17 = Q2 (the page's NAND equations). Write-only (open bus). The CIC reset clears it (Mapper::reset).
45 — GA23C (INES_Mapper_045) Curated MMC3 Four outer registers written in turn at $6000 (mask $F001): CHR-OR, PRG-OR, CHR-AND + high bits, PRG-AND (inverted) + lock. $6001 resets and unlocks, as does a soft reset. $5000-$5FFF reads the menu DIP switch on D0. WRAM only when a NES 2.0 header declares it. The page gives no power-on or reset value for the outer registers. Power-on, soft reset and $6001 set [$00, $00, $0F, $00], so CHR-AND passes every MMC3 CHR bit (T-GA23C-POWERON, maintainer 2026-10-05). The evidence: two Famicom Yarou menus (54 and Vol.5) draw with CHR banks 0-7 before their first outer-register write, which needs CHR-AND at $A or more. CHR-RAM is unbanked: addressed straight from PPU A10-A12, past the MMC3 banks and the outer CHR registers (T-GA23C-CHRRAM, fixed v3.0.1). The page is silent on CHR-RAM; mapper 372, the GA23C with a ROM/RAM switch, documents its RAM as unbanked, and Famicom Yarou Vol.1 uploads all 8 KiB with every CHR register at 0 and then draws with R0-R5 = 0, 2, 4, 5, 6, 7.
47 — Spike V'Ball + NWC (INES_Mapper_047) BestEffort MMC3 One block bit in the PRG-RAM window, gated like mapper 37. The page is Disch's notes only. The Kasheng 2-in-1 (Mortal Kombat 6, Samurai Spirits) is often labelled 47 (512 KiB CHR, $6000 written with $C0/$60 while WRAM is disabled); it is NES 2.0 mapper 291 (NES_2_0_Mapper_291), which this project does not implement.
74 — Waixing 43-393 (INES_Mapper_074) Curated MMC3 CHR banks 8 and 9 are 2 KiB of CHR-RAM. 8 KiB work RAM. A dump that writes $A001 with bit 5 set ($EC/$ED), $5FF3, or MMC3 registers 8-11 is a Waixing FS005 re-release, mapper 176 submapper 2 (INES_Mapper_176), not this board.
83 0/1/2 Cony / Yoko (INES_Mapper_083) Curated 16-bit M2, up or down Three PRG modes, $6000 ROM (subs 0/1) or 32 KiB banked WRAM (sub 2), 1 KiB / 2 KiB / outer-banked CHR. On iNES the submapper follows the page's CHR-size heuristic. The DIP and scratch-RAM masks are "probably" on the page and are decoded inside $5000-$5FFF only.
91 0 JY830623C / YY840238C (INES_Mapper_091) Curated 64 unfiltered PPU A12 rises 2 KiB CHR x4, 8 KiB PRG x2 + fixed 16 KiB, outer bank from the $8000-$9FFF write address.
91 1 EJ-006-1 BestEffort M2, down by 5 every 4th cycle The page does not say when it asserts; this board asserts on the decrement that would go below zero, then stops until $7007.
105 — NES-EVENT (NES_EVENT) Curated 30-bit M2 timer An embedded Mmc1 runs the serial port. PRG locked to the first 32 KiB until $A000 I goes 0 then 1; O picks the chip; the timer fires at $20000000 \| DIP<<25, default the tournament setting (DIP C, NWC_TOURNAMENT_DIP). Reset relocks.
121 — Kasheng A9711 / A9713 (INES_Mapper_121) BestEffort MMC3 Protection array at $5000, the bit-reversed $8001 latch and $8003 index overrides, CHR A18 from PPU A12 (A9711) or a $5180 outer bank (A9713, told apart by 512 KiB PRG). The page's masks are "probably".
153 — Bandai LZ93D50 + WRAM (INES_Mapper_153) Curated LZ93D50 In m016_bandai_fcg.rs (outside its EEPROM region): $8000-$8003 bit 0 is the outer 256 KiB PRG bank, $800D bit 5 the WRAM enable; 8 KiB of unbanked CHR-RAM, addressed directly and never through the CHR bank registers (in v2.9.6 and v2.9.7 all eight 1 KiB windows aliased the first 1 KiB, which striped Famicom Jump II's title); the WRAM is the battery save. Per the page, the game itself freezes on a black screen when it boots with zero-filled WRAM (the default fresh-cartridge state here) and runs after a soft reset.
163 — Nanjing FC-001 (INES_Mapper_163) Curated — 32 KiB PRG from $5000/$5200 with the mode register's D0/D1 swap (not on 1 MiB boards) and the boot-in-bank-3 rule; feedback register at $5100/$5500; the automatic CHR-RAM switch latches PPU A9 on each rise of A13, modelled as a nametable access that follows a pattern access.
191 — (INES_Mapper_191) BestEffort MMC3 CHR bank bit 7 selects 2 KiB CHR-RAM. Disch's notes. The 192 KiB / 160 KiB translations need the non-power-of-two mirroring below. Q Boy (Sachen, CHR-RAM) is often labelled 191 but writes only $4100/$4101: it is a Sachen 8259 board, mapper 141 (Sachen_8259), and does not boot as 191.
192 — Waixing FS308 (INES_Mapper_192) Curated MMC3 CHR banks 8-11 are 4 KiB of CHR-RAM.
194 — (INES_Mapper_194) BestEffort MMC3 CHR banks 0 and 1 are 2 KiB of CHR-RAM. Disch's notes.
195 — Waixing FS303 (INES_Mapper_195) Curated MMC3 A PPU write to a bank mapped to ROM selects which banks are RAM, from that bank's number (the page's eight-row table; power-on $80). CHR A10-A12 reach the RAM, so $80 and $82 share it. The optional 4 KiB at $5000 appears when a NES 2.0 header declares PRG-RAM.
228 — Action 52 / Cheetahmen II (INES_Mapper_228) Curated — The register latches the write ADDRESS (mirroring, chip, page, size) and data (CHR low bits). On the 1.5 MiB image chip 3 is the third 512 KiB and chip 2 is open bus. Reset clears it.
249 — Waixing T9552 (T9552) Curated MMC3 $5000 selects a PRG A14-A17 / CHR A12-A17 scrambling pattern; the file is stored in the $5000=$00 order. Pinned to the page's worked example.

Non-power-of-two ROM sizes. Every board in mmc3_boards.rs reduces a PRG or CHR-ROM bank onto the image by the doubling algorithm of Non_power_of_two_ROM_size (the smaller ROM is mirrored up to the next power of two: 192 KiB reads as ABCC, 160 KiB grows 20 -> 24 -> 32 banks), not by a plain modulo. A modulo sent the MMC3's all-ones fixed bank to bank 15 of a 24-bank image, which holds no reset vector, so the 192 KiB and 160 KiB mapper 191 translations booted to a blank frame. For power-of-two images the two are identical.

Mapper 4, corrected. The NES 2.0 submappers of mapper 4 were mis-assigned: submapper 1 was read as "NEC" and 4 as Sharp. NES_2_0_submappers defines 1 as the MMC6, 2 as MMC3C with hard-wired mirroring, 3 as Acclaim's MC-ACC, 4 as the NEC MMC3 and 5 as the T9552 scrambler. All five are now modelled: the MMC6's 1 KiB of internal RAM with its per-half read and write enables (MMC6.md), $A000 ignored on submapper 2, the MC-ACC's falling-edge A12 counter behind a /8 prescaler, NEC on 4, and T9552 on 5 (the file in the $02 order). An iNES 1.0 StarTropics stays a plain MMC3, as the submapper page advises. The Mmc3Revision names were also wrong: MMC3A is the alternate behaviour, not Sharp. MC-ACC (4.3) was BestEffort at v2.9.6, because its prescaler reset and phase come from a forum measurement the MMC3 page links, not from the page.

MC-ACC, promoted to Curated (v2.9.7). Six Acclaim titles with NES 2.0 submapper-3 headers were run headless with scripted input and compared against the same dumps forced to submapper 0. The dumps are local and gitignored: Alien 3, Terminator 2, The Incredible Crash Dummies, WWF King of the Ring, WWF WrestleMania: Steel Cage Challenge and T&C Surf Designs. On the first run, v2.9.6's model broke four of them: HUDs missing, garbled title text, and Alien 3 black. The cause was not the model but the PPU. It reported one A12 edge per scanline, and the MC-ACC's /8 prescaler expects the hardware's eight (docs/ppu-2c02.md, dots 257-320). With the PPU fixed, all six boot cleanly. The four that use the raster split draw it correctly only under MC-ACC: their status bars, portraits and title cards appear, and forced to submapper 0 they corrupt. The other two draw identically under either model. The same PPU fix brings mapper 91 submapper 0 ("64 unfiltered rises") and the J.Y. ASIC's A12 mode ("unfiltered, eight per scanline") to their documented rates; both had been eight times slow.

MMC3-core boards with their own counter. mmc3_clones, 176 (FK23C), 268 (CoolBoy) and 513 (Sachen 9602) clocked their IRQ counter on every A12 rise, which was right only while the PPU delivered one rise per line. Since v2.9.7 they apply MMC3's filter (a12_filter.rs: a rise counts after three CPU cycles low), and their save states pack the filter into the old last_a12 byte (format versions bumped; older states load with the byte read as the level).

GTROM (111), promoted to Curated. Three documented behaviours were missing:

  • the latch decodes /ROMSEL, A14 and A12 high, which is $5000-$5FFF and $7000-$7FFF only, and a read there latches the value floating on the bus (Mapper::notify_floating_read);
  • each nametable page is 8 KiB covering $2000-$3EFF unmirrored, so $3000-$3EFF is bonus RAM (Mapper::nametable_unfolded);
  • PRG is an SST39SF040 that games rewrite to save.

The flash model is sst39sf040.rs, written from the datasheet's command table: byte program, 4 KiB sector erase, chip erase and software ID. Programming only clears bits. Operation time is not modelled, so every command completes within its write. The flashed image is the board's battery save. It is exposed through a new Mapper::save_data seam rather than sram(), which keeps meaning "the RAM in the $6000 window", the thing the open-bus rule, the libretro memory map and RetroAchievements rely on. parse marks the cartridge battery-backed. The desktop .sav, the mobile bridge and libretro's SAVE_RAM all persist save_data(), a power cycle keeps it, and a power-on movie resets it with clear_save_data, which on a flash board restores the ROM as loaded rather than zero-filling it. A save state carries only the 4 KiB sectors that differ from the ROM. The desktop keys .sav files by the ROM's hash, so a flash image is never applied to a different ROM. RetroArch keys saves by content name, so a renamed or updated ROM can load an older image there.

UNROM 512 (30) uses the same model on its flashable wiring, which needs the flash window AND CHR-RAM. The CHR-ROM images headered as mapper 30 are Waixing FS005 .WXN conversions (UNROM_512.md). Their MMC3-style register writes reached the flash in this release's first draft and rewrote 21,602 bytes of Shui Hu Zhuan; they are never flashed. The four-screen board now maps the last 8 KiB of its 32 KiB CHR-RAM over $2000-$3EFF, where it used to approximate that as single-screen.

The promotion is backed by the CC0 fixture in roster_boards.rs: the register window, the floating-read latch, bonus RAM and a byte program through the real bus, plus the battery flag. The old tier note named "Ninja Ryukenden" as GTROM's only dump. That image is a different board, an MMC1 variant with CHR-ROM that the page describes only as "non-serialized", and it is not supported.

Mutation proof. 31 mutants, each reverting one documented behaviour above (an equation, a mask, a lock, an IRQ direction, a dispatch, a hook call, a save path), were all CAUGHT by the unit tests or roster_boards.rs. Two needed a new test first: the bus's floating-read call, which no mapper-level test can reach, and the PPU's unfolded $3xxx path. The record is in the v2.9.6 plan.

Mapper accuracy tiering (v1.2.0)

Every supported family is classified Core / Curated / BestEffort by rustynes-mappers::mapper_tier(id, submapper) — an honesty marker (runtime behaviour is identical) that keeps the accuracy claim precise as long-tail coverage grows. Core (the original 51) and Curated are gated by the AccuracyCoin / commercial-ROM oracle suites; BestEffort (reference-ported boards with no redistributable fixture, register-decode unit-tested only) is excluded from that gate. The invariant — no BestEffort mapper backs an oracle ROM — is enforced at the classifier level (BestEffort is structurally never accuracy-gated; the three tier id-sets are disjoint) and by the curated construction of the byte-oracle corpus. See docs/adr/0011-mapper-tiering.md. Current split: 191 families — 51 Core + 109 Curated (160 accuracy-gated) + 31 BestEffort (v2.3.4 added 154 and 243; v2.9.6 added 17 families and promoted 111, see above). Submapper-level exceptions: 91.1 and 176.2 are BestEffort, 4.3 (MC-ACC) is Curated (BestEffort until v2.9.7), and 12.1 is unsupported. The v2.1.0 "Fathom" F3 batch promoted 86 previously- BestEffort families to Curated: each has a cleanly-booting staged commercial-ROM dump (57 already in tests/roms/external/ + 29 sourced from GoodNES v3.23b) wired into a byte-identity boot-snapshot oracle in external_extended.rs (ADR 0011), taking accuracy-gated coverage from 60 → 146. The remaining 26 BestEffort families have no cleanly-booting redistributable dump — the 16 NES 2.0 high-id boards (268/286/289/290/299/301/303/305/306/312/320/336/348/349/366/513, which GoodNES v3.23b's iNES-1.0 headers cannot encode); 8 boards with no matching cart (29/39/81/104/174/179/238/261); and 2 boards whose only dump jams at boot (50 SMB2j FDS-conversion, and 111, whose "Ninja Ryukenden" dump turned out at v2.9.6 to be a different board; GTROM itself is now Curated) — and stay register-decode and save-state unit-tested only. To keep that long tail from silently rotting, the v2.1.0 "Fathom" F3.1 CI boot-smoke sweep (crates/rustynes-test-harness/tests/v21_best_effort_sweep.rs) exercises the full parse -> construct -> dispatch -> run-loop integration for every BestEffort family: it derives the set live from the mapper_tier classifier (so future promotions in/out are covered automatically), builds a synthetic minimal iNES / NES 2.0 image for each, and runs ~60 headless frames asserting no panic and a well-formed framebuffer. It gates integration robustness only (register/bank/tick decode never panics or divides by zero), promotes nothing, and adds no oracle claim — accuracy stays defined by the Core/Curated gate. The two NTDEC boards 81/174 validate a non-zero-multiple-of-8-KiB CHR-ROM and honestly reject a CHR-RAM header with a typed RomError (not a panic), so the sweep hands them CHR-ROM geometry. The mapper implementations described below (reference-ported across v1.2.0-v1.8.9) are unchanged by the promotion — only the tier marker moved. The v1.6.0 batch ports MMC3-clone variants (44/49/52/115/134/189/205/238/245/348/366, on a shared MMC3-style core with an A12 falling-edge IRQ + per-board outer-bank transform), the Sachen 8259 A/B/C 2 KiB-CHR variants (141/138/139 — siblings of the existing 8259D mapper 137), and discrete unlicensed / FDS-conversion / multicart boards (42/50 with CPU-cycle IRQs, 46/51/57/104/120/290/301 hook-free). Mapper 35 is the J.Y. Company single-game "extended" board folded into m035_jy_asic.rs (same silicon as 209). The v1.7.0 batch ports the next reusable-ASIC BMC/pirate cores: the Waixing FK23C 8/16 Mbit BMC (176, $5000 config + MMC3 surface + A12 IRQ), COOLBOY / MINDKIDS (268, MMC3 + four $6000 outer registers), Sachen 9602 (513, MMC3 + PRG-A19/A20 outer) and 3011 (136, the TXC protection accumulator driving an 8 KiB CHR select), Waixing 164 (split $5000/$5100 PRG), 253 (Dragon Ball Z VRC4-clone, per-1 KiB CHR regs + CHR-RAM escape + scaled CPU-cycle IRQ) and 286 (BS-5 DIP-gated multicart), the Kaiser FDS-conversion family (56/142 KS202/KS7032 with an up-counting M2 IRQ, 303 KS7017 with a down-counting M2 IRQ + read-ack, and the window boards 305/306/312), and the BMC multicarts 261/289/320/ 336/349. All are BestEffort: register-decode + save-state round-trip unit-tested, outside the AccuracyCoin / oracle gate.

KS7032 (142) has no work RAM at $6000 (v2.9.5). nesdev_wiki/INES_Mapper_142 gives that window an 8 KiB switchable PRG-ROM bank, selected by bank-select value 4. The model had read a zero work RAM there until an undocumented register set a ROM flag, and it now always serves the bank. KS202 (56) keeps the RAM its own page documents. Core ledger F-09.

MMC3-clone A12/IRQ timing oracle (Fathom F3.3). The eleven Mmc3CloneMapper boards (44/49/52/115/134/189/205/238/245/348/366) all route their $8000-$FFFF register space — including the IRQ ports $C000/$C001/$E000/$E001 — into a single shared Mmc3Clone core, so the A12-clocked scanline IRQ is board-independent by construction. crates/rustynes-test-harness/tests/mmc3_clone_a12.rs is a chip-level oracle (no ROM files, deterministic, runs in the default cargo test) that pins that timing as additive evidence — behind the existing Curated classification of the eight Curated members (44/49/52/115/134/189/205/245) and, since the three high-id boards 238/348/366 are BestEffort, giving those real IRQ-timing evidence too. It does not move any tier. The centerpiece drives every clone board and a reference plain Mmc3 (Sharp / rev A) through the identical canonical rendering-scanline A12 sequence and asserts the clone reproduces the reference's per-scanline IRQ-assert bitmap bit-for-bit: the first assertion lands on rising edge latch + 1 (an initial $C001 reload consumes edge 0, then latch decrements reach zero) and re-asserts every latch + 1 scanlines once acknowledged. The suite additionally pins the $E001/$E000 enable/ack gate, the $C001 reload periodicity, and the A12 edge filter (holding A12 high across consecutive reads clocks the counter exactly once — no double-clock). The reference Mmc3 is the oracle, so any board whose shared core drifted from MMC3's scanline timing would fail; the non-zero latches used throughout keep the comparison on the mechanism both cores agree on unconditionally (the Sharp/NEC reload-to-zero sub-cadence and the ~3-M2-cycle too-close-edges hardware sub-filter are revision/accuracy nuances outside the Curated clone's remit).

The v1.8.9 "Backlog" beta.6 batch ports four more well-documented NTDEC / TXC / discrete-BMC multicart cores, none with an IRQ (MapperCaps::NONE): NTDEC TC-112 (193, Fighting Hero — a $6000-$7FFF four-register surface with one switchable 8 KiB PRG window over three fixed and three 2 KiB CHR selects), the discrete BMC 2-in-1 (204, an address-decoded NROM/UNROM multicart), NTDEC N625092 (221, a $8000 mode register + $C000 inner-PRG register with NROM / UNROM / NROM-256 sub-modes), and TXC/BMC-11160 (299, one value-decoded register selecting a 32 KiB PRG bank + an 8 KiB CHR bank + mirroring). All four are BestEffort: register-decode + save-state round-trip unit-tested, outside the AccuracyCoin / oracle gate. The same pass also widened the UNIF board-name table (unif.rs) — wiring well-known board aliases (e.g. 11160→299, N625092→221, COOLBOY→268, FK23C→176, the Kaiser KS70xx family, BS-5→286, SA-9602B→513, NTBROM→68, SL1ROM→1, TEROM→4) to the families RustyNES already implements, with board-resolution unit tests for each.

NSF player (synthetic mapper, v1.1.0)

NSF chiptune files are not cartridges — they have no iNES header and no PPU program. They are played through a synthetic NsfMapper (nsf.rs), built by the dedicated Nes::from_nsf path (not parse). The mapper serves the program image ($8000-$FFFF, with $5FF8-$5FFF 4 KiB bank-switching; those registers are write-only and read as open bus, per the NSF spec's readable-address list), 8 KiB WRAM at $6000, and a tiny hand-assembled 6502 driver at $5000; the reset/NMI/IRQ vectors ($FFFA-$FFFF) are overridden to point at the driver. Reset runs init for the selected song. At the standard 60 Hz it enables vblank NMI and the ordinary 60 Hz NMI calls play each frame; at a non-standard rate (a PAL 50 Hz tune or any custom µs divider from the header speed word, on the NTSC console) it instead disables the APU frame IRQ and arms a mapper cycle-timer that raises a $5FF1-acked IRQ every period CPU cycles, whose handler calls play. Because this reuses the normal lockstep run_frame, the APU produces audio identically to a cartridge and the determinism contract is untouched. The Mapper trait carries three default-no-op nsf_* hooks (song count / current / set) so the bus + Nes can drive track selection without downcasting. Scope: base 2A03 + expansion-chip audio, NTSC / PAL / custom play rates, and both the classic NESM and the extended chunked NSFE containers; the FDS-style $5FF6/$5FF7 RAM banking remains deferred.

Edge cases and gotchas

  1. MMC1 consecutive-write bug. Writes on adjacent CPU cycles after the first are ignored — but only the data (bit 0): the bit-7 reset is never ignored (nesdev_wiki/MMC1.xhtml, "Consecutive-cycle writes"). Bill & Ted's Excellent Adventure needs the data half (an INC on $FF writes a reset, then a $00 that must be dropped); Shinsenden needs the reset half (it sets bit 7 on an RRA abs,X's second write and crashes if that reset is dropped). Until v2.8.2 the oracle filtered the reset too; the MiSTer RTL did not, and was right (RTL audit R-3.5a). Pinned by a_reset_on_the_cycle_after_a_write_is_never_ignored and a_data_write_on_the_cycle_after_a_reset_is_ignored.
  2. MMC3 IRQ revision differences. The Sharp MMC3B / MMC3C (the default) asserts whenever a clock leaves the counter at 0, so a latch of $00 fires every scanline; the MMC3A and non-Sharp MMC3B (the alternate revision, Mmc3Revision::Nec) assert on a 1 -> 0 decrement and on a $C001 reload to 0, but not when a counter already at 0 reloads 0 by itself. Star Trek: 25th Anniversary requires the Sharp behaviour. NES 2.0 submapper 4 selects the alternate revision (submapper 1 is the MMC6); an iNES 1.0 dump uses the default unless Nes::set_mmc3_revision_override forces one (v3.1.0). Until the v3.1.0 review this item called Sharp "MMC3A" and put MMC3B on submapper 1, both wrong.

The counter rule and the IRQ's deferred output (v2.9.9, T-ORACLE-001). On each filtered A12 rise the counter follows the NESdev MMC3 page exactly: if the reload flag (set by a $C001 write) is set or the counter is zero, it reloads from $C000's latch and the flag clears; otherwise it decrements. The IRQ then asserts if the counter is zero and IRQs are enabled. On the default chip that is after any of the three paths; on the alternate chip it is only after a decrement. The IRQ line is raised at the first per-cycle hook after the rise that asserts it: notify_a12 sets irq_assert_pending_next_cycle, and the next notify_cpu_cycle raises the line. Which CPU cycle that is comes from the bus's order: Cpu::start_cycle catches the PPU up to the access and then calls SystemBus::cpu_clock, which calls the hook, so a rise caught up before the access is raised in its own cycle and one caught up after it (end_cycle) from the next. That is the same split the removed mmc3-m2-phase-irq feature made from phase data (this paragraph said "one CPU cycle after the rise" for every rise until the #583 review; ADR 0002's 2026-10-05 correction). An $E000 write in between cancels the assertion still in flight, as it is the same line. Which rises clock the counter is unchanged; only when the line is seen moves. Until v2.9.9 a $C001 reload asserted only if the write had cleared a non-zero counter (a latch the page does not have), and the IRQ was seen on the cycle of the rise. That latch made 4-scanline_timing sub-test 2 pass by raising the IRQ a scanline late, which is what failed sub-test 3. With the page's rule and the deferred output, both 4-scanline_timing ROMs (mmc3_test, mmc3_test_2) first fail at sub-test 9 instead of 3, and mmc3_test/5-MMC3 passes; with the PPU's A12 stream corrected (T-MMC3-BG-A12, docs/ppu-2c02.md pitfall 4: the background's fetches at the page's dot 324, and a visible line's dot 0 driving the background CHR address except where the odd-frame skip replaced it) they pass all 13 sub-tests. The delay replaced v2.0.0's mmc3-m2-phase-irq feature, which deferred only rises seen in the M2-high half of a cycle; it passed the same ROMs because it makes, in effect, the same split. This form takes that split from the order of the catch-up and the per-cycle hook, with no phase data from the bus, so it is kept and the feature is removed. The MMC3 save-state section is version 4 (it carries the in-flight flag); version 3 is refused. ADR 0002 keeps the history of the earlier attempts. 3. MMC2/MMC4 latch on tile fetch. PPU calls a "tile fetched" notification with the tile address; mapper switches CHR bank if tile == $FD or $FE. Used for Punch-Out's character animations. 4. MMC5 CHR bank sets. Two register sets: A ($5120-$5127) and B ($5128-$512B). The PPU's sprite tile fetch path calls PpuBus::ppu_read_sprite, which SystemBus forwards to Mapper::ppu_read_sprite; MMC5 resolves it through the A set. Every other CHR access (background fetches, $2007) goes through ppu_read / ppu_write, and the MMC5 picks the set itself, from its own decode of the PPU's $2000 (8x16 sprites) and $2001 (render enables), delivered by Mapper::notify_ppu_register_write at the undecoded address, since the chip ignores mirrors. Per the NESdev MMC5 page and the hardware tests it cites: in 8x8 mode only the A set is used, for everything; in 8x16 mode background fetches use the B set while rendering, and $2007 uses the set written last (reset to A by selecting 8x8), except that $2007 reads use the A set while extended attributes are on. Which registers drive which window follows $5101 exactly as the page's table gives, and a register value indexes banks of the selected size. Changed in v2.9.9 (T-MMC5-8X8-SET): before it, 8x8 mode drew backgrounds from the B set, sprites read the A set as 1 KiB banks whatever $5101 said, and the 8, 4 and 2 KiB modes indexed 1 KiB banks and read B registers the table does not use. 5. VRC2/4 mapping confusion. Different VRC2/4 variants share iNES mapper IDs but route registers differently. NES 2.0 submappers disambiguate. 6. Namco 163 N163 audio enable bit. Disabled by default; ROM must set it. Some ROMs forget; default-on causes glitches in those. 7. Bus conflict timing. The bus-conflict-AND happens at the time of the write; emulators that compute the conflict after the bank-switch read get it wrong. 8. PRG-RAM enable bit. Some mappers (MMC1, MMC3) have a PRG-RAM enable that, when clear, causes reads to return open bus and writes to be ignored. Required for Low G Man music to play correctly under MMC3. 9. NES 2.0 submapper routing. VRC2/VRC4, MMC3 revision, BNROM/NINA, bus-conflict variants, and some multicarts require submapper-specific dispatch. Treat an iNES fallback as a compatibility guess, not proof of board identity. 10. Expansion audio mix levels. VRC6, Sunsoft 5B, Namco 163, MMC5, VRC7, and FDS audio use different cartridge output paths and board-dependent levels. Keep mapper audio behind explicit state and tests so future PAL, Famicom adapter, or front-loader mix options can be added without changing mapper banking behavior. 11. A PRG window larger than the PRG-ROM mirrors it (v2.9.0, re-audit NC-01). Mappers 46, 57, 58, 61, 62, 202 and 212 accept an image smaller than their 32 KiB window (16 KiB, or an odd count of 16 KiB banks for 202 and 212), and the window's index ran past the end: a panic on the first fetch from $C000-$FFFF in 32 KiB mode. The index is now taken modulo the ROM length, as MMC3 always did, which is what a smaller part does on hardware with its missing address lines. It is the identity for every image whose window fits. Pinned by tests/undersized_prg.rs. Validate a board's reads against the ROM length, not against the bank count: a count clamped with .max(1) is still a count of banks that do not exist. 12. load_state must refuse any value the board's own registers cannot produce (v2.9.0, re-audit NC-02 / NC-06 / NC-07). A field a fetch or clock uses unmasked, restored raw, passes the load and fails on the next tick, after the restore's rollback can help. Three were found: GTROM (111) banks (an index panic), the BS-5 (286) DIP setting, which must be one of its four (a shift overflow in 1 << (dip + 4)), and the FDS audio cycle prescaler, which must be 0-15 (an add overflow). Validate before assigning, and return MapperError::Invalid.

Test plan

  • holy_diver_battery_test / holy_mapperel (tepples): detects mappers and verifies bank reachability for each PRG/CHR bank. Wired into CI as the mapper bank-reachability + IRQ regression net (crates/rustynes-test-harness/tests/holy_mapperel.rs, gated on --features test-roms): the 19 committed zlib-licensed ROMs (tests/roms/holy_mapperel/) are each driven to their settled result screen and pinned by an insta framebuffer-hash snapshot, so a silent mapper-detection / bank-layout / RAM-sizing / IRQ regression flips exactly that ROM's hash and fails loudly. The net promotes nothing; it pins the honest current result, including the documented MMC1/FME-7 WRAM-disable residual (see docs/accuracy-ledger.md).
  • mmc3_test_2 (5 sub-ROMs): MMC3 IRQ behavior including the Sharp/NEC distinction and edge cases.
  • mmc3_irq_tests (blargg): MMC3 IRQ timing.
  • vrc24test (AWJ): all VRC2/4 variants.
  • AccuracyCoin (100thCoin): single-cartridge accuracy battery covering many mappers.
  • Per-mapper boot test: golden-master framebuffer for the first 60 frames of attract mode of one freely-distributable ROM per mapper.
  • NES 2.0 header corpus: mapper/submapper fixtures for revision-sensitive boards, including MMC3A/B/C, VRC2/VRC4 address wiring, BNROM/NINA, and bus-conflict-free board variants.

Open questions

Re-checked against the code at v3.1.0 (records item DOC-03): the first two and the fourth are answered, and are kept with their answers rather than deleted.

  • MMC3 default revision when iNES (no submapper) is detected. Answered: the default is Sharp (the "normal" IRQ behaviour); NES 2.0 submapper 4 selects the alternate one (MMC3A and the non-Sharp MMC3B), and since v3.1.0 any mapper-4 game can be forced either way (Nes::set_mmc3_revision_override, desktop [emulation] mmc3_irq_revision, T-MMC3-NEC-OVERRIDE). This line used to call the Sharp default "MMC3A", which is the other revision.
  • Mapper #5 (MMC5) audio scope. Answered: the two extra pulses and the raw PCM channel landed behind mapper-audio (Track C2 / Phase 2.3; the audio table in docs/compatibility.md).
  • VRC7 FM audio. YM2413-derived; only Lagrange Point uses it commercially. Banking + IRQ landed in Track C2 / Phase 2.4 (mapper 85; same mapper-audio feature flag as VRC6 / Sunsoft 5B / Namco 163 / MMC5). The FM synthesizer landed via a clean-room pure-Rust port of emu2413 v1.5.9 (MIT) at crates/rustynes-apu/src/opll.rs; ADR 0006 (docs/adr/0006-vrc7-audio-landed.md) supersedes the ADR 0004 deferral. Lagrange Point plays with in-game audio (mixed via the mapper-audio slot).
  • Pirate / multicart mappers. Answered by policy: none were in the initial scope; many are in now (191 families, docs/STATUS.md), each admitted under the long-tail policy in docs/compatibility.md (demand, a fixture or a specific NESdev page, and NES 2.0 detection).