Cartridge file format (iNES + NES 2.0)¶
References: ref-docs/research-report.md §Cartridge format and mappers
→ iNES and NES 2.0; ref-docs/nesdev-wiki-technical-report.md §ROM And
Music File Formats; Nesdev iNES and
NES 2.0.
Purpose¶
Parse iNES 1.0 and NES 2.0 ROM files into a Cartridge value that the mapper subsystem can use.
File structure¶
[ 16 bytes header ]
[ 512 bytes trainer (only if header[6] bit 2 set) ]
[ PRG-ROM ]
[ CHR-ROM ]
[ misc ROM, NES 2.0 only ]
Header layout¶
| Off | Field |
|---|---|
| 0-3 | Magic: "NES\x1A" (0x4E 0x45 0x53 0x1A) |
| 4 | PRG-ROM size LSB |
| 5 | CHR-ROM size LSB |
| 6 | Flags 6: nametable arrangement (bit 0), battery PRG-RAM (bit 1), trainer present (bit 2), four-screen (bit 3), mapper D3-D0 (bits 4-7) |
| 7 | Flags 7: console type LSBs (bits 0-1), NES 2.0 ID (bits 2-3 == 10 for NES 2.0), mapper D7-D4 (bits 4-7) |
| 8 | Mapper D11-D8 (bits 0-3), submapper (bits 4-7) — NES 2.0 only |
| 9 | PRG-ROM size MSB nibble (bits 0-3), CHR-ROM size MSB nibble (bits 4-7) — NES 2.0 only |
| 10 | PRG-RAM shift (bits 0-3), PRG-NVRAM shift (bits 4-7) — NES 2.0 only |
| 11 | CHR-RAM shift (bits 0-3), CHR-NVRAM shift (bits 4-7) — NES 2.0 only |
| 12 | CPU/PPU timing (bits 0-1: 0=NTSC, 1=PAL, 2=multi, 3=Dendy) — NES 2.0 only |
| 13 | Vs. PPU type (bits 0-3) or extended console type (bits 4-7) — NES 2.0 only |
| 14 | Misc ROM count (bits 0-1) — NES 2.0 only |
| 15 | Default expansion device (bits 0-5) — NES 2.0 only |
Detection rule¶
If false, parse as iNES 1.0, but treat upper mapper bits cautiously. Older tools often wrote non-zero padding or signature strings into bytes 7-15, corrupting mapper high bits. A strict loader may reject dirty headers; RustyNES parses leniently for compatibility but should surface a diagnostic when dirty padding changes the mapper or region interpretation.
Mapper number¶
mapper = (header[6] >> 4) | (header[7] & 0xF0) | (if is_nes2 { (header[8] & 0x0F) << 8 } else { 0 });
iNES 1.0 mapper numbers cover 0..=255. NES 2.0 extends to 0..=4095.
ROM sizing¶
Standard notation (MSB nibble != $F): size = ((MSB << 8) | LSB) * unit_size. PRG unit = 16 KiB; CHR unit = 8 KiB.
Exponent-multiplier (MSB nibble == $F): the LSB byte encodes EEEEEEMM where E is exponent (6 bits) and MM is multiplier code; size = 2^E * (MM*2 + 1) bytes. MM values map to multipliers 1, 3, 5, 7. Used for non-power-of-2 ROM sizes.
RAM sizing (NES 2.0)¶
Same encoding for CHR-RAM and the NVRAM variants.
Mirroring¶
iNES: bit 0 of header[6] = vertical (1) or horizontal (0); bit 3 = four-screen override. NES 2.0 same encoding (mirroring is mostly a property of mapper-controlled state for any non-trivial mapper — this header bit is the initial mirroring).
Console type¶
NES 2.0 byte 7 bits 0-1: 00 = NES/Famicom, 01 = Vs. System, 10 = Playchoice 10, 11 = extended (see byte 13).
Region (NES 2.0)¶
Byte 12 bits 0-1: 00 = NTSC, 01 = PAL, 10 = multi-region, 11 = Dendy. iNES 1.0 has only the legacy bit in byte 9 (largely useless; many ROMs are mis-tagged).
Default input device (NES 2.0)¶
Byte 15 bits 0-5 identify the default expansion or input device. The current frontend assumes standard controllers unless mapper or test harness metadata overrides it. Full use of this field belongs with the v1.x expanded-input work, especially for Zapper, Four Score, Famicom expansion devices, and special controllers.
Public API¶
pub fn parse(bytes: &[u8]) -> Result<Cartridge, RomError>;
pub enum RomError {
Truncated { needed: usize, got: usize },
BadMagic,
UnsupportedMapper(u16),
InvalidConfig(String),
}
parse validates the magic, applies the detection rule, computes expected file length (header + optional trainer + PRG + CHR + optional misc), errors Truncated if short, then dispatches on mapper to construct the right dyn Mapper and returns Cartridge.
The 16-byte header itself has a separate decode/encode pair used by tooling:
pub fn parse_header(bytes: &[u8]) -> Result<Header, RomError>;
pub fn serialize_header(h: &Header) -> [u8; HEADER_LEN]; // canonical 16-byte layout
serialize_header is the exact inverse of parse_header for the standard
(non-exponent-multiplier) size notation. It re-derives the mapper low/mid/hi
nibbles, the flags-6/flags-7 bits, the submapper, the standard PRG/CHR unit
counts, the NES 2.0 region/console/RAM-shift bytes, and the Vs. byte-13 nibbles.
A round-trip test guards parse → serialize → parse equality.
Header editor (v1.7.0 "Forge" Workstream A2, frontend tooling)¶
The frontend ships an iNES / NES 2.0 header editor + read-only "Cartridge
Info" pane (crates/rustynes-frontend/src/debugger/header_editor.rs,
native-only, opened from Debug → Cartridge Info / Header Editor...). It edits
the 16-byte header of a ROM file on disk — never the running core. Decode +
re-encode reuse the canonical parse_header / serialize_header round-trip
above, so the tool cannot drift from the loader; "Write header to file"
re-serializes the edited Header and overwrites only the file's first 16 bytes
(the ROM body is untouched). Sizes are edited in their 16 KiB / 8 KiB unit
counts so the re-encode stays in the standard notation. Source inspiration:
FCEUX iNesHeaderEditor.cpp.
Edge cases¶
- Lying iNES headers. Many old dumps incorrectly set the four-screen bit, the trainer bit, or claim a mapper variant. Strategy: trust the file but expose overrides via
parse_with_override(bytes, options)for the test harness. - Trainer. The 512-byte trainer block (if present) loads at
$7000-$71FF. Few games use it; keep parser support but don't make UI features for it. - PRG/CHR size mismatches. If the file is shorter than the header claims, error. If longer, accept the trailing bytes as misc-ROM (NES 2.0) or warn (iNES 1.0).
- CHR-RAM detection. iNES 1.0 has CHR-ROM size = 0 mean CHR-RAM. NES 2.0 separates them via byte 11.
- PRG-RAM detection. iNES 1.0 has no reliable PRG-RAM size field. Strategy: if mapper is known to use PRG-RAM (MMC1, MMC3, MMC5), assume 8 KB. NES 2.0 byte 10 is authoritative.
- NES 2.0 submappers are not optional metadata. MMC3 revision, VRC2/VRC4 address wiring, BNROM/NINA variants, bus-conflict-free homebrew boards, and several multicarts can require submapper-specific behavior.
- Alternative nametable bit is mapper-specific. Do not globally interpret header[6] bit 3 as "four-screen" for every mapper. Some mappers use it for one-screen, alternate CIRAM wiring, or mapper-specific board variants.
Test plan¶
- Round-trip parse: parse a corpus of test ROMs, re-serialize the header, confirm byte-equivalence (with garbage zeroed for iNES 1.0).
- Property tests: random byte arrays starting with the magic; assert
parseeither succeeds or returns a typed error (no panics). - Real-world: parse the
nes-test-romscorpus; assert noUnsupportedMapperfor mapper IDs in our coverage matrix.
Open questions¶
UNIFformat support. UNIF (.unf) is an alternate, chunked container used by translation patches, pirate dumps, and multicarts. It carries no mapper number — the cartridge is identified by a board-name string in itsMAPRchunk. Implemented in v1.6.0 (Workstream E2):rustynes_mappers::unifparses the 32-byte header + length-prefixed chunks (MAPR/PRG?/CHR?/MIRR/BATR/TVCI), resolves the board name to an iNES mapper viaboard_to_mapper(the puNES/Mesen2 table, RustyNES-implemented subset, vendor-prefix-tolerant, incl. the Sachen 8259 A/B/C/D split), and synthesizes an equivalent NES 2.0 image that flows through the standardparse()path (so all mapper construction is shared).parse()dispatches on the"UNIF"magic. This unlocks the UNIF-only dumps that have no iNES equivalent..fdsFamicom Disk System format. Out of v1.0 scope; defer to FDS support phase.