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 | Console type 1 (Vs. System): Vs. PPU type (bits 0-3), Vs. hardware type (bits 4-7). Console type 3 (Extended): extended console type (bits 0-3), bits 4-7 reserved. Otherwise unused — NES 2.0 only |
| 14 | Misc ROM count (bits 0-1) — NES 2.0 only |
| 15 | Default expansion device (bits 0-6) — 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 ("DiskDude!" puts 'D' = $44 in byte 7 and adds
64 to the mapper number). RustyNES applies the NESdev "iNES" rule: if the
header is not NES 2.0 and bytes 12-15 are not all zero, the upper four mapper
bits (byte 7's high nibble) are masked off (since v2.9.8). A clean iNES 1.0
header has zeros there, so a well-formed dump of mapper 16-255 is unaffected,
and NES 2.0 headers are exempt because bytes 12-15 are real fields. The
per-game database still runs first in the frontend and the coverage harness,
but it rewrites only bytes 6-7, not the dirty tail, so a database mapper of 16
or more on such a dump would be masked as well. Every corrected "DiskDude!"
dump in the local corpus has a database mapper below 16, so none is affected.
Mapper number¶
dirty = !is_nes2 && header[12..16] != [0; 4];
mapper = (header[6] >> 4)
| (if dirty { 0 } else { 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. Header carries all four:
prg_ram_size and chr_ram_size (the volatile low nibbles), prg_nvram_size
and chr_nvram_size (the non-volatile high nibbles). The PRG-RAM window a board
allocates at $6000-$7FFF is Header::prg_ram_window(), the volatile and
non-volatile sizes together, because some carts (StarTropics / MMC6) declare
their save RAM only in the NVRAM nibble. Until v2.9.8 prg_ram_size held that
sum and the NVRAM sizes had no field; the window, and so every board's
allocation, is unchanged. No board allocates CHR-NVRAM from the header.
iNES 1.0 has no RAM size fields: prg_ram_size reports a nominal 8 KiB,
chr_ram_size 8 KiB when there is no CHR-ROM (else 0), and both NVRAM sizes 0.
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).
Byte 13 is decoded per console type (NESdev "NES 2.0" §"Vs. System Type" and §"Extended Console Type"):
- Vs. System (console type 1):
Header::vs_ppu_typefrom the low nibble (VsPpuType; the reserved nibbles$1,$6,$7,$C-$Fdecode as the 2C03), andHeader::vs_hardware_typefrom the high nibble (VsHardwareType:UniSystemand its four protection variants, the twoDualSystemtypes, andReserved(7..=15)).Header::is_vs_dual_system()is true for types 5 and 6; until v2.9.8 that was avs_dual_systemfield and the type itself was not kept, so a canonical encode wrote every dual board as 5. - Extended (console type 3):
Header::extended_console_typefrom the low nibble (ExtendedConsoleType:$0-$Cnamed,Reserved(13..=15)). - Otherwise byte 13 is unused and both fields are
None.
The two Option fields are Some exactly when the header is NES 2.0 and the
console type is the matching one, so an iNES 1.0 dump's junk in byte 13 never
becomes a hardware type.
Miscellaneous ROMs (NES 2.0)¶
Byte 14 bits 0-1 give the number of miscellaneous ROMs present
(Header::misc_rom_count, 0..=3; 0 on iNES 1.0). The area itself is whatever
follows CHR-ROM in the file.
Region (NES 2.0)¶
Byte 12 bits 0-1: 00 = NTSC, 01 = PAL, 10 = multi-region, 11 = Dendy. The core plays multi-region at NTSC timing. iNES 1.0 has only the legacy bit in byte 9 (largely useless; many ROMs are mis-tagged), and parse_header ignores it: an iNES 1.0 image always parses as NTSC. Old dump tools wrote "DiskDude!" over bytes 7-15 (byte 9 = 's' = $73, bit 0 set), and on the staged corpus three pirate multicart images carry $01 there in an otherwise clean header with nothing to say whether it is meant.
Where a PAL iNES 1.0 game gets its region (v2.9.8). From the per-game
database, on the load path, before the core parses the header
(rustynes_gamedb::load_time_entry then apply_header_overrides, the one
chokepoint the desktop, browser and coverage harness share). A PAL or Dendy row
rewrites the iNES 1.0 header as the NES 2.0 header that describes the same
board, with the region in byte 12: the mapper number the iNES 1.0 parse
settled on, submapper 0, the plain byte-4/5 sizes (byte 9 = 0), PRG-RAM and
CHR-RAM stated as the iNES 1.0 heuristics give them (8 KiB, and 8 KiB of CHR-RAM
when there is no CHR-ROM), and bytes 13-15 zero. Because NES 2.0 sizes are taken
at their word where iNES 1.0 sizes are not -- the v2.9.6 MMC3 multicart boards
take their own default instead of iNES 1.0's nominal 8 KiB -- a second candidate
states no RAM at all. Each candidate is parsed and compared with the iNES 1.0
board (cartridge identity, mirroring, console type, battery, and the mapper's
serialized state, debug view, capability flags and RAM sizes); the first that
matches is used, and if none does the region is refused rather than bought with
a different board. A unit sweep over every mapper id finds no board refused.
Vs. System and PlayChoice-10 carts are never promoted (those cabinets exist only
in NTSC timing). A NES 2.0 header keeps its own region: a vendored database row
never rewrites byte 12. parse_header itself is unchanged -- the byte-9 rule
above still holds for an image no database row describes.
Default input device (NES 2.0)¶
Byte 15 bits 0-6 identify the default expansion or input device. Since v2.9.8
it is parsed into Header::default_expansion_device (ExpansionDevice: the
NESdev codes $00-$4F by name, Unassigned(n) for $06 and $50-$7F;
Unspecified on iNES 1.0), but nothing selects an input device from it: the
frontend still 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_preserving(h: &Header, original: &[u8; HEADER_LEN]) -> [u8; HEADER_LEN];
Since v2.9.8 Header models every field the header defines, and it is
#[non_exhaustive]: outside rustynes-mappers a Header comes from
parse_header, or from Header::default() (an empty iNES 1.0 mapper-0 header)
plus field assignment, so a later field is not an API break. What it does not
model are the reserved bits (byte 12 bits 2-7, byte 13 for console types 0 and
2, byte 13 bits 4-7 for console type 3, byte 14 bits 2-7, byte 15 bit 7), the
exact value of a reserved Vs. PPU nibble, and iNES 1.0 bytes 8-15, which are not
part of that format.
The crate-private canonical encoding writes every field, choosing the standard
size notation where it can express the size and the exponent-multiplier
notation otherwise (the order the NESdev page prescribes). For every header
parse_header produces, parse_header(canonical(h)) == h;
canonical_encoding_round_trips_every_parsed_header checks that over every
byte-7 x byte-13 pair, a byte-4/byte-9 sweep and 200,000 random headers, and
canonical_encoding_round_trips_constructed_nes2_headers over headers built
field by field. Until v2.9.8 that encoding was public as serialize_header
(deprecated since v2.9.3) and lost the NVRAM nibbles, the exponent notation,
the Vs. hardware type, the extended console type and bytes 14-15; it was
removed, per ADR 0042's 2026-10-01 amendment.
serialize_header_preserving is the public writer. It starts
from the 16 bytes the Header was parsed from and rewrites only the bits of
fields whose value changed, each in its canonical encoding. An unedited header
therefore comes back byte for byte, reserved bits included, and an edit touches
only its own bits. Two
edits re-encode more: a console-type change rewrites byte 13, because byte 13
means something different for each console type, and toggling NES 2.0 re-encodes
the whole header canonically, because bytes 7-15 change meaning with the format.
Tests pin both properties: identity over every byte-7 x byte-13 pair plus
200,000 random headers, and a changed-bit mask per editable field.
v2.9.3 changed this. Until then the header editor wrote the canonical
encoding, which lost the bits listed above. That encoding also wrote mapper
bits 8-11 into byte 7's high nibble instead of bits 4-7, so every mapper from 16
up was saved wrong (mapper 66 as 2). The encoder is fixed and
canonical_encoding_round_trips_every_mapper_id guards it. serialize_header
was deprecated then and removed at v2.9.8.
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. It
decodes with parse_header, so it cannot drift from the loader. "Write header
to file" writes the edits over the bytes the file held with
serialize_header_preserving (above) 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, capped at $EFF units so the re-encode stays in the standard notation
(a byte-9 nibble of $F would select the exponent notation; until v2.9.8 the
cap was 4,095 and a count above $EFF was written as an exponent-notation size
nobody asked for). Since v2.9.8 every field is editable: the NVRAM sizes, the
Vs. hardware type, the extended console type, the miscellaneous ROM count and
the expansion-device code. 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.