Skip to content

Mapper Ecosystem

DoubleGate edited this page Jun 29, 2026 · 1 revision

Mappers and Cartridge Ecosystem

References: 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), plus numerous long-tail and pirate mappers.

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>;
}

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 prg_ram_size: u32,
    pub chr_ram_size: u32,
    pub has_battery: bool,
    pub has_trainer: bool,
    pub is_nes2: bool,
}

rustynes_mappers::parse(&[u8]) -> Result<(Cartridge, Box<dyn Mapper>), RomError> parses an iNES or NES 2.0 file, constructs the appropriate concrete mapper, and returns the metadata header together with the boxed mapper as a tuple.

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.

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.
  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. 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.

Per-game mirroring override. 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.

Bus conflicts

Some early mappers 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.

Bus conflict behavior is board-specific. The mapper implementation should decide conflicts from mapper/submapper/board metadata, not only from mapper number.

Mapper accuracy tiering

Every supported family is classified Core / Curated / BestEffort by rustynes-mappers::mapper_tier(id, submapper) — an honesty marker 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. Current split: 172 families.

NSF player (synthetic mapper)

NSF chiptune files are not cartridges — they have no iNES header and no PPU program. They are played through a synthetic NsfMapper. The mapper serves the program image, 8 KiB WRAM at $6000, and a tiny hand-assembled 6502 driver at $5000; the reset/NMI/IRQ vectors are overridden to point at the driver. Reset runs init for the selected song and enables vblank NMI; the ordinary 60 Hz NMI then calls play each frame. Because this reuses the normal lockstep run_frame, the APU produces audio identically to a cartridge and the determinism contract is untouched.

Edge cases and gotchas

  1. MMC1 consecutive-write bug. Writes on adjacent CPU cycles after the first are ignored.
  2. MMC3 IRQ pattern-table revision differences. MMC3A (Sharp) generates IRQ even with latch = $00; MMC3B (NEC) does not.
  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.
  4. MMC5 8x16 sprite CHR. Two separate CHR banks for sprites and BG. PPU must tell the mapper which fetch type is in progress.
  5. Namco 163 N163 audio enable bit. Disabled by default; ROM must set it.
  6. PRG-RAM enable bit. Some mappers have a PRG-RAM enable that, when clear, causes reads to return open bus and writes to be ignored.
  7. Expansion audio mix levels. VRC6, Sunsoft 5B, Namco 163, MMC5, VRC7, and FDS audio use different cartridge output paths and board-dependent levels.

Clone this wiki locally