Files
disasm/Excitebike (JU)/OVERVIEW.md
T

22 KiB
Raw Blame History

Excitebike (JU) — Game Flow from Power-On

This document describes the execution flow of the disassembled Excitebike (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 (14,111 lines)
Output reconstructed_prg.bin (16,384 bytes)

Interrupt vectors (CPU $FFFA–$FFFF)

Vector Address Handler
NMI $C23B NMI
RESET $C184 Reset
IRQ / BRK $FFF0 ($FFF0 — points into audio data region, 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 (headers, CHR, and all PRG bytes).

Build fix applied

The original source contained a bpl LookupTableEntry_FAF5 branch at CPU $FAB4 (line 9425 of prg.s), but the symbol LookupTableEntry_FAF5 was undefined. The branch target CPU $FAF5 fell in the middle of what the disassembler classified as lda #$16 (A9 at $FAF4, 16 at $FAF5). The fix:

  • Replaced bpl LookupTableEntry_FAF5 with bne *-$1B at CPU $FAB4, producing the exact original bytes d0 e3 (the disassembler had mislabeled bne as bpl).

3. Reset — CPU $FFFC → $C184

Reset ($C184)

On power-on or reset:

  1. sei — disable maskable interrupts.
  2. cld — clear decimal mode.
  3. lda #$00 / sta PPUCTRL — disable PPU rendering and NMI.
  4. ldx #$FF / txs — initialize stack pointer to $FF.
  5. Wait for two VBlanks — poll PPUSTATUS bit 7 twice (@vblank1, @vblank2). This is the PPU warm-up delay.
  6. Reset magic check — compare ResetMagic1 ($05CE) against $A5 and ResetMagic2 ($05CF) against $5A:
    • If both match → warm start: set Y = $04 (preserve more RAM)
    • If either mismatches → cold start: set Y = $07 (clear more RAM)
  7. Clear RAM — clear zero page and one page ($0000–$00FF, or $0000–$06FF depending on warm/cold start) using indirect pointer at $00/$01.
  8. Persistent RAM init — if reset magic was valid, jsr InitializePersistentRam ($C278):
    • Copy 31-byte template (PersistentRecordInitialDataC091 at $C091) to both PersistentRecord0 ($0580) and PersistentRecord1 ($05A3)
    • Fill PersistentBuffer ($06E0) with $40, set byte at $07AF to $09
    • Set reset magic bytes to $A5/$5A
    • Zero tail bytes of both persistent records
  9. sta APUDMALOAD — disable DMC.
  10. lda #$06 / sta PPUMASK / sta $18 — enable background + sprites, set $18.
  11. jsr InitializeRuntimePpuState — fill OAM shadow with $F8, process PPU entries, zero scroll/render fields.
  12. lda #$90 / jsr SetPpuControl — set PPUCTRL = $90 (NMI enabled, $2000 base).
  13. Falls into @main ($C1A4) — the permanent main loop.

4. Main Loop — @main ($C1A4)

After reset, Reset never returns. It enters the infinite main loop:

@main ($C1A4):
    jsr ShiftRuntimeState        ; $E8BC — shift runtime bytes $18-$1F right
    lda NmiSyncState             ; $48
    bmi @main                    ; loop if $FF (NMI_SYNC_PENDING)
    beq @main                    ; loop if $00
    ; NmiSyncState == $01 (NMI_SYNC_COMPLETE): proceed
    jsr DisableNmi               ; $C362 — clear NMI bit in PPUCTRL
    lda NmiSyncState
    cmp #NMI_SYNC_COMPLETE       ; $01
    bne @alternate               ; if not complete, receive instead
    jsr TransmitRuntimeDataCode  ; $C6C8 — serial transmit runtime data
    jmp @finish
@alternate:
    jsr ReceiveRuntimeDataCode   ; $C739 — serial receive runtime data
@finish:
    lda #NMI_SYNC_PENDING        ; $FF
    sta NmiSyncState             ; $48 — reset sync flag for next frame
    jsr WaitForVblankEndAndEnableNmi  ; wait for VBlank, set NMI bit
    jmp @main                    ; loop forever

The main loop is NMI-synchronized: it blocks until NmiSyncState ($48) becomes $01 (set by NMI each frame), then performs serial I/O (transmit or receive runtime data), resets the sync flag to $FF, waits for the next VBlank, enables NMI, and repeats.

ShiftRuntimeState ($E8BC)

Shifts runtime state bytes $18–$1F right by one bit. The carry-in is derived from controller bit 1 ($19 bit 2), enabling serial/data shifting tied to controller input.


5. NMI / Frame Heartbeat — NMI ($C23B)

The NMI fires once per frame and is the central real-time driver:

  1. jsr DisableNmi ($C362) — clear bit 7 of PpuControlShadow ($10), write to PPUCTRL ($2000). NMI is disabled during the handler to prevent re-entry.
  2. PPU mask: if $45 == 0, PPUMASK = PpuMaskShadow | $1A (enable background + sprites); otherwise skip.
  3. OAM DMA: write $00 to OAMADDR ($2003), then $02 to OAMDMA ($4014) — copy $0200–$02FF to sprite RAM.
  4. jsr NmiPpuUpdate ($C27E) — process one PPU update command stream (indexed by $45), then reset $0300/$0301/$45 and PPU address to $3F00.
  5. Optional rider/lane update: if $47 != 0, jsr UpdateRiderLanePositionStates and jsr NmiRuntimeUpdate.
  6. Runtime counters: if $03B1 | $03B2 == 0, increment $3F and jsr UpdateRuntimeCounters.
  7. jsr ReadControllers ($E8E5) — read both controller ports ($4016/$4017), update Controller1Held ($14), Controller1Pressed ($15), Controller2Held ($16), Controller2Pressed ($17).
  8. jsr UpdateAudio ($F8DE) — update all four APU channels (Pulse 1, Pulse 2, Noise, Sound Effect) plus sound effects and DMA.
  9. jsr DispatchNmiState ($C2A9) — dispatch to the current NMI state handler (see below).
  10. Read PPUSTATUS ($2002) to reset the address latch.
  11. jsr WaitForVblankEndAndEnableNmi — wait for VBlank end, set NMI bit in PPUCTRL.
  12. rti — return from interrupt.

NmiPpuUpdate ($C27E)

Processes a single PPU update command:

  • Load pointer from PpuUpdatePointerLow/High ($C0F6/$C111 tables) indexed by $45.
  • Call ProcessPpuUpdateStream ($D3C1) which reads the command stream and writes bytes to $2007.
  • Zero out $0300, $0301, and $45.
  • Reset PPU address to $3F00 (four writes to $2006).

6. NMI State Machine — DispatchNmiState ($C2A9)

The NMI dispatcher supports two modes based on NmiDispatchMode ($40):

Mode 0 — Direct state dispatch (DispatchNmiState @ $C2A9)

If NmiDispatchMode == 0:

  • Load NmiState ($41), double it, use as index into NmiStateHandlerTable ($C000–$C01B).
  • Jump indirect through zero-page $00/$01 (high byte left in A, stored to $01 by code at $CB36).

NMI State Handler Table ($C000–$C01B) — 14 entries for states $00–$0D:

State Handler Address Purpose
$00 NmiState00 Menu/title — selection handling
$01 $C395 Menu horizontal input
$02 UpdateGameplayRuntime Main gameplay update
$03 DispatchNmiSequenceStep Sequence step dispatch
$04 NmiState04_07 Shared state $04/$07 handler
$05 UpdateGameplayRuntime Main gameplay update
$06 DispatchNmiSequenceStep Sequence step dispatch
$07 NmiState04_07 Shared state $04/$07 handler
$08 $C3BD Menu vertical input
$09 UpdateRaceRuntime Race gameplay update
$0A UpdateGameplayRuntime Main gameplay update
$0B DispatchNmiSequenceStep Sequence step dispatch
$0C $C43D Serial handshake state $0C
$0D $C41C Serial handshake state $0D

Mode 1 — Sequence dispatch

If NmiDispatchMode != 0:

  • Disable PPU rendering.
  • Wait until $30 == 0 (not busy).
  • Look up sequence pointer from NmiStateToSequenceIndex + NmiSequencePointerLow/High tables.
  • Call the sequence handler at the current NmiSequenceStep ($44).
  • If step < 0 (after increment/decrement), go to InitializeRuntimePpuState.

NMI Sequence Pointers ($C03C–$C07F) — 9 sequences:

Index Address Handlers
0 $C03C $C2BD, $C514
1 $C040 SetSerialUpdateSelector06, $C3F3, $C514
2 $C046 SerialUpdateSelectorCluster, ClearRuntimeZeroPage, InitializeCourseRuntime, SetSerialUpdateSelector17, SetSerialUpdateSelector0E, InitializeGameRuntime
3 $C052 SetSerialUpdateSelector06, SetSerialUpdateSelector04, SelectNmiUpdateForState, CallC92CWithZero, CompareAndUpdateRecord
4 $C05C SetSerialUpdateSelector06, SetSerialUpdateSelector09, Prepare0300Structure, $C514
5 $C064 $C551, ClearRuntimeZeroPage, InitializeCourseRuntime, SetSerialUpdateSelector17, $C562, $C5C2
6 $C070 SetSerialUpdateSelector06, $C575, $C514
7 $C07A ClearRuntimeZeroPage, RestoreWorkingRecordFields, $C518
8 $C076 $C582, $C518

7. Key NMI State Handlers

NmiState00 ($C346)

Title/menu state:

  • If $31 == 0: initialize $43, $4A = 0, $03F7 = $0F, NmiState = 2 (gameplay).
  • Else: jsr UpdateMenuSelection with count 3; if no input change, set sprite 0 Y from MenuInitialYTable; otherwise handle selection done and advance state.

NmiState01 ($C397)

Menu horizontal input:

  • Filter controller buttons, jsr UpdateMenuSelection (count 5).
  • If input changed, move horizontal marker; else store MenuSelectionIndex to $43 and advance to next NMI state, entering sequence mode.

NmiState08 ($C3BD)

Menu vertical input:

  • jsr UpdateMenuSelection (count 6).
  • Based on selection: advance state, jump to sequence entry, or handle choice 5 (reset runtime, preserve selection).

NmiState04_07 ($C465)

Shared handler for states $04/$07:

  • If $34 == 0: advance NMI state and enter sequence mode; clamp state to max 7.
  • Else: jsr $C92C, check bit 4 of $3F, either return or jmp StateHelperCAFA.

NmiState0C / NmiState0D ($C41C / $C43D)

Serial handshake states:

  • State $0C: if sequence step == 0, set sync complete; else if sync pending, copy record tails and continue handshake.
  • State $0D: similar handshake with persistent record synchronization.

8. Gameplay Runtime

UpdateGameplayRuntime ($C99A)

Called from NMI states $02, $05, $0A:

  • jsr $CA08 (update four rider slots).
  • If $03B1 | $03B2 != 0: jump to PPU update tail.
  • Otherwise: jsr $D918, jsr $DD8D, jsr UpdateAllRiderScriptStateE733, jsr $E927.
  • If $52 != 0: jsr StateHelperCA9B.
  • If $03F2 != 0: optional updates ($DA9F, $DBC4, $DEBB).
  • jsr UpdateGlobalCounters, jsr BuildGlobalDisplayEAC5, jsr CompareRuntimeSlots, jsr CheckSlotState.
  • jsr UpdateTriggeredSlots, jsr UpdateFourSlots, jsr UpdateFourSlotPositionsE96C, jsr UpdateDisplayState, jsr UpdateFourSlot8CE836, jsr BuildFourSlotSpritesEA44, jsr ProcessCourseDefinition, jsr UpdateSlotPairState, jsr UpdateGlobalFC, jsr UpdateGlobalDisplay, jsr ProcessRuntimeEntries, jsr PrepareSortedSlots, jsr UpdateRuntimeSlots, jsr UpdateRiderCourseStateE70B, jsr UpdatePpuState, jsr ProcessRiderSlots, jmp ProcessSortedSlots.

UpdateRaceRuntime ($C8D6)

Race-specific update:

  • jsr $CA1B, jsr UpdateGlobalRuntimeEB17, jsr UpdateAccumulator, jsr UpdateGlobalPosition, jsr UpdateRiderCourseStateE70B, jsr ProcessCourseDefinition, jsr $F68D, jsr $F755, jsr $C229, then jmp UpdatePpuState.

9. Audio System

UpdateAudio ($F8DE) — called from NMI:

  1. APUFRAMECNT ($4017) = $C0.
  2. jsr UpdatePulse1Channel, jsr UpdatePulse2Channel, jsr UpdateNoiseChannel, jsr UpdateSoundEffect.
  3. Zero out $FF, $FE, $FD, $FB.
  4. If $07FF != 0: sta APUDMALOAD ($4011), decrement $07FF.

Audio channel configuration uses tables at $FF00+ (period tables) and $FF3E+ (mask tables). Audio sequence/data lives at $FF64–$FFE9.


10. Serial I/O System

Excitebike uses the controller port $4016 for serial communication (likely the Famicom 3D System or a similar serial peripheral — peripheral identity unknown):

Transmit path (TransmitRuntimeDataCode at $C6C8):

  • Configure a 64-byte block from $0060, transmit it.
  • Configure a 20-byte block from $05E0 (working buffer), transmit it.
  • Uses pulse-length encoding: long pulse = 1, short pulse = 0, MSB first.
  • Preamble: short pulses, then long pulses, then short pulses, then one long pulse.

Receive path (ReceiveRuntimeDataCode at $C739):

  • Disable NMI, configure receive blocks, receive data.
  • Validate 5-byte header against ReceiveHeaderTableBaseC17F ($C17F: $E8).
  • If valid: set state variables ($A0–$A3), set $45 = $1A, wait for NMI update.
  • Receive second block (working buffer), validate checksum, return success/failure.

Serial helpers: TransmitByte ($C66E), ReceiveByte ($C6A5), TransmitPulseShort ($C64E), TransmitPulseLong ($C653), ReceiveSerialBit ($C691), SerialWaitPulse ($C639).


11. Zero-Page / RAM Symbols

Address Symbol Use
$00–$01 TMP00/TMP01 General scratch / indirect pointer
$08–$09 SerialBitCountLo/Hi Serial bit counter
$0E SerialBlockPtr Serial block pointer
$0D SerialBlockLength Serial block length
$10 PpuControlShadow $2000 mirror
$11 PpuMaskShadow $2001 mirror
$12–$1F — Runtime state bytes (shifted each frame by ShiftRuntimeState)
$14 Controller1Held Controller 1 held buttons
$15 Controller1Pressed Controller 1 newly pressed
$16 Controller2Held Controller 2 held buttons
$17 Controller2Pressed Controller 2 newly pressed
$31 — Menu/timing state
$34 — Menu selection state
$3F — Runtime counter
$40 NmiDispatchMode NMI dispatch mode (0=direct, 1=sequence)
$41 NmiState Current NMI state ($00–$0D)
$42 MenuSelectionIndex Current menu selection
$43–$4A — State/temp variables
$44 NmiSequenceStep Current NMI sequence step
$45 — PPU update index
$47 — Optional update flag
$48 NmiSyncState $FF=PENDING, $01=COMPLETE
$49 InputActionLatch Menu input latch
$A4 RiderMotionState Rider motion state ($01=disabled, $02=active, $03=transition, $04=complete)
$0100 — DMA/APU scratch
$0200 OamBuffer OAM shadow / sprite DMA source
$0300–$0301 — PPU update command structure pointer
$03B1–$03B2 — Timed update flag
$0580 PersistentRecord0 First persistent record (31 bytes + tails)
$05A3 PersistentRecord1 Second persistent record
$05CE–$05CF ResetMagic1/2 $A5/$5A warm-start signature
$05E0 WorkingBuffer Working buffer for serial I/O
$06E0 PersistentBuffer Persistent buffer (256 bytes)
$07FF — Audio DMA trigger

12. PPU / Rendering

  • OAM DMA: Every NMI, DMA from $0200–$02FF via OAMDMA ($4014).
  • PPU update streams: Indexed pointer tables at $C0F6 (low) / $C111 (high), 27 entries. Processed by ProcessPpuUpdateStream ($D3C1) during NMI.
  • Scroll: PpuControlShadow ($10) and PpuMaskShadow ($11) are software mirrors, updated during NMI and written to $2000/$2001.
  • OAM shadow: Filled with $F8 (offscreen) by FillOamShadowWithF8 during runtime PPU init.

13. Summary of Execution Flow

Power On / Reset
    │
    ▼
Reset ($C184) ────────────────────────────────────────┐
    │ sei, cld, disable PPU/NMI, stack=$FF            │
    │ wait 2× VBlank (PPU warm-up)                    │
    │ check reset magic ($A5,$5A at $05CE/$05CF)      │
    │   valid → warm start (preserve more RAM)        │
    │   invalid → cold start (clear more RAM)         │
    │ clear RAM ($0000–$00FF or $0000–$06FF)          │
    │ if valid: InitializePersistentRam               │
    │   (copy template to both persistent records)    │
    │ disable DMC, PPUMASK=$06, PPUCTRL=$90 (NMI on)  │
    │ InitializeRuntimePpuState                       │
    │                                                 │
    ▼                                                 │
@main ($C1A4) ────────────────────────────────────────┤
    │                                                 │
    ├─ jsr ShiftRuntimeState   ($E8BC) ──────────────┐
    │   shift $18-$1F right, carry from controller    │
    │                                                 │
    ├─ lda NmiSyncState ($48)                          │
    ├─ bmi @main      (if $FF = PENDING, loop)        │
    ├─ beq @main      (if $00, loop)                  │
    │   (only proceed when NMI set it to $01)         │
    │                                                 │
    ├─ jsr DisableNmi     ($C362)                     │
    ├─ if NmiSyncState == $01:                        │
    │     jsr TransmitRuntimeDataCode ($C6C8)         │
    │   else:                                         │
    │     jsr ReceiveRuntimeDataCode ($C739)          │
    │                                                 │
    ├─ lda #NMI_SYNC_PENDING ($FF)                    │
    │   sta NmiSyncState                              │
    ├─ jsr WaitForVblankEndAndEnableNmi               │
    └─ jmp @main   ◄──────────────────────────────────┘
                                                        │
NMI ($C23B) [once per frame]                            │
    │                                                 │
    ├─ jsr DisableNmi   (PPUCTRL bit7 = 0)            │
    ├─ PPUMASK = PpuMaskShadow \| $1A (if $45==0)     │
    ├─ OAM DMA ($0200-$02FF → $4014)                  │
    ├─ jsr NmiPpuUpdate ($C27E)  [PPU update stream]  │
    ├─ if $47 != 0:                                     │
    │     jsr UpdateRiderLanePositionStates           │
    │     jsr NmiRuntimeUpdate ($D14E)                │
    ├─ if $03B1|$03B2 == 0: inc $3F, jsr UpdateRuntimeCounters │
    ├─ jsr ReadControllers ($E8E5)                    │
    ├─ jsr UpdateAudio ($F8DE)  [4 channels + SFX]    │
    ├─ jsr DispatchNmiState ($C2A9)                   │
    │     ├─ NmiDispatchMode==0: direct state table   │
    │     │   $00=NmiState00 (menu/title)             │
    │     │   $01=$C395 (menu horiz)                  │
    │     │   $02=$C99A (UpdateGameplayRuntime)       │
    │     │   $03=DispatchNmiSequenceStep             │
    │     │   $04/$07=NmiState04_07                   │
    │     │   $05=$C99A (UpdateGameplayRuntime)       │
    │     │   $06=DispatchNmiSequenceStep             │
    │     │   $07=NmiState04_07                       │
    │     │   $08=$C3BD (menu vert)                   │
    │     │   $09=$C8D6 (UpdateRaceRuntime)           │
    │     │   $0A=$C99A (UpdateGameplayRuntime)       │
    │     │   $0B=DispatchNmiSequenceStep             │
    │     │   $0C=$C43D (serial handshake)            │
    │     │   $0D=$C41C (serial handshake)            │
    │     └─ NmiDispatchMode!=0: sequence dispatch    │
    │         (9 sequences at $C03C-$C07F)            │
    │                                                 │
    ├─ read PPUSTATUS                                 │
    ├─ WaitForVblankEndAndEnableNmi                   │
    │   (set NmiSyncState = $01 implicitly via        │
    │    main loop's view of completion)              │
    └─ rti                                            │

Serial I/O (from main loop, once per frame)
    ├─ TransmitRuntimeDataCode ($C6C8)  [when sync complete]
    │     ├─ 64-byte block from $0060                 │
    │     └─ 20-byte block from $05E0 (working buffer)│
    └─ ReceiveRuntimeDataCode ($C739)  [alternate]
          ├─ validate 5-byte header ($C17F)           │
          ├─ receive second block                     │
          └─ checksum verify                          │

Audio (from NMI via UpdateAudio at $F8DE)
    ├─ Pulse 1 channel ($4000-$4003)                 │
    ├─ Pulse 2 channel ($4004-$4007)                 │
    ├─ Noise channel ($400C-$400F)                   │
    ├─ Sound effect                                 │
    └─ DMC load if $07FF != 0                        │

14. Notes

  • Build verification: make in copilot/ produces reconstructed_prg.bin that is byte-identical to prg.bin and to the PRG bank of rom.nes.
  • Vectors confirm NMI at $C23B, RESET at $C184, IRQ at $FFF0 (points into audio data region — likely a placeholder or unused).
  • The main loop (@main at $C1A4) is strictly NMI-synchronized via NmiSyncState ($48): it loops until the NMI sets the state to $01 (COMPLETE), performs serial I/O, resets to $FF (PENDING), waits for VBlank, and repeats.
  • Serial I/O uses controller port $4016 for pulse-length encoded data transmission/reception — likely for the Famicom 3D System or a similar serial peripheral.
  • The NMI state machine supports both direct state dispatch (14 states via table at $C000) and sequence dispatch (9 sequences via tables at $C03C–$C07F).
  • Many routines carry address-based labels (NmiState04_07, UpdateSideScrollingGameplay_C073, etc.) reflecting the automated first-pass disassembly; semantic naming is provisional.
  • symbols.inc (embedded at the top of prg.s) provides NES hardware register names and key zero-page symbols.
  • One build fix was required: an undefined branch target LookupTableEntry_FAF5 was replaced with a PC-relative bne *-$1B to produce the original d0 e3 bytes.