MUSCLE (U): decode the bytecode VM layer (Pass 3b)
The 42 token programs were the last undecoded part of MUSCLE. Each now carries a token-by-token listing above its unchanged .byte data, generated by _tools/bytecode_dis.py and wired into the build as 'make pass3'. Encoding, derived from RunBytecodeInterpreter ($9AE8) and BytecodeHandlerTable ($9237): the dispatch is one byte per element. A byte in $C8-$EF selects one of the 40 handler-table entries; every other byte takes the literal path at $9B08 and is pushed onto the operand list at $0033[Y], accumulated into $003C[Y]. The one exception is four opcodes whose handler's first instructions are 'lda ($1A),Y / iny', so they consume the following byte inline: $CB LOOP_BEGIN -> $7C,X count + $7E,X body address $D1 SET_RATE -> $43,X $D7 DRAW_SPRITE -> count for the $A660 blit $E1 SET_WAIT -> $7A,X $CB and $C9 are the loop pair: LOOP_END decrements $7C,X and, if non-zero, reloads Y from $7E,X, rewinding the interpreter to the top of the body. Decoded 377 opcodes and 937 literal operands across 42 programs; all 40 defined opcodes are used, so the opcode set is complete. Two standalone programs contain no byte in $C8-$EF at all, so every byte takes the literal path - the listing states that rather than implying code. Each mnemonic was read off its handler's own straight-line path up to its first 'jmp $9AE8' / 'jmp $9B25'. Handlers that only tail-call a game routine are named for the routine rather than guessed at, since the semantics live in the callee. bytecode_dis.py inserts comment lines only; .byte data is copied through verbatim. Verified: all 16,384 PRG bytes still emitted exactly once, every value equal to the ROM, zero audit mismatches, and 'make regen' (pass1 -> pass2 -> pass3) reproduces prg.s byte-for-byte. Byte-identical ROM verified. MUSCLE (U): trace the stage payload with py65 (Pass 3c) The stage payload at $B0FB-$BEBF could not be decoded statically, and the trace confirms why: across all 16 KB of code there is exactly ONE operand in that range - $BD28, the literal pointer table itself. The real address is formed at runtime by 'adc $BD28,X', where A is a value carried in the bytecode stream, so the readers are indexed by a runtime value and cannot be enumerated by searching the code. FCEUX cannot observe this either: its Lua API offers registerexec / registerexecute / registerrun / registerwrite but no memory-read hook, and with no static list of readers there is nothing to hook. _tools/trace_stage_payload.py therefore uses py65's ObservableMemory.subscribe_to_read, which fires on every read of a range and can report PC from inside the hook. py65 is only a 6502 core, so the tracer adds the minimum hardware to boot the game: a scanline counter off processorCycles, $2002 vblank at scanline 241 cleared on read, NMI at the same scanline, $4014 OAM DMA, and the $2005/$2006/$2007 latches. Without NMI the game never leaves $80E0 (lda $0321 / beq), because $0321 is the flag the bytecode VM raises. Results over 300 frames (26.8M cycles, 4,071 NMIs): 2,548 payload reads from 44 distinct reader PCs, touching 914 of 3,421 bytes. Findings: - The hottest reader is $AC82, the compressed-VRAM RLE decoder: 1,046 of its 1,128 reads advance by exactly +1, comparing each byte against $C0. - $9B50 is a backward copy (lda ($12),Y / sta ($10),Y / dey), stepping -1. - 107 distinct 8-byte blocks had all eight bytes read, with spacing dominated by $08 - a dense 8-byte record array - plus a sparser group on a $D0 stride ($BBE0, $BCB0, $BD80, $BE50). - Readers use three disciplines: forward sequential, backward sequential, and variable stride consistent with a fixed-offset field walk. py65 is deliberately NOT a build dependency: the tool documents unpacking the wheel on demand. 'make regen' is unaffected. Byte-identical ROM verified. MUSCLE (U): fix the NMI/controller bugs in the payload tracer; correct the Pass 3c model The Pass 3c tracer had two bugs that were suppressing all progress, both now fixed in _tools/trace_stage_payload.py (previously scratch, now durable): * NMI re-armed by $2002 reads. The vblank flag must be cleared once per frame, not on every read of $2002 -- the main loop polls $2002 and spins on the bytecode VM flag at $8226, so each poll cleared bit 7 and re-armed NMI inside the same scanline. That gave 40,900 NMIs across 3,000 frames and corrupted all game state. A per-frame nmi_fired flag restores exactly one NMI per frame (verified: 600 frames -> 600 NMIs). * Input went to $4017. The poll routine at $AA69 is `lda $4016,X` with X=1 and only compares bits 0-1, so driving $4017 consumed nothing -- all masks tested gave byte-identical traces. The game consumes joy1 on $4016 and needs a real latch/shift register. With it, START matters: 47.7% coverage vs 39.0%. Match entry is two discrete START presses (title -> char select -> match). Coverage 26.3% -> 40.9%. Both bugs and the port subtlety are documented in the tool header so they are not re-derived. Corrects the Pass 3c conclusion. $B0FB-$BEBF is NOT per-stage level data: * $BD28 is an 8-entry per-ACTOR entry-offset table, not a stage table. $A063 `adc $BD28,X` with X = ActorIndex ($1E), plus #$32 at $A071 when PlayerActiveFlag ($78,X) is set. Observed actors are $00-$04. Forcing all 8 actor indices adds zero new bytes. * The sound engine reads the same window: $B03C `lda (SoundReadPointerLo),Y` with `cmp #$F0 / sbc #$F0 / tay / lda $B0E3,y` is note decoding, which is where the $FF/$F0 high-nibble terminators come from. So the window is a shared pool -- per-actor bytecode, compressed-VRAM RLE, and music note streams -- and stage cycling cannot raise coverage past ~40%. The remaining 2,161 B need other music tracks and scripted match play, not taps. Also corrects two stale claims: $0326 is not a level byte (never leaves $00 even in a confirmed match), and $0320 is a boolean, not a screen-state machine. Build unaffected: byte-identical, and `make regen` still reproduces prg.s byte-for-byte. MUSCLE (U): promote to the top-level inventory as #16 git mv _wip/MUSCLE (U) -> MUSCLE (U). The relative symlink copilot/rom.nes -> ../M.U.S.C.L.E. (U).nes survives the move untouched. * copilot/Makefile: tool paths ../../../_tools/ -> ../../_tools/ for NESDIS, PASS2 and BYTECODE. `make` prints the byte-identity message and `make regen` still reproduces prg.s byte-for-byte, both verified after the move. * MUSCLE (U)/OVERVIEW.md: ROM/build paths updated; the "promote to the numbered inventory" item is now closed. * Root OVERVIEW.md: added as #16, removed from the _wip skeleton table (20 -> 19), added a ### 16 detail section, and its vector/mapping notes moved out of the skeleton section. All 16 top-level projects verified byte-identical. Add Pass 1 verification tool; teach nesdis.py the undocumented opcodes _tools/verify_pass1.py verifies Pass 1 across every top-level project in two independent directions: * Faithful - does the source still rebuild its ROM byte-identical? * Complete - does the committed source claim as much code as a fresh trace can actually reach? The second is the one that catches real problems. A project can reproduce all 16,384 bytes perfectly while calling 95% of them data, because the tracer gave up early. Instruction *counts* are compared rather than bytes, so the check is independent of comment style (which is not uniform across the repo). Result: all 16 projects are faithful, and Joypad Test Cartridge is the only truncated Pass 1 -- 282 committed instructions against 1,773 reachable. nesdis.py gains three fixes needed to make that verdict trustworthy: * --undocumented (default OFF) decodes the 105 undocumented NMOS opcodes. They are used as NOP padding and as real RMW ops, and a tracer that halts on them loses most of the code: Joypad goes 580 -> 3,846 code bytes. Default off so MUSCLE's `make regen` still reproduces prg.s byte-for-byte. * Undocumented opcodes are emitted as explicit `.byte` with the disassembly in the comment. ca65 V2.19 implements none of them, so they cannot be written as mnemonics; keeping them in the code region preserves the code/data split and the audit trail. Official 0xEA NOP still encodes normally. * Two latent bugs, both now reachable: - RESET and IRQ are commonly the same address, and the semantic-name map was overwriting, renaming the primary entry point to `IrqHandler`. Now setdefault, in RESET/NMI/IRQ priority order. - render() stamped every branch operand as Label_XXXX and never reconciled it with the semantic name map, so a branch into a named routine emitted a label that was never defined and the file would not assemble. Joypad Test Cartridge (U): redo Pass 1, complete Pass 2; merge _wip into the inventory Pass 1 (redone). The committed split claimed 282 instructions / 580 bytes (3.5%). The tracer halted at $C09E on `DCP $3C20` ($CF) -- an undocumented opcode used as ordinary code -- and filed 3,256 bytes of real code as `.byte` data. Now 1,773 instructions / 3,836 bytes (23.4%), a 6.6x increase. Verified against a fresh trace: 1,769 emitted instructions + 4 undocumented opcodes written as explicit bytes = 1,773, an exact match. Byte accounting stays exact at 3,836 + 12,548 = 16,384. ca65 V2.19 implements none of the undocumented opcodes, so the 4 affected instructions (DCP $3C20, LAX $20D4, NOP $C1,X, RRA ($D0),X) are emitted as explicit `.byte` with the disassembly in the comment. They stay in the code region, so the split and the audit trail are both preserved. Pass 2 (complete). All 202 labels renamed; zero `Label_Cxxxx` remaining. Renames are keyed by address and verified to have bound to the intended address, not just to some address. 202 routine headers, every one describing real behaviour -- no "undetermined in Pass 1" boilerplate left. `locals.inc` carries 99 aliases, the proven semantic ones sitting beside the generated `ZpXX` entries per the alias pattern in AGENTS.md. Two naming errors in the previous draft are corrected: $55 is MarkerValue (it holds $70/$88/$A0 and is compared against them), not a "P1 Down timer"; and the twelve MarkerVar variables are named by position because which player/button each belongs to is not established. Pass 3 is now outstanding, and the old Pass 3 was wrong: it described $C09E-$C173 and $C17F-$CCF6 as data, and both are code. After the redo there is exactly one data region, $CF06-$FFFF, confirmed by orphan scan. Documentation. The root inventory now lists all 36 projects in one table -- the 20 `_wip/` projects are rows 17-36 rather than a second table that could drift. That second table had wrong mirroring for 12 of them (Duck Hunt, Golf, Hogan's Alley, Ice Climber, DK Jr Math, Lode Runner, Lunar Pool, Mario Bros, Millipede, Pyramid, Raid [a2], Urban Champion were all recorded H when they are V, and vice versa); every value is now read from the ROM rather than trusted. Build status is split into two honest tiers: rows 1-16 are BYTE-IDENTICAL and compare the whole ROM, rows 17-36 are PRG-IDENTICAL because their Makefiles only run `cmp reconstructed_prg.bin prg.bin`. Header and CHR are not proven for those. All 36 build cleanly; the WIP count was also corrected from 19 to 20. `_tools/verify_pass1.py` checks all 36 and reports none truncated. Mario Bros (JU): Pass 1 disassembly, 14.6% -> 72.2% code The skeleton prg.s was a 12-line .incbin of the whole PRG. It is now a real disassembly: 5,735 instructions, 11,829 code bytes (72.2%), 937 labels and routine headers, byte accounting exact at 11,800 + 4,584 = 16,384, and BYTE-IDENTICAL against prg.bin. Coverage came from four steps, not one: vectors + direct jsr/jmp/branch, official opcodes 1,185 instr 14.6% --undocumented (105 extra NMOS opcodes) 1,210 instr 14.9% --auto-seed (61 table-referenced entries) 4,187 instr 51.1% --extra-seed 0xC0B6,0xC5A3 5,735 instr 72.2% Almost everything sits behind computed dispatch. Three mechanisms are now documented in the project OVERVIEW: * $CD9E is a stack-based indirect call: it pops the table address with `pla`, indexes it by A*2 with `lda ($14),y`, then `jmp ($0014)`. Because the pointer arrives on the stack it cannot be followed statically from the dispatcher. * $C5A3 is a 20-entry table of `JSR` thunks ending in `rts`. Probing it linearly gives twenty valid targets, and seeding it recovers most of the last 21%. * $C0B6 is the NMI phase dispatcher, reachable only by computed dispatch. Two overlapping-decode bugs in _tools/nesdis.py had to be fixed first: * Control-flow targets must win over fall-through. `bne` at $C4BA targets $C528, but the fall-through path had decoded $C527 as a 2-byte `NOP #$A5` whose operand swallowed $C528. No assembler branches into the middle of an instruction, so the spanning decode is now dropped in favour of the target; its bytes fall out as data, leaving byte accounting unchanged. * Branch targets inside a surviving instruction must be pruned from the target set. Otherwise emit_code's linear walk consumes those bytes as an operand, so the label is referenced but never defined and the file does not assemble at all -- `Error: Symbol 'Label_C528' is undefined`. Neither change disturbs an existing pipeline: MUSCLE's `make regen` still reproduces prg.s byte-for-byte, Joypad Test Cartridge still builds byte-identical, and _tools/verify_pass1.py reports all 16 top-level projects faithful with none truncated. Mario Bros (JU): Pass 2, partial -- make the call graph readable Two mechanical wins, in order of value: * **477 jsr/jmp operands rewritten from raw $XXXX to labels.** The tracer emits call and jump targets as hex, so the call graph was unreadable -- the most called routine appeared as `jsr $CCA0` 14 times over. Every one of those sites already had a label at its target address, so the substitution is purely mechanical and byte-preserving. Zero raw hex call operands remain. * **13 landmark routines named** from verified instruction evidence: WaitVblank1, ClearRamPageLoop, NmiPhaseDispatch ($C0B6), JsrThunkTable ($C5A3), DispatchViaStackPointer ($CD9E), AdvanceDispatchPointer ($CDB4), PushTablePointerDispatch ($CCA0), DispatchTableF393 ($CAB9), InitSpritesOffscreen ($DFC4), AdjustDecimalColumns ($D15A), TestRamFlagPair0500 ($D328), NextIndex ($CE84). **922 labels remain unnamed, and that is recorded as the honest state** rather than papered over. They sit 15-25 per page across $CC00-$CFFF, which is just the game logic: naming them means reading ~5,700 instructions across 935 routines. Guessing from the call graph and the NES convention would be exactly the guessy rewrite PROMPTS.md rules out. The productive next step is more reach, not more naming. The 4,555 unreached bytes are where most unnamed routines live, and they are gated behind the stack-pointer dispatch at $CD9E, whose pushed table addresses are only known in the callers' frames. Still BYTE-IDENTICAL; all 16 top-level projects remain faithful. Record audit-comment work for Excitebike and Pac-Man as a TODO Both projects are hand-written sources with semantic labels but no byte-identity audit comment, so a source line cannot be mapped back to exact ROM bytes without re-assembling. Every other project in the repo carries one. Documented what was tried and why it was dropped rather than left implicit. Addresses from 'ca65 -l' work fine -- the listing offset indexes prg.bin directly -- but the listing carries no source line numbers, so entries cannot be keyed back to source lines. Byte values also have to come from prg.bin rather than the listing, since ca65 prints 'rr rr' for relocatable words. No tool was committed, because the one written does not work. Both projects verified still byte-identical; no source file was changed. Add TODO.md handoff; bring AGENTS.md back in line with the repo AGENTS.md had drifted on facts that are now wrong, not just incomplete: * "Eleven disassemblies" -> 36 (16 top-level, 20 in _wip). The index it points at had the same stale count. * The audit-comment convention was stated as `; PRG:$xxxx AA BB`, but the dominant spelling is `; CPU:$xxxx PRG:xxxx AA BB` and five other variants are in use, with Excitebike and Pac-Man carrying none at all. Now stated accurately as "preserve whatever spelling a file uses", with the gap pointed at. * `_tools/` was described as holding splitnes.py and chr2gif.py. It holds six tools now, including the Pass 1 tracer and the verifier. * ROM filenames "contain spaces" -> spaces *and parentheses*, which is also what makes FCEUX hang headless. Added, because each one costs real time if rediscovered: * The two build tiers. The 20 _wip projects only `cmp reconstructed_prg.bin prg.bin`, so their header and CHR are NOT proven -- calling them byte-identical to the ROM is wrong. * A promotion procedure, since that has now been done twice. * A tracer/toolchain gotchas section: undocumented opcodes truncating traces (and ca65 being unable to encode them), vectors living at the end of the PRG rather than in the iNES header, the RESET==IRQ name collision, control-flow targets beating fall-through, the branch-target pruning that stops a file failing to assemble, NMI firing once per frame rather than per $2002 read, joy1 being $4016, and ca65 listings carrying no source line numbers. TODO.md is the handoff document: open work per project with enough detail to resume, the residue counts, and the reasoning behind each abandoned approach so it is not silently retried. Nothing rebuilt or changed; all projects still verify. Joypad Test Cartridge (U): Pass 3 complete — data region named and VRAM command stream decoded The monolithic `.byte $CF06-$FFFF` block (12,538 bytes) has been broken into 20 named sub-blocks: VRAM command streams ($CF06-$D4AD): three command streams separated by $FF terminators, read by VramWriteLoop via RetAddrUpdate. Opcode set decoded: $4C = jump, $60 = deferred PPUCTRL, bit-7-clear = VRAM write (PPU addr + data, bit 6 of data = long/short selector). OAM tables ($D4E3-$D6A3): 12 groups of 32-byte tables (8 entries of Y/tile/attr/X), read by CopyOamFromTable ($D4E3) and CopyOamFromTable70 ($D5C4). Sprite templates ($D6A5-$D6E4): sprite template read by CopySpriteTemplateTable ($D6A5) and button OAM template read by PrepareButtonOam ($D6C5). Padding: $FF ROM padding ($D6E5-$FFF9) and vectors ($FFFA-$FFFF). OVERVIEW.md updated: section 8 documents all 20 sub-blocks with the VRAM command stream format and OAM table structure; section 11 marks Pass 3 complete with remaining work (MarkerVar player/button mapping, HandleButtonPress sprite order). Build verified byte-identical. Root OVERVIEW.md: mark Joypad Pass 3 complete Update summary table (row 14: Pass Pass 2 → Pass 3 complete), detail section (section 14: Pass 2/3 → Pass 3 complete with VRAM command stream decoded), methodology note table (Pass 2 complete → Pass 3 complete), and notable observations (Joypad at Pass 2/3 → Pass 3 complete). Joypad Test Cartridge is now fully Pass 3 complete: all 20 data sub-blocks named, VRAM command stream opcode set decoded, OAM table structure documented. TODO.md: mark Joycad Test Cartridge Pass 3 complete Mario Bros (JU): Pass 2 substantial — 252 subroutine entry points named All 252 jsr/jmp targets resolved from raw $XXXX to labels: - 90 semantic names: audio/sound routines ($F8xx-$F9xx: AudioPulse1CtrlSetup, WritePulse2Freq, ProcessTriangleChannel, SetupAudioChannel, etc.), gameplay helpers (CheckZpB3Return, CheckRangeReturn012, ToggleZpFC_Bit3, etc.) - 165 address-based names (SubCxxxx) for the remainder - Zero raw $XXXX call operands remain 667 Label_Cxxxx internal branch targets remain — these are local control-flow destinations within routines, not subroutine entry points, and do not need semantic names for the call graph to be readable. Build verified BYTE-IDENTICAL. Root OVERVIEW.md updated (row 26: Pass 0 skeleton → Pass 1 complete, Pass 2 substantial). TODO.md updated. Lunar Pool (U): Pass 1 complete, 57.8% code; nesdis.py opcode/emitter fixes The skeleton prg.s was a 12-line .incbin of the whole PRG. It is now a real disassembly: 4,586 instructions, 9,475 code bytes (57.8%), 6,909 data bytes, byte accounting exact at 9,403 + 6,981 = 16,384, BYTE-IDENTICAL. Coverage: vectors + direct jsr/jmp/branch reached 1,259 instructions (15.6%). --undocumented took it to 4,572 (57.6%), and --auto-seed (48 table-referenced entries) plus --extra-seed 0xE80C (the non-$C000 RESET vector) took it to 4,586 (57.8%). Three undocumented opcodes were missing from nesdis.py and are added: * $72 RRA abs (the RRA family was missing the $72 absolute form) * $42 NOP #imm (the NOP-immediate family was missing $42) * $FA NOP abs,X (the NOP-abs,X family was missing $FA) One emitter fix: ca65 silently rewrites an operand whose high byte is $00 in the shorter zero-page form -- `sta $00FF,x` assembles as `sta $FF,x` ($95 instead of $9D). render() now emits the `a:` prefix for ABS/ABSX/ABSY/IND operands with a high byte of $00, forcing the absolute form so the emitted bytes match the ROM. operand_value() strips the prefix when parsing. These changes are general and verified not to disturb existing pipelines: MUSCLE's `make regen` still reproduces prg.s byte-for-byte. Remaining gaps: 6,909 bytes (42.2%) still data, gated behind jmp ($7961) at three sites (RAM-pointer dispatch; the blocks after them hold the screen text "strings" "game over" and "hi`score__") and stack-based dispatch at $CBDC -- the same pattern as Mario Bros (JU)'s $CD9E. TODO.md: add Lunar Pool as an open item Adds comments for all lines to Pacman and Excitebike Moves Pacman 'locals' and 'symbols' definitions into the right places
This commit is contained in:
1 parent
e9caa8cb36
commit
837007843d
50 files changed
+62760
-12932
No files matched your search
@@ -1,3 +1,6 @@
|
||||
ROMS/*
|
||||
**/*.o
|
||||
|
||||
_tools/__pycache__/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -2,29 +2,44 @@
|
||||
|
||||
## What this repo is
|
||||
|
||||
Eleven AI-generated disassemblies of NES ROMs. The contract (stated in `PROMPTS.md` and verified by `make` in every `copilot/` folder) is **byte-identical rebuilds**: `reconstructed.nes` must match the original ROM exactly. Not for distribution (`README.md`).
|
||||
Thirty-six AI-generated disassemblies of NES ROMs: 16 promoted to top-level folders and 20 skeletons still under `_wip/`. The contract (stated in `PROMPTS.md` and verified by `make` in every `copilot/` folder) is **byte-identical rebuilds**: `reconstructed.nes` must match the original ROM exactly. Not for distribution (`README.md`).
|
||||
|
||||
## Layout
|
||||
|
||||
- Each project owns a folder with a `copilot/` build dir (`prg.s`, `header.s`, `chr.s`, `linker.cfg`, `symbols.inc`, `locals.inc`, `Makefile`, and a copy/symlink `rom.nes`) and a per-project `OVERVIEW.md` at the folder root (power-on flow analysis).
|
||||
- Root `OVERVIEW.md` is the index: vectors, build fixes, and summary for all 11 projects. **Keep it in sync** when a project changes — its DK Jr. entries drifted badly before being corrected.
|
||||
- `PROMPTS.md` is the workflow contract. `default_project.tar.gz` is the empty-project skeleton (prg.s, symbols.inc, linker.cfg, Makefile, locals.inc). `_ROMS/` is the master ROM store; `_tools/` has `splitnes.py` and `chr2gif.py`.
|
||||
- Each project owns a folder with a `copilot/` build dir (`prg.s`, `header.s`, `chr.s`, `linker.cfg`, `symbols.inc`, `locals.inc`, `Makefile`, and a copy/symlink `rom.nes`) and a per-project `OVERVIEW.md` at the folder root (power-on flow analysis). Some older ones use `main.s` instead of `prg.s`, and a few lack `prg.bin` (carve the PRG out of `rom.nes` instead).
|
||||
- Root `OVERVIEW.md` is the index: vectors, build fixes, and summary for all 36 projects. **Keep it in sync** when a project changes — its DK Jr. entries drifted badly before being corrected, and its vector column later turned out to have five rows wrong from copy-paste duplication.
|
||||
- `TODO.md` is the handoff: open work per project, plus the gotchas that cost real time. Read it before starting anything non-trivial.
|
||||
- `PROMPTS.md` is the workflow contract. `default_project.tar.gz` is the empty-project skeleton (prg.s, symbols.inc, linker.cfg, Makefile, locals.inc). `_ROMS/` is the master ROM store.
|
||||
- `_tools/`: `nesdis.py` (Pass 1 tracer), `pass2.py` and `bytecode_dis.py` (Pass 2/3 emitters), `trace_stage_payload.py` (py65-based runtime tracer), `verify_pass1.py` (rebuild + re-trace check across all projects), `splitnes.py`, `chr2gif.py`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd "<Project>/copilot" && make # builds reconstructed.nes, then cmp vs rom.nes
|
||||
python3 _tools/verify_pass1.py # rebuilds and re-traces every project
|
||||
```
|
||||
|
||||
`make`'s default target includes the byte-identity `cmp` — a build that doesn't print "Byte-identical ROM verified." is a failure. Requires `ca65`/`ld65` on PATH.
|
||||
|
||||
**There are two build tiers, and the difference is real.** Top-level projects compare the whole ROM against `copilot/rom.nes`. The 20 `_wip/` projects only run `cmp reconstructed_prg.bin prg.bin`, so their **header and CHR are not proven** — do not describe them as byte-identical to the ROM.
|
||||
|
||||
## Promoting a project out of `_wip/`
|
||||
|
||||
1. `git mv "_wip/<name>" "<name>"` — the relative `copilot/rom.nes -> ../<name>.nes` symlink survives untouched.
|
||||
2. Add `copilot/rom.nes` (a space-free copy or symlink of the original) and switch the Makefile from `cmp reconstructed_prg.bin prg.bin` to `cmp reconstructed.nes rom.nes`.
|
||||
3. Change `../../../_tools/` to `../../_tools/` in the Makefile — the depth changed by one.
|
||||
4. Verify `make`, then `make regen` if a regen pipeline exists.
|
||||
5. Add it to the root inventory table and re-number the `_wip` rows.
|
||||
|
||||
|
||||
## Irreversible conventions (do not "clean up" past these)
|
||||
|
||||
- Every instruction carries a trailing `; PRG:$xxxx AA BB` comment with PRG offset and exact emitted bytes. This is the byte-identity audit trail — preserve it even while renaming labels or restructuring files.
|
||||
- Every instruction carries a trailing audit comment with its CPU address, PRG offset and exact emitted bytes. The dominant spelling is `; CPU:$C123 PRG:0123 AD 21 03`, but **five other spellings are in use across the repo** and Excitebike and Pac-Man have no byte comments at all. This is the byte-identity audit trail — preserve whatever spelling a file uses even while renaming labels or restructuring files. See the TODO in root `OVERVIEW.md` for closing the gap.
|
||||
- Unreached/unresolved bytes stay as data (`Label_/Data/` `.byte` or `.incbin` blocks with ROM provenance), never deleted or reformatted.
|
||||
- Routine headers follow the template in `prg.s`: global + local description, parameters, returns, clobbers.
|
||||
- Two separate symbol files: `symbols.inc` = NES hardware constants only; `locals.inc` = zero-page and forward references. Unresolved zero-page bytes keep neutral `ZpXX` names rather than speculative names.
|
||||
- Pass discipline: Pass 1 = code/data split (stop for review), Pass 2 = label replacement, Pass 3 = semantic naming of data tables/structures. Do not skip straight to guessy rewrites.
|
||||
- Two separate symbol files: `symbols.inc` = NES hardware constants only; `locals.inc` = zero-page and forward references. Unresolved zero-page bytes keep neutral `ZpXX` names rather than speculative names. Proven semantic names may sit *beside* the generated `ZpXX` entries as aliases.
|
||||
- Pass discipline: Pass 1 = code/data split (stop for review), Pass 2 = label replacement, Pass 3 = semantic naming of data tables/structures. Do not skip straight to guessy rewrites. When a pass is only partly achievable, record the residue and why rather than inventing names.
|
||||
- `make regen` (where it exists, e.g. MUSCLE) must keep reproducing `prg.s` byte-for-byte on the standard library alone. Re-check it after any `nesdis.py` change.
|
||||
|
||||
## Hard-won quirks (details in root `OVERVIEW.md` per-project "Build fix")
|
||||
|
||||
@@ -33,5 +48,22 @@ cd "<Project>/copilot" && make # builds reconstructed.nes, then cmp vs rom.nes
|
||||
- Donkey Kong Jr. is architecturally different: reset entry is $C66E, the reset path idles forever in an LFSR loop, and the entire frame state machine runs inside NMI. Its `DeferredTaskHandlerTable` entry 0 deliberately mirrors the IRQ vector word at $BFFE.
|
||||
- IRQ vectors often point at unused/data targets in these games — verify before assuming a handler is broken.
|
||||
- Unlicensed titles break static-trace assumptions: Pyramid (U) pokes a mapper unlock at `$4100` (beyond the register space) despite a mapper-0 header, and dispatches through `jmp ($0002)` built from RAM — trace via its two queue tables (`$C3A6`, `$CCD5`), and do NOT fall-through-seed gaps that follow `rts`/`jmp`/`brk` (that misclassifies data as code).
|
||||
- ROM filenames contain spaces; builds `incbin` a space-free `rom.nes` inside `copilot/`. Keep that copy/symlink working and don't rename it.
|
||||
- ROM filenames contain spaces **and parentheses** (e.g. `M.U.S.C.L.E. (U).nes`); builds `incbin` a space-free `rom.nes` inside `copilot/`. Keep that copy/symlink working and don't rename it. Note that a name with spaces/parens is also what makes FCEUX hang headless — symlink it to a clean name and run under Xvfb.
|
||||
- `.gitignore` contains `ROMS/*` but the actual directory is `_ROMS` — the pattern matches nothing, so ROM binaries stay tracked. Check before committing large blobs.
|
||||
|
||||
## Tracer and toolchain gotchas (2026-10-17)
|
||||
|
||||
Full detail and reasoning in `TODO.md`. These are the ones that silently corrupt results rather than failing loudly.
|
||||
|
||||
- **Undocumented opcodes silently truncate a trace.** A tracer that knows only the 151 official opcodes halts on the first one used as ordinary code. Joypad Test Cartridge died at `$C09E` on `DCP $3C20` (`$CF`) and filed **3,256 bytes of real code as `.byte`**. `nesdis.py --undocumented` decodes all 105 (default **off**, so existing `make regen` pipelines stay byte-stable).
|
||||
- **ca65 V2.19 implements none of the undocumented opcodes**, so they cannot be written as mnemonics. `nesdis.py` emits them as explicit `.byte` with the disassembly in the comment — that keeps them in the code region, so both the split and the audit trail survive. ca65 is also happy to accept plain `nop`, which is official (`0xEA`).
|
||||
- **Vectors are not in the iNES header.** They are the three little-endian words at the *end* of the 16 KB PRG (`$FFFA–$FFFF` in the `$C000` mirror). Header bytes 0–9 carry only sizes, mirroring and mapper. Parsing them from `header.bin` as if they were header fields yields garbage.
|
||||
- **RESET and IRQ are usually the same address.** The semantic-name map must not overwrite on collision, or the primary entry gets renamed to `IrqHandler`. Use `setdefault` in RESET/NMI/IRQ priority order.
|
||||
- **Control-flow targets must win over fall-through.** A fall-through decode whose *operand* spans an address that another path branches to is the wrong decode — no assembler branches mid-instruction. Mario Bros had a `bne` at `$C4BA` targeting `$C528` swallowed by a `NOP #$A5` at `$C527`.
|
||||
- **Branch targets inside a surviving instruction must be pruned** from the target set, or the emitter's linear walk consumes those bytes as an operand, the label is referenced but never defined, and **the file does not assemble at all**.
|
||||
- **A plain vector trace reaches only ~15% of a real game.** Almost everything sits behind computed dispatch. Look for `jmp ($xx)`, and for dispatchers that pop the table address with `pla` — that form cannot be followed from the dispatcher because the pointer is only knowable in the caller's frame (Mario Bros `$CD9E`). Probing a suspected data region linearly with `nesdis.py --probe` is cheap and settles whether it is code; `--auto-seed` plus a couple of `--extra-seed` addresses took Mario Bros from 14.6% to 72.2%.
|
||||
- **When emulating to trace, NMI must fire once per frame, not per `$2002` read.** The main loop polls `$2002` to sync on vblank, and each read clears bit 7, re-arming NMI within the same scanline. Guarding on the vblank flag alone gave **40,900 NMIs across 3,000 frames** and flattened coverage at 26%.
|
||||
- **Controller input goes on joy1 = `$4016`** with a real latch/shift register. The poll routine's `lda $4016,X` with X=1 lands on `$4017`, but only bits 0–1 are compared, so driving `$4017` consumes nothing and changes no coverage at all.
|
||||
- **`ca65 -l` listings contain no source line numbers** — they emit `<offset>r <n> <bytes> <source text>`. You cannot key listing entries back to source lines, and the listing prints `rr rr` for relocatable words, so byte values must come from `prg.bin`.
|
||||
- **py65 is not a build dependency.** Unpack on demand (`pip download --no-deps py65`, then `unzip` into a lib dir; `PY65LIB` env var points at it). `/tmp` is volatile — a reboot wiped it mid-session. Anything reusable belongs in `_tools/`.
|
||||
|
||||
+5453
-5454
File diff suppressed because it is too large.
Load diff
@@ -13,7 +13,7 @@ This document describes the execution flow of the disassembled Joypad Test Cartr
|
||||
| CHR | 1 × 8 KB |
|
||||
| Mirroring | Horizontal |
|
||||
| PRG bank start | CPU $C000 |
|
||||
| Source file | `copilot/prg.s` (~1,360 lines with headers) |
|
||||
| Source file | `copilot/prg.s` (4,516 lines with headers) |
|
||||
| Output | `reconstructed.nes` (24,592 bytes) |
|
||||
|
||||
### Interrupt vectors (CPU $FFFA–$FFFF)
|
||||
@@ -30,7 +30,7 @@ This document describes the execution flow of the disassembled Joypad Test Cartr
|
||||
|
||||
`make` in `copilot/` assembles `prg.s` with `ca65`, links with `ld65`, and verifies against `prg.bin`.
|
||||
|
||||
**Verification result: BYTE-IDENTICAL** — `reconstructed_prg.bin` matches `prg.bin` exactly (all 16,384 bytes), and `prg.bin` matches the PRG bank of `Joypad Test Cartridge (U).nes`.
|
||||
**Verification result: BYTE-IDENTICAL** — `make` compares the whole reconstructed ROM against `copilot/rom.nes` and prints `Byte-identical ROM verified.` Byte accounting is exact: 3,836 code + 12,548 data = 16,384 of 16,384 bytes.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,9 +38,26 @@ This document describes the execution flow of the disassembled Joypad Test Cartr
|
||||
|
||||
| Pass | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Pass 1 | Complete | Code/data split — ~580 bytes traced code (~3.5%) or explicit `.byte` data (~96.5%) with `; CPU:$xxxx PRG:$xxxx` annotations |
|
||||
| Pass 2 | Complete | Routine headers added for all labeled positions; key routines semantically named (`Reset`, `NmiHandler`, `VramWrite`, `PpuScrollLatch`, etc.); zero-page aliases in `locals.inc` |
|
||||
| Pass 3 | Complete | Data regions identified and documented (VRAM burst write table at $0302, pointer table at $0000, test pattern/button mapping data in $CE98–$FFFF); remaining `Label_Cxxxx` references are internal branch targets within routines |
|
||||
| Pass 1 | **Redone** | Was badly truncated: 282 instructions / 580 bytes (3.5%). Now **1,773 instructions / 3,836 bytes (23.4%)** — a 6.6x increase. Root cause: the tracer only knew the 151 official opcodes and halted at `$C09E` on `DCP $3C20` (`$CF`), filing 3,256 bytes of real code as `.byte`. Verified against a fresh trace: 1,769 emitted instructions + 4 undocumented opcodes written as explicit bytes = 1,773, an exact match |
|
||||
| Pass 2 | **Complete** | 202 labels, **zero `Label_Cxxxx` remaining**; 202 routine headers, all describing real behaviour (no "undetermined in Pass 1" boilerplate); `locals.inc` carries 99 aliases, half of them proven semantic names sitting beside the generated `ZpXX` entries |
|
||||
| Pass 3 | **Complete** | The single data region `$CF06–$FFFF` has been broken into 20 named sub-blocks: VRAM command streams read by `VramWriteLoop`/`RetAddrUpdate`, seven 32-byte OAM table groups read by `CopyOamFromTable`/`CopyOamFromTable70`, the 32-byte sprite template read by `CopySpriteTemplateTable`, the 32-byte button OAM template read by `PrepareButtonOam`, and 10,517 bytes of `$FF` ROM padding. The `RetAddrUpdate` VRAM command stream opcode set has been decoded (`$4C` = jump, `$60` = deferred PPUCTRL, bit-7-clear bytes = VRAM write with PPU addr + data, bit 6 of data = long/short command selector). |
|
||||
|
||||
### Why Pass 1 was truncated
|
||||
|
||||
The tracer descended from the three vectors, following direct `jsr`/`jmp`/branch
|
||||
targets. It stopped at `$C09E`, where the byte is `$CF` = `DCP $3C20` — an
|
||||
undocumented read-modify-write op used here as ordinary code. Everything after
|
||||
that point was written out as data until a later run promoted it.
|
||||
|
||||
This cartridge is an unusually clear demonstration: nothing exotic is involved,
|
||||
just one undocumented opcode in the middle of the main loop. `nesdis.py` now
|
||||
takes `--undocumented` (default **off**, so other projects' `make regen` stays
|
||||
byte-stable) to decode all 105 undocumented NMOS opcodes.
|
||||
|
||||
ca65 V2.19 implements none of them, so the 4 affected instructions
|
||||
(`DCP $3C20`, `LAX $20D4`, `NOP $C1,X`, `RRA ($D0),X`) are emitted as explicit
|
||||
`.byte` with the disassembly in the comment. They stay in the code region, so
|
||||
the split and the audit trail are both preserved.
|
||||
|
||||
---
|
||||
|
||||
@@ -144,17 +161,64 @@ Full list in `copilot/locals.inc`.
|
||||
|
||||
## 8. Data Regions
|
||||
|
||||
The PRG is approximately 3.5% code / 96.5% data. The data regions include:
|
||||
The PRG is **23.4% code / 76.6% data** (3,836 / 12,548 bytes). The data region `$CF06–$FFFF` has been broken into 20 named sub-blocks:
|
||||
|
||||
- **$C09E–$C173** (214 bytes): Main loop inline code/data (pointer table references, VRAM write parameters)
|
||||
- **$C17F–$CCF6** (2,936 bytes): Test patterns, button mapping tables, screen data
|
||||
- **$CD39–$CD3E** (6 bytes): PPU mask toggle code fragment
|
||||
- **$CE98–$FFFF** (12,648 bytes): Test pattern data, button mapping tables, screen layout data
|
||||
| Address | Size | Name | Contents |
|
||||
|---------|------|------|----------|
|
||||
| `$CF06–$CF28` | 35 B | `VramCommandStream1` | VRAM command stream start — PPU addr/data pairs read by `VramWriteLoop` |
|
||||
| `$CF29` | 1 B | `VramStream1Terminator` | `$FF` terminator ending first command stream |
|
||||
| `$CF2A–$CF3F` | 22 B | `VramCommandInlineCode` | Inline code after `jsr $CE98` — `lax $20D4`, `nop $C1,X`, `$61` padding |
|
||||
| `$CF40–$D061` | 290 B | `VramCommandBlock2` | VRAM command/data block — PPU writes and OAM attribute patterns |
|
||||
| `$D062` | 1 B | `VramStream2Terminator` | `$FF` terminator ending second command stream |
|
||||
| `$D063–$D1F3` | 401 B | `VramCommandBlock3` | VRAM command/data block — continues command stream patterns |
|
||||
| `$D1F4` | 1 B | `VramStream3Terminator` | `$FF` terminator ending third command stream |
|
||||
| `$D1F5–$D4AD` | 697 B | `VramCommandBlock4` | Largest VRAM command/data block — 697 bytes of command stream |
|
||||
| `$D4AE` | 1 B | `OamTableStreamTerminator` | `$FF` terminator before OAM gap data |
|
||||
| `$D4AF–$D4E1` | 51 B | `OamTableGap` | Gap data before OAM table at `$D4E3` |
|
||||
| `$D4E2` | 1 B | `OamTable1Terminator` | `$FF` terminator before OAM table group 1 |
|
||||
| `$D4E3–$D5C2` | 224 B | `OamTableGroups1` | OAM table groups 1-7 (`CopyOamFromTable` reads `$D4E3`) — 7×32 byte tables, each with 8 entries of Y/tile/attr/X |
|
||||
| `$D5C3` | 1 B | `OamTableStream2Terminator` | `$FF` terminator before second OAM table group |
|
||||
| `$D5C4–$D603` | 64 B | `OamTableGroups2` | OAM table groups 1-2 (`CopyOamFromTable70` reads `$D5C4`) — 2×32 byte tables |
|
||||
| `$D604–$D643` | 64 B | `OamSpritePadding` | 64 bytes of `$F4` sprite off-screen padding |
|
||||
| `$D644–$D6A3` | 96 B | `OamTableGroups3` | OAM table groups 3-5 — 3×32 byte tables before sprite template |
|
||||
| `$D6A4` | 1 B | `SpriteTableTerminator` | `$FF` terminator before sprite template table |
|
||||
| `$D6A5–$D6C4` | 32 B | `SpriteTemplateTable` | Sprite template — 8×4 Y/tile/attr/X entries (`CopySpriteTemplateTable` reads `$D6A5`) |
|
||||
| `$D6C5–$D6E4` | 32 B | `ButtonOamTemplateTable` | Button OAM template — 8×4 Y/tile/attr/X entries (`PrepareButtonOam` reads `$D6C5`) |
|
||||
| `$D6E5–$FFF9` | 10,517 B | `RomPadding` | `$FF` ROM padding |
|
||||
| `$FFFA–$FFFF` | 6 B | `ResetVectors` | Reset/NMI/IRQ vectors |
|
||||
|
||||
Notable data structures:
|
||||
- Pointer table at $0000–$0001 (read by `VramPtrTableRead` at $CD99)
|
||||
- VRAM burst write table at $0302 (read by `VramBurstWrite` at $CE4F)
|
||||
- Button mapping / test pattern tables in the $C000–$FFFF range
|
||||
The previous Pass 3 claimed four data regions totalling ~15.8 KB, including `$C09E–$C173` and `$C17F–$CCF6`. Both are code. Those claims were an artefact of the truncated Pass 1 and have been removed.
|
||||
|
||||
### VRAM Command Stream Format
|
||||
|
||||
The VRAM command stream is read by `VramWriteLoop` (`$CD45`), dispatched through `VramPtrTableRead` (`$CD99`) and `RetAddrUpdate` (`$CDC8`). The stream uses the `RetAddrUpdate` return-address modification trick: `jsr $CE98` (`DispatchVramOp`) pushes a return address, `RetAddrUpdate` advances it by 2 to skip the command byte, and execution resumes inline after the dispatch returns.
|
||||
|
||||
Command byte format (read by `DispatchVramOp` at `$CE98`):
|
||||
- **Low 4 bits**: data length (number of bytes following)
|
||||
- **High 4 bits**: command type
|
||||
|
||||
Opcodes:
|
||||
- `$4C` (`HandleJmpOpcode`): jump — reads 2-byte address from stream, rewrites dispatch pointer
|
||||
- `$60` (`HandlePpuCtrlOpcode`): deferred PPUCTRL write — pops return address from stack as new stream pointer
|
||||
- Bit 7 clear, not `$4C`/`$60` (`VramWriteLoop`): VRAM write — opcode byte = PPU addr high, next byte = PPU addr low, then data bytes. Bit 6 of each data byte selects short (1 byte) vs long (multiple bytes) command.
|
||||
- Bit 7 set: end of stream
|
||||
|
||||
### OAM Table Structure
|
||||
|
||||
OAM tables consist of 8 entries of 4 bytes each (Y, tile, attr, X), matching the NES OAM layout:
|
||||
- `SpriteTemplateTable` (`$D6A5`): Y=$79 for all entries, player sprite tiles
|
||||
- `ButtonOamTemplateTable` (`$D6C5`): Y=$79 for all entries, button indicator tiles
|
||||
- `OamTableGroups1` (`$D4E3`): 7 groups with varying Y values ($37, $67, $57, $97, $87, $C7, $B7, $5F, $6F)
|
||||
- `OamTableGroups2` (`$D5C4`): 2 groups with Y values $37 and $67
|
||||
- `OamTableGroups3` (`$D644`): 3 groups with Y values $27, $37, $57, $67
|
||||
|
||||
Notable data structures (addresses from the code that reads them):
|
||||
|
||||
- VRAM pointer at `$00/$01`, read indirectly by `VramPtrTableRead` ($CD99), `WriteVramStreamByte` ($CD74) and `DispatchVramOp` ($CE98)
|
||||
- VRAM burst command table at `$0302`, read by `VramBurstWrite` ($CE4F) and written by `StoreVramOpResult` ($CEB2); `SetBurstWriteIndex` primes it with `$22`
|
||||
- VRAM op length limit at `$0300`, tested by `VramOpLengthCheck` ($CEF3)
|
||||
- Sprite template tables at `$D6A5` (`CopySpriteTemplateTable`) and `$D6C5` (`PrepareButtonOam`), copied into the OAM shadow at `$02E0`
|
||||
- Further OAM tables at `$D4E3` (`CopyOamFromTable`) and `$D5C4` (`CopyOamFromTable70`)
|
||||
|
||||
---
|
||||
|
||||
@@ -173,10 +237,19 @@ make # → ca65 + ld65 + cmp reconstructed.nes rom.nes → "Byte-identica
|
||||
- `RetAddrUpdate` ($CDC8) implements a return-address modification trick: it reads the return address from the stack, advances it by 2, and stores it back. This is used with `VramPtrWrite` ($CD3F) to implement runtime-computed control flow (effectively a coroutine/threaded dispatch).
|
||||
- The IRQ vector points to $C000 (Reset) and is effectively unused.
|
||||
- Large portions of the PRG are test pattern and button mapping data, not executable code.
|
||||
- `PollExpansionPortLoop` reads `$4017` and compares bits 0-1, but nothing consumes
|
||||
the result — the branch is always taken the same way. The read is present but inert.
|
||||
- `MarkerVar01`–`MarkerVar12` (zero page `$5A`, `$5B`, `$56`–$5F`, `$60`, `$61`, `$63`,
|
||||
`$64`) drive the twelve `MarkerUpdateN` indicator groups. Which player and button each
|
||||
belongs to is **not** established, so they are named by position rather than by button.
|
||||
- A draft Pass 2 `locals.inc` called `$55` a "P1 Down timer". The instructions do not
|
||||
support that: `ApplyMarkerValueEnableAudio` stores `$70`/`$88`/`$A0` there and
|
||||
`DispatchOnMarkerValue` compares it against `$70` and `$88`. It is `MarkerValue`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Next Steps
|
||||
|
||||
- **Promotion complete** — moved to top-level `Joypad Test Cartridge (U)/` folder with full ROM build (`header.s`, `chr.s`, `linker.cfg`, `Makefile`).
|
||||
- **Optional deeper Pass 3 work** — extract button mapping and test pattern tables from untraced regions into labeled `.byte` blocks; refine remaining zero-page aliases.
|
||||
- **Pass 3 is complete.** All 20 data sub-blocks are named (section 8), the VRAM command stream opcode set is decoded, and the OAM table structure is documented.
|
||||
- Establish which player/button each `MarkerVar` belongs to. This needs the OAM layout for the indicator sprites and the button bit order confirmed against the shift-register read path, not just assumed from the NES convention.
|
||||
- `HandleButtonPress1`–`HandleButtonPress8` write OAM tiles in the order `$0202, $0206, $020E, $020A, $0212, $0216, $021A, $021E` — sprites 1, 2, 4, 3, 5, 6, 7, 8. The out-of-order third and fourth entries are unexplained; resolving them would confirm which bit maps to which button.
|
||||
@@ -1,46 +1,149 @@
|
||||
; Joypad Test Cartridge (U) zero-page aliases - Pass 2
|
||||
; Semantic names for addresses with observed usage patterns; ZpXX names for
|
||||
; addresses whose role is not yet uniquely determined.
|
||||
; Joypad Test Cartridge (U) application-specific RAM aliases - Pass 1
|
||||
; Generated from every direct zero-page operand in reachable code:
|
||||
; ref counts are reads/writes, `where` lists the referencing addresses.
|
||||
; ZpXX names indicate storage with a role not yet unique enough for a behavioral name;
|
||||
; Pass 2 replaces them with names derived from observed usage.
|
||||
|
||||
VramPtrLo = $00 ; VRAM write pointer low byte (indirect, used by VramWriteLoop/VramWrite)
|
||||
VramPtrHi = $01 ; VRAM write pointer high byte (indirect)
|
||||
VramLen = $02 ; VRAM write data length counter
|
||||
PpuScrollX = $03 ; PPU horizontal scroll value (latched to $2005)
|
||||
Zp04 = $04 ; Temporary zero-page storage
|
||||
RetAddrTempLo = $05 ; Return address temp low byte (used by RetAddrUpdate)
|
||||
RetAddrTempHi = $06 ; Return address temp high byte (used by RetAddrUpdate)
|
||||
Zp0D = $0D ; Temporary zero-page storage
|
||||
PpuMaskShadow = $0E ; PPUMASK shadow register (bits cleared for sprite/bg)
|
||||
PpuScrollY = $0EC ; PPU vertical scroll value (latched to $2005)
|
||||
PpuScrollX_Init = $0ED ; PPU horizontal scroll value written during init
|
||||
PpuMaskValue = $0EE ; PPUMASK value written during init (also shadow)
|
||||
PpuCtrlShadow = $EF ; PPUCTRL shadow register (NMI bit cleared via and #$7F)
|
||||
VramToggleFlag = $CF ; Flag toggled each frame (asl/rol at $C095-$C09D)
|
||||
NmiFlagByte = $E0 ; Flag cleared during NMI ($CD0F)
|
||||
Zp00 = $00 ; 22 refs; reads=13 writes=9; used at $C013, C05D, C252, CB16, +18 more
|
||||
Zp01 = $01 ; 9 refs; reads=4 writes=5; used at $CD95, CD97, CDA8, CDB8, +5 more
|
||||
Zp02 = $02 ; 7 refs; reads=4 writes=3; used at $CDF2, CE10, CE1F, CE9A, +3 more
|
||||
Zp03 = $03 ; 4 refs; reads=2 writes=2; used at $CE98, CEB2, CEE5, CEE7
|
||||
Zp04 = $04 ; 2 refs; reads=1 writes=1; used at $CEA5, CEC2
|
||||
Zp05 = $05 ; 7 refs; reads=4 writes=3; used at $CDCC, CDD5, CDDA, CDE1, +2 more
|
||||
Zp06 = $06 ; 5 refs; reads=2 writes=3; used at $CDD1, CDE8, CEC4, CED2
|
||||
Zp40 = $40 ; 57 refs; reads=32 writes=25; used at $C0E7, C0E9, C0EB, C0EF, +30 more
|
||||
Zp41 = $41 ; 3 refs; reads=3 writes=0; used at $C1BB, C304, CBB7
|
||||
Zp42 = $42 ; 2 refs; reads=0 writes=2; used at $CCCE, CCDC
|
||||
Zp43 = $43 ; 2 refs; reads=2 writes=0; used at $C1BF, C308
|
||||
Zp44 = $44 ; 3 refs; reads=3 writes=0; used at $C1C3, C30C, CCCC
|
||||
Zp45 = $45 ; 2 refs; reads=1 writes=1; used at $CC75
|
||||
Zp46 = $46 ; 1 refs; reads=1 writes=0; used at $CCDA
|
||||
Zp47 = $47 ; 2 refs; reads=1 writes=1; used at $CC72
|
||||
Zp49 = $49 ; 3 refs; reads=2 writes=1; used at $CC88, CC94
|
||||
Zp4A = $4A ; 1 refs; reads=1 writes=0; used at $CCA0
|
||||
Zp4B = $4B ; 3 refs; reads=2 writes=1; used at $CC85, CCAC
|
||||
Zp4C = $4C ; 1 refs; reads=1 writes=0; used at $CCB8
|
||||
Zp4D = $4D ; 10 refs; reads=5 writes=5; used at $CC92, CC9C, CC9E, CCA8, +6 more
|
||||
Zp4E = $4E ; 40 refs; reads=19 writes=21; used at $C212, C2BC, C331, C3A3, +27 more
|
||||
Zp4F = $4F ; 4 refs; reads=2 writes=2; used at $C2BE, C335, CB74, CBD9
|
||||
Zp51 = $51 ; 3 refs; reads=1 writes=2; used at $C2C0, C339, CB76
|
||||
Zp52 = $52 ; 3 refs; reads=1 writes=2; used at $C2C2, C33D, CB78
|
||||
Zp53 = $53 ; 13 refs; reads=6 writes=7; used at $C0DB, C0FD, C107, C2C6, +6 more
|
||||
Zp54 = $54 ; 13 refs; reads=6 writes=7; used at $C0DD, C0F9, C101, C2C4, +6 more
|
||||
Zp55 = $55 ; 8 refs; reads=6 writes=2; used at $C0D7, C113, C119, C11F, +4 more
|
||||
Zp56 = $56 ; 21 refs; reads=12 writes=9; used at $C2CC, C371, C37D, C60A, +15 more
|
||||
Zp57 = $57 ; 16 refs; reads=10 writes=6; used at $C2D2, C763, C769, C76E, +12 more
|
||||
Zp59 = $59 ; 14 refs; reads=10 writes=4; used at $C8BD, C8C3, C8C8, C8CF, +10 more
|
||||
Zp5A = $5A ; 14 refs; reads=10 writes=4; used at $CA16, CA1C, CA21, CA28, +10 more
|
||||
Zp5B = $5B ; 21 refs; reads=12 writes=9; used at $C2CA, C3B6, C3C4, C597, +15 more
|
||||
Zp5C = $5C ; 16 refs; reads=10 writes=6; used at $C2D0, C6F0, C6F6, C6FB, +12 more
|
||||
Zp5E = $5E ; 14 refs; reads=10 writes=4; used at $C84A, C850, C855, C85C, +10 more
|
||||
Zp5F = $5F ; 14 refs; reads=10 writes=4; used at $C9A3, C9A9, C9AE, C9B5, +10 more
|
||||
Zp60 = $60 ; 21 refs; reads=12 writes=9; used at $C2C8, C429, C439, C524, +15 more
|
||||
Zp61 = $61 ; 16 refs; reads=10 writes=6; used at $C2CE, C67D, C683, C688, +12 more
|
||||
Zp63 = $63 ; 14 refs; reads=10 writes=4; used at $C7D7, C7DD, C7E2, C7E9, +10 more
|
||||
Zp64 = $64 ; 14 refs; reads=10 writes=4; used at $C930, C936, C93B, C942, +10 more
|
||||
Zp65 = $65 ; 40 refs; reads=18 writes=22; used at $C2D4, C36D, C379, C380, +18 more
|
||||
Zp66 = $66 ; 3 refs; reads=0 writes=3; used at $C2D6, CAAA, CB8C
|
||||
Zp68 = $68 ; 3 refs; reads=0 writes=3; used at $C2D8, CAAC, CB8E
|
||||
Zp69 = $69 ; 3 refs; reads=0 writes=3; used at $C2DA, CAAE, CB90
|
||||
Zp6A = $6A ; 9 refs; reads=4 writes=5; used at $C2B8, C2EA, C2EE, CA8C, +3 more
|
||||
ZpC1 = $C1 ; 2 refs; reads=1 writes=1; used at $C2A3
|
||||
ZpCF = $CF ; 6 refs; reads=3 writes=3; used at $C095, C287, CB4B
|
||||
ZpD0 = $D0 ; 2 refs; reads=1 writes=1; used at $CB53
|
||||
ZpD1 = $D1 ; 1 refs; reads=1 writes=0; used at $C28F
|
||||
ZpE0 = $E0 ; 3 refs; reads=1 writes=2; used at $CCEF, CCF2, CD0F
|
||||
ZpEC = $EC ; 2 refs; reads=1 writes=1; used at $C04D, CE44
|
||||
ZpED = $ED ; 2 refs; reads=1 writes=1; used at $C04B, CE3F
|
||||
ZpEE = $EE ; 7 refs; reads=2 writes=5; used at $C03D, C084, C279, CB3D, +3 more
|
||||
ZpEF = $EF ; 15 refs; reads=6 writes=9; used at $C036, C07D, C272, CB36, +11 more
|
||||
|
||||
; Controller / button state (indexed by button number via $4E,X)
|
||||
JoyBtnStateBase = $4E ; Base address for indexed button states ($4E,X)
|
||||
JoyBtnState_P1_A = $4E ; Player 1 button A state/timer
|
||||
JoyBtnState_P1_B = $4F ; Player 1 button B state/timer
|
||||
JoyBtnState_P1_Select = $50 ; Player 1 select state/timer (if used)
|
||||
JoyBtnState_P1_Start = $51 ; Player 1 start state/timer
|
||||
JoyBtnState_P1_Up = $52 ; Player 1 up state/timer
|
||||
FrameCounterLo = $53 ; Frame counter low byte (incremented each frame)
|
||||
FrameCounterHi = $54 ; Frame counter high byte
|
||||
JoyBtnTimer_P1_Down = $55 ; Player 1 down button timer
|
||||
JoyBtnTimer_P1_Left = $56 ; Player 1 left button timer
|
||||
JoyBtnTimer_P1_Right = $57 ; Player 1 right button timer
|
||||
JoyBtnTimer_P1_Start2 = $5C ; Player 1 start timer (second instance)
|
||||
JoyBtnTimer_P1_A2 = $61 ; Player 1 button A timer (second instance)
|
||||
JoyBtnTimer_P2_Down = $5E ; Player 2 down button timer
|
||||
JoyBtnTimer_P2_Left = $59 ; Player 2 left button timer
|
||||
JoyBtnTimer_P2_Right = $64 ; Player 2 right button timer
|
||||
JoyBtnTimer_P2_A = $5F ; Player 2 button A timer
|
||||
JoyBtnTimer_P2_B = $5A ; Player 2 button B timer
|
||||
; ---------------------------------------------------------------------------
|
||||
; Pass 2 semantic aliases
|
||||
;
|
||||
; Aliases sit beside the generated ZpXX names rather than replacing them, since
|
||||
; several addresses are reused for more than one role. Each name below is
|
||||
; justified by the referencing routines named in its comment.
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
; Generic zero-page aliases (role not yet uniquely determined)
|
||||
Zp60 = $60 ; Temporary zero-page storage
|
||||
Zp65 = $65 ; Temporary zero-page storage
|
||||
Zp66 = $66 ; Temporary zero-page storage
|
||||
Zp68 = $68 ; Temporary zero-page storage
|
||||
Zp69 = $69 ; Temporary zero-page storage
|
||||
; VRAM writer state. $00/$01 form the indirect pointer used by
|
||||
; `lda (Zp00),y` in WriteVramStreamByte, VramPtrTableRead and DispatchVramOp.
|
||||
VramPtrLo = $00
|
||||
VramPtrHi = $01
|
||||
VramOpLength = $02 ; byte count for the current VRAM op
|
||||
VramOpResult = $03 ; DispatchVramOp / StoreVramOpResult
|
||||
VramOpTemp = $04
|
||||
RetAddrTempLo = $05 ; RetAddrUpdate pulls the caller's PC off the stack
|
||||
RetAddrTempHi = $06
|
||||
|
||||
; Player 1 controller bits. Read by ReadControllerBitsLoop as
|
||||
; `lda $4016,x / lsr a / rol Zp40,x` for x = 0..7, so $40 holds all eight
|
||||
; buttons and is subsequently rotated one bit at a time by
|
||||
; PollControllerShiftRegister and DispatchButtonBits.
|
||||
P1Buttons = $40 ; bit0 A, 1 B, 2 Select, 3 Start, 4 Up, 5 Down, 6 Left, 7 Right
|
||||
P1ButtonA = $40 ; aliases for the individual bit tests
|
||||
P1ButtonB = $41
|
||||
P1ButtonSelect = $42
|
||||
P1ButtonStart = $43
|
||||
P1ButtonUp = $44
|
||||
P1ButtonDown = $45
|
||||
P1ButtonLeft = $46
|
||||
P1ButtonRight = $47
|
||||
|
||||
; Previous-direction latch written by LatchPreviousStickState (`sta Zp42,x`),
|
||||
; read by CheckStickUp/Down/Left/Right against $10/$20/$C0 thresholds.
|
||||
PrevStickState = $42
|
||||
StickDiagonalState = $46
|
||||
|
||||
; Second and third shift registers from the alternate read paths
|
||||
; (`rol Zp47,x` at ReadControllerBitsAltLoop, `rol Zp4B,x` at
|
||||
; ReadControllerBitsAlt2Loop).
|
||||
P1ButtonsAlt = $47
|
||||
P2Buttons = $4B
|
||||
ButtonShiftAlt = $65 ; second shift register in DispatchButtonBits
|
||||
|
||||
; Direction accumulators compared in CheckStickUp..Right
|
||||
StickAccumUp = $4A
|
||||
StickAccumDown = $4B
|
||||
StickAccumLeft = $4C
|
||||
StickAccumRight = $4D
|
||||
|
||||
; 16-bit frame counter: IncrementFrameCounter does
|
||||
; `inc Zp54 / bne / inc Zp53`, so $53 is the high byte and $54 the low byte.
|
||||
FrameCounterHi = $53 ; incremented on carry out of the low byte
|
||||
FrameCounterLo = $54 ; incremented every frame
|
||||
|
||||
; Holds one of $70 / $88 / $A0. DispatchOnMarkerValue compares it against $70
|
||||
; and $88 to choose a test mode; ApplyMarkerValueEnableAudio stores it to
|
||||
; $0200 and to the shadow at $EE. NOTE: an earlier Pass 2 draft called this a
|
||||
; "P1 Down timer", which the instructions do not support.
|
||||
MarkerValue = $55
|
||||
|
||||
; Per-button-state marker variables. Each drives one group of
|
||||
; MarkerUpdateN routines, which OR $F0 or $D0 into the variable and write the
|
||||
; result to an OAM Y-coordinate slot. Which player and button each belongs to
|
||||
; is NOT yet established, so they are named by position, not by button.
|
||||
MarkerVar01 = $60 ; MarkerUpdate1
|
||||
MarkerVar02 = $5B ; MarkerUpdate2
|
||||
MarkerVar03 = $56 ; MarkerUpdate3
|
||||
MarkerVar04 = $61 ; MarkerUpdate4
|
||||
MarkerVar05 = $5C ; MarkerUpdate5
|
||||
MarkerVar06 = $57 ; MarkerUpdate6
|
||||
MarkerVar07 = $63 ; MarkerUpdate7
|
||||
MarkerVar08 = $5E ; MarkerUpdate8
|
||||
MarkerVar09 = $59 ; MarkerUpdate9
|
||||
MarkerVar10 = $64 ; MarkerUpdate10
|
||||
MarkerVar11 = $5F ; MarkerUpdate11
|
||||
MarkerVar12 = $5A ; MarkerUpdate12
|
||||
|
||||
ButtonHoldCounter = $6A ; PollControllerCountFrames: inc / cmp #$0F
|
||||
VramBurstIndex = $0302 ; SetBurstWriteIndex stores #$22 here
|
||||
|
||||
; PPU register shadows. $EF is read by WaitForVblank and masked with #$7F
|
||||
; before reaching $2000; $EE is masked with #$E7 by PpuMaskSetup.
|
||||
PpuScrollXInit = $ED ; latched to $2005 by PpuScrollLatch
|
||||
PpuMaskShadow = $EE ; PPUMASK shadow
|
||||
PpuCtrlShadow = $EF ; PPUCTRL shadow
|
||||
|
||||
; Set at $E0 and polled by `bit ZpE0 / bmi` in WaitForNmiComplete; NMI clears it.
|
||||
NmiInProgressFlag = $E0
|
||||
+4229
-1352
File diff suppressed because it is too large.
Load diff
@@ -1,35 +1,202 @@
|
||||
al 00CE89 .Label_CE89
|
||||
al 00CE6E .Label_CE6E
|
||||
al 00CE5F .Label_CE5F
|
||||
al 00CE8A .Label_CE8A
|
||||
al 00CF05 .VramOpReturn
|
||||
al 00CEF3 .VramOpLengthCheck
|
||||
al 00CEC9 .VramOpDataStream
|
||||
al 00CEB2 .StoreVramOpResult
|
||||
al 00CE98 .DispatchVramOp
|
||||
al 00CE89 .VramBurstSkipRun
|
||||
al 00CE6E .VramBurstWriteNext
|
||||
al 00CE5F .VramBurstWriteChunk
|
||||
al 00CE8A .VramBurstNextCommand
|
||||
al 00CE4F .VramBurstWrite
|
||||
al 00CE3C .PpuScrollLatch
|
||||
al 00CE33 .Label_CE33
|
||||
al 00CE39 .Label_CE39
|
||||
al 00CE16 .Label_CE16
|
||||
al 00CE12 .Label_CE12
|
||||
al 00CE33 .WriteVramByteStream
|
||||
al 00CE39 .VramWriteDone
|
||||
al 00CE16 .WriteVramZeroFill
|
||||
al 00CE12 .VramWriteSetup
|
||||
al 00CDEE .VramWrite
|
||||
al 00CDC8 .RetAddrUpdate
|
||||
al 00CDB1 .Label_CDB1
|
||||
al 00CDA3 .Label_CDA3
|
||||
al 00CDB1 .HandleJmpOpcode
|
||||
al 00CDA3 .HandlePpuCtrlOpcode
|
||||
al 00CD99 .VramPtrTableRead
|
||||
al 00CD8D .Label_CD8D
|
||||
al 00CD74 .Label_CD74
|
||||
al 00CD71 .Label_CD71
|
||||
al 00CD6E .Label_CD6E
|
||||
al 00CD69 .Label_CD69
|
||||
al 00CD5C .Label_CD5C
|
||||
al 00CD8D .AdvanceVramPointerByY
|
||||
al 00CD74 .WriteVramStreamByte
|
||||
al 00CD71 .AdvanceVramReadIndex
|
||||
al 00CD6E .ComputeVramByteCount
|
||||
al 00CD69 .ReturnFromVramPtrWrite
|
||||
al 00CD5C .WritePpuCtrlFromShadow
|
||||
al 00CD45 .VramWriteLoop
|
||||
al 00CD3F .VramPtrWrite
|
||||
al 00CD39 .EnableSpritesAndBackground
|
||||
al 00CD33 .WritePpuMask
|
||||
al 00CD2F .PpuMaskSetup
|
||||
al 00CD24 .OamDmaSetup
|
||||
al 00CD17 .PpuCtrlSetup
|
||||
al 00CD0A .NmiContinue
|
||||
al 00CCF7 .NmiHandler
|
||||
al 00C178 .Label_C178
|
||||
al 00C174 .ClearOAM_Routine
|
||||
al 00C05D .ClearRAM2_Loop
|
||||
al 00C013 .ClearRAM1_Loop
|
||||
al 00CCF2 .WaitForNmiComplete
|
||||
al 00CCE2 .WaitForVblank
|
||||
al 00CCDA .LatchPreviousDiagonalState
|
||||
al 00CCE1 .ReturnFromStickDecode
|
||||
al 00CCCC .LatchPreviousStickState
|
||||
al 00CCD4 .CheckStickDiagonal
|
||||
al 00CCC4 .CheckStickRight
|
||||
al 00CCB8 .CheckStickLeft
|
||||
al 00CCAC .CheckStickDown
|
||||
al 00CCA0 .CheckStickUp
|
||||
al 00CC81 .ReadControllerBitsAlt2Loop
|
||||
al 00CC7F .ReadControllerBitsAlt2Init
|
||||
al 00CC6E .ReadControllerBitsAltLoop
|
||||
al 00CC6C .ReadControllerBitsAltInit
|
||||
al 00CC5B .PollExpansionPortLoop
|
||||
al 00CC59 .PollExpansionPortInit
|
||||
al 00CC4F .LatchControllerStrobeAlt
|
||||
al 00CC46 .CopyOamShadowLoop
|
||||
al 00CC18 .WriteOamFromButtonState
|
||||
al 00CC11 .ReadControllerBitSingle
|
||||
al 00CC04 .ReadControllerBitsLoop
|
||||
al 00CC02 .ReadControllerBitsInit
|
||||
al 00CBF8 .LatchControllerStrobe
|
||||
al 00CBF5 .ExitToInit70
|
||||
al 00CBF2 .ContinueTestMode70
|
||||
al 00CBE6 .CheckFrameCounterLimit70
|
||||
al 00CBE0 .IncrementFrameCounter70
|
||||
al 00CBC0 .UpdateOamTestMode70
|
||||
al 00CBB1 .CheckButtonsTestMode70
|
||||
al 00CBAB .UpdateMarkersAndTimers70
|
||||
al 00CB9A .MainLoopTestMode70
|
||||
al 00CB5D .CopyOamFromTable70
|
||||
al 00CB16 .ClearRamPass4Loop
|
||||
al 00CB0A .WriteRamPattern70Loop
|
||||
al 00CB03 .InitTestScreen70
|
||||
al 00CB00 .ShiftButtonBit8Done
|
||||
al 00CAF7 .ShiftButtonBit7
|
||||
al 00CAEE .ShiftButtonBit6
|
||||
al 00CAE5 .ShiftButtonBit5
|
||||
al 00CADC .ShiftButtonBit4
|
||||
al 00CAD3 .ShiftButtonBit3
|
||||
al 00CACA .ShiftButtonBit2
|
||||
al 00CAC1 .ShiftButtonBit1
|
||||
al 00CAB8 .ShiftButtonBit0
|
||||
al 00CAB1 .ResetButtonTimers
|
||||
al 00CA8A .ResetButtonMarkerState
|
||||
al 00CA89 .MarkerUpdate12_Return
|
||||
al 00CA7D .MarkerUpdate12_Low
|
||||
al 00CA6B .MarkerUpdate12_Mid
|
||||
al 00CA56 .MarkerUpdate12_High
|
||||
al 00CA16 .MarkerUpdate12
|
||||
al 00CA0A .MarkerUpdate11_Low
|
||||
al 00C9F8 .MarkerUpdate11_Mid
|
||||
al 00C9E3 .MarkerUpdate11_High
|
||||
al 00C9A3 .MarkerUpdate11
|
||||
al 00C997 .MarkerUpdate10_Low
|
||||
al 00C985 .MarkerUpdate10_Mid
|
||||
al 00C970 .MarkerUpdate10_High
|
||||
al 00C930 .MarkerUpdate10
|
||||
al 00C924 .MarkerUpdate9_Low
|
||||
al 00C912 .MarkerUpdate9_Mid
|
||||
al 00C8FD .MarkerUpdate9_High
|
||||
al 00C8BD .MarkerUpdate9
|
||||
al 00C8B1 .MarkerUpdate8_Low
|
||||
al 00C89F .MarkerUpdate8_Mid
|
||||
al 00C88A .MarkerUpdate8_High
|
||||
al 00C84A .MarkerUpdate8
|
||||
al 00C83E .MarkerUpdate7_Low
|
||||
al 00C82C .MarkerUpdate7_Mid
|
||||
al 00C817 .MarkerUpdate7_High
|
||||
al 00C7D7 .MarkerUpdate7
|
||||
al 00C7D6 .MarkerUpdate6_Return
|
||||
al 00C7CA .MarkerUpdate6_Low
|
||||
al 00C7B8 .MarkerUpdate6_Mid
|
||||
al 00C7A3 .MarkerUpdate6_High
|
||||
al 00C763 .MarkerUpdate6
|
||||
al 00C757 .MarkerUpdate5_Low
|
||||
al 00C745 .MarkerUpdate5_Mid
|
||||
al 00C730 .MarkerUpdate5_High
|
||||
al 00C6F0 .MarkerUpdate5
|
||||
al 00C6E4 .MarkerUpdate4_Low
|
||||
al 00C6D2 .MarkerUpdate4_Mid
|
||||
al 00C6BD .MarkerUpdate4_High
|
||||
al 00C67D .MarkerUpdate4
|
||||
al 00C671 .MarkerUpdate3_Low
|
||||
al 00C65F .MarkerUpdate3_Mid
|
||||
al 00C64A .MarkerUpdate3_High
|
||||
al 00C60A .MarkerUpdate3
|
||||
al 00C5FE .MarkerUpdate2_Low
|
||||
al 00C5EC .MarkerUpdate2_Mid
|
||||
al 00C5D7 .MarkerUpdate2_High
|
||||
al 00C597 .MarkerUpdate2
|
||||
al 00C58B .MarkerUpdate1_Low
|
||||
al 00C579 .MarkerUpdate1_Mid
|
||||
al 00C564 .MarkerUpdate1_High
|
||||
al 00C524 .MarkerUpdate1
|
||||
al 00C523 .ReturnFromPrepareButtonOam
|
||||
al 00C520 .CallPrepareButtonOam
|
||||
al 00C4FC .HandleButtonPress8
|
||||
al 00C50A .FinishButtonDispatch
|
||||
al 00C4D4 .HandleButtonPress7
|
||||
al 00C4E2 .ButtonBit12_Test
|
||||
al 00C4AC .HandleButtonPress6
|
||||
al 00C4BA .ButtonBit11_Test
|
||||
al 00C484 .HandleButtonPress5
|
||||
al 00C492 .ButtonBit10_Test
|
||||
al 00C45C .HandleButtonPress4
|
||||
al 00C46A .ButtonBit9_Test
|
||||
al 00C43C .ButtonBit8_Test
|
||||
al 00C42F .ButtonBit7_Test
|
||||
al 00C40D .HandleButtonPress3
|
||||
al 00C41B .ButtonBit6_Test
|
||||
al 00C3E5 .HandleButtonPress2
|
||||
al 00C3F3 .ButtonBit5_Test
|
||||
al 00C3C7 .ButtonBit4_Test
|
||||
al 00C3BC .ButtonBit3_Test
|
||||
al 00C39C .HandleButtonPress1
|
||||
al 00C3AA .ButtonBit2_Test
|
||||
al 00C380 .ButtonBit1_Test
|
||||
al 00C377 .ButtonBit0_Test
|
||||
al 00C361 .DispatchButtonBits
|
||||
al 00C35E .ExitToInitAlt
|
||||
al 00C356 .SetBurstWriteIndex
|
||||
al 00C34A .CheckFrameCounterLimitAlt
|
||||
al 00C344 .IncrementFrameCounterAlt
|
||||
al 00C315 .UpdateOamFromButtons
|
||||
al 00C2FE .CheckAnyButtonHeld
|
||||
al 00C2F5 .UpdateAllButtonMarkers
|
||||
al 00C2E4 .PollControllerCountFrames
|
||||
al 00C2A7 .CopyOamFromTable
|
||||
al 00C252 .ClearRamPass3Loop
|
||||
al 00C246 .WriteRamPatternLoop
|
||||
al 00C23F .InitTestScreen88
|
||||
al 00C21A .FillOamShadowLoop
|
||||
al 00C210 .ResetButtonStateAndOam
|
||||
al 00C1FD .ReadButtonBitUpdateOam
|
||||
al 00C1F4 .CopyButtonOamLoop
|
||||
al 00C1EE .PrepareButtonOam
|
||||
al 00C1CC .UpdateAllOamEntries
|
||||
al 00C1AD .ScanAllControllerBits
|
||||
al 00C1A2 .CopySpriteTemplateLoop
|
||||
al 00C1A0 .CopySpriteTemplateTable
|
||||
al 00C18F .SampleControllerKeepXAlt
|
||||
al 00C17F .SampleControllerKeepX
|
||||
al 00C178 .ClearOamLoop
|
||||
al 00C174 .ClearOam
|
||||
al 00C15C .ApplyMarkerValueEnableAudio
|
||||
al 00C157 .LoadMarkerValue70
|
||||
al 00C152 .LoadMarkerValueA0
|
||||
al 00C14D .LoadMarkerValue88
|
||||
al 00C126 .ExitToInit
|
||||
al 00C12F .EnterTestMode88
|
||||
al 00C129 .EnterTestMode70
|
||||
al 00C10E .RestartInit
|
||||
al 00C10B .ContinueFrame
|
||||
al 00C0FF .CheckFrameCounterLimit
|
||||
al 00C0F9 .IncrementFrameCounter
|
||||
al 00C111 .DispatchOnMarkerValue
|
||||
al 00C135 .UpdateButtonMarker
|
||||
al 00C0E7 .PollControllerShiftRegister
|
||||
al 00C0DF .MainGameLoop
|
||||
al 00C05D .ClearRamPass2Loop
|
||||
al 00C055 .InitTestScreen
|
||||
al 00C013 .ClearRamPass1Loop
|
||||
al 00C00A .WaitVblank3
|
||||
al 00C005 .WaitVblank2
|
||||
al 00C000 .Reset
|
||||
File renamed without changes.
@@ -0,0 +1,499 @@
|
||||
# MUSCLE (U) — Pass 3 (data names and structures)
|
||||
|
||||
ROM: `MUSCLE (U)/M.U.S.C.L.E. (U).nes` (24,592 bytes, iNES). Status: **PASS 3 COMPLETE —
|
||||
byte-identical, including the bytecode VM layer.** 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` / `pass3` / `regen` regenerate |
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd "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 pass3 # pass2 + _tools/bytecode_dis.py token listings over the 42 VM programs
|
||||
make regen # same as pass3; the whole pipeline is byte-for-byte reproducible
|
||||
```
|
||||
|
||||
## Pass 3b — the bytecode VM, decoded
|
||||
|
||||
The 42 token programs (`BytecodeProgram_00`–`_25` plus `StandaloneBytecodeProgram`–`_3`) are the
|
||||
game's scripting layer. Each now carries a byte-for-byte token listing above its unchanged `.byte`
|
||||
data, produced by `_tools/bytecode_dis.py` (wired in as `make pass3`).
|
||||
|
||||
### The encoding
|
||||
|
||||
`RunBytecodeInterpreter` (`$9AE8`) is the whole dispatch:
|
||||
|
||||
```
|
||||
$9AEA lda ($1A),y ; fetch one byte
|
||||
$9AEC iny
|
||||
$9AED cmp #$F0 / bcs $9B08 ; >= $F0 -> literal
|
||||
$9AF1 cmp #$C8 / bcc $9B08 ; < $C8 -> literal
|
||||
$9AF5 sbc #$C8 / asl a / tax
|
||||
$9AF9 lda $9237,x / sta $1C ; BytecodeHandlerTable
|
||||
$9AFE lda $9238,x / sta $1D
|
||||
$9B05 jmp ($001C) ; run the handler
|
||||
```
|
||||
|
||||
and the literal path (`$9B08`) pushes the byte onto the operand list:
|
||||
|
||||
```
|
||||
$9B08 pha
|
||||
$9B09 lda ($1A),y / iny ; the operand byte itself
|
||||
$9B0C jsr $94C8 ; save stream position, restore the operand index
|
||||
...
|
||||
$9B22 sta $0033,Y ; operand list
|
||||
$9B25 jsr $9CBB ; block/stream bookkeeping
|
||||
```
|
||||
|
||||
So **the encoding is one byte per element**: a byte in `$C8`–`$EF` selects one of the 40
|
||||
`BytecodeHandlerTable` entries, and every other byte is a literal operand appended to `$0033[Y]`
|
||||
(and accumulated into `$003C[Y]`). There are no variable-length opcodes *except* four.
|
||||
|
||||
**Four opcodes take one inline operand byte.** Their handler's first instructions are
|
||||
`lda ($1A),Y / iny`, so the byte after the token is consumed by the opcode itself:
|
||||
|
||||
| Opcode | Handler | Inline byte stored in | Meaning |
|
||||
|--------|---------|------------------------|---------|
|
||||
| `$CB` | `$946D` | `$7C,X` (count) and `$7E,X` (body address) | `LOOP_BEGIN` |
|
||||
| `$D1` | `$950B` | `$43,X` | `SET_RATE` |
|
||||
| `$D7` | `$937C` | used as a count for the `$A660` blit | `DRAW_SPRITE` |
|
||||
| `$E1` | `$9465` | `$7A,X` | `SET_WAIT` |
|
||||
|
||||
`$CB`/`$C9` are the loop pair: `LOOP_BEGIN` marks the stream position and loads the counter, and
|
||||
`LOOP_END` (`$9477`) decrements `$7C,X` and, if still non-zero, reloads `Y` from `$7E,X` — which
|
||||
rewinds the interpreter to the top of the loop body.
|
||||
|
||||
### What was decoded
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Programs listed | **42** (37 in `BytecodeProgramPointerTable` + 4 standalone, one shared) |
|
||||
| Opcodes decoded | **377** |
|
||||
| Literal operands | **937** |
|
||||
| Opcodes defined / used | 40 / 40 — the opcode set is complete, nothing unused |
|
||||
|
||||
Two of the four standalone programs (`_1`, 24 bytes; `_3`, 84 bytes) contain **no byte in
|
||||
`$C8`–`$EF`**, so the interpreter takes the literal path for every byte and the whole block is
|
||||
operand data rather than an instruction stream. The listing says so explicitly rather than implying
|
||||
they are code.
|
||||
|
||||
### Confidence
|
||||
|
||||
Each mnemonic was read off its handler's own straight-line path, stopping at the handler's first
|
||||
`jmp $9AE8` / `jmp $9B25`. Mnemonics that describe register-level behaviour (`ACC_ADD_1_STEP`,
|
||||
`STEP_ACCUM`, `ROT_STEP_SUB`, …) are read directly off the instructions. Those that merely tail-call
|
||||
a game routine are named for that routine (`VM_CALL_82E1`, `PLAY_SEQ_BD20`) rather than guessed at,
|
||||
because the semantic content lives in the callee, not the handler.
|
||||
|
||||
---
|
||||
|
||||
## Pass 3c — tracing the stage payload
|
||||
|
||||
Static analysis cannot reach this region, and the trace confirms why. Across all 16 KB of code
|
||||
there is exactly **one** operand in `$B0FB–$BEBF`:
|
||||
|
||||
```
|
||||
operands in $B0FB-$BEBF : 1
|
||||
-> $BD28 StageDataPointerLow, the literal pointer table itself
|
||||
```
|
||||
|
||||
The real address is formed at runtime, `$A062`:
|
||||
|
||||
```
|
||||
$A062 clc
|
||||
$A063 adc $BD28,X ; A (a value carried in the bytecode stream) + table byte
|
||||
$A066 sta $12
|
||||
```
|
||||
|
||||
so the readers are indexed by a *runtime value* and cannot be enumerated by searching the code.
|
||||
|
||||
### Method
|
||||
|
||||
FCEUX's Lua API has no memory-read hook — only `registerexec`, `registerexecute`, `registerrun`
|
||||
and `registerwrite` — so it cannot observe these reads either, and with no static list of readers
|
||||
there is nothing to hook. `_tools/trace_stage_payload.py` therefore uses **py65**, whose
|
||||
`ObservableMemory.subscribe_to_read` fires on every read of a range and can report the CPU's PC
|
||||
from inside the hook.
|
||||
|
||||
py65 is only a 6502 core, so the tracer adds the minimum hardware to get the game running: a
|
||||
scanline counter driven off `processorCycles`, `$2002` vblank asserted at scanline 241 and cleared
|
||||
on read, NMI fired at the same scanline (MUSCLE does its per-frame work in `NmiHandler` `$86CB`),
|
||||
`$4014` OAM DMA, and the `$2005`/`$2006`/`$2007` latches.
|
||||
|
||||
Without NMI the game never leaves `$80E0: lda $0321 / beq $80E0`, because `$0321` is the flag the
|
||||
bytecode VM raises — so the stage data is only reachable once interrupts work.
|
||||
|
||||
### Results (300 frames, 26.8M cycles, 4,071 NMIs)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Payload reads captured | **2,548** |
|
||||
| Distinct reader PCs | **44** |
|
||||
| Unique payload addresses touched | **914** of 3,421 bytes in range |
|
||||
|
||||
Reader PCs are 1-2 bytes above the real instruction, because py65 advances PC past the opcode
|
||||
before fetching the effective address.
|
||||
|
||||
### What the access pattern shows
|
||||
|
||||
**1. The payload is read through indirect pointers, confirming the static finding.** The hottest
|
||||
reader resolves to `$AC82`:
|
||||
|
||||
```
|
||||
$AC82 lda ($10),Y
|
||||
$AC84 cmp #$C0
|
||||
$AC86 bne $AC96
|
||||
$AC88 lda $10 / clc / adc #$01 / sta $10 ; advance the stream pointer
|
||||
```
|
||||
|
||||
This is the compressed-VRAM RLE decoder - it walks the payload **sequentially** (1,046 of its
|
||||
1,128 reads advance by exactly +1), comparing each byte against `$C0`. That matches the `$AC82`
|
||||
RLE decoder already named in the data section.
|
||||
|
||||
**2. There is a second, backward-copy reader.** `$9B51` resolves to
|
||||
|
||||
```
|
||||
$9B50 lda ($12),Y
|
||||
$9B52 ldy $14
|
||||
$9B54 sta ($10),Y
|
||||
$9B56 dey
|
||||
```
|
||||
|
||||
and its reads step by **-1**, sixty times - a reverse copy between two buffers.
|
||||
|
||||
**3. The payload contains 8-byte-aligned record arrays.** 107 distinct 8-byte blocks had all eight
|
||||
bytes read, and the spacing between consecutive blocks is dominated by **$08**:
|
||||
|
||||
```
|
||||
$B1F0 $B1F8 $B200 $B208 $B210 $B218 ... (stride $08)
|
||||
$B3B8 $B3C0 $B3C8 $B3D0 ... $B5A0 (a long $08-strided run - the object lists)
|
||||
$B600 $B608 $B610 ... $B638
|
||||
$B8A0 $B8A8 $B8B0 ... $B910
|
||||
```
|
||||
|
||||
with a second, sparser group on a **$D0 stride**:
|
||||
|
||||
```
|
||||
$BBE0 $BCB0 $BD80 $BE50
|
||||
```
|
||||
|
||||
so at least two record tables exist: a dense 8-byte-stride array and a $D0-stride array.
|
||||
|
||||
**4. Readers use three access disciplines** - sequential forward (+1), sequential backward (-1),
|
||||
and variable stride (+$15/+$14/+$11 at `$ACB2`, +2/+$4D at `$AF5B`/`$B03D`), the last consistent
|
||||
with a field-at-fixed-offset walk across records.
|
||||
|
||||
### Still open
|
||||
|
||||
* Which reader corresponds to which semantic field is not yet pinned down; the trace gives the
|
||||
geometry (8-byte records, $08 and $D0 strides) but not the field names.
|
||||
* Only 914 of 3,421 bytes were touched, because the run never entered every stage. A longer run
|
||||
that cycles through all stages would cover more and may reveal further strides.
|
||||
* The compressed-VRAM payload and the object-list records are interleaved in one block; the
|
||||
boundary between them is not yet resolved.
|
||||
|
||||
FCEUX remains useful as an independent oracle for any of these conclusions - it is a complete,
|
||||
correct emulator where this tracer is a deliberately partial one.
|
||||
|
||||
---
|
||||
|
||||
## Pass 3d — the payload is a shared pool, not stage data
|
||||
|
||||
Pass 3c assumed `$B0FB–$BEBF` was per-stage level data and that "cycle all stages" would cover
|
||||
it. A longer, properly-clocked run **disproves that**. Coverage plateaus near 40% no matter how
|
||||
many stages are entered, and forcing every actor index to run adds **zero** new bytes.
|
||||
|
||||
### Two tracer bugs, both of which were suppressing everything
|
||||
|
||||
**1. NMI re-armed by `$2002` reads.** The vblank flag must be cleared once per *frame*, not on
|
||||
every read of `$2002`. The main loop polls `$2002` and spins on the bytecode VM flag at
|
||||
`MainGameLoop_OnZero_8226` (`lda $0321 / beq $8235`); each poll clears bit 7, re-arming NMI
|
||||
inside the same scanline. Guarding only on `not vblank` produced **40,900 NMIs across 3,000
|
||||
frames** and silently corrupted all game state. A separate per-frame `nmi_fired` flag restores
|
||||
exactly one NMI per frame — verified: 600 frames → 600 NMIs.
|
||||
|
||||
**2. Input went to the wrong port.** The poll routine at `$AA69` is
|
||||
|
||||
```
|
||||
$AA69 ldx #$01 / stx $4016 / dex / stx $4016 / ldy #$08 / lda $4016,X / and #$03 / cmp #$01
|
||||
```
|
||||
|
||||
so the *observed* reads are of `$4017` (X=1) and only bits 0-1 are ever compared. Driving
|
||||
`$4017` therefore consumes nothing: all three masks tested gave byte-identical traces. The game
|
||||
reads **joy1 on `$4016`**, and needs a real latch/shift register — a write with bit0 set latches,
|
||||
reads shift `A,B,Select,Start,Up,Down,Left,Right` out LSB-first. With that, START visibly
|
||||
matters: **47.7% coverage vs 39.0%** for other masks.
|
||||
|
||||
Reaching a match takes **two discrete START presses** — title → character select → match. Rapid
|
||||
re-tapping does not work, and each press must be held ~3 frames.
|
||||
|
||||
### What `$BD28` actually indexes
|
||||
|
||||
`$BD28` is not a stage table. The one operand in the window feeds:
|
||||
|
||||
```
|
||||
$A04A lda $0324 / beq $A062
|
||||
$A04F lda ActorIndex ($1E) / beq $A062
|
||||
$A063 adc $BD28,X ; X = ActorIndex
|
||||
$A06A lda PlayerActiveFlag,X ($78,X)
|
||||
$A071 adc #$32 ; +$32 when that actor is active
|
||||
```
|
||||
|
||||
`$BD28 = 6B 6B 6E 75 75 80 5A 78` is an **8-entry per-actor entry-offset table**, and
|
||||
`$B0FB + $BD28,n` is where actor *n*'s bytecode starts. Observed `ActorIndex` values over a
|
||||
2,400-frame match are `$00–$04`, so the live actor set is 0–4.
|
||||
|
||||
### The sound engine reads the same window
|
||||
|
||||
With input working, the reader list gained PCs *inside* `$B0FB–$BEBF`. These are not new code —
|
||||
they are the sound engine's own stream reader:
|
||||
|
||||
```
|
||||
$B03C lda (SoundReadPointerLo),Y
|
||||
$B03E cmp #$F0
|
||||
$B040 bcc StoreShortNoteDuration
|
||||
$B043 sbc #$F0 / tay / lda $B0E3,y ; NoteDurationTable at $B0E3
|
||||
```
|
||||
|
||||
`SoundReadPointerLo` ($EE/$EF) points into the payload while music plays, and the `$F0` compare
|
||||
plus the `$B0E3` duration lookup is note decoding. This is why the windows' bytes carry so many
|
||||
`$FF` terminators — `$FF`/`$F0` are the high-nibble codes of the note stream.
|
||||
|
||||
So `$B0FB–$BEBF` is a **shared data pool**, mixing at least three things: per-actor bytecode
|
||||
scripts, the compressed-VRAM RLE stream (`$AC82`), and music note streams. It is not
|
||||
stage-specific, which is why stage cycling cannot raise coverage past ~40%.
|
||||
|
||||
### Coverage summary
|
||||
|
||||
| Run | Frames | NMIs | Payload reads | Readers | Coverage |
|
||||
|---|---|---|---|---|---|
|
||||
| Pass 3c (NMI broken) | 300 | 4,071 | 2,548 | 44 | 26.3% |
|
||||
| NMI fixed, no input | 600 | 600 | 29,808 | 14 | 39.0% |
|
||||
| Two START presses | 2,000 | 2,000 | 79,192 | 21 | 40.9% |
|
||||
| Actor sweep 0–7 | 2,400 | 2,400 | 98,392 | 14 | 38.7% |
|
||||
|
||||
Largest unreached spans after the two-START run (2,161 B in 15 runs) are `$BC00–$BEBF` (704 B),
|
||||
`$B980–$BB0F` (400 B), `$BB20–$BBEF` (208 B), `$B0FB–$B1A0` (166 B) and `$B81E–$B89D` (128 B).
|
||||
|
||||
### What would actually close the gap
|
||||
|
||||
Not stage cycling. The remaining bytes need **other music tracks playing** and **actors 2–4 in
|
||||
their non-default states**. Both need a scripted match (moves, grapples, ring-outs), not a
|
||||
button-tap sequence — i.e. gameplay simulation, which is well beyond a coverage tracer.
|
||||
|
||||
`$0326` is *not* a level byte: it never changes from `$00` in any run, including a confirmed
|
||||
match. `$0320` toggles `$00`/`$FF` and behaves as a boolean, not a screen-state machine.
|
||||
|
||||
---
|
||||
|
||||
## Still Open
|
||||
|
||||
* ~~**Promote to the numbered inventory.**~~ **Done** — promoted to top-level `MUSCLE (U)/` and
|
||||
added as #16 in the root inventory. `copilot/Makefile` tool paths moved from `../../../_tools/`
|
||||
to `../../_tools/` for `NESDIS`/`PASS2`/`BYTECODE`; `make` and `make regen` both verified after
|
||||
the move.
|
||||
* **The payload record structure is partially decoded** (`$B0FB–$BEBF`, 3,525 bytes) — see
|
||||
"Pass 3d" above for what the emulator run established. It is a *shared pool* (per-actor
|
||||
bytecode + compressed VRAM + music note streams), not per-stage data; the per-record field names
|
||||
and the boundaries between the three uses are still unresolved.
|
||||
@@ -1,8 +1,9 @@
|
||||
CA65 ?= ca65
|
||||
LD65 ?= ld65
|
||||
PYTHON ?= python3
|
||||
NESDIS ?= ../../../_tools/nesdis.py
|
||||
PASS2 ?= ../../../_tools/pass2.py
|
||||
NESDIS ?= ../../_tools/nesdis.py
|
||||
PASS2 ?= ../../_tools/pass2.py
|
||||
BYTECODE ?= ../../_tools/bytecode_dis.py
|
||||
|
||||
all: reconstructed.nes verify
|
||||
|
||||
@@ -44,9 +45,15 @@ pass2: pass1
|
||||
$(PYTHON) $(PASS2) structure prg.s data_regions.tsv
|
||||
$(PYTHON) $(PASS2) locals prg.s zero_page_names.tsv locals.inc zero_page_ranges.tsv
|
||||
|
||||
regen: pass2
|
||||
# Pass 3: decode the bytecode VM programs into a readable token listing above each
|
||||
# program's unchanged .byte data. Bytecode_dis.py only inserts comment lines; the
|
||||
# .byte data it reads is copied through verbatim.
|
||||
pass3: pass2
|
||||
$(PYTHON) $(BYTECODE) prg.bin prg.s
|
||||
|
||||
regen: pass3
|
||||
|
||||
clean:
|
||||
rm -f *.o reconstructed.nes reconstructed_prg.bin reconstructed.map reconstructed.lbl
|
||||
|
||||
.PHONY: all verify clean regen pass1 pass2
|
||||
.PHONY: all verify clean regen pass1 pass2 pass3
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
|
500 Internal Server Error
An error occurred: An error occurred Gitea Version: 28.2.0 |