Files
disasm/MUSCLE (U)/OVERVIEW.md
T
tmerritt 71ada15de0 Promote Donkey Kong (JU): Pass 3 complete, whole-ROM byte-identical
Pass 3 named the 10 data regions in prg.s, then the project was promoted
out of _wip/ so `make` now proves header + PRG + CHR against the original
ROM instead of only the PRG bank.

Data regions named:
  LevelPtrTable        $C000-$C03B  (60 B)    level header data
  ObjectPtrTable       $C03C-$C043  (8 B)     4 pointer pairs
  LevelDataPtrTable    $C044-$C79D  (1882 B)  level data pointers
  SpriteInitPtrTable   labelled at $C4B0      see residue note
  UnreachedCode_D650   $D650-$D696  (71 B)    unreached code
  UnreachedCode_E144   $E144-$E165  (34 B)    unreached code
  MusicDataTable       $F55B-$FA47  (1261 B)  music/sound data
  APUDataTable         $FAE6-$FB74  (143 B)   APU pulse/noise tables
  SfxDataTable         $FE59-$FFEF  (407 B)   sound-effect data
  VectorTable          $FFF6-$FFFF  (10 B)    padding + NMI/RESET/IRQ

Promotion (per the AGENTS.md checklist):
  - git mv "_wip/Donkey Kong (JU)" "Donkey Kong (JU)"
  - add copilot/rom.nes -> ../Donkey Kong (JU).nes symlink
  - add header.s / chr.s (one-line .incbin of header.bin / chr.bin)
  - linker.cfg gains HEADER ($0000/16 B) and CHR ($0000/8 KB)
  - Makefile switched from `cmp reconstructed_prg.bin prg.bin` to
    `cmp reconstructed.nes rom.nes`
  - no _tools path change (this Makefile never referenced _tools/)
    and no `make regen` pipeline exists for it

Doc corrections:
  - project OVERVIEW.md said Mirroring Vertical; header byte 6 = $00
    means Horizontal (the root index was right) — fixed
  - root OVERVIEW.md: row 18 now BYTE-IDENTICAL, tier paragraph and
    summary table refreshed (they had drifted around the Lunar Pool and
    Urban Champion promotions), new per-project detail entry
  - counts corrected to 19 top-level / 17 _wip in OVERVIEW.md,
    TODO.md and AGENTS.md

Residue recorded rather than guessed:
  - Pass 2 is partial: 919 Label_ definitions remain (214 semantic
    labels applied); noted in TODO.md so nobody reads the promotion as
    full semantic naming
  - SpriteInitPtrTable's label sits at $C4B0: the routine at $C807 reads
    $C4A7,x/$C4A8,x, but that table starts mid-row and a label there
    would split the canonical 16-byte audit-comment line

Verification:
  make clean && make  -> "Byte-identical ROM verified."
  verify_pass1.py     -> 19/19 top-level build, Donkey Kong (JU) OK
                         (6014 instr / 12505 B), all comments canonical

Doc-drift sweep: build tiers, mirroring, and stale status tails

Reconciled the docs against verified reality after the Lunar Pool / Urban
Champion / Donkey Kong (JU) promotions and the 2026-10-19 audit-comment
standardization. No source behaviour changed except the two build fixes below.

Build tier (whole-ROM vs PRG-only)
  - AGENTS.md: the 17 `_wip/` projects are real disassemblies at the PRG-build
    tier, not "skeletons"; header.s/chr.s/rom.nes are promotion additions;
    verify_pass1.py covers the top-level projects.
  - Root OVERVIEW.md: `_wip` prose, audit-comment narrative and summary table
    refreshed (37.5% figure, SEEDED vs OK wording, re-trace false alarm).
  - Promoted projects whose build blocks still claimed `make` emits
    `reconstructed_prg.bin` compared to `prg.bin` (Balloon Fight, Baseball,
    CIRCUS, Clu Clu Land, Excitebike, NES PowerPad, Pac-Man (USA), Tennis):
    now describe the whole-ROM `reconstructed.nes` vs `rom.nes`.
  - Lunar Pool / Urban Champion / CIRCUS also had stale `cd "_wip/..."`
    build paths and "Promotion" items; retargeted to their top-level folders.

Mirroring (header byte 6 bit 0: 0 = Horizontal, 1 = Vertical)
  - Corrected DK Jr. prose "horizontal mirroring (flag 6 = $01)" -> vertical,
    and both `_wip/Pac-Man` variant notes "Both use vertical mirroring" ->
    horizontal (both are byte-6 = $00).
  - Re-audited every project OVERVIEW: 0 rows and 0 prose claims now disagree.

Stale status tails
  - Retitled/restatused the `_wip` OVERVIEWs to their real `prg.s` (Golf,
    Ice Climber (U), Lode Runner, Millipede, Pac-Man Tengen, Pac-Man (U),
    Popeye 0/1, Raid, Raid a2, Hogan's Alley, Mario Bros); Golf's
    skeleton-era contents rows and Next Steps corrected.
  - Dropped the skeleton-era "## Next Steps (per `PROMPTS.md`)" heading and
    the "(from the skeleton)" wording across all project OVERVIEWs.
  - Rewrote Lunar Pool / Urban Champion Next Steps (both already Pass 1-3
    complete and promoted; their tails still described pending Pass 2 and
    promotion); fixed root row 13 (Wild Gunman) and row 20 (Duck Hunt).

Also lands two code fixes carried with this sweep:
  - `_wip/Hogan's Alley (JU)`: $E9F1-$E9FD and $EA13-$EA14 reclassified as
    data with canonical audit comments (the seeded build had decoded a data
    table base as code, breaking the build since 2ae09f7). `make` ->
    BYTE-IDENTICAL again; reconstructed_prg.bin/.lbl refreshed.
  - `_wip/Pyramid (U)`: `Label_D72E` -> `NMI:` (byte-identical).

Verification:
  make (Hogan's Alley, Pyramid)      -> BYTE-IDENTICAL, BYTE-IDENTICAL
  _tools/verify_pass1.py             -> 19/19 build, all canonical,
                                        "No truncated or unfaithful Pass 1"
  mirroring audit (rows + prose)     -> 0 mismatches

Donkey Kong (JU): complete Pass 2 — all 919 Label_ definitions named

The last non-zero Label_ residue in the repo is now gone. Every one of the
919 address-derived definitions in copilot/prg.s is replaced:

* 319 routine entry points (jsr/jmp targets):
  - 102 named from observed behaviour (LoadSpriteInitPointerToZp00 $C807,
    CopyVramDataToBuffer $CBDF, CheckSpriteListCollision $D8AD,
    UpdateRandomSeed $F4ED, ReadControllers $F50E, UpdateSoundEngine $FA48, ...)
  - 217 left as SubC<addr> — honest residue where the body is too
    context-dependent to name without guessing (Golf: 108, Mario Bros: 223)
  - the 77 already-named entries untouched
* 600 internal branch targets named <Routine>_Block / <Routine>_Block_n in
  address order within their owning routine (DK Jr./Balloon Fight convention).
  Ownership is control-flow containment, so a loop that physically precedes
  its own entry lands correctly (FlushPpuBuffer_Block $F1EC under $F228).
* Two proposal collisions disambiguated by indexing zero-page slot:
  UpdateObjectListZp5D/DispatchObjectStateZp5E ($DA16/$DA4C) vs
  UpdateObjectListZpAE/DispatchObjectStateZpAF ($E1E9/$E250).

jsr/jmp operands stay numeric (Wild Gunman style); only branch operands are
symbolic. Applied with _tools/pass2.py apply (919 defs + 2,622 refs rewritten,
comments re-aligned); the ROM still rebuilds byte-identically and
verify_pass1.py reports 19/19 canonical with no truncated split.

Docs synced: project OVERVIEW.md gains a Pass 2 Details section, root
OVERVIEW.md rows + conventions, TODO.md section 4 closed. dk_full_map.tsv now
records the complete applied map (its zp rows remain unapplied proposals).

_wip status correction: five false "Pass 2 complete" claims + map audit

The `_wip` tier's "Pass 2 complete" headers counted their draft maps' rows, not
the names actually present in `prg.s`, and described the count of *neutral*
`ZpXX` slots in `locals.inc` as "zero-page semantic aliases". Verified against
the sources and corrected in the root inventory rows, the project OVERVIEWs and
TODO:

  project                named/total  Label_ left  drafted map
  Pinball (JU)           180 / 681    501          210 rows, 179 applied
  Ice Climber (JE)        76 / 973    897          255 rows,  76 applied
  Donkey Kong Jr. Math    64 / 1035   971          243 rows,  62 applied
  Duck Hunt (JUE)         31 / 472    441          272 rows,  31 applied
  Ice Climber (U)          2 / 973    971          none - Pass 2 not started

No `_wip` project is Pass 2 complete. Ice Climber (U) was the worst case: the
2026-10-19 sweep itself restated it "Pass 2 complete" while describing its 971
`Label_` definitions in the same paragraph - a copy-paste from (JE); (U) has no
map file at all.

Drafted maps audited row by row against the committed split: they are not
applicable. 100/243 (DK Jr. Math), 96/255 (Ice Climber (JE)) and 17/210
(Pinball) rows point at addresses that are not instruction boundaries - they
were built against an older code/data split - while most of the rest are
unlabelled boundaries that no operand references, or (Duck Hunt) competing
candidate names for one label (`Label_C2BF` alone carries 205). Rows that are
both insertable and corroborated by control flow: 0 in every map. Recorded as
stale and kept for provenance; Pass 2 here needs a fresh map derived from the
committed split.

Also corrected while verifying:
- Mario Bros entry counts (docs said 252 / 90 + 165 / 477 sites / 156 neutral
  slots; the file holds 262 distinct `jsr`/`jmp` targets = 39 semantic + 223
  `SubC<addr>`, 478 sites, 151 slots).
- root `_wip` prose line/label ranges (4,482-17,180 lines, 192-971 `Label_`,
  11,126 total) and the Ice Climber code figure (74.6% / 6,112 instr, not
  74.7% / 6,113).
- TODO §4 re-scoped to the promoted tier, with the `_wip` residue table added.

Verification: `make` across all 17 `_wip` projects -> BYTE-IDENTICAL (17/17);
`_tools/verify_pass1.py` -> 19/19 build, all canonical, "No truncated or
unfaithful Pass 1 found". No source bytes changed.

Balloon Fight (JU): apply locals.inc/symbols.inc names to prg.s (Pass 3)

Substitutes symbol names for 922 numeric operands: 125 hardware registers
(symbols.inc) and 797 of 813 zero-page/RAM operands (locals.inc). The 15
multi-alias addresses were resolved per routine; 16 operands stay numeric
because $13 is used there as a plain high-byte scratch beside Temp12 and
none of its three aliases fits.

- _tools/apply_symbols.py: the substitution tool (dry run by default;
  --sites supplies per-routine choices for multi-alias addresses, --report
  lists the residue). Only the operand token is rewritten, so the
  `; CPU:$.... PRG:....  XX XX` audit comments survive byte for byte.
- prg.s: operand tokens only. make prints "Byte-identical ROM verified."
- OVERVIEW.md (project): instruction quotes now match prg.s, new
  alias-reuse table, and section 10 rewritten from the stale "497 Label_"
  prose to the real naming state and the 16-site residue.
- OVERVIEW.md (root), TODO.md, AGENTS.md: status, counts, tool list.

verify_pass1.py: 19/19 OK, all audit comments canonical.

Bomberman (U): apply locals.inc/symbols.inc names to prg.s (Pass 3)

Substitutes 362 zero-page operands in copilot/prg.s with the names already
declared in copilot/locals.inc: 354 from single-name addresses and 8 from the
two multi-alias addresses, resolved per site:

  $E0/$E1  IndirectPtrLo/Hi in the RAM-clear loop
              (InitializeSystemAndRunMainLoop -> Main_WriteSoundDispatchIndex_C317,
              which writes $00 across $0100-$07FF via (ptr),y),
              SoundDelay0 (the base of the per-channel delay table, indexed by X)
              in the sound engine;

No symbols.inc substitution was needed: copilot/prg.s contains no numeric
hardware operands ($2000-$4017) at all. All 362 operands were already written
with two hex digits, so no ca65 `a:` prefix was required.

make -> Byte-identical ROM verified. Operand token only; mnemonics, spacing and
the trailing `; CPU:$.... PRG:....  XX XX` audit comments are untouched.

Remaining numeric operands (758) address bytes that no .inc file names:
505 zero-page (133 addresses), 171 RAM $0100-$07FF (46 addresses) and 82
table reads in $8000+ (54 addresses).

Bomberman (U): substitute 24 in-file table labels at ROM operand sites

Uses the new `--labels` option of `apply_symbols.py` with the
ld65 `-Ln` output (`reconstructed.lbl`). Only labels that prg.s
actually defines and that live in the PRG (+) are considered,
so CHR/header segment labels cannot leak in.

24 numeric operands (`lda ,x`, `adc ,y`, etc.) now read
`lda SystemPaletteData,x`, `adc GameplayObjectClassTable,y`, etc.
All 24 point at exact label addresses; the remaining 58 ROM-space
numeric operands point at interior bytes or unlabelled tables and
stay numeric.

make -> Byte-identical ROM verified.

Bomberman (U): update docs for Pass 3 symbol application

- Project OVERVIEW.md: corrected source file from main.s to prg.s/header.s/chr.s;
  output name from reconstructed_bomberman.nes to reconstructed.nes;
  rewritten §10 Zero-Page/RAM Symbols table to match locals.inc (with aliases
  and unnamed residue documented);
  fixed §4 step 1/4 to use IndirectPtrLo/Hi and SoundDelay0 names;
  fixed §11 InesHeader location (now header.s);
  added §2 Pass 3 summary table;
  added §13 notes on legacy main.s and Main_WriteSoundDispatchIndex_C317 misnomer.

- Root OVERVIEW.md §3: updated source line count (7,553), file list, and added
  Pass 3 summary.

Both builds verified byte-identical.

Pass 3 symbol application to all 19 top-level projects (2026-10-19)

Ran _tools/apply_symbols.py --write on all 19 promoted projects to substitute
symbolic names from symbols.inc (hardware registers) and locals.inc (zero-page
RAM aliases, forward references) into prg.s operands. The tool preserves the
canonical audit comments (; CPU:$XXXX PRG:XXXX AA BB) and preserves byte
identity — all projects still rebuild byte-identical to their original ROMs.

Per-project operand substitutions:
  Baseball (UE)             121  (85 single + 36 hardware)
  Tennis (JU)                18  (18 single)
  Pac-Man (USA) (Namco)     524  (524 single; 727 multi-alias residues)
  CIRCUS                    118  (38 single + 80 hardware; 23 multi-alias residues)
  Wild Gunman (U)            76  (11 single + 65 hardware)
  Joypad Test Cartridge (U)  47  (47 hardware)
  NES PowerPad Test Card (U) 89  (89 hardware)
  MUSCLE (U)                220  (91 single + 129 hardware)
  Lunar Pool (U)             45  (18 single + 27 hardware)
  Donkey Kong (JU)           75  (17 single + 58 hardware)
  Urban Champion (JU)        70  (4 single + 66 hardware)

Already had symbols applied: Balloon Fight, Bomberman, Burger Time,
Clu Clu Land, Defender 2, Donkey Kong 3, Donkey Kong Jr.

Skipped: Excitebike (JU) — inline prg.s definitions conflict with locals.inc
values and source operands don't match ROM byte encodings; automatic
substitution breaks byte identity.

Documentation updated:
  OVERVIEW.md — added Pass 3 symbol notes to all 19 project entries,
  added Urban Champion as section #36
  TODO.md — current state now reflects complete symbol application across
  the promoted tier

Verification: python3 _tools/verify_pass1.py reports all 19 projects OK/SEEDED,
all canonical comments, no truncated or unfaithful Pass 1.
2026-10-19 14:55:08 -04:00

24 KiB
Raw Blame History

MUSCLE (U) — Pass 3 (data names and structures)

ROM: MUSCLE (U)/M.U.S.C.L.E. (U).nes (24,592 bytes, iNES). Status: PASS 3 COMPLETE — byte-identical, including the bytecode VM layer. All three passes are done: Pass 1 split code from data, Pass 2 replaced every Label_XXXX and 87 zero-page aliases, Pass 3 named and structured every data region, finished the routine descriptions, and resolved the reused zero page and the unreachable stubs.

make regen in copilot/ replays Pass 1 → Pass 3 from prg.bin and is byte-for-byte reproducible. Every instruction still carries its ; CPU:$xxxx PRG:xxxx AA BB audit comment, so none of this touched a byte.

Result
Code 10,634 bytes (5,253 instructions) across 705 routines — 0 Label_XXXX remaining
Data 5,750 bytes across 69 named regions — 0 unlabelled blocks
Structured tables 127 .word entries emitted one per line with their resolved target
Routine descriptions 0 boilerplate ; Global: lines remaining (94 hand-written, 606 generated from call sites)
Zero page 163 aliases: 89 named, 1 reused byte carrying two names, 73 ZpXX
Build Byte-identical ROM verified.

ROM Facts

Property Value
Mapper 0 (NROM)
PRG 16 KB, mapped low at $8000–$BFFF (mirrored to $C000–$FFFF)
CHR 8 KB
Mirroring Vertical
Battery / trainer none
ROM SHA-256 d3da17f2b67d517e4d519659407d6f0e3cd7a2a4d89d31da6bb2727afce798dd
PRG SHA-256 c9061b6d3d06f56e3c2f89337d8e59c159f494ff9efad50852fcd19621f0b03e

The skeleton's $C000 assumption was wrong. A 16 KB NROM PRG is normally mirrored from $C000, but the RESET vector is $8000, so this image is wired to $8000 and every address in the source (and in linker.cfg) is $8000-based. The CPU still fetches the vectors from the mirror at $FFFA, which is why InterruptVectors is the last six bytes of the PRG.

Interrupt Vectors (PRG $3FFA–$3FFF)

Vector Target Note
NMI $86CB (NmiHandler) the entire per-frame update; see flow below
RESET $8000 (Reset) confirms the low mapping
IRQ $8764 (IrqHandler) a single RTI, shared with the NMI epilogue; see below

Power-On Flow

Reset ($8000):

  1. sei, ldx #$FF, stx $0320, txs — X points the stack at the top of page 1 and $0320 is set to $FF as the "an NMI already fired during init" latch.
  2. Two lda $2002 / bpl spin loops blank the PPU and confirm vblank. Each write is followed by a nop before its poll ($800D, $8018) — the align-before-read habit of the original assembler.
  3. $4017 = $40 (APU frame counter IRQ inhibit), $4010 = 0, and zero the DMC address/length pair at zero page $FA/$FB (the same two bytes the controller reader later reuses).
  4. Clear $0326, $032B, $032C, zero page $FF, then fill $0300-$032C with zero.
  5. Set SourcePointerLo/Hi = $9E/$B8 and DestinationPointerLo/Hi = $00/$04, then run UpdateNametableFromStream, which decodes the compressed nametable into $0400.
  6. If the stream actually changed VRAM, latch the tighter scroll bound into $0328-$032A, reset the frame counters to 0/1/2/3, and fall into StartStageSequence.

NmiHandler ($86CB):

  1. Push A, X, Y in that order.
  2. lda $0320 / beq $86D8 / jmp $8759. Reset leaves $0320 = $FF, so the first NMI skips the frame update entirely; from then on $0320 is cleared each frame (see 6) and this is a fall-through.
  3. Set $0320 = $FF and bump the 16-bit FrameCounterLo/Hi in zero page $24/$25.
  4. $2001 = 0 (blank rendering), $2003 = 0, $4014 = $02 — OAM DMA from $0200.
  5. Dispatch on the game state at $0322: zero goes to the SubModeFlag test, negative jumps to the object-list housekeeping at $86AD, positive falls into UpdateRoundLogic. The branches call PollControllerAndAdvance, UpdateStageActors, UpdateObjectCollisions, RunGameStateDispatcher, BuildOamList and PlayDeathSequence in turn.
  6. FinishNmiUpdates ($8751): lda $0321 / bne $8759 — the $0320 latch is cleared only when $0321 is zero, so a frame that requested continued work stays latched. Then lda $2002 to release the PPU address latch, jsr UpdateSoundFrame, and pla/tay/pla/tax/pla.

The IRQ vector word is the NMI epilogue's RTI. After the three plas the next byte is $8764, which is both IrqHandler and the rti that ends every NMI — one RTI serving two vectors. The game never re-enables interrupts, so the IRQ is unreachable in practice.

Architecture: this ROM dispatches by jump table, not by direct call

A plain recursive trace from the vectors reaches only 50.6% of the PRG. The gap is not dead code — it is reached through four dispatch tables and two zero-page indirect jumps that a static descender cannot follow. Resolving them took code coverage to 64.9%.

Mechanism Location Size Index
BytecodeProgramPointerTable $8CEF 38 × 16-bit (token & $3F) at $9AD8
BytecodeHandlerTable $9237 40 × 16-bit token - $C8 at $9AF9
SecondaryBytecodeHandlerTable $9F2B 16 × 16-bit (token & $0F) at $9F85
LevelVramWriterTable $AC35 3 × 16-bit the level byte $0326 at $AC28
DispatchThroughPointer jmp ($001C) at $9B05 — reached from the first three tables
jmp ($0010) $AC32 — reached from LevelVramWriterTable

RunBytecodeInterpreter ($9AE8) reads a token through (BytecodeStreamPointerLo),y; tokens $C8–$EF are converted to a handler address and dispatched through jmp ($001C); everything else is an operand consumed by the running handler.

The handler tables have holes. The interpreter masks the program index with $3F, so BytecodeProgramPointerTable is indexed 0–63 — but entry 38 already reads the first byte of BytecodeProgram_00, so the table is exactly 38 entries and the caller must guarantee a smaller index. BytecodeHandlerTable entries 0 and 2 hold $928D and $9287, which are the addresses of the code immediately after the table: slots the assembler never filled.

Pass 3 — Data Structure

All 69 data regions are labelled. The four composite regions from Pass 1 were split into their real tables using the readers in decoded code:

$8CEF–$9286 (1,432 bytes) → three structures

Region Size What it is
BytecodeProgramPointerTable $8CEF–$8D3A 76 38 × .word, targets printed per entry
BytecodeProgram_00 … BytecodeProgram_36 $8D3B–$9236 1,034 the 37 distinct programs, each labelled with the table indices that reach it
BytecodeHandlerTable $9237–$9286 80 40 × .word opcode handlers

The programs are recovered from the pointer table: sort the 38 entries, and each program runs from one entry to the next. The two duplicate entries account for 37 distinct programs.

$B0CB–$BFFF (3,893 bytes) → eleven structures

Region Size What it is
SoundNotePeriodTable $B0CB–$B0E2 24 12 × .word tone periods (cmp #$0C bounds the index)
SoundNoteDurationTable $B0E3–$B0F2 16 note durations, index duration - $F0
ApuChannelEnableBitsSet $B0F3–$B0F6 4 OR-ed into ApuEnableMask, indexed by channel*4
ApuChannelEnableBitsClear $B0F7–$B0FA 4 AND-ed into ApuEnableMask, same index
StageNametableAndObjectData $B0FB–$B91A + _1 $B971–$BD27 + _2 $BD2A–$BEBF 3,523 the per-stage payload
StageDataFiller $B91B–$B970 86 an 86-byte $FF pad between stage blocks
StageDataPointerLow $BD28–$BD29 2 one base offset per player; $32 apart
StageDataTail $BED0–$BF8F 192 tail of the stage block
InitialOamAttributes / InitialOamTiles $BEC0–$BECF 16 8 entries each, ldx #$07
VramBlockByteCounts / VramBlockSourceLowBytes $BF90–$BF9F 16 8 transfer blocks each; high byte always $02
SoundSequenceAndLevelData $BFA0–$BFFA 91 the $B1A1/$B1B0/$B2B9/$B3B2 sequences the sound engine points at
InterruptVectors $BFFB–$BFFF 5 NMI / RESET / IRQ

The stage payload is reached through StageDataPointerLow plus the 16-bit offset the bytecode stream carries, so no constant address indexes it and its record structure is not resolvable from a static trace. That is recorded in the source rather than guessed at.

$A5F4–$A65F (108 bytes) → two structures

StageDescriptorPointerTable ($A5F4–$A617, 18 × .word) and StageDescriptorData ($A618–$A65F). Two readers share the table: one reads (A & $1E) from entry 0, the other starts at entry 9, and entry 18 already reads descriptor text — so it is exactly 18 entries. The targets sit twelve bytes apart ($A618, $A624, $A630, $A63C, $A648) with a final ten-byte record at $A654; each is a short header followed by a descending run of tile indices.

Remaining tables

Region Size What it is
AttributeBlockDeltaTable $8B7D–$8B91 21 7 × 3-byte {source lo, source hi, destination lo} deltas
OamShadowTemplate $9EF4–$9F13 32 the initial OAM block copied into $0300
VramRunFillValues $ACBD–$ACC8 12 the 12 fill values the VRAM run tokens $B8–$C3 select
AnimationFrameCollisionBits $AE35–$AE44 16 animation frame → collision flag bits
StandaloneBytecodeProgram … _3 194 four token programs not in BytecodeProgramPointerTable

What Pass 3 settled

The $14/$15 reuse. These are both the 16-bit block counter for the compressed-VRAM transfer and the accumulator for the collision bit flags. locals.inc carries both names, and zero_page_ranges.tsv applies StreamBlockCountHi only inside AdvanceCountWord, so the same byte is not mislabelled at all 53 of its other uses. This is the alias pattern AGENTS.md asks for — RandomStateAlt = $E4 beside RandomState0 = $54 — done with a range instead of a blind rename.

The four hand-seeded entries are provably dead. $8644, $8653, $9CAD and $ABF7 have no 16-bit reference anywhere in the image except jsr $8644 at $8654, and none of them appears in either bytecode handler table. They are kept as code — rts-terminated and register-plausible — but their headers now say so explicitly rather than leaving a reader to wonder.

$AF37–$AF3A stays data. lda $E9 / bne $AF34 sits right after the rts that ends InitializeSoundEngine and branches back into the middle of it. Unreachable, incoherent as a routine, and referenced by nothing. It is labelled UnreachableSoundInitIsland so the decision is visible.

Every routine header now says something. The 94 subsystem routines keep hand-written descriptions; the other 606 get a generated line naming the routine and its visible call sites, which is factual. Nothing still reads "Contextual control-flow entry identified from its parent routine".

copilot/ Contents

File Purpose
rom.nes symlink to ../M.U.S.C.L.E. (U).nes
prg.bin / header.bin / chr.bin split image (_tools/splitnes.py)
prg.s fully classified and named PRG source (12,922 lines)
header.s / chr.s incbin of the untouched header and CHR
symbols.inc shared NES hardware constants
locals.inc 163 zero-page aliases with read/write counts and referencing addresses
routine_names.tsv Pass 2 routine names (address → name)
label_names.tsv generated: the full Label_XXXX → final name map
zero_page_names.tsv Pass 2 zero-page names with usage notes
zero_page_ranges.tsv Pass 3 range-scoped names for reused bytes
data_regions.tsv Pass 3 data-region labels, record formats and notes
routine_purposes.tsv the 94 hand-written ; Global: descriptions
linker.cfg HEADER at $0000, PRG at $8000, CHR at $0000
Makefile all builds and byte-compares; pass1 / pass2 / pass3 / regen regenerate

Build

cd "MUSCLE (U)/copilot"
make          # header.o + prg.o + chr.o -> reconstructed.nes, cmp vs rom.nes
# "Byte-identical ROM verified."
make pass1    # re-run _tools/nesdis.py with the recorded classification flags
make pass2    # pass1 + naming + descriptions + data structuring + locals.inc
make pass3    # pass2 + _tools/bytecode_dis.py token listings over the 42 VM programs
make regen    # same as pass3; the whole pipeline is byte-for-byte reproducible

Pass 3b — the bytecode VM, decoded

The 42 token programs (BytecodeProgram_00–_25 plus StandaloneBytecodeProgram–_3) are the game's scripting layer. Each now carries a byte-for-byte token listing above its unchanged .byte data, produced by _tools/bytecode_dis.py (wired in as make pass3).

The encoding

RunBytecodeInterpreter ($9AE8) is the whole dispatch:

$9AEA  lda ($1A),y          ; fetch one byte
$9AEC  iny
$9AED  cmp #$F0 / bcs $9B08  ; >= $F0 -> literal
$9AF1  cmp #$C8 / bcc $9B08  ; <  $C8 -> literal
$9AF5  sbc #$C8 / asl a / tax
$9AF9  lda $9237,x  / sta $1C ; BytecodeHandlerTable
$9AFE  lda $9238,x  / sta $1D
$9B05  jmp ($001C)            ; run the handler

and the literal path ($9B08) pushes the byte onto the operand list:

$9B08  pha
$9B09  lda ($1A),y / iny      ; the operand byte itself
$9B0C  jsr $94C8             ; save stream position, restore the operand index
...
$9B22  sta $0033,Y           ; operand list
$9B25  jsr $9CBB             ; block/stream bookkeeping

So the encoding is one byte per element: a byte in $C8–$EF selects one of the 40 BytecodeHandlerTable entries, and every other byte is a literal operand appended to $0033[Y] (and accumulated into $003C[Y]). There are no variable-length opcodes except four.

Four opcodes take one inline operand byte. Their handler's first instructions are lda ($1A),Y / iny, so the byte after the token is consumed by the opcode itself:

Opcode Handler Inline byte stored in Meaning
$CB $946D $7C,X (count) and $7E,X (body address) LOOP_BEGIN
$D1 $950B $43,X SET_RATE
$D7 $937C used as a count for the $A660 blit DRAW_SPRITE
$E1 $9465 $7A,X SET_WAIT

$CB/$C9 are the loop pair: LOOP_BEGIN marks the stream position and loads the counter, and LOOP_END ($9477) decrements $7C,X and, if still non-zero, reloads Y from $7E,X — which rewinds the interpreter to the top of the loop body.

What was decoded

Programs listed 42 (37 in BytecodeProgramPointerTable + 4 standalone, one shared)
Opcodes decoded 377
Literal operands 937
Opcodes defined / used 40 / 40 — the opcode set is complete, nothing unused

Two of the four standalone programs (_1, 24 bytes; _3, 84 bytes) contain no byte in $C8–$EF, so the interpreter takes the literal path for every byte and the whole block is operand data rather than an instruction stream. The listing says so explicitly rather than implying they are code.

Confidence

Each mnemonic was read off its handler's own straight-line path, stopping at the handler's first jmp $9AE8 / jmp $9B25. Mnemonics that describe register-level behaviour (ACC_ADD_1_STEP, STEP_ACCUM, ROT_STEP_SUB, …) are read directly off the instructions. Those that merely tail-call a game routine are named for that routine (VM_CALL_82E1, PLAY_SEQ_BD20) rather than guessed at, because the semantic content lives in the callee, not the handler.


Pass 3c — tracing the stage payload

Static analysis cannot reach this region, and the trace confirms why. Across all 16 KB of code there is exactly one operand in $B0FB–$BEBF:

operands in $B0FB-$BEBF : 1
  -> $BD28    StageDataPointerLow, the literal pointer table itself

The real address is formed at runtime, $A062:

$A062  clc
$A063  adc $BD28,X      ; A (a value carried in the bytecode stream) + table byte
$A066  sta $12

so the readers are indexed by a runtime value and cannot be enumerated by searching the code.

Method

FCEUX's Lua API has no memory-read hook — only registerexec, registerexecute, registerrun and registerwrite — so it cannot observe these reads either, and with no static list of readers there is nothing to hook. _tools/trace_stage_payload.py therefore uses py65, whose ObservableMemory.subscribe_to_read fires on every read of a range and can report the CPU's PC from inside the hook.

py65 is only a 6502 core, so the tracer adds the minimum hardware to get the game running: a scanline counter driven off processorCycles, $2002 vblank asserted at scanline 241 and cleared on read, NMI fired at the same scanline (MUSCLE does its per-frame work in NmiHandler $86CB), $4014 OAM DMA, and the $2005/$2006/$2007 latches.

Without NMI the game never leaves $80E0: lda $0321 / beq $80E0, because $0321 is the flag the bytecode VM raises — so the stage data is only reachable once interrupts work.

Results (300 frames, 26.8M cycles, 4,071 NMIs)

Payload reads captured 2,548
Distinct reader PCs 44
Unique payload addresses touched 914 of 3,421 bytes in range

Reader PCs are 1-2 bytes above the real instruction, because py65 advances PC past the opcode before fetching the effective address.

What the access pattern shows

1. The payload is read through indirect pointers, confirming the static finding. The hottest reader resolves to $AC82:

$AC82  lda ($10),Y
$AC84  cmp #$C0
$AC86  bne $AC96
$AC88  lda $10 / clc / adc #$01 / sta $10    ; advance the stream pointer

This is the compressed-VRAM RLE decoder - it walks the payload sequentially (1,046 of its 1,128 reads advance by exactly +1), comparing each byte against $C0. That matches the $AC82 RLE decoder already named in the data section.

2. There is a second, backward-copy reader. $9B51 resolves to

$9B50  lda ($12),Y
$9B52  ldy $14
$9B54  sta ($10),Y
$9B56  dey

and its reads step by -1, sixty times - a reverse copy between two buffers.

3. The payload contains 8-byte-aligned record arrays. 107 distinct 8-byte blocks had all eight bytes read, and the spacing between consecutive blocks is dominated by $08:

$B1F0 $B1F8 $B200 $B208 $B210 $B218 ...   (stride $08)
$B3B8 $B3C0 $B3C8 $B3D0 ... $B5A0          (a long $08-strided run - the object lists)
$B600 $B608 $B610 ... $B638
$B8A0 $B8A8 $B8B0 ... $B910

with a second, sparser group on a $D0 stride:

$BBE0  $BCB0  $BD80  $BE50

so at least two record tables exist: a dense 8-byte-stride array and a $D0-stride array.

4. Readers use three access disciplines - sequential forward (+1), sequential backward (-1), and variable stride (+$15/+$14/+$11 at $ACB2, +2/+$4D at $AF5B/$B03D), the last consistent with a field-at-fixed-offset walk across records.

Still open

  • Which reader corresponds to which semantic field is not yet pinned down; the trace gives the geometry (8-byte records, $08 and $D0 strides) but not the field names.
  • Only 914 of 3,421 bytes were touched, because the run never entered every stage. A longer run that cycles through all stages would cover more and may reveal further strides.
  • The compressed-VRAM payload and the object-list records are interleaved in one block; the boundary between them is not yet resolved.

FCEUX remains useful as an independent oracle for any of these conclusions - it is a complete, correct emulator where this tracer is a deliberately partial one.


Pass 3d — the payload is a shared pool, not stage data

Pass 3c assumed $B0FB–$BEBF was per-stage level data and that "cycle all stages" would cover it. A longer, properly-clocked run disproves that. Coverage plateaus near 40% no matter how many stages are entered, and forcing every actor index to run adds zero new bytes.

Two tracer bugs, both of which were suppressing everything

1. 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 MainGameLoop_OnZero_8226 (lda $0321 / beq $8235); each poll clears bit 7, re-arming NMI inside the same scanline. Guarding only on not vblank produced 40,900 NMIs across 3,000 frames and silently corrupted all game state. A separate per-frame nmi_fired flag restores exactly one NMI per frame — verified: 600 frames → 600 NMIs.

2. Input went to the wrong port. The poll routine at $AA69 is

$AA69  ldx #$01 / stx $4016 / dex / stx $4016 / ldy #$08 / lda $4016,X / and #$03 / cmp #$01

so the observed reads are of $4017 (X=1) and only bits 0-1 are ever compared. Driving $4017 therefore consumes nothing: all three masks tested gave byte-identical traces. The game reads joy1 on $4016, and needs a real latch/shift register — a write with bit0 set latches, reads shift A,B,Select,Start,Up,Down,Left,Right out LSB-first. With that, START visibly matters: 47.7% coverage vs 39.0% for other masks.

Reaching a match takes two discrete START presses — title → character select → match. Rapid re-tapping does not work, and each press must be held ~3 frames.

What $BD28 actually indexes

$BD28 is not a stage table. The one operand in the window feeds:

$A04A  lda $0324 / beq $A062
$A04F  lda ActorIndex ($1E) / beq $A062
$A063  adc $BD28,X              ; X = ActorIndex
$A06A  lda PlayerActiveFlag,X ($78,X)
$A071  adc #$32                 ; +$32 when that actor is active

$BD28 = 6B 6B 6E 75 75 80 5A 78 is an 8-entry per-actor entry-offset table, and $B0FB + $BD28,n is where actor n's bytecode starts. Observed ActorIndex values over a 2,400-frame match are $00–$04, so the live actor set is 0–4.

The sound engine reads the same window

With input working, the reader list gained PCs inside $B0FB–$BEBF. These are not new code — they are the sound engine's own stream reader:

$B03C  lda (SoundReadPointerLo),Y
$B03E  cmp #$F0
$B040  bcc StoreShortNoteDuration
$B043  sbc #$F0 / tay / lda $B0E3,y     ; NoteDurationTable at $B0E3

SoundReadPointerLo ($EE/$EF) points into the payload while music plays, and the $F0 compare plus the $B0E3 duration lookup is note decoding. This is why the windows' bytes carry so many $FF terminators — $FF/$F0 are the high-nibble codes of the note stream.

So $B0FB–$BEBF is a shared data pool, mixing at least three things: per-actor bytecode scripts, the compressed-VRAM RLE stream ($AC82), and music note streams. It is not stage-specific, which is why stage cycling cannot raise coverage past ~40%.

Coverage summary

Run Frames NMIs Payload reads Readers Coverage
Pass 3c (NMI broken) 300 4,071 2,548 44 26.3%
NMI fixed, no input 600 600 29,808 14 39.0%
Two START presses 2,000 2,000 79,192 21 40.9%
Actor sweep 0–7 2,400 2,400 98,392 14 38.7%

Largest unreached spans after the two-START run (2,161 B in 15 runs) are $BC00–$BEBF (704 B), $B980–$BB0F (400 B), $BB20–$BBEF (208 B), $B0FB–$B1A0 (166 B) and $B81E–$B89D (128 B).

What would actually close the gap

Not stage cycling. The remaining bytes need other music tracks playing and actors 2–4 in their non-default states. Both need a scripted match (moves, grapples, ring-outs), not a button-tap sequence — i.e. gameplay simulation, which is well beyond a coverage tracer.

$0326 is not a level byte: it never changes from $00 in any run, including a confirmed match. $0320 toggles $00/$FF and behaves as a boolean, not a screen-state machine.


Still Open

  • Promote to the numbered inventory. Done — promoted to top-level MUSCLE (U)/ and added as #16 in the root inventory. copilot/Makefile tool paths moved from ../../../_tools/ to ../../_tools/ for NESDIS/PASS2/BYTECODE; make and make regen both verified after the move.
  • The payload record structure is partially decoded ($B0FB–$BEBF, 3,525 bytes) — see "Pass 3d" above for what the emulator run established. It is a shared pool (per-actor bytecode + compressed VRAM + music note streams), not per-stage data; the per-record field names and the boundaries between the three uses are still unresolved.