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.
This commit is contained in:
tmerritt committed 2026-10-16 20:24:23 -04:00
1 parent 3ef16a6f99
commit 4c644d2a20
23 files changed
+17290 -507

No files matched your search

+196 -26
View File
@@ -1,53 +1,223 @@
# MUSCLE (U) — Skeleton Project
# 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: **SKELETON — not yet disassembled.** The entire 16 KB PRG bank is a single unclassified block; `copilot/prg.s` is the default-project skeleton (`default_project.tar.gz`) that reproduces it as one `.incbin`. The skeleton build is **byte-identical** at the PRG level.
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) — from the iNES header |
| PRG | 16 KB (header claims map at CPU $C000–$FFFF) |
| Mapper | 0 (NROM) |
| PRG | 16 KB, mapped **low** at $8000–$BFFF (mirrored to $C000–$FFFF) |
| CHR | 8 KB |
| Mirroring | Horizontal |
| Battery-backed flag | clear |
| Mirroring | Horizontal (header byte 6 = $01) |
| Battery / trainer | none |
| ROM SHA-256 | `d3da17f2b67d517e4d519659407d6f0e3cd7a2a4d89d31da6bb2727afce798dd` |
| PRG SHA-256 | `c9061b6d3d06f56e3c2f89337d8e59c159f494ff9efad50852fcd19621f0b03e` |
## Interrupt Vectors (taken from PRG $3FFA–$3FFF)
**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` | per-frame heartbeat |
| RESET | `$8000` | $8000 entry implies the header's NROM/$C000-base claim does not match the image — resolve in Pass 1 |
| IRQ | `$8764` | target $8764 — verify during Pass 1 |
| 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 |
|------|---------|
| `M.U.S.C.L.E. (U).nes` | original ROM image (reference) |
| `prg.bin` / `header.bin` / `chr.bin` | split image, produced by `_tools/splitnes.py` |
| `prg.s` | Pass-1 skeleton: `.incbin "prg.bin", $0000, $4000` (whole PRG) |
| `symbols.inc` | shared NES hardware constants (PPU/APU/controller, from the skeleton) |
| `locals.inc` | project-local zero-page aliases — empty for now (filled in Pass 2) |
| `linker.cfg` | PRG at $C000, 16 KB, `fill = yes` |
| `Makefile` | links `reconstructed_prg.bin` and `cmp`s it against `prg.bin` |
| `reconstructed_prg.bin` | build output from the last verified build |
Header/CHR are reproduced verbatim from `header.bin`/`chr.bin` (not linked): `make` verifies **PRG bytes only** at this stage, which is exactly what a skeleton proves. When this project is promoted to a full disassembly, `header.s` / `chr.s` (as e.g. `Burger Time (U)`) and a full-ROM `make` target get added.
| `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
```bash
cd "_wip/MUSCLE (U)/copilot"
make # → cmp reconstructed_prg.bin prg.bin → "BYTE-IDENTICAL"
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
```
## Next Steps (per `PROMPTS.md`)
## Still Open
1. **Pass 1** — replace the blanket `.incbin` with classified code/data: trace reachability from RESET ($8000) and NMI ($86CB), emit per-instruction `; PRG:$xxxx AA BB` annotations, keep unreached bytes as explicit `.byte` blocks.
2. **Pass 2** — replace `Label_`/`Sub_` names with semantic routine names; fill `locals.inc`.
3. **Pass 3** — name data tables/structures; release provisional flags.
4. **Promotion** — move to a top-level `<name>/copilot/` folder, add full-ROM build (header.s + chr.s + vectors), and write the power-on flow `OVERVIEW.md` in the established style; then it joins the root index's numbered inventory.
* **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.