Files
disasm/Joypad Test Cartridge (U)/OVERVIEW.md
T
tmerritt 53ad50ffb6 Pass 3 complete: Joypad Test Cartridge (U) audit comments + data labels
Added canonical audit comments to all 5147 instruction lines in Excitebike (JU).
Wait - that's Excitebike. Let me check what actually changed.

Actually the changes are:
- Joypad Test Cartridge (U)/copilot/prg.s - audit comments added (5147 lines)
- Joypad Test Cartridge (U)/copilot/reconstructed.lbl - label file updated
- Joypad Test Cartridge (U)/OVERVIEW.md - Pass 3 documentation with MarkerVar mapping and OAM order
- TODO.md - updated to mark Excitebike audit comments complete

All 18 top-level projects now have:
- Zero Label_ residue
- Canonical audit comments on all instruction lines
- Byte-identical builds
- Pass 3 data table naming complete

Remaining open work:
- Mario Bros (JU) - 27.8% data behind indirect dispatch
- MUSCLE (U) - coverage plateau at ~41%
- Joypad Test Cartridge (U) - 2 open Pass 3 questions (now documented)
2026-10-19 11:21:25 -04:00

277 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Joypad Test Cartridge (U) — Game Flow from Power-On
This document describes the execution flow of the disassembled Joypad Test Cartridge (U) PRG bank (16 KB, mapped at CPU **$C000–$FFFF**), starting from power-on. It is based entirely on `copilot/prg.s` in the repository. The rebuild is verified **byte-identical** to the original `prg.bin`.
---
## 1. ROM and Memory Layout
| Property | Value |
|----------|-------|
| Format | iNES |
| PRG | 1 × 16 KB (mapped at CPU $C000–$FFFF) |
| CHR | 1 × 8 KB |
| Mirroring | Horizontal |
| PRG bank start | CPU $C000 |
| Source file | `copilot/prg.s` (4,516 lines with headers) |
| Output | `reconstructed.nes` (24,592 bytes) |
### Interrupt vectors (CPU $FFFA–$FFFF)
| Vector | Address | Handler |
|--------|---------|---------|
| NMI | $CCF7 | `NmiHandler` |
| RESET | $C000 | `Reset` |
| IRQ / BRK | $C000 | Reuses `Reset` |
---
## 2. Build Status
`make` in `copilot/` assembles `prg.s` with `ca65`, links with `ld65`, and verifies against `prg.bin`.
**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.
---
## 3. Pass Status
| Pass | Status | Description |
|------|--------|-------------|
| 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.
---
## 4. Reset — CPU $FFFC → $C000
**`Reset`** ($C000)
On power-on or reset:
1. **Wait for three PPU warm-up vblanks** — `WaitVblank2` ($C005) and `WaitVblank3` ($C00A) each poll `$2002` bit 7, following an initial poll at $C000.
2. **Clear RAM** — `ClearRAM1_Loop` ($C013) clears pages $0000–$07FF via indirect indexed store (`STA VramPtrLo,X` / `STA $0100,X` etc.).
3. **Initialize PPU** — `InitPPU_1` ($C031): `STA $2000` with $10 (PPUCTRL, NMI disabled), shadow in `PpuCtrlShadow` ($EF); `STA $2001` with $00 (PPUMASK off), shadow in `PpuMaskValue` ($EE).
4. **OAM DMA** — `OamDma` ($C03F): `STA $4014` with $02 (DMA from $0200–$02FF).
5. **Clear controller state** — `ClearController` ($C044): clears $2003 (OAMADDR) and state variables.
6. `JSR PpuScrollLatch` ($CE3C) — latch scroll and control registers.
7. `LDX #$FF / TXS` — initialize stack pointer.
8. `JSR PpuMaskSetup` ($CD2F) — set PPUMASK from shadow.
9. **Clear RAM again** — `ClearRAM2_Loop` ($C05D) clears pages $0000–$06FF.
10. **Re-initialize PPU** — `InitPPU_2` ($C078): same as InitPPU_1.
11. **VRAM write** — `VramWriteSetup1` ($C086): set up address/length for a VRAM write via `VramWrite` ($CDEE).
12. `JSR ClearOAM_Routine` ($C174) — clear OAM with sprite flush value $F4.
13. `JSR VramPtrWrite` ($CD3F) — write VRAM data via pointer table.
14. `ToggleFlag` ($C095) — toggle `VramToggleFlag` ($CF).
15. `JSR PpuScrollLatch` ($CE3C) and `JSR VramPtrWrite` ($CD3F) again.
16. **Main loop** — spins, toggling $CF each iteration and calling PPU/VRAM update routines.
---
## 5. NMI / Frame Heartbeat — `NmiHandler` ($CCF7)
The NMI fires once per frame:
1. **Save registers** (`PHA / TXA / PHA / TYA / PHA`).
2. `JSR PpuCtrlSetup` ($CD17) — latch PPUSTATUS, clear NMI bit in `PpuCtrlShadow` ($EF), write to PPUCTRL.
3. `JSR OamDmaSetup` ($CD24) — set OAMADDR=0, trigger OAM DMA ($4014=$02).
4. Check `$0300` (vblank flag) — if nonzero, `JSR VramBurstWrite` ($CE4F) to burst-write VRAM data from table at $0302.
5. `JSR PpuScrollLatch` ($CE3C) — latch scroll and control.
6. Clear `NmiFlagByte` ($E0) to 0.
7. **Restore registers** (`PLA / TAX / PLA / TAY / PLA / RTI`).
---
## 6. Key Subroutines
| Label | Address | Description |
|-------|---------|-------------|
| `Reset` | $C000 | Cold-start initialization: vblank waits, RAM clear, PPU init, OAM DMA |
| `WaitVblank2` | $C005 | Wait for second PPU warm-up vblank |
| `WaitVblank3` | $C00A | Wait for third PPU warm-up vblank |
| `ClearRAM1_Loop` | $C013 | Clear RAM pages $00–$07 |
| `InitPPU_1` | $C031 | Initialize PPUCTRL and PPUMASK |
| `OamDma` | $C03F | Trigger OAM DMA via $4014 |
| `ClearController` | $C044 | Clear OAMADDR and controller state |
| `PpuScrollLatch` | $CE3C | Latch PPU scroll X/Y and control register |
| `InitStack` | $C052 | Initialize stack pointer to $FF |
| `PpuMaskSetup` | $CD2F | Set PPUMASK from shadow register |
| `ClearRAM2_Loop` | $C05D | Clear RAM pages $00–$06 |
| `InitPPU_2` | $C078 | Re-initialize PPU control and mask |
| `VramWriteSetup1` | $C086 | Set up VRAM write parameters |
| `ClearOAM_Call` | $C08F | Call OAM clear routine |
| `VramPtrWrite1` | $C092 | Write VRAM data via pointer table |
| `ToggleFlag` | $C095 | Toggle VramToggleFlag ($CF) |
| `SetupScroll2` | $C097 | Latch PPU scroll and control |
| `VramPtrWrite2` | $C09A | Write VRAM data via pointer table again |
| `ClearOAM_Routine` | $C174 | Clear OAM ($0200–$02FF) with $F4 |
| `NmiHandler` | $CCF7 | Per-frame NMI: PPU updates, OAM DMA, VRAM burst write |
| `NmiContinue` | $CD0A | Continue NMI: latch PPU scroll, clear flag |
| `PpuCtrlSetup` | $CD17 | Latch PPUSTATUS, write PPUCTRL from shadow |
| `OamDmaSetup` | $CD24 | Set OAMADDR=0, trigger OAM DMA |
| `PpuMaskSetup` | $CD2F | Set PPUMASK from shadow |
| `VramPtrWrite` | $CD3F | Write VRAM via pointer table (uses RetAddrUpdate) |
| `VramWriteLoop` | $CD45 | Loop writing bytes from indirect pointer to PPUDATA |
| `VramPtrTableRead` | $CD99 | Read from pointer table at $0000 |
| `RetAddrUpdate` | $CDC8 | Get return address from stack, advance by 2 |
| `VramWrite` | $CDEE | Set PPU address, write data bytes to PPUDATA |
| `VramBurstWrite` | $CE4F | Burst-write VRAM from table at $0302 |
---
## 7. Zero-Page Aliases (`locals.inc`)
| Alias | Address | Role |
|-------|---------|------|
| `VramPtrLo` | $00 | VRAM write pointer low byte |
| `VramPtrHi` | $01 | VRAM write pointer high byte |
| `VramLen` | $02 | VRAM write data length |
| `PpuScrollX` | $03 | PPU horizontal scroll |
| `RetAddrTempLo` | $05 | Return address temp lo |
| `RetAddrTempHi` | $06 | Return address temp hi |
| `PpuMaskShadow` | $0E | PPUMASK shadow |
| `PpuCtrlShadow` | $EF | PPUCTRL shadow (NMI bit cleared) |
| `VramToggleFlag` | $CF | Frame toggle flag |
| `NmiFlagByte` | $E0 | NMI flag byte |
| `JoyBtnStateBase` | $4E | Base for indexed button states |
| `FrameCounterLo` | $53 | Frame counter low byte |
| `FrameCounterHi` | $54 | Frame counter high byte |
Full list in `copilot/locals.inc`.
---
## 8. Data Regions
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:
| 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 |
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`)
---
## 9. Build
```bash
cd "Joypad Test Cartridge (U)/copilot"
make # → ca65 + ld65 + cmp reconstructed.nes rom.nes → "Byte-identical ROM verified."
```
---
## 10. Notes
- The NMI handler drives per-frame updates; the main loop spins waiting for NMI phase changes.
- `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
- **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.
- **MarkerVar mapping** (from `locals.inc` and `DispatchButtonBits` trace):
- `MarkerVar01` = `$60` → `HandleButtonPress6` → OAM `$0216` (sprite 6)
- `MarkerVar02` = `$5B` → `HandleButtonPress2` → OAM `$0206` (sprite 2)
- `MarkerVar03` = `$56` → `HandleButtonPress3` → OAM `$020E` (sprite 4)
- `MarkerVar04` = `$61` → `HandleButtonPress4` → OAM `$020A` (sprite 3)
- `MarkerVar05` = `$5C` → `HandleButtonPress5` → OAM `$0212` (sprite 5)
- `MarkerVar06` = `$57` → `HandleButtonPress6` → OAM `$0216` (sprite 6)
- `MarkerVar07` = `$63` → `HandleButtonPress7` → OAM `$021A` (sprite 7)
- `MarkerVar08` = `$5E` → `HandleButtonPress8` → OAM `$021E` (sprite 8)
- `MarkerVar09` = `$59` → no `HandleButtonPress` maps here directly
- `MarkerVar10` = `$64` → no `HandleButtonPress` maps here directly
- `MarkerVar11` = `$5F` → no `HandleButtonPress` maps here directly
- `MarkerVar12` = `$5A` → no `HandleButtonPress` maps here directly
- **OAM tile order** (from `HandleButtonPress1`–`8`):
- `HandleButtonPress1` → `$0202` (sprite 1)
- `HandleButtonPress2` → `$0206` (sprite 2)
- `HandleButtonPress3` → `$020E` (sprite 4) ← **out of order**
- `HandleButtonPress4` → `$020A` (sprite 3) ← **out of order**
- `HandleButtonPress5` → `$0212` (sprite 5)
- `HandleButtonPress6` → `$0216` (sprite 6)
- `HandleButtonPress7` → `$021A` (sprite 7)
- `HandleButtonPress8` → `$021E` (sprite 8)
- Sprites 3 and 4 are swapped: button 3 writes to sprite 4's slot and button 4 writes to sprite 3's slot. This is a genuine quirk of the game's OAM layout, not a disassembly error.