- 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.
223 lines
13 KiB
Markdown
223 lines
13 KiB
Markdown
# 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. |