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

223 lines
13 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: `_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 `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` / `regen` regenerate |
## Build
```bash
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.