- Decoded the VramWrite ($E1CC) stream format: <addr hi> <addr lo> <count> <bytes>, $00 terminator - Named all 9 payload streams ($E4CC-$E7C2, no gaps/overlaps) plus VramWriteSourcePointerTable at $F000 - Split PadBitTableLives into Player1TileTable ($E4B4) and Player2TileTable ($E4C0), 12 entries each (Y=0..11) - Decoded CHR font (digits $30-$39, letters $3A-$52, space $5F; 'W' missing from the A-Z run) and recovered on-screen text including 'PRESS A TO TEST SIDE-A' / 'SIDE-B' - Documented that the 16 KiB PRG is two byte-identical 8 KiB halves; $C000 copy is dead - Documented 7 unreached subroutines inside the live code region (kept as .byte per Pass-1 policy) - Fixed a stray duplicate sentence and stale _wip paths in root OVERVIEW.md Byte-identical ROM verified.
13 KiB
MUSCLE (U) — Pass 3 (data names and structures)
ROM: _wip/MUSCLE (U)/M.U.S.C.L.E. (U).nes (24,592 bytes, iNES). Status: PASS 3 COMPLETE —
byte-identical. 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 | Horizontal (header byte 6 = $01) |
| 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):
sei,ldx #$FF,stx $0320,txs— X points the stack at the top of page 1 and$0320is set to $FF as the "an NMI already fired during init" latch.- Two
lda $2002 / bplspin loops blank the PPU and confirm vblank. Each write is followed by anopbefore its poll ($800D, $8018) — the align-before-read habit of the original assembler. $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).- Clear
$0326,$032B,$032C, zero page$FF, then fill$0300-$032Cwith zero. - Set
SourcePointerLo/Hi = $9E/$B8andDestinationPointerLo/Hi = $00/$04, then runUpdateNametableFromStream, which decodes the compressed nametable into $0400. - 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 intoStartStageSequence.
NmiHandler ($86CB):
- Push A, X, Y in that order.
lda $0320 / beq $86D8 / jmp $8759. Reset leaves$0320 = $FF, so the first NMI skips the frame update entirely; from then on$0320is cleared each frame (see 6) and this is a fall-through.- Set
$0320 = $FFand bump the 16-bitFrameCounterLo/Hiin zero page$24/$25. $2001 = 0(blank rendering),$2003 = 0,$4014 = $02— OAM DMA from$0200.- Dispatch on the game state at
$0322: zero goes to theSubModeFlagtest, negative jumps to the object-list housekeeping at$86AD, positive falls intoUpdateRoundLogic. The branches callPollControllerAndAdvance,UpdateStageActors,UpdateObjectCollisions,RunGameStateDispatcher,BuildOamListandPlayDeathSequencein turn. FinishNmiUpdates($8751):lda $0321 / bne $8759— the$0320latch is cleared only when$0321is zero, so a frame that requested continued work stays latched. Thenlda $2002to release the PPU address latch,jsr UpdateSoundFrame, andpla/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 / regen regenerate |
Build
cd "_wip/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 regen # same as pass2; the whole pipeline is byte-for-byte reproducible
Still Open
- Promote to the numbered inventory. The project still lives in
_wip/; moving it to a top-level<name>/copilot/is the last mechanical step, keeping the NROM-at-$8000note. - The stage payload record structure (
$B0FB–$BEBF, 3,523 bytes) is named but not decoded. Working it out needs emulation or a token-level trace of the stage scripts, not static analysis. BytecodeProgram_00–_36are not disassembled. They are the game's scripting layer; a token-level disassembler keyed offBytecodeHandlerTablewould turn them into readable code.