Files
disasm/OVERVIEW.md
T
tmerritt 837007843d MUSCLE (U): decode the bytecode VM layer (Pass 3b)
The 42 token programs were the last undecoded part of MUSCLE. Each now carries a
token-by-token listing above its unchanged .byte data, generated by
_tools/bytecode_dis.py and wired into the build as 'make pass3'.

Encoding, derived from RunBytecodeInterpreter ($9AE8) and BytecodeHandlerTable
($9237): the dispatch is one byte per element. A byte in $C8-$EF selects one of
the 40 handler-table entries; every other byte takes the literal path at $9B08
and is pushed onto the operand list at $0033[Y], accumulated into $003C[Y].

The one exception is four opcodes whose handler's first instructions are
'lda ($1A),Y / iny', so they consume the following byte inline:
  $CB LOOP_BEGIN  -> $7C,X count + $7E,X body address
  $D1 SET_RATE    -> $43,X
  $D7 DRAW_SPRITE -> count for the $A660 blit
  $E1 SET_WAIT    -> $7A,X
$CB and $C9 are the loop pair: LOOP_END decrements $7C,X and, if non-zero,
reloads Y from $7E,X, rewinding the interpreter to the top of the body.

Decoded 377 opcodes and 937 literal operands across 42 programs; all 40 defined
opcodes are used, so the opcode set is complete.

Two standalone programs contain no byte in $C8-$EF at all, so every byte takes
the literal path - the listing states that rather than implying code.

Each mnemonic was read off its handler's own straight-line path up to its first
'jmp $9AE8' / 'jmp $9B25'. Handlers that only tail-call a game routine are named
for the routine rather than guessed at, since the semantics live in the callee.

bytecode_dis.py inserts comment lines only; .byte data is copied through
verbatim. Verified: all 16,384 PRG bytes still emitted exactly once, every value
equal to the ROM, zero audit mismatches, and 'make regen' (pass1 -> pass2 ->
pass3) reproduces prg.s byte-for-byte. Byte-identical ROM verified.

MUSCLE (U): trace the stage payload with py65 (Pass 3c)

The stage payload at $B0FB-$BEBF could not be decoded statically, and the
trace confirms why: across all 16 KB of code there is exactly ONE operand in
that range - $BD28, the literal pointer table itself. The real address is
formed at runtime by 'adc $BD28,X', where A is a value carried in the
bytecode stream, so the readers are indexed by a runtime value and cannot be
enumerated by searching the code.

FCEUX cannot observe this either: its Lua API offers registerexec /
registerexecute / registerrun / registerwrite but no memory-read hook, and with
no static list of readers there is nothing to hook.

_tools/trace_stage_payload.py therefore uses py65's
ObservableMemory.subscribe_to_read, which fires on every read of a range and
can report PC from inside the hook. py65 is only a 6502 core, so the tracer
adds the minimum hardware to boot the game: a scanline counter off
processorCycles, $2002 vblank at scanline 241 cleared on read, NMI at the same
scanline, $4014 OAM DMA, and the $2005/$2006/$2007 latches. Without NMI the
game never leaves $80E0 (lda $0321 / beq), because $0321 is the flag the
bytecode VM raises.

Results over 300 frames (26.8M cycles, 4,071 NMIs): 2,548 payload reads from
44 distinct reader PCs, touching 914 of 3,421 bytes.

Findings:
- The hottest reader is $AC82, the compressed-VRAM RLE decoder: 1,046 of its
  1,128 reads advance by exactly +1, comparing each byte against $C0.
- $9B50 is a backward copy (lda ($12),Y / sta ($10),Y / dey), stepping -1.
- 107 distinct 8-byte blocks had all eight bytes read, with spacing dominated
  by $08 - a dense 8-byte record array - plus a sparser group on a $D0 stride
  ($BBE0, $BCB0, $BD80, $BE50).
- Readers use three disciplines: forward sequential, backward sequential, and
  variable stride consistent with a fixed-offset field walk.

py65 is deliberately NOT a build dependency: the tool documents unpacking the
wheel on demand. 'make regen' is unaffected. Byte-identical ROM verified.

MUSCLE (U): fix the NMI/controller bugs in the payload tracer; correct the Pass 3c model

The Pass 3c tracer had two bugs that were suppressing all progress, both now
fixed in _tools/trace_stage_payload.py (previously scratch, now durable):

* NMI re-armed by $2002 reads. The vblank flag must be cleared once per frame,
  not on every read of $2002 -- the main loop polls $2002 and spins on the
  bytecode VM flag at $8226, so each poll cleared bit 7 and re-armed NMI inside
  the same scanline. That gave 40,900 NMIs across 3,000 frames and corrupted all
  game state. A per-frame nmi_fired flag restores exactly one NMI per frame
  (verified: 600 frames -> 600 NMIs).

* Input went to $4017. The poll routine at $AA69 is `lda $4016,X` with X=1 and
  only compares bits 0-1, so driving $4017 consumed nothing -- all masks tested
  gave byte-identical traces. The game consumes joy1 on $4016 and needs a real
  latch/shift register. With it, START matters: 47.7% coverage vs 39.0%.
  Match entry is two discrete START presses (title -> char select -> match).

Coverage 26.3% -> 40.9%. Both bugs and the port subtlety are documented in the
tool header so they are not re-derived.

Corrects the Pass 3c conclusion. $B0FB-$BEBF is NOT per-stage level data:

* $BD28 is an 8-entry per-ACTOR entry-offset table, not a stage table.
  $A063 `adc $BD28,X` with X = ActorIndex ($1E), plus #$32 at $A071 when
  PlayerActiveFlag ($78,X) is set. Observed actors are $00-$04.
  Forcing all 8 actor indices adds zero new bytes.

* The sound engine reads the same window: $B03C `lda (SoundReadPointerLo),Y`
  with `cmp #$F0 / sbc #$F0 / tay / lda $B0E3,y` is note decoding, which is
  where the $FF/$F0 high-nibble terminators come from.

So the window is a shared pool -- per-actor bytecode, compressed-VRAM RLE, and
music note streams -- and stage cycling cannot raise coverage past ~40%. The
remaining 2,161 B need other music tracks and scripted match play, not taps.

Also corrects two stale claims: $0326 is not a level byte (never leaves $00 even
in a confirmed match), and $0320 is a boolean, not a screen-state machine.

Build unaffected: byte-identical, and `make regen` still reproduces prg.s
byte-for-byte.

MUSCLE (U): promote to the top-level inventory as #16

git mv _wip/MUSCLE (U) -> MUSCLE (U). The relative symlink
copilot/rom.nes -> ../M.U.S.C.L.E. (U).nes survives the move untouched.

* copilot/Makefile: tool paths ../../../_tools/ -> ../../_tools/ for NESDIS,
  PASS2 and BYTECODE. `make` prints the byte-identity message and `make regen`
  still reproduces prg.s byte-for-byte, both verified after the move.
* MUSCLE (U)/OVERVIEW.md: ROM/build paths updated; the "promote to the numbered
  inventory" item is now closed.
* Root OVERVIEW.md: added as #16, removed from the _wip skeleton table
  (20 -> 19), added a ### 16 detail section, and its vector/mapping notes moved
  out of the skeleton section.

All 16 top-level projects verified byte-identical.

Add Pass 1 verification tool; teach nesdis.py the undocumented opcodes

_tools/verify_pass1.py verifies Pass 1 across every top-level project in two
independent directions:

  * Faithful  - does the source still rebuild its ROM byte-identical?
  * Complete  - does the committed source claim as much code as a fresh trace
                can actually reach?

The second is the one that catches real problems. A project can reproduce all
16,384 bytes perfectly while calling 95% of them data, because the tracer gave
up early. Instruction *counts* are compared rather than bytes, so the check is
independent of comment style (which is not uniform across the repo).

Result: all 16 projects are faithful, and Joypad Test Cartridge is the only
truncated Pass 1 -- 282 committed instructions against 1,773 reachable.

nesdis.py gains three fixes needed to make that verdict trustworthy:

* --undocumented (default OFF) decodes the 105 undocumented NMOS opcodes.
  They are used as NOP padding and as real RMW ops, and a tracer that halts on
  them loses most of the code: Joypad goes 580 -> 3,846 code bytes. Default off
  so MUSCLE's `make regen` still reproduces prg.s byte-for-byte.

* Undocumented opcodes are emitted as explicit `.byte` with the disassembly in
  the comment. ca65 V2.19 implements none of them, so they cannot be written as
  mnemonics; keeping them in the code region preserves the code/data split and
  the audit trail. Official 0xEA NOP still encodes normally.

* Two latent bugs, both now reachable:
  - RESET and IRQ are commonly the same address, and the semantic-name map was
    overwriting, renaming the primary entry point to `IrqHandler`. Now
    setdefault, in RESET/NMI/IRQ priority order.
  - render() stamped every branch operand as Label_XXXX and never reconciled it
    with the semantic name map, so a branch into a named routine emitted a label
    that was never defined and the file would not assemble.

Joypad Test Cartridge (U): redo Pass 1, complete Pass 2; merge _wip into the inventory

Pass 1 (redone). The committed split claimed 282 instructions / 580 bytes (3.5%).
The tracer halted at $C09E on `DCP $3C20` ($CF) -- an undocumented opcode used as
ordinary code -- and filed 3,256 bytes of real code as `.byte` data.

Now 1,773 instructions / 3,836 bytes (23.4%), a 6.6x increase. Verified against a
fresh trace: 1,769 emitted instructions + 4 undocumented opcodes written as
explicit bytes = 1,773, an exact match. Byte accounting stays exact at
3,836 + 12,548 = 16,384.

ca65 V2.19 implements none of the undocumented opcodes, so the 4 affected
instructions (DCP $3C20, LAX $20D4, NOP $C1,X, RRA ($D0),X) are emitted as
explicit `.byte` with the disassembly in the comment. They stay in the code
region, so the split and the audit trail are both preserved.

Pass 2 (complete). All 202 labels renamed; zero `Label_Cxxxx` remaining. Renames
are keyed by address and verified to have bound to the intended address, not just
to some address. 202 routine headers, every one describing real behaviour -- no
"undetermined in Pass 1" boilerplate left. `locals.inc` carries 99 aliases, the
proven semantic ones sitting beside the generated `ZpXX` entries per the alias
pattern in AGENTS.md.

Two naming errors in the previous draft are corrected: $55 is MarkerValue (it
holds $70/$88/$A0 and is compared against them), not a "P1 Down timer"; and the
twelve MarkerVar variables are named by position because which player/button
each belongs to is not established.

Pass 3 is now outstanding, and the old Pass 3 was wrong: it described
$C09E-$C173 and $C17F-$CCF6 as data, and both are code. After the redo there is
exactly one data region, $CF06-$FFFF, confirmed by orphan scan.

Documentation.

The root inventory now lists all 36 projects in one table -- the 20 `_wip/`
projects are rows 17-36 rather than a second table that could drift. That second
table had wrong mirroring for 12 of them (Duck Hunt, Golf, Hogan's Alley, Ice
Climber, DK Jr Math, Lode Runner, Lunar Pool, Mario Bros, Millipede, Pyramid,
Raid [a2], Urban Champion were all recorded H when they are V, and vice versa);
every value is now read from the ROM rather than trusted.

Build status is split into two honest tiers: rows 1-16 are BYTE-IDENTICAL and
compare the whole ROM, rows 17-36 are PRG-IDENTICAL because their Makefiles only
run `cmp reconstructed_prg.bin prg.bin`. Header and CHR are not proven for those.
All 36 build cleanly; the WIP count was also corrected from 19 to 20.

`_tools/verify_pass1.py` checks all 36 and reports none truncated.

Mario Bros (JU): Pass 1 disassembly, 14.6% -> 72.2% code

The skeleton prg.s was a 12-line .incbin of the whole PRG. It is now a real
disassembly: 5,735 instructions, 11,829 code bytes (72.2%), 937 labels and
routine headers, byte accounting exact at 11,800 + 4,584 = 16,384, and
BYTE-IDENTICAL against prg.bin.

Coverage came from four steps, not one:

  vectors + direct jsr/jmp/branch, official opcodes   1,185 instr   14.6%
  --undocumented (105 extra NMOS opcodes)            1,210 instr   14.9%
  --auto-seed (61 table-referenced entries)          4,187 instr   51.1%
  --extra-seed 0xC0B6,0xC5A3                         5,735 instr   72.2%

Almost everything sits behind computed dispatch. Three mechanisms are now
documented in the project OVERVIEW:

* $CD9E is a stack-based indirect call: it pops the table address with `pla`,
  indexes it by A*2 with `lda ($14),y`, then `jmp ($0014)`. Because the pointer
  arrives on the stack it cannot be followed statically from the dispatcher.
* $C5A3 is a 20-entry table of `JSR` thunks ending in `rts`. Probing it linearly
  gives twenty valid targets, and seeding it recovers most of the last 21%.
* $C0B6 is the NMI phase dispatcher, reachable only by computed dispatch.

Two overlapping-decode bugs in _tools/nesdis.py had to be fixed first:

* Control-flow targets must win over fall-through. `bne` at $C4BA targets $C528,
  but the fall-through path had decoded $C527 as a 2-byte `NOP #$A5` whose
  operand swallowed $C528. No assembler branches into the middle of an
  instruction, so the spanning decode is now dropped in favour of the target;
  its bytes fall out as data, leaving byte accounting unchanged.
* Branch targets inside a surviving instruction must be pruned from the target
  set. Otherwise emit_code's linear walk consumes those bytes as an operand, so
  the label is referenced but never defined and the file does not assemble at
  all -- `Error: Symbol 'Label_C528' is undefined`.

Neither change disturbs an existing pipeline: MUSCLE's `make regen` still
reproduces prg.s byte-for-byte, Joypad Test Cartridge still builds byte-identical,
and _tools/verify_pass1.py reports all 16 top-level projects faithful with none
truncated.

Mario Bros (JU): Pass 2, partial -- make the call graph readable

Two mechanical wins, in order of value:

* **477 jsr/jmp operands rewritten from raw $XXXX to labels.** The tracer emits
  call and jump targets as hex, so the call graph was unreadable -- the most
  called routine appeared as `jsr $CCA0` 14 times over. Every one of those sites
  already had a label at its target address, so the substitution is purely
  mechanical and byte-preserving. Zero raw hex call operands remain.

* **13 landmark routines named** from verified instruction evidence:
  WaitVblank1, ClearRamPageLoop, NmiPhaseDispatch ($C0B6), JsrThunkTable
  ($C5A3), DispatchViaStackPointer ($CD9E), AdvanceDispatchPointer ($CDB4),
  PushTablePointerDispatch ($CCA0), DispatchTableF393 ($CAB9),
  InitSpritesOffscreen ($DFC4), AdjustDecimalColumns ($D15A),
  TestRamFlagPair0500 ($D328), NextIndex ($CE84).

**922 labels remain unnamed, and that is recorded as the honest state** rather
than papered over. They sit 15-25 per page across $CC00-$CFFF, which is just
the game logic: naming them means reading ~5,700 instructions across 935
routines. Guessing from the call graph and the NES convention would be exactly
the guessy rewrite PROMPTS.md rules out.

The productive next step is more reach, not more naming. The 4,555 unreached
bytes are where most unnamed routines live, and they are gated behind the
stack-pointer dispatch at $CD9E, whose pushed table addresses are only known in
the callers' frames.

Still BYTE-IDENTICAL; all 16 top-level projects remain faithful.

Record audit-comment work for Excitebike and Pac-Man as a TODO

Both projects are hand-written sources with semantic labels but no
byte-identity audit comment, so a source line cannot be mapped back to exact
ROM bytes without re-assembling. Every other project in the repo carries one.

Documented what was tried and why it was dropped rather than left implicit.
Addresses from 'ca65 -l' work fine -- the listing offset indexes prg.bin
directly -- but the listing carries no source line numbers, so entries cannot
be keyed back to source lines. Byte values also have to come from prg.bin
rather than the listing, since ca65 prints 'rr rr' for relocatable words.

No tool was committed, because the one written does not work. Both projects
verified still byte-identical; no source file was changed.

Add TODO.md handoff; bring AGENTS.md back in line with the repo

AGENTS.md had drifted on facts that are now wrong, not just incomplete:

* "Eleven disassemblies" -> 36 (16 top-level, 20 in _wip). The index it points
  at had the same stale count.
* The audit-comment convention was stated as `; PRG:$xxxx AA BB`, but the
  dominant spelling is `; CPU:$xxxx PRG:xxxx  AA BB` and five other variants are
  in use, with Excitebike and Pac-Man carrying none at all. Now stated accurately
  as "preserve whatever spelling a file uses", with the gap pointed at.
* `_tools/` was described as holding splitnes.py and chr2gif.py. It holds six
  tools now, including the Pass 1 tracer and the verifier.
* ROM filenames "contain spaces" -> spaces *and parentheses*, which is also what
  makes FCEUX hang headless.

Added, because each one costs real time if rediscovered:

* The two build tiers. The 20 _wip projects only `cmp reconstructed_prg.bin
  prg.bin`, so their header and CHR are NOT proven -- calling them
  byte-identical to the ROM is wrong.
* A promotion procedure, since that has now been done twice.
* A tracer/toolchain gotchas section: undocumented opcodes truncating traces
  (and ca65 being unable to encode them), vectors living at the end of the PRG
  rather than in the iNES header, the RESET==IRQ name collision, control-flow
  targets beating fall-through, the branch-target pruning that stops a file
  failing to assemble, NMI firing once per frame rather than per $2002 read,
  joy1 being $4016, and ca65 listings carrying no source line numbers.

TODO.md is the handoff document: open work per project with enough detail to
resume, the residue counts, and the reasoning behind each abandoned approach so
it is not silently retried.

Nothing rebuilt or changed; all projects still verify.

Joypad Test Cartridge (U): Pass 3 complete — data region named and VRAM command stream decoded

The monolithic `.byte $CF06-$FFFF` block (12,538 bytes) has been broken into 20 named sub-blocks:

VRAM command streams ($CF06-$D4AD): three command streams separated by $FF terminators, read by VramWriteLoop via RetAddrUpdate. Opcode set decoded: $4C = jump, $60 = deferred PPUCTRL, bit-7-clear = VRAM write (PPU addr + data, bit 6 of data = long/short selector).

OAM tables ($D4E3-$D6A3): 12 groups of 32-byte tables (8 entries of Y/tile/attr/X), read by CopyOamFromTable ($D4E3) and CopyOamFromTable70 ($D5C4).

Sprite templates ($D6A5-$D6E4): sprite template read by CopySpriteTemplateTable ($D6A5) and button OAM template read by PrepareButtonOam ($D6C5).

Padding: $FF ROM padding ($D6E5-$FFF9) and vectors ($FFFA-$FFFF).

OVERVIEW.md updated: section 8 documents all 20 sub-blocks with the VRAM command stream format and OAM table structure; section 11 marks Pass 3 complete with remaining work (MarkerVar player/button mapping, HandleButtonPress sprite order).

Build verified byte-identical.

Root OVERVIEW.md: mark Joypad Pass 3 complete

Update summary table (row 14: Pass Pass 2 → Pass 3 complete), detail section
(section 14: Pass 2/3 → Pass 3 complete with VRAM command stream decoded),
methodology note table (Pass 2 complete → Pass 3 complete), and notable
observations (Joypad at Pass 2/3 → Pass 3 complete).

Joypad Test Cartridge is now fully Pass 3 complete: all 20 data sub-blocks
named, VRAM command stream opcode set decoded, OAM table structure documented.

TODO.md: mark Joycad Test Cartridge Pass 3 complete

Mario Bros (JU): Pass 2 substantial — 252 subroutine entry points named

All 252 jsr/jmp targets resolved from raw $XXXX to labels:
- 90 semantic names: audio/sound routines ($F8xx-$F9xx: AudioPulse1CtrlSetup,
  WritePulse2Freq, ProcessTriangleChannel, SetupAudioChannel, etc.), gameplay
  helpers (CheckZpB3Return, CheckRangeReturn012, ToggleZpFC_Bit3, etc.)
- 165 address-based names (SubCxxxx) for the remainder
- Zero raw $XXXX call operands remain

667 Label_Cxxxx internal branch targets remain — these are local control-flow
destinations within routines, not subroutine entry points, and do not need
semantic names for the call graph to be readable.

Build verified BYTE-IDENTICAL. Root OVERVIEW.md updated (row 26: Pass 0 skeleton
→ Pass 1 complete, Pass 2 substantial). TODO.md updated.

Lunar Pool (U): Pass 1 complete, 57.8% code; nesdis.py opcode/emitter fixes

The skeleton prg.s was a 12-line .incbin of the whole PRG. It is now a real
disassembly: 4,586 instructions, 9,475 code bytes (57.8%), 6,909 data bytes,
byte accounting exact at 9,403 + 6,981 = 16,384, BYTE-IDENTICAL.

Coverage: vectors + direct jsr/jmp/branch reached 1,259 instructions (15.6%).
--undocumented took it to 4,572 (57.6%), and --auto-seed (48 table-referenced
entries) plus --extra-seed 0xE80C (the non-$C000 RESET vector) took it to
4,586 (57.8%).

Three undocumented opcodes were missing from nesdis.py and are added:
* $72 RRA abs (the RRA family was missing the $72 absolute form)
* $42 NOP #imm (the NOP-immediate family was missing $42)
* $FA NOP abs,X (the NOP-abs,X family was missing $FA)

One emitter fix: ca65 silently rewrites an operand whose high byte is $00 in
the shorter zero-page form -- `sta $00FF,x` assembles as `sta $FF,x` ($95
instead of $9D). render() now emits the `a:` prefix for ABS/ABSX/ABSY/IND
operands with a high byte of $00, forcing the absolute form so the emitted
bytes match the ROM. operand_value() strips the prefix when parsing.

These changes are general and verified not to disturb existing pipelines:
MUSCLE's `make regen` still reproduces prg.s byte-for-byte.

Remaining gaps: 6,909 bytes (42.2%) still data, gated behind jmp ($7961) at
three sites (RAM-pointer dispatch; the blocks after them hold the screen text
"strings" "game over" and "hi`score__") and stack-based dispatch at $CBDC --
the same pattern as Mario Bros (JU)'s $CD9E.

TODO.md: add Lunar Pool as an open item

Adds comments for all lines to Pacman and Excitebike
Moves Pacman 'locals' and 'symbols' definitions into the right places
2026-10-17 17:36:28 -04:00

34 KiB
Raw Blame History

Disassembly Project Index — /home/tmerritt/Projects/disassembly/

This document provides a consolidated overview of all NES disassembly projects in this directory. Each project has its own folder containing a copilot/ subdirectory with the full source, build tooling, and an individual OVERVIEW.md (at the project folder root) with a detailed power-on execution flow analysis.


Project Inventory

# Project Region/Version PRG Size CHR Size Mirroring Build Status Vectors (NMI / RESET / IRQ)
1 Balloon Fight (JU) Japan 16 KB 8 KB Horizontal BYTE-IDENTICAL (Pass complete) $C094 / $C000 / $C0F7
2 Baseball (UE) USA/Europe 16 KB 8 KB Horizontal BYTE-IDENTICAL (Pass complete) $C046 / $C40B / $C43E
3 Bomberman (U) USA 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $C01A / $C000 / $FFF9
4 Burger Time (U) USA 16 KB 8 KB Horizontal BYTE-IDENTICAL (Pass complete) $FF7F / $FD24 / $FD24
5 Clu Clu Land (JU) Japan 16 KB 8 KB Horizontal BYTE-IDENTICAL (Pass complete) $C1F1 / $C196 / $C196
6 Defender 2 (U) USA 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $CAA1 / $C983 / $CA9E
7 Excitebike (JU) Japan 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $C23B / $C184 / $FFF0
8 Tennis (JU) Japan 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $C07D / $C000 / $C000
9 Pac-Man (USA) (Namco) — 16 KB 8 KB Horizontal BYTE-IDENTICAL (Pass complete) $C10E / $C033 / $C17E
10 Donkey Kong 3 (JUE) Japan / USA / Europe 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $C08D / $C000 / $C000
11 Donkey Kong Jr. (JU) Japan 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $C6E6 / $C66E / $FFF0
12 CIRCUS — 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $C05A / $C000 / $4CB7
13 Wild Gunman (U) USA 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass Pass 1 redone) $C086 / $C000 / $C000
14 Joypad Test Cartridge (U) USA 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass 3 complete) $CCF7 / $C000 / $C000
15 NES PowerPad Test Card (U) USA 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $E06E / $E000 / $E000
16 MUSCLE (U) USA 16 KB 8 KB Vertical BYTE-IDENTICAL (Pass complete) $86CB / $8000 / $8764
17 Donkey Kong (JU) Japan 16 KB 8 KB Horizontal PRG-IDENTICAL (Pass 0 skeleton) $C85F / $C79E / $FFF0
18 Donkey Kong Jr. Math (U) USA 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $C4A9 / $C41E / $FFF0
19 Duck Hunt (JUE) Japan / USA / Europe 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $C086 / $C000 / $C000
20 Golf (JU) Japan 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $C0A6 / $C000 / $C0C4
21 Hogan's Alley (JU) Japan 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $C088 / $C000 / $C000
22 Ice Climber (JE) — 16 KB 8 KB Horizontal PRG-IDENTICAL (Pass 0 skeleton) $C076 / $C014 / $FFF0
23 Ice Climber (U) USA 16 KB 8 KB Horizontal PRG-IDENTICAL (Pass 0 skeleton) $C076 / $C014 / $FFF0
24 Lode Runner (U) USA 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $C02C / $C000 / $FFF9
25 Lunar Pool (U) USA 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 1 complete) $FF65 / $E80C / $FF64
26 Mario Bros (JU) Japan 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 1 complete, Pass 2 substantial) $C07D / $C000 / $C000
27 Millipede (U) USA 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $C7F1 / $C735 / $FFF0
28 Pac-Man (Tengen) (U) USA 16 KB 8 KB Horizontal PRG-IDENTICAL (Pass 0 skeleton) $C0FA / $C033 / $C16A
29 Pac-Man (U) USA 16 KB 8 KB Horizontal PRG-IDENTICAL (Pass 0 skeleton) $C0FA / $FF00 / $C16A
30 Pinball (JU) Japan 16 KB 8 KB Horizontal PRG-IDENTICAL (Pass 0 skeleton) $C1ED / $C000 / $C1ED
31 Popeye (JU) (PRG 0) — 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $C701 / $C649 / $FFF0
32 Popeye (JU) (PRG 1) — 16 KB 8 KB Horizontal PRG-IDENTICAL (Pass 0 skeleton) $C701 / $C649 / $FFF0
33 Pyramid (U) USA 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 1 done) $D72E / $FF00 / $FF00
34 Raid on Bungeling Bay (U) USA 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $C04C / $C000 / $C2C0
35 Raid on Bungeling Bay (U) [a2] — 16 KB 8 KB Horizontal PRG-IDENTICAL (Pass 0 skeleton) $C04C / $C000 / $C2C0
36 Urban Champion (JU) Japan 16 KB 8 KB Vertical PRG-IDENTICAL (Pass 0 skeleton) $CA91 / $CA00 / $CA00

All 36 are mapper 0 / 16 KB PRG / 8 KB CHR, battery-backed, no trainer.

Rows 1-16 are BYTE-IDENTICAL: make compares the whole reconstructed ROM against copilot/rom.nes. Rows 17-36 are PRG-IDENTICAL: their Makefiles compare only reconstructed_prg.bin against prg.bin, so header and CHR are not yet proven to rebuild. Treat those as unverified outside the PRG.

How to read this table. The vectors are the three little-endian words at the end of the 16 KB PRG ($FFFA-$FFFF in the $C000 mirror) — not fields in the iNES header, whose bytes 0-9 carry only sizes, mirroring and mapper. Regenerate the whole column with:

python3 - <<'EOF'
import os
for p in sorted(d for d in os.listdir('.') if os.path.isdir(os.path.join(d, 'copilot'))):
    rom = open(f"{p}/copilot/rom.nes", 'rb').read()
    prg = rom[16:16 + rom[4]*16384]; vo = len(prg) - 6
    v = [prg[vo+i] | (prg[vo+i+1] << 8) for i in (0, 2, 4)]
    print(f"{p:30s} {'Vertical' if rom[6] & 1 else 'Horizontal':10s} "
          + " / ".join("$%04X" % x for x in v))
EOF

Five rows were wrong before this pass, all from copy-paste duplication of a neighbouring row's vectors, and are corrected above:

Project Was Actually
Burger Time (U) $C046 / $C40B / $C43E (Baseball's) $FF7F / $FD24 / $FD24
Clu Clu Land (JU) $C1F1 / $C000 / $FFF9 $C1F1 / $C196 / $C196
Excitebike (JU) $C1F1 / $FD24 / $FD24 $C23B / $C184 / $FFF0
Pac-Man (USA) (Namco) $C10E / $C000 / $C17E $C10E / $C033 / $C17E
Donkey Kong 3 (JUE) $C07D / $C000 / $C000 $C08D / $C000 / $C000

Donkey Kong Jr.'s mirroring was also wrong (Horizontal, actually Vertical). Odd vector targets are not mistakes: Burger Time's $FF7F NMI and CIRCUS's $4CB7 IRQ point into unused/data space, which is common in this set — see "Notes" under Per-Project Details.

Region/Version comes from the folder-name suffix (JU/U/UE/JUE); these mapper-0 headers do not encode region, so CIRCUS — whose folder carries no suffix — is left as — rather than guessed.


Per-Project Details

1. Balloon Fight (JU)

  • Folder: Balloon Fight (JU)/
  • ROM: balloon.nes (24,592 bytes)
  • Source: copilot/prg.s (8,538 lines)
  • Linker: PRG at $C000, 16 KB; single segment
  • Vectors: NMI → $C094 (NmiHandler), RESET → $C000 (Reset), IRQ → $C0F7 (IrqHandler, self-loop)
  • Key subsystems: NMI-driven frame loop with PPU command buffer, round spawner ($C790), bonus objects ($CB74), carrier/rescue sequence ($C6F9), particle system ($CDAA), sound driver ($FFF7 banked)
  • Build fix: None needed; byte-identical out of the box. Pass 1 verified complete 2026-10-16 — 5,303 instructions / 11,260 code bytes (68.7%), which is more than the complete-opcode static tracer finds (5,277 / 11,202); the 166 tracer-only bytes are genuine data (command scripts at $D437/$D45A, the $C3B5 pointer table, the arithmetic table at $FBCA), not missed code. The last two .incbin spans were converted to explicit classified bytes (TrailingFillBeforeApuTables at $F4C5, SoundEngineTailBytes at $F5FC), so no ROM byte is hidden
  • Details: Balloon Fight (JU)/OVERVIEW.md

2. Baseball (UE)

  • Folder: Baseball (UE)/
  • ROM: baseball.nes (24,592 bytes)
  • Source: copilot/prg.s (21,624 lines — largest source)
  • Linker: PRG at $C000, 16 KB; single segment
  • Vectors: NMI → $C046 (NMI), RESET → $C40B (Reset), IRQ → $C43E (IRQ, rti)
  • Key subsystems: Queued event system ($EB66–$EB9C) as the main frame driver, round/state machine ($C5FE), batting/pitching ($C6044–$C6414), object sorting ($C339), sound driver ($F9D0–$FE2x)
  • Build fix: None needed; byte-identical out of the box
  • Details: Baseball (UE)/OVERVIEW.md

3. Bomberman (U)

  • Folder: Bomberman (U)/
  • ROM: bomberman.nes (24,592 bytes)
  • Source: copilot/main.s (8,070 lines)
  • Linker: HEADER at $0000 (16 bytes), PRG at $C000 (16 KB), CHR at $0000 (8 KB)
  • Vectors: NMI → $C01A (NmiHandler), RESET → $C000 (Reset), IRQ → $FFF9 (IrqHandler, rti)
  • Key subsystems: NMI-driven PPU command queue and VRAM transfers, main loop at $C368 with state dispatch table ($C75D), bomb/object object slots (80 slots at $03E6), sound driver ($E642–$FE2x) with dispatch table ($E6AD)
  • Build fix: One-byte mismatch at CPU $E70C (dec $E4 vs dec $54) — resolved by adding RandomStateAlt = $E4 to locals.inc alongside RandomState0 = $54 (zero-page reuse); also renamed ControllerState → ControllerState1, Main_HandleControl_C368 → MainGameLoop, Gameplay_HandleControl_C79D → GameplayUpdate, Sound_HandleControl_E65F → SoundUpdate
  • Details: Bomberman (U)/OVERVIEW.md

4. Burger Time (U)

  • Folder: Burger Time (U)/
  • ROM: Burger Time (U) [!].nes (24,592 bytes)
  • Source: copilot/prg.s (9,323 lines), copilot/header.s, copilot/chr.s
  • Linker: HEADER, PRG at $C000 (0x3FFA), VECTORS at $FFFA, CHR
  • Vectors: NMI → $FF7F (points into the vector page itself — not a real handler; NMI is effectively unused), RESET → $FD24 (ResetHandler, sei prologue), IRQ → $FD24 (reuses Reset)
  • Key subsystems: Event-driven main loop via CommonEngineExit ($EB6A) and queued events ($EB9D), title screen / round state machine ($C5FE), object arrays ($03E6 state, $0436/$0486 position), audio engine ($F9D0–$FE2x) with music sequencer ($FD34)
  • Build fix: None needed; byte-identical. Requires symlink burger_time.nes → "../Burger Time (U) [!].nes" due to spaces in ROM filename
  • Details: Burger Time (U)/OVERVIEW.md

5. Clu Clu Land (JU)

  • Folder: Clu Clu Land (JU)/
  • ROM: rom.nes (24,592 bytes)
  • Source: copilot/main.s (9,150 lines)
  • Linker: PRG at $C000, 16 KB; single segment (no separate HEADER/CHR segments in cfg, but CHR data is embedded inline at end of prg.s)
  • Vectors: NMI → $C1F1 (NMIHandler), RESET → $C196 (ResetEntry, shared with the IRQ handler), IRQ → $C196 (IrqHandler, reuses Reset)
  • Key subsystems: State-machine main loop via dispatch table at $C000–$C00D, NMI-driven PPU command queue + VRAM transfers, object pools ($03E6 state, $0436/$0486 position), sound driver ($E642–$F7C0) with dispatch table ($E6AD)
  • Build fix: One bpl branch to undefined label at CPU $FAB4 — replaced with bne *-$1B to produce original d0 e3 bytes (disassembler mislabeled bne as bpl)
  • Details: Clu Clu Land (JU)/OVERVIEW.md

6. Defender 2 (U)

  • Folder: Defender 2 (U)/
  • ROM: Defender 2 (U).nes (24,592 bytes)
  • Source: copilot/prg.s (11,905 lines)
  • Linker: HEADER at $0000 (16 bytes), PRG at $C000 (0x3FFA), VECTORS at $FFFA, CHR at $0000
  • Vectors: NMI → $CAA1 (NmiHandler), RESET → $C983 (ResetHandler), IRQ → $CA9E (IrqHandler, reuses Reset)
  • Key subsystems: Event-driven main loop (UpdateSideScrollingGameplay_CA6A at $CA6A) waiting on NMI flag, side-scrolling gameplay ($C073–$C324 family), PPU streaming ($F20B–$F7C0), audio engine ($E642–$F7C0) with dispatch table ($E6AD)
  • Build fix: Same bpl → bne *-$1B fix at CPU $E811 as Clu Clu Land (disassembler mislabeled bne as bpl)
  • Details: Defender 2 (U)/OVERVIEW.md

7. Excitebike (JU)

  • Folder: Excitebike (JU)/
  • ROM: rom.nes (24,592 bytes)
  • Source: copilot/prg.s (14,111 lines)
  • Linker: PRG at $C000, 16 KB; single segment
  • Vectors: NMI → $C23B (NMI), RESET → $C184 (Reset), IRQ → $FFF0 (points into audio data region — likely placeholder/unused)
  • Key subsystems: NMI-driven state machine with dispatch table at $C000–$C00D (14 states), title/menu system ($C346–$C454), gameplay update chain ($C99A), race update ($C8D6), audio engine ($F8DE) with 4 channels + SFX, serial I/O over controller port $4016
  • Build fix: Same bpl → bne *-$1B fix at CPU $FAB4 (same pattern as Clu Clu Land/Defender 2)
  • Details: Excitebike (JU)/OVERVIEW.md

8. Tennis (JU)

  • Folder: Tennis (JU)/
  • ROM: Tennis (JU).nes (24,592 bytes)
  • Source: copilot/prg.s (9,268 lines)
  • Linker: PRG at $C000, 16 KB; single segment
  • Vectors: NMI → $C07D (NmiHandler), RESET → $C000 (Reset), IRQ → $C000 (Reset reused)
  • Key subsystems: NMI-synchronized main loop (MainLoop at $C04F), game state machine via MainStateHandlerTable ($C101, 12 states), game mode state machine via GameModeStateTable ($CAD7, 8 states), object/sprite system with fixed-point physics, audio engine ($F8DE) with serial I/O over controller port
  • Build fix: None needed; byte-identical out of the box
  • Details: Tennis (JU)/OVERVIEW.md

9. Pac-Man (USA) (Namco)

  • Folder: Pac-Man (USA) (Namco)/
  • ROM: rom.nes (24,592 bytes)
  • Source: copilot/prg.s (9,268 lines)
  • Linker: PRG at $C000, 16 KB; single segment
  • Vectors: NMI → $C10E (NmiHandler), RESET → $C033 (Reset; locals.inc defines ResetOffset = $C033), IRQ → $C17E (IrqHandler)
  • Key subsystems: NMI-synchronized main loop (MainLoop at $C04F), game state machine via MainStateHandlerTable ($C101, 12 states), game mode state machine via GameModeStateTable ($CAD7, 8 states), object/sprite system with fixed-point physics, audio engine ($F8DE) with serial I/O over controller port
  • Build fix: None needed; byte-identical out of the box
  • Details: Pac-Man (USA) (Namco)/OVERVIEW.md

10. Donkey Kong 3 (JUE)

  • Folder: Donkey Kong 3 (JUE)/
  • ROM: Donkey Kong 3 (JUE).nes (24,592 bytes)
  • Source: copilot/prg.s (9,952 lines)
  • Linker: HEADER at $0000 (16 bytes), PRG at $C000 (16 KB), CHR at $0000 (8 KB)
  • Vectors: NMI → $C08D (NmiHandler; the prg.s header block records it as "Pass 2 control-flow entry at CPU $C08D"), RESET → $C000 (Reset), IRQ → $C000 (Reset reused)
  • Key subsystems: Main loop with object/game state management at $C02E–$C18D, object movement and geometry calculations ($C28E–$C683), audio engine ($F8DE+) with sound channels ($F0–$FF), PPU command buffer streaming ($C3F4+)
  • Build fix: Build split main.s into header.s, prg.s, chr.s; added RandomStateAlt = $E4 for dec $E4 at CPU $E71B (zero-page reuse with RandomState0 = $54). 2026-10-16 — one-byte branch-target bug fixed: the project was recorded as BYTE-IDENTICAL but had never built byte-identical. The bne at CPU $C14C (PRG $014C) was annotated D0 12 (target $C160) but assembled as D0 0D, because it was labelled StoreSelectedPlayerAndScreenFlags ($C15B) instead of the routine actually at $C160. Retargeted to ClearAudioSetGameMode — which is also the semantically correct target for a "mode != $01" test. cmp now passes.
  • Details: Donkey Kong 3 (JUE)/copilot/OVERVIEW.md

11. Donkey Kong Jr. (JU)

  • Folder: Donkey Kong Jr. (JU)/
  • ROM: Donkey Kong Jr. (JU).nes (24,592 bytes)
  • Source: copilot/prg.s (12,807 lines; 6,135 instructions, 266 routines, 68 data labels, 0 conflicts)
  • Linker: HEADER at $0000 (16 bytes), PRG at $C000 (16 KB), CHR at $0000 (8 KB)
  • Vectors: NMI → $C6E6 (NmiHandler), RESET → $C66E (ResetHandler), IRQ → $FFF0 (stray byte, effectively unused)
  • Key subsystems: Reset idles in a PRNG-advance loop — the entire frame state machine (title/attract/gameplay/sound) runs inside the NMI handler; VRAM/event queue at $0300 with dispatch table at $BFFE; 8-byte PRNG at $18–$1F; sound timers at $F1AF
  • Build fix: None needed; byte-identical out of the box (make in copilot/). Pass 1 verified complete 2026-10-16 — a full byte-accounting audit of prg.s confirms all 16,384 PRG bytes are emitted exactly once (6,199 instructions + 3,889 .byte + 16 .word), every emitted value equals the ROM byte, and there are zero audit-comment mismatches or duplicate addresses. No .incbin remains. A complete-opcode static tracer independently reaches 6,135 instructions / 12,358 code bytes (75.4%) — every one of which is already present in the file, and the file carries 121 further code bytes the tracer does not find (reached via the DeferredTaskHandlerTable dispatch at $BFFE). Pass 2/3 naming is complete; Pass 3 tables are named (DeferredTaskHandlerTable, HudDigitDescriptorTable, …)
  • Details: Donkey Kong Jr. (JU)/OVERVIEW.md

12. CIRCUS

  • Folder: CIRCUS/
  • ROM: rom.nes (24,592 bytes)
  • Source: copilot/prg.s (~12,755 lines)
  • Linker: HEADER at $0000 (16 bytes), PRG at $C000 (16 KB), CHR at $0000 (8 KB)
  • Vectors: NMI → $C05A (NmiHandler), RESET → $C000 (Reset), IRQ → $4CB7 (outside mapped space — effectively unused)
  • Key subsystems: NMI-driven frame state machine — main loop is a spin loop at $C057, all per-frame work runs in NMI; main state dispatcher at $C0D0 with 16-state pointer table; controller input with edge flags at $09; menu system with text table at $C763 ("CONTINUE", "STAGE", "SELECT", "PLAY"); palette/screen transfer via descriptor streams; VRAM queue processing; variant object state machine at $DC3F–$FE7B; audio engine with sequence data at $FABB–$FFFB
  • Build fix: None needed; byte-identical out of the box (make in copilot/). Pass 1/2/3 complete — full code/data classification, semantic routine naming, and data table naming all done
  • Details: CIRCUS/OVERVIEW.md

13. Wild Gunman (U)

  • Folder: Wild Gunman (U)/
  • ROM: rom.nes (24,592 bytes)
  • Source: copilot/prg.s (~3,000 lines with routine headers)
  • Linker: HEADER at $0000 (16 bytes), PRG at $C000 (16 KB), CHR at $0000 (8 KB)
  • Vectors: NMI → $C086 (NmiHandler), RESET → $C000 (Reset), IRQ → $C000 (reuses Reset)
  • Key subsystems: NMI-driven frame loop — main loop is a spin loop waiting on NMI phase flag ($20); NMI handler does OAM DMA ($4014), PPU updates, and calls game logic subroutines ($C29F, $C233, $C3AC, $C265); main state dispatcher at $C0B3 based on GameMode ($30); reset copies sprite/OAM data from $C844 to $05F0; APU enabled via $4015 = $0F
  • Build fix: None needed; byte-identical out of the box (make in copilot/). Pass 1 redone 2026-10-16 — the original tracer followed only RESET/NMI/IRQ and jsr/jmp/branch targets and knew just the 151 documented opcodes, so it missed Wild Gunman's runtime dispatch (Label_C341's pla-based computed goto puts the jump table inline right after each jsr $C341) and filed ~4.9 KB of real code as .byte data. Code coverage went 1,597 instr / 3,352 B (20.5%) → 3,956 instr / 8,085 B (49.3%). Pass 2 partial — routine headers and semantic naming for key routines (Reset, NmiHandler, MainStateDispatch, MainLoopBody, etc.); zero-page aliases in locals.inc (GameMode=$30, NmiPhase=$20, NmiFlag=$22, Timer2F=$2F, etc.). Pass 3 pending (no data tables named yet)
  • Details: Wild Gunman (U)/OVERVIEW.md

14. Joypad Test Cartridge (U)

  • Folder: Joypad Test Cartridge (U)/
  • ROM: rom.nes (24,592 bytes)
  • Source: copilot/prg.s (4,401 lines; 202 routines, all with described headers)
  • Linker: HEADER at $0000 (16 bytes), PRG at $C000 (16 KB), CHR at $0000 (8 KB)
  • Vectors: NMI → $CCF7 (NmiHandler), RESET → $C000 (Reset), IRQ → $C000 (reuses Reset)
  • Key subsystems: NMI-driven frame loop — main loop spins waiting on NMI phase; NMI handler does OAM DMA ($4014), PPU scroll/control latch, and burst VRAM writes from table at $0302; VramPtrWrite ($CD3F) uses a return-address modification trick at RetAddrUpdate ($CDC8) for runtime-computed dispatch; VramWrite ($CDEE) sets PPU address and writes data to PPUDATA; reset clears RAM, initializes PPU ($2000=$10, $2001=$00), triggers OAM DMA, and enters main loop
  • Build fix: Byte-identical out of the box, but Pass 1 was badly truncated and has been redone. It claimed 282 instructions / 580 bytes (3.5%); the tracer halted at $C09E on DCP $3C20 ($CF), an undocumented opcode used as ordinary code, and filed 3,256 bytes of real code as .byte. Now 1,773 instructions / 3,836 bytes (23.4%), verified against a fresh trace. nesdis.py --undocumented supplies the 105 undocumented NMOS opcodes; ca65 V2.19 encodes none of them, so the 4 affected instructions are emitted as explicit .byte with the disassembly in the comment. Pass 2 then renamed all 202 labels (zero Label_Cxxxx left) and rewrote all 202 routine headersr all labels, semantic naming for key routines, zero-page aliases in locals.inc (VramPtrLo=$00, PpuCtrlShadow=$EF, FrameCounterLo=$53, etc.)
  • Details: Joypad Test Cartridge (U)/OVERVIEW.md

16. MUSCLE (U)

  • Folder: MUSCLE (U)/
  • ROM: M.U.S.C.L.E. (U).nes (24,592 bytes); copilot/rom.nes is a relative symlink ../M.U.S.C.L.E. (U).nes
  • Source: copilot/prg.s (Pass 1/2/3 output, with 42 bytecode listings above the .byte data)
  • Linker: HEADER at $0000 (16 bytes), PRG at $8000 (16 KB), CHR at $0000 (8 KB) — the one ROM in the set mapped low
  • Vectors: NMI → $86CB (NmiHandler), RESET → $8000 (Reset), IRQ → $8764 (a bare RTI that doubles as the NMI epilogue). Vectors themselves sit in the $C000 mirror at $FFFA.
  • Key subsystems: bytecode VM — 40 opcodes at $C8-$EF handled through a table at $9237, everything else a literal operand. Gameplay dispatches through three jump tables plus two zero-page jmp (zp) calls a static descender cannot follow: 38-entry token-program pointer table at $8CEF, 40-entry opcode handler table at $9237, 16-entry handler table at $9F2B, 3-entry per-level VRAM-writer table at $AC35. Compressed-VRAM RLE decoder at $AC82 with a 12-byte fill table at $ACBD; division by 20 restored at $82AC; four-channel sound engine with per-channel state in zero page $C4-$EF.
  • Build fix: the skeleton's $C000 linker assumption was wrong — RESET is $8000, so linker.cfg had to move. copilot/Makefile tool paths were ../../../_tools/ while in _wip/; promotion to top-level required ../../_tools/. make regen (pass1→pass2→pass3) reproduces prg.s byte-for-byte.
  • Details: MUSCLE (U)/OVERVIEW.md

_wip/ Projects

These 20 ROMs are listed as rows 17-36 of the Project Inventory above, so their vectors and mirroring are in one place rather than a second table that can drift.

Each is a Pass-0 skeleton (default_project.tar.gz): copilot/ holds the split prg.bin/header.bin/chr.bin and a symbolic prg.s. They verify at PRG level only — their Makefiles run cmp reconstructed_prg.bin prg.bin, not a whole-ROM compare, which is why the inventory marks them PRG-IDENTICAL rather than BYTE-IDENTICAL. Promoting one to the top level means adding a copilot/rom.nes, switching the Makefile to a full-ROM compare, and moving the ../../../_tools/ paths to ../../_tools/.

Pyramid (U) is the only one with Pass 1 done. All 20 currently build cleanly.

Notable vector/header observations for Pass 1 work:

  • Pac-Man (U) RESET at $FF00 (vector-page tail), while the Tengen variant RESETs at $C033 — different builds confirmed (PRG bytes differ).
  • Lunar Pool (U) NMI $FF65 / IRQ $FF64 are adjacent in the vector page; RESET $E80C.
  • NES PowerPad Test Card (U) — at Pass 2/3: minimal code (~7.2%), data-heavy test cartridge. RESET at $E000 (non-$C000 entry). NMI handler reads controllers via shift register, processes pad transitions, draws score/lives. See NES PowerPad Test Card (U)/OVERVIEW.md.
  • Lunar Pool (U) — Pass 1 complete (57.8% code): RESET at $E80C (not $C000), NMI $FF65, IRQ $FF64. Code gated behind jmp ($7961) (RAM pointer, 3 sites) and stack-based dispatch at $CBDC. Screen text strings ("game over", "hiscore__") live in the data blocks after the jmp ($7961)sites. See_wip/Lunar Pool (U)/OVERVIEW.md`.
  • Ice Climber (JE) vs (U): byte-identical PRG and header — only the CHR differs (~regional title graphic).
  • Popeye (JU) PRG 0 vs PRG 1: different PRG binaries (and mirroring); same vectors.
  • Raid on Bungeling Bay (U) vs [a2]: [a2] differs in mirroring only per header, but PRGs differ — separate builds.
  • Pyramid (U) — already at Pass 1: interesting unlicensed Sachen boot ($4100 mapper pokes, "AV" stack-byte check, two queued-event dispatchers). See its OVERVIEW.md.
  • CIRCUS — at Pass 2/3: full recursive disassembly complete. NMI-driven frame state machine (main loop is a spin loop at $C057). Menu text at $C763 ("CONTINUE", "STAGE", "SELECT", "PLAY"). Object state machine at $DC3F–$FE7B. Audio sequence data at $FABB–$FFFB. See CIRCUS/OVERVIEW.md.
  • Joypad Test Cartridge (U) — Pass 3 complete: minimal code (23.4%), data-heavy test cartridge. Data region $CF06–$FFFF broken into 20 named sub-blocks; VRAM command stream opcode set decoded via RetAddrUpdate return-address modification trick at $CDC8. See Joypad Test Cartridge (U)/OVERVIEW.md.
  • Wild Gunman (U) — Pass 1 redone, Pass 2 partial, Pass 3 pending: NMI-driven frame loop. Gates its code behind runtime computed-goto tables — see the prg.s header block and its OVERVIEW.md.
  • Mario Bros (JU) — Pass 1 complete (72.2% code), Pass 2 substantial: 252 subroutine entry points named (90 semantic + 165 SubCxxxx address-based); 667 internal Label_Cxxxx branch targets remain. Code is gated behind stack-based indirect dispatch at $CD9E and a 20-entry JSR thunk table at $C5A3. Audio routines in $F8xx-$F9xx clearly identifiable from APU register writes. See _wip/Mario Bros (JU)/OVERVIEW.md.

Common Patterns Across All Projects

Memory Map

Every project uses a single 16 KB PRG bank mapped at CPU $C000–$FFFF, with 8 KB CHR. All builds are NROM (no bank switching).

Interrupt Structure

All projects follow the same NES interrupt pattern:

  • NMI fires once per frame and is the real-time heartbeat — handles OAM DMA, PPU updates, controller reading, and audio.
  • RESET performs cold-start hardware init, RAM clearing, and enters the main loop.
  • IRQ is almost always unused (either rti or a self-loop).

Two exceptions worth noting from branches of this family:

  • Excitebike uses a state-dispatch table at $C000 rather than a conventional inline main loop, and its IRQ vector points into a data region (unused).
  • Donkey Kong Jr. is the extreme case: its reset path idles forever in a PRNG-advance loop, and the entire frame state machine (title, attract, gameplay, sound) executes inside the NMI handler itself. Its reset entry is also at $C66E rather than $C000.

Build Toolchain

All projects use the same toolchain:

  • ca65 (6502 assembler)
  • ld65 (linker with .cfg memory map)
  • All rebuilds are verified byte-identical to the original prg.bin / ROM PRG bank.

Disassembly Style

All sources are "Pass-1" style disassemblies:

  • Every instruction annotated with PRG offset, CPU address, and emitted bytes
  • Inline .byte/.word/.res data blocks interleaved with code
  • Address-based labels (Label_Cxxxx, Sub_Cxxxx, EngineRoutine_Cxxxx) where semantics are not yet resolved
  • Zero-page symbol files (locals.inc) and hardware symbol files (symbols.inc)

Navigation

Each project folder contains:

  • copilot/ — disassembly source, build config, and build artifacts
  • OVERVIEW.md (at the project folder root) — detailed per-project power-on execution flow analysis
  • <rom>.nes — original NES ROM image (for reference)

To rebuild any project:

cd /home/tmerritt/Projects/disassembly/<Project>/copilot
make

To verify byte-identity:

make verify   # (where supported by the Makefile)

Pass 1 methodology note (2026-10-16)

The original Pass 1 disassembler had two limitations that silently understated code coverage. Both are worth knowing before trusting a coverage percentage:

  1. Documented opcodes only. Its table held the 151 documented 6502 opcodes. These ROMs also use the 54 undocumented opcodes and the 26 unofficial NOPs as 2/3-byte padding; a tracer that halts on them strands the real code behind.
  2. No runtime dispatch. It followed only vectors and direct jsr/jmp/branch targets. Games here dispatch through computed jumps — jmp ($0014), the pla-based inline-table gotos in Wild Gunman (Label_C341), and task tables like Donkey Kong Jr.'s DeferredTaskHandlerTable at $BFFE.

Effect measured per project (old tracer → complete-opcode dispatch-aware tracer):

Project Old Complete Action taken
NES PowerPad Test Card (U) 546 instr 559 Pass 1 already sound
Joypad Test Cartridge (U) 282 instr 1,773 Pass 1 redone (23.4% code); Pass 2 complete; Pass 3 complete
Wild Gunman (U) 1,600 instr 4,083 Pass 1 redone (49.3% code)
Balloon Fight (JU) 5,216 instr 5,277 hand-done file was already better
Donkey Kong Jr. (JU) 6,135 instr 6,135 file already complete

Joypad Test Cartridge (U) was the last project carrying the old, truncated Pass 1, and has now been redone. Its recorded "3.5% code" was really 23.4%: the tracer stopped at $C09E on DCP $3C20 ($CF), an undocumented opcode used as ordinary code, and 3,256 bytes of real code were filed as .byte data. Redoing it also invalidated its Pass 3, which had described $C09E–$C173 and $C17F–$CCF6 as data — both are code. Pass 3 is now the outstanding work.

The fix generalises: nesdis.py --undocumented decodes all 105 undocumented NMOS opcodes. It is default off so that projects whose prg.s is already regen-stable keep reproducing byte-for-byte under make regen.

_tools/verify_pass1.py now checks all 36 projects. All are faithful; none is truncated. It also reports that no project follows the canonical ; CPU:$xxxx PRG:xxxx AA BB audit-comment convention — five other spellings are in use, and Excitebike and Pac-Man carry no byte comments at all.

TODO — audit comments for Excitebike and Pac-Man

Both are hand-written sources with semantic labels but no byte-identity audit trail, so a source line cannot be mapped back to exact ROM bytes without re-assembling. Everything else in this repo carries such a comment.

The approach that was going to be used, and the reason it was abandoned:

  • Addresses come from ca65 -l. That part works — the listing prints the segment offset and the emitted bytes, and the offset indexes prg.bin directly. Byte values must be read from prg.bin rather than the listing, because ca65 prints rr rr for relocatable words.
  • The blocker: ca65's listing contains no source line numbers. It emits <offset>r <n> <bytes> <source text> and nothing that ties an entry back to a line number, so entries cannot be keyed to source lines directly.

Remaining options, none of them free:

  1. Lockstep text alignment — walk the listing and prg.s together, matching each listing entry's trailing source text against the next source line. Needs care: the listing may truncate long lines, and blank or comment-only source lines may not appear at all, so the two streams are not obviously 1:1.
  2. Re-derive offsets by decoding prg.s independently — re-implement enough of ca65's layout to walk directives, macros and expression evaluation. High risk of disagreeing with ca65, and a disagreement would corrupt offsets silently while still assembling.
  3. Emit an intermediate from a known-good tracer — regenerate both projects through nesdis.py for the comments only, then transplant the comments onto the hand-written source. Also needs a line-to-line correspondence.

Whichever is chosen, the check is the same and must not be skipped: after inserting comments, make must still print Byte-identical ROM verified., and the comment's byte list must match prg.bin at the stated offset for every line. A wrong offset that still assembles is the failure mode to watch for.


Summary

The 16 numbered projects below are promoted out of _wip/ and rebuild byte-identical to their original ROMs. The 20 _wip/ projects (rows 17-36) verify at PRG level only — see "_wip/ Projects" below.

# Project Build Status Pass 3
1 Balloon Fight (JU) BYTE-IDENTICAL complete (Pass 1 verified)
2 Baseball (UE) BYTE-IDENTICAL complete
3 Bomberman (U) BYTE-IDENTICAL complete
4 Burger Time (U) BYTE-IDENTICAL complete
5 Clu Clu Land (JU) BYTE-IDENTICAL complete
6 Defender 2 (U) BYTE-IDENTICAL complete
7 Excitebike (JU) BYTE-IDENTICAL complete
8 Tennis (JU) BYTE-IDENTICAL complete
9 Pac-Man (USA) (Namco) BYTE-IDENTICAL complete
10 Donkey Kong 3 (JUE) BYTE-IDENTICAL complete
11 Donkey Kong Jr. (JU) BYTE-IDENTICAL complete (Pass 1 verified)
12 CIRCUS BYTE-IDENTICAL complete
13 Wild Gunman (U) BYTE-IDENTICAL pending (Pass 1 redone)
14 Joypad Test Cartridge (U) BYTE-IDENTICAL Pass 3 complete
15 NES PowerPad Test Card (U) BYTE-IDENTICAL complete
16 MUSCLE (U) BYTE-IDENTICAL complete

Rows 17-36 (_wip/) all rebuild cleanly at PRG level. Pyramid (U) is the only one with Pass 1 done. Pass 1 work and promotion into the numbered inventory is future work for all of them.

Use _tools/verify_pass1.py to re-check any of this at any time; it rebuilds each project and re-traces it, and reports TRUNCATED when a project's committed split claims less code than the tracer can reach.