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.
500 lines
24 KiB
Markdown
500 lines
24 KiB
Markdown
# 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.
|