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

500 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `pla`s 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
```bash
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.