Files
disasm/_wip/MUSCLE (U)/OVERVIEW.md
T
tmerritt 4c644d2a20 Pass 3 for NES PowerPad Test Card (U): name all data tables and VramWrite streams
- 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.
2026-10-16 20:24:23 -04:00

13 KiB
Raw Blame History

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):

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

NmiHandler ($86CB):

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

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

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

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

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

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

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

Pass 3 — Data Structure

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

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

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

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

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

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

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

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

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

Remaining tables

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

What Pass 3 settled

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

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

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

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

copilot/ Contents

File Purpose
rom.nes symlink to ../M.U.S.C.L.E. (U).nes
prg.bin / header.bin / chr.bin split image (_tools/splitnes.py)
prg.s fully classified and named PRG source (12,922 lines)
header.s / chr.s incbin of the untouched header and CHR
symbols.inc shared NES hardware constants
locals.inc 163 zero-page aliases with read/write counts and referencing addresses
routine_names.tsv Pass 2 routine names (address → name)
label_names.tsv generated: the full Label_XXXX → final name map
zero_page_names.tsv Pass 2 zero-page names with usage notes
zero_page_ranges.tsv Pass 3 range-scoped names for reused bytes
data_regions.tsv Pass 3 data-region labels, record formats and notes
routine_purposes.tsv the 94 hand-written ; Global: descriptions
linker.cfg HEADER at $0000, PRG at $8000, CHR at $0000
Makefile all builds and byte-compares; pass1 / pass2 / 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-$8000 note.
  • 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–_36 are not disassembled. They are the game's scripting layer; a token-level disassembler keyed off BytecodeHandlerTable would turn them into readable code.