19 KiB
Tennis (JU) — Game Flow from Power-On
This document describes the execution flow of the disassembled Tennis (JU) 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 / NES 2.0 |
| PRG | 1 × 16 KB (mapped at CPU $C000–$FFFF) |
| CHR | 1 × 8 KB |
| Mirroring | Vertical |
| PRG bank start | CPU $C000 |
| Source file | copilot/prg.s (9,268 lines) |
| Output | reconstructed_prg.bin (16,384 bytes) |
Interrupt vectors (CPU $FFFA–$FFFF)
| Vector | Address | Handler |
|---|---|---|
| NMI | $C07D | NmiHandler |
| RESET | $C000 | Reset |
| IRQ / BRK | $C000 | Reset (reused — unused) |
2. Build Status
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 Tennis (JU).nes.
3. Reset — CPU $FFFC → $C000
Reset ($C000)
On power-on or reset:
cld— clear decimal mode.sei— disable maskable interrupts.- Wait for VBlank — poll
PPUSTATUSbit 7 (@Flow_C002). PPUCTRL=$00,PPUMASK=$00— disable rendering and NMI.- Stack pointer = $FF.
X = FrameCounter($27), thenY=$06,WorkPtrHi=$02,WorkPtrLo=$00,A=$00.- Clear RAM —
sta (WorkPtrLo),yloop decrementingWorkPtrHifrom $02 down, effectively clearing $0000–$02FF. Xis 0 at this point; if non-zero (it never is on entry), skip; elseX=$5F.stx $0500— set $0500 to $5F (or 0).jsr ClearNametables($C195) — clear nametable memory.jsr HideAllSprites($C1A5) — hide all sprites by writing $F4 to OAM.PPUSCROLL=$00twice;GameActiveFlag=$01.APUSTATUS=$0F— enable all four APU channels.PPUCTRL=$90,PpuCtrlShadow=$90— enable NMI, $2000 base.PpuMaskShadow=$06.- Falls into
MainLoop($C04F) — the permanent foreground loop.
4. Main Loop — MainLoop ($C04F)
After reset, control enters the infinite foreground loop:
MainLoop ($C04F):
NmiWorkComplete ($30) = $00
if GameActiveFlag ($38) != 0:
if GameStateStep ($3E) == 1:
jsr RunAudioEngine ($FFF7)
Flow_C060:
jsr UpdateTimers ($C375)
jsr UpdateGameState ($C0AA)
NmiUpdatesEnabled ($32) = $01
Flow_C06A:
if NmiWorkComplete ($30) != 0:
FrameCounter ($27)++
NmiUpdatesEnabled ($32) = $00
jmp MainLoop
else:
jsr RollRng ($C456)
jmp Flow_C06A
The main loop is NMI-synchronized: it waits for NmiWorkComplete ($30) to become non-zero (set by NMI each frame), then advances the frame counter, clears the update-enabled flag, and loops. If NMI work is not complete, it rolls the RNG and retries.
5. NMI / Frame Heartbeat — NmiHandler ($C07D)
The NMI fires once per frame:
- Save registers (A, X, Y on stack).
- OAM DMA: write $00 to OAMADDR ($2003), then $02 to OAMDMA ($4014) — copy $0200–$02FF to sprite RAM.
- If
NmiUpdatesEnabled($32) == 0, skip to@Flow_C0A0. - Otherwise:
jsr WritePalette($C1B5) — conditional palette upload based onPaletteUpdateMode($3C).jsr WritePpuBuffer($C2EC) — flush pending PPU command buffer at $0590.jsr SetScroll($C3FD) — write scroll from $1E/$1F to PPUSCROLL.jsr ReadControllers($C2A4) — read both controller ports.
NmiWorkComplete ($30) = $01— signal main loop that the frame update is done.NmiTriggerFlag ($33) = $01.- Restore registers,
rti.
The label IrqHandler appears at the same rti address — IRQ/BRK vector points here, but it's unused (same as Reset).
5. Game State Machine — UpdateGameState ($C0AA)
Called each main-loop iteration:
UpdateGameState ($C0AA):
if GameActiveFlag ($38) == 0:
Flow_C0B1:
if ControllerHeld ($1A) & START ($10):
if StartButtonLatch ($34) != 0 → skip
else: StartButtonLatch=$01
Y = GameState ($3D)
if GameState == 7 → Flow_C0EA
if GameState == 4 → Flow_C0CC
if GameState == 6 → skip
else → Flow_C0FC
Flow_C0FC:
DispatchInlineAddressTable ($C38B) with GameState index
→ GameStateHandlerTable ($C101)
else (GameActiveFlag == 0):
Flow_C119:
if ControllerHeld & $30 (START|SELECT):
if bit $10 set → Flow_C134
else: GameActiveFlag=$00, GameState=$00, PendingStateFlag=$00
jsr MatchRoutine_CBCB, jsr DisableRendering
GameMode=$SelectedGameMode ($E0), rts
Flow_C134: (complex state-transition logic with StateTransitionFlag, GameMode, etc.)
Flow_C180:
GameStateStep ($3E) → DispatchInlineAddressTable → GameStateStepHandlerTable ($C185)
MainStateHandlerTable ($C101) — indexed by GameState ($3D) as a byte offset (even offsets $00–$16):
| Offset | Handler | Description |
|---|---|---|
| $00 | GameState00_InitializeMatch ($C70A) |
Match initialization |
| $02 | GameState01_WaitForServe ($C797) |
Wait for serve input |
| $04 | GameState02_PrepareRally ($C7B7) |
Prepare rally |
| $06 | GameState03_RunRally ($C7C6) |
Run rally |
| $08 | GameState04_PointEnded ($C907) |
Point ended |
| $0A | GameState05_UpdateScore ($CA07) |
Update score |
| $0C | GameState06_AdvanceGame ($C9DE) |
Advance game |
| $0E | GameState07_PauseOrTransition ($C42B) |
Pause/transition (RTS) |
| $10 | GameState08_WaitForTimer ($CA28) |
Wait for timer |
| $12 | GameState09_RestorePreviousState ($CA31) |
Restore state |
| $14 | GameState0A_ResumePreviousState ($CA3A) |
Resume state |
| $16 | GameState0B_FinishTransition ($CA5B) |
Finish transition |
GameStateStepHandlerTable ($C185) — indexed by GameStateStep ($3E):
| Offset | Handler |
|---|---|
| $00 | ModeStep00_DrawSelectionScreen ($CA87) |
| $02 | ModeStep01_Countdown ($CAC9) |
| $04 | ModeStep02_InitializeSelection ($CADD) |
| $06 | ModeStep03_UpdateSelection ($CAE4) |
| $08 | ModeStep04_ConfirmSelection ($CAEE) |
| $0A | ModeStep05_StartDemoOrMatch ($CAF4) |
| $0C | ModeStep06_CompleteSetup ($CC04) |
Key state handlers:
GameState00_InitializeMatch($C70A): First-time init — clear nametable, render routine, palette mode, setPendingStateFlag. Subsequent calls advance through sub-states ($87/$88 counter), render sprites, enable rendering, setGameState=$07, callMatchRoutine_CBCB.GameState01_WaitForServe($C797): Disable rendering, clear nametable, render court + score, zero point winners, clear animation flags, set ball trajectory flags, advanceGameState.GameState02_PrepareRally($C7B7): Enable rendering, clear PPU mask shadow bit 4, hide sprites, render routine, advanceGameState.GameState03_RunRally($C7C6): Render routine, setPpuBufferReady,MatchRoutine_C7E7(copy initial object state),PhysicsRoutine_D7E1, handle serving player,EngineRoutine_C436, advanceGameState.
6. NMI-Driven Per-Frame Work
The NMI handler (called every VBlank) performs:
| Step | Routine | Address | Purpose |
|---|---|---|---|
| OAM DMA | — | $C084–$C089 | Copy $0200–$02FF to sprite RAM |
| Palette update | WritePalette |
$C1B5 | Conditional palette upload ($3F00/$3F10) |
| PPU buffer flush | WritePpuBuffer |
$C2EC | Write pending PPU commands from $0590 buffer |
| Scroll | SetScroll |
$C3FD | Write scroll X/Y from $1E/$1F |
| Controller read | ReadControllers |
$C2A4 | Read both ports, build held/newly-pressed |
The main loop calls UpdateGameState ($C0AA) which dispatches to the active game state handler, then advances timers and audio.
7. Object / Sprite System
- OAM buffer at $0200–$02FF, written via
WriteMetaspriteToOam($C440). - Object data pointers:
ObjectDataPointerLo/Hi($A8/$A9),ObjectDataOffset($AA),ActiveObjectIndex($AB). - Animation:
AnimationFrame($B0),AnimationTimer($B1),PoseFrameTimer($B3),PlayerAnimationId($B4),PoseSequenceOffset($B5),PlayerPoseId($B6),PlayerPoseFlags($B7). - Racket:
RacketTargetX($B8),RacketTargetY($B9),RacketContactFlags($BA),RacketAimOffset($BB). - Player physics:
PlayerMotionControl($60/$65 for P1/P2), X/Y position + subpixel ($61–$69), target coordinates ($80–$83), action type/state ($84–$86), facing direction ($87), position ($88–$89). - Ball:
BallMotionControl($6A), X/Y position + subpixel ($6B–$6E), height ($6F/$70), target offset ($71), vertical velocity ($72/$73), state flags ($7A), bounce state ($7B), contact state ($8F), trajectory flags ($8D), collision flags ($8E).
8. Audio System
RunAudioEngine ($FFF7) — jumps to AudioEngineUpdate ($F8E0). Called from MainLoop when GameStateStep == 1 and GameActiveFlag != 0.
InitializeAudioEngine ($E1F8):
- Clear audio request pointer ($F0/$F1 = $0006), channel table pointer ($F2/$F3 = $0620), sequence table pointer from ROM $F100/$F101.
audioWorkValue=$40,APUSTATUS=$1F,APUFRAMECNT=$C0.- Clear 16 bytes through ($F0), clear 16 entries at 8-byte stride through ($F2).
InitializeAudioChannels ($EED0):
- Clear audio channel flags, scan 16 eight-byte slots through ($F2).
- Slots with values $01–$04 activate a channel: copy 4-byte parameters through indirect pointer $F6.
Audio data at $FF00–$FFF5 (period/mask tables, sequences, instruments), $F450–$F49A (runtime command data), AudioEventSequenceData at $FDFA–$FFEA.
Audio RAM symbols ($F0–$FF): audioRequestPtr, audioChannelTablePtr, audioSequenceTablePtr, audioWorkPtr, audioWorkValue, audioChannelFlags, audioRequestIndex, audioChannelOffset, audioIndirectPtr, plus channel-specific effect timers ($F5–$F6), sequence pointers/indices ($F7–$FA), stream position ($FB), event priority ($FC), request flags ($FD–$FF).
9. Zero-Page / RAM Symbols (key subset)
| Address | Symbol | Use |
|---|---|---|
| $01–$02 | WorkPtrLo/Hi | General indirect pointer |
| $09 | PpuCtrlShadow | $2000 mirror |
| $0A | PpuMaskShadow | $2001 mirror |
| $1A | ControllerHeld | Controller buttons held |
| $1B | Zp1B | Previous controller state |
| $20 | Zp20 | Timer counter |
| $25 | StateTimer | State countdown timer |
| $27 | FrameCounter | Frame tick counter |
| $30 | NmiWorkComplete | NMI frame-sync flag |
| $31 | PpuBufferReady | PPU buffer ready flag |
| $32 | NmiUpdatesEnabled | NMI updates enabled flag |
| $33 | NmiTriggerFlag | NMI trigger flag |
| $34 | StartButtonLatch | Start button debounce latch |
| $35 | StateTransitionFlag | State transition flag |
| $38 | GameActiveFlag | Game active flag |
| $39 | GameMode | Current game mode |
| $3C | PaletteUpdateMode | Palette update mode |
| $3D | GameState | Main game state |
| $3E | GameStateStep | Game state step/substep |
| $3F | DemoCounter | Demo counter |
| $43 | PendingStateFlag | Pending state flag |
| $44 | StateSubstep | State substep |
| $46 | PlayerCourtOrder | Player court order |
| $48–$4B | ModeAnimTimer/Frame A&B | Mode animation timers/frames |
| $4C | ServingPlayer | Serving player |
| $4D | ModeAnimSubframe | Animation subframe |
| $4E | MessageTimer | Message timer |
| $4F | MessagePlayerIndex | Message player index |
| $50 | PointWinner | Point winner |
| $51–$53 | Player/2 Point/Game Score | Score values |
| $57–$5D | Score-related state | Score display/update state |
| $5E | PendingGameState | Pending game state |
| $60–$6E | Player/Ball motion state | Player and ball physics |
| $78–$7F | Animation/ball state | Animation flags, ball state/collision |
| $80–$8F | Target/action/collision state | Player targets, action state, collision flags |
| $A8–$AA | ObjectDataPointerLo/Hi, Offset | Object data pointers |
| $AB | ActiveObjectIndex | Active object index |
| $B0–$C9 | Animation/pose/racket state | Animation frames, pose, racket targets |
| $CA–$CC | Contact/bounce state | Racket/ball contact state |
| $CD–$CF | Movement/action state | Movement speed, computer/action state |
| $E0 | SelectedGameMode | Selected game mode |
| $E6–$FF | Audio engine state | Audio request pointers, channel flags, sequence state |
10. PPU / Rendering
- OAM DMA: Every NMI, DMA from $0200–$02FF via
OAMDMA($4014). - PPU buffer:
WritePpuBuffer($C2EC) writes a command buffer at $0590 to PPU whenPpuBufferReady($31) is set. - Scroll:
SetScroll($C3FD) writes $1E/$1F to PPUSCROLL. - Palette:
WritePalette($C1B5) conditionally uploads from RAM buffers based onPaletteUpdateMode($3C). - Palette shadow:
PpuCtrlShadow($09),PpuMaskShadow($0A). - Sprite hiding:
HideAllSprites($C1A5) writes $F4 to OAM. - Metasprite rendering:
WriteMetaspriteToOam($C440) writes sprite data from a pointer.
11. Data Tables
| Label | Address | Description |
|---|---|---|
MainStateHandlerTable |
$C101 | 12 main game state handler pointers |
GameStateStepHandlerTable |
$C185 | 7 mode step handler pointers |
GameStateHandlerTable (game mode) |
$CAD7 | 8 game-mode state handler pointers |
InitialMatchObjectStateData |
$C847 | Initial object state data for match start |
EngineTable_* |
Various | Inline data tables embedded between code |
AudioEventSequenceData |
$FDFA | Audio event sequence data |
| Audio tables | $FF00–$FFF5 | Period/mask tables, sequences, instruments |
12. Summary of Execution Flow
Power On / Reset
│
▼
Reset ($C000) ───────────────────────────────────────────────────────┐
│ cld, sei, wait VBlank, PPUCTRL/PPUMASK=$00 │
│ stack=$FF, clear RAM $0000–$02FF │
│ $0500=$5F, clear nametables, hide sprites │
│ PPUSCROLL=$00, GameActiveFlag=$01 │
│ APUSTATUS=$0F (all channels on) │
│ PPUCTRL=$90 (NMI on), PpuCtrlShadow=$90 │
│ PpuMaskShadow=$06 │
│ │
▼ │
MainLoop ($C04F) ─────────────────────────────────────────────────────┤
│ │
├─ NmiWorkComplete ($30) = $00 │
├─ if GameActiveFlag && GameStateStep==1: RunAudioEngine ($FFF7) │
├─ jsr UpdateTimers ($C375) │
├─ jsr UpdateGameState ($C0AA) ──► state machine dispatch │
│ ├─ State $00: InitializeMatch │
│ ├─ State $01: WaitForServe │
│ ├─ State $02: PrepareRally │
│ ├─ State $03: RunRally │
│ ├─ State $04: PointEnded │
│ ├─ State $05: UpdateScore │
│ ├─ State $06: AdvanceGame │
│ ├─ State $07: PauseOrTransition (RTS) │
│ ├─ State $08–$0B: WaitForTimer / Restore / Resume / Finish │
│ └─ return to MainLoop │
│ │
├─ NmiUpdatesEnabled ($32) = $01 │
├─ wait for NmiWorkComplete ($30) != 0 ◄── NMI sets this │
├─ FrameCounter++, NmiUpdatesEnabled=$00 │
└─ jmp MainLoop │
│ │
│ (if NmiWorkComplete==0: jsr RollRng ($C456), retry) │
│ │
NMI ($C07D) [once per frame] │
│ │
├─ save A, X, Y │
├─ OAM DMA ($0200–$02FF → $4014) │
├─ if NmiUpdatesEnabled: │
│ jsr WritePalette ($C1B5) │
│ jsr WritePpuBuffer ($C2EC) │
│ jsr SetScroll ($C3FD) │
├─ jsr ReadControllers ($C2A4) → $4D/$4E/$4F │
├─ NmiWorkComplete ($30) = $01 (unblocks main loop) │
├─ NmiTriggerFlag ($33) = $01 │
├─ restore registers, rti │
│ │
IrqHandler ($C17E): rti (unused) │
13. Notes
- Build verification:
makeincopilot/producesreconstructed_prg.binthat is byte-identical toprg.binand to the PRG bank ofTennis (JU).nes. - Vectors confirm NMI at $C07D, and both RESET and IRQ at $C000 (the reset handler doubles as the IRQ handler, just a
rti). - The foreground main loop (
MainLoopat $C04F) is strictly NMI-synchronized viaNmiWorkComplete($30): it loops until NMI sets it to $01, processes game state and timers, then waits again. UpdateGameState($C0AA) dispatches to one of 12 main game states viaMainStateHandlerTableat $C101, indexed byGameState($3D).- State $04 contains a nested secondary state machine (
GameStateStepHandlerTableat $C185, 7 steps) for mode selection/setup. - Many routine labels are address-based (
EngineRoutine_C1DD,MatchRoutine_C7E7, etc.) reflecting the automated first-pass disassembly; semantic naming is provisional. symbols.incprovides NES hardware register names and controller masks;locals.incprovides extensive zero-page symbol definitions covering player/ball physics, audio, and game state.- The disassembly includes inline data tables (
EngineTable_*) embedded between code regions, preserved verbatim for byte-identity.