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

16 KiB
Raw Blame History

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

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.