Pass 3 named the 10 data regions in prg.s, then the project was promoted
out of _wip/ so `make` now proves header + PRG + CHR against the original
ROM instead of only the PRG bank.
Data regions named:
LevelPtrTable $C000-$C03B (60 B) level header data
ObjectPtrTable $C03C-$C043 (8 B) 4 pointer pairs
LevelDataPtrTable $C044-$C79D (1882 B) level data pointers
SpriteInitPtrTable labelled at $C4B0 see residue note
UnreachedCode_D650 $D650-$D696 (71 B) unreached code
UnreachedCode_E144 $E144-$E165 (34 B) unreached code
MusicDataTable $F55B-$FA47 (1261 B) music/sound data
APUDataTable $FAE6-$FB74 (143 B) APU pulse/noise tables
SfxDataTable $FE59-$FFEF (407 B) sound-effect data
VectorTable $FFF6-$FFFF (10 B) padding + NMI/RESET/IRQ
Promotion (per the AGENTS.md checklist):
- git mv "_wip/Donkey Kong (JU)" "Donkey Kong (JU)"
- add copilot/rom.nes -> ../Donkey Kong (JU).nes symlink
- add header.s / chr.s (one-line .incbin of header.bin / chr.bin)
- linker.cfg gains HEADER ($0000/16 B) and CHR ($0000/8 KB)
- Makefile switched from `cmp reconstructed_prg.bin prg.bin` to
`cmp reconstructed.nes rom.nes`
- no _tools path change (this Makefile never referenced _tools/)
and no `make regen` pipeline exists for it
Doc corrections:
- project OVERVIEW.md said Mirroring Vertical; header byte 6 = $00
means Horizontal (the root index was right) — fixed
- root OVERVIEW.md: row 18 now BYTE-IDENTICAL, tier paragraph and
summary table refreshed (they had drifted around the Lunar Pool and
Urban Champion promotions), new per-project detail entry
- counts corrected to 19 top-level / 17 _wip in OVERVIEW.md,
TODO.md and AGENTS.md
Residue recorded rather than guessed:
- Pass 2 is partial: 919 Label_ definitions remain (214 semantic
labels applied); noted in TODO.md so nobody reads the promotion as
full semantic naming
- SpriteInitPtrTable's label sits at $C4B0: the routine at $C807 reads
$C4A7,x/$C4A8,x, but that table starts mid-row and a label there
would split the canonical 16-byte audit-comment line
Verification:
make clean && make -> "Byte-identical ROM verified."
verify_pass1.py -> 19/19 top-level build, Donkey Kong (JU) OK
(6014 instr / 12505 B), all comments canonical
Doc-drift sweep: build tiers, mirroring, and stale status tails
Reconciled the docs against verified reality after the Lunar Pool / Urban
Champion / Donkey Kong (JU) promotions and the 2026-10-19 audit-comment
standardization. No source behaviour changed except the two build fixes below.
Build tier (whole-ROM vs PRG-only)
- AGENTS.md: the 17 `_wip/` projects are real disassemblies at the PRG-build
tier, not "skeletons"; header.s/chr.s/rom.nes are promotion additions;
verify_pass1.py covers the top-level projects.
- Root OVERVIEW.md: `_wip` prose, audit-comment narrative and summary table
refreshed (37.5% figure, SEEDED vs OK wording, re-trace false alarm).
- Promoted projects whose build blocks still claimed `make` emits
`reconstructed_prg.bin` compared to `prg.bin` (Balloon Fight, Baseball,
CIRCUS, Clu Clu Land, Excitebike, NES PowerPad, Pac-Man (USA), Tennis):
now describe the whole-ROM `reconstructed.nes` vs `rom.nes`.
- Lunar Pool / Urban Champion / CIRCUS also had stale `cd "_wip/..."`
build paths and "Promotion" items; retargeted to their top-level folders.
Mirroring (header byte 6 bit 0: 0 = Horizontal, 1 = Vertical)
- Corrected DK Jr. prose "horizontal mirroring (flag 6 = $01)" -> vertical,
and both `_wip/Pac-Man` variant notes "Both use vertical mirroring" ->
horizontal (both are byte-6 = $00).
- Re-audited every project OVERVIEW: 0 rows and 0 prose claims now disagree.
Stale status tails
- Retitled/restatused the `_wip` OVERVIEWs to their real `prg.s` (Golf,
Ice Climber (U), Lode Runner, Millipede, Pac-Man Tengen, Pac-Man (U),
Popeye 0/1, Raid, Raid a2, Hogan's Alley, Mario Bros); Golf's
skeleton-era contents rows and Next Steps corrected.
- Dropped the skeleton-era "## Next Steps (per `PROMPTS.md`)" heading and
the "(from the skeleton)" wording across all project OVERVIEWs.
- Rewrote Lunar Pool / Urban Champion Next Steps (both already Pass 1-3
complete and promoted; their tails still described pending Pass 2 and
promotion); fixed root row 13 (Wild Gunman) and row 20 (Duck Hunt).
Also lands two code fixes carried with this sweep:
- `_wip/Hogan's Alley (JU)`: $E9F1-$E9FD and $EA13-$EA14 reclassified as
data with canonical audit comments (the seeded build had decoded a data
table base as code, breaking the build since 2ae09f7). `make` ->
BYTE-IDENTICAL again; reconstructed_prg.bin/.lbl refreshed.
- `_wip/Pyramid (U)`: `Label_D72E` -> `NMI:` (byte-identical).
Verification:
make (Hogan's Alley, Pyramid) -> BYTE-IDENTICAL, BYTE-IDENTICAL
_tools/verify_pass1.py -> 19/19 build, all canonical,
"No truncated or unfaithful Pass 1"
mirroring audit (rows + prose) -> 0 mismatches
Donkey Kong (JU): complete Pass 2 — all 919 Label_ definitions named
The last non-zero Label_ residue in the repo is now gone. Every one of the
919 address-derived definitions in copilot/prg.s is replaced:
* 319 routine entry points (jsr/jmp targets):
- 102 named from observed behaviour (LoadSpriteInitPointerToZp00 $C807,
CopyVramDataToBuffer $CBDF, CheckSpriteListCollision $D8AD,
UpdateRandomSeed $F4ED, ReadControllers $F50E, UpdateSoundEngine $FA48, ...)
- 217 left as SubC<addr> — honest residue where the body is too
context-dependent to name without guessing (Golf: 108, Mario Bros: 223)
- the 77 already-named entries untouched
* 600 internal branch targets named <Routine>_Block / <Routine>_Block_n in
address order within their owning routine (DK Jr./Balloon Fight convention).
Ownership is control-flow containment, so a loop that physically precedes
its own entry lands correctly (FlushPpuBuffer_Block $F1EC under $F228).
* Two proposal collisions disambiguated by indexing zero-page slot:
UpdateObjectListZp5D/DispatchObjectStateZp5E ($DA16/$DA4C) vs
UpdateObjectListZpAE/DispatchObjectStateZpAF ($E1E9/$E250).
jsr/jmp operands stay numeric (Wild Gunman style); only branch operands are
symbolic. Applied with _tools/pass2.py apply (919 defs + 2,622 refs rewritten,
comments re-aligned); the ROM still rebuilds byte-identically and
verify_pass1.py reports 19/19 canonical with no truncated split.
Docs synced: project OVERVIEW.md gains a Pass 2 Details section, root
OVERVIEW.md rows + conventions, TODO.md section 4 closed. dk_full_map.tsv now
records the complete applied map (its zp rows remain unapplied proposals).
_wip status correction: five false "Pass 2 complete" claims + map audit
The `_wip` tier's "Pass 2 complete" headers counted their draft maps' rows, not
the names actually present in `prg.s`, and described the count of *neutral*
`ZpXX` slots in `locals.inc` as "zero-page semantic aliases". Verified against
the sources and corrected in the root inventory rows, the project OVERVIEWs and
TODO:
project named/total Label_ left drafted map
Pinball (JU) 180 / 681 501 210 rows, 179 applied
Ice Climber (JE) 76 / 973 897 255 rows, 76 applied
Donkey Kong Jr. Math 64 / 1035 971 243 rows, 62 applied
Duck Hunt (JUE) 31 / 472 441 272 rows, 31 applied
Ice Climber (U) 2 / 973 971 none - Pass 2 not started
No `_wip` project is Pass 2 complete. Ice Climber (U) was the worst case: the
2026-10-19 sweep itself restated it "Pass 2 complete" while describing its 971
`Label_` definitions in the same paragraph - a copy-paste from (JE); (U) has no
map file at all.
Drafted maps audited row by row against the committed split: they are not
applicable. 100/243 (DK Jr. Math), 96/255 (Ice Climber (JE)) and 17/210
(Pinball) rows point at addresses that are not instruction boundaries - they
were built against an older code/data split - while most of the rest are
unlabelled boundaries that no operand references, or (Duck Hunt) competing
candidate names for one label (`Label_C2BF` alone carries 205). Rows that are
both insertable and corroborated by control flow: 0 in every map. Recorded as
stale and kept for provenance; Pass 2 here needs a fresh map derived from the
committed split.
Also corrected while verifying:
- Mario Bros entry counts (docs said 252 / 90 + 165 / 477 sites / 156 neutral
slots; the file holds 262 distinct `jsr`/`jmp` targets = 39 semantic + 223
`SubC<addr>`, 478 sites, 151 slots).
- root `_wip` prose line/label ranges (4,482-17,180 lines, 192-971 `Label_`,
11,126 total) and the Ice Climber code figure (74.6% / 6,112 instr, not
74.7% / 6,113).
- TODO §4 re-scoped to the promoted tier, with the `_wip` residue table added.
Verification: `make` across all 17 `_wip` projects -> BYTE-IDENTICAL (17/17);
`_tools/verify_pass1.py` -> 19/19 build, all canonical, "No truncated or
unfaithful Pass 1 found". No source bytes changed.
Balloon Fight (JU): apply locals.inc/symbols.inc names to prg.s (Pass 3)
Substitutes symbol names for 922 numeric operands: 125 hardware registers
(symbols.inc) and 797 of 813 zero-page/RAM operands (locals.inc). The 15
multi-alias addresses were resolved per routine; 16 operands stay numeric
because $13 is used there as a plain high-byte scratch beside Temp12 and
none of its three aliases fits.
- _tools/apply_symbols.py: the substitution tool (dry run by default;
--sites supplies per-routine choices for multi-alias addresses, --report
lists the residue). Only the operand token is rewritten, so the
`; CPU:$.... PRG:.... XX XX` audit comments survive byte for byte.
- prg.s: operand tokens only. make prints "Byte-identical ROM verified."
- OVERVIEW.md (project): instruction quotes now match prg.s, new
alias-reuse table, and section 10 rewritten from the stale "497 Label_"
prose to the real naming state and the 16-site residue.
- OVERVIEW.md (root), TODO.md, AGENTS.md: status, counts, tool list.
verify_pass1.py: 19/19 OK, all audit comments canonical.
Bomberman (U): apply locals.inc/symbols.inc names to prg.s (Pass 3)
Substitutes 362 zero-page operands in copilot/prg.s with the names already
declared in copilot/locals.inc: 354 from single-name addresses and 8 from the
two multi-alias addresses, resolved per site:
$E0/$E1 IndirectPtrLo/Hi in the RAM-clear loop
(InitializeSystemAndRunMainLoop -> Main_WriteSoundDispatchIndex_C317,
which writes $00 across $0100-$07FF via (ptr),y),
SoundDelay0 (the base of the per-channel delay table, indexed by X)
in the sound engine;
No symbols.inc substitution was needed: copilot/prg.s contains no numeric
hardware operands ($2000-$4017) at all. All 362 operands were already written
with two hex digits, so no ca65 `a:` prefix was required.
make -> Byte-identical ROM verified. Operand token only; mnemonics, spacing and
the trailing `; CPU:$.... PRG:.... XX XX` audit comments are untouched.
Remaining numeric operands (758) address bytes that no .inc file names:
505 zero-page (133 addresses), 171 RAM $0100-$07FF (46 addresses) and 82
table reads in $8000+ (54 addresses).
Bomberman (U): substitute 24 in-file table labels at ROM operand sites
Uses the new `--labels` option of `apply_symbols.py` with the
ld65 `-Ln` output (`reconstructed.lbl`). Only labels that prg.s
actually defines and that live in the PRG (+) are considered,
so CHR/header segment labels cannot leak in.
24 numeric operands (`lda ,x`, `adc ,y`, etc.) now read
`lda SystemPaletteData,x`, `adc GameplayObjectClassTable,y`, etc.
All 24 point at exact label addresses; the remaining 58 ROM-space
numeric operands point at interior bytes or unlabelled tables and
stay numeric.
make -> Byte-identical ROM verified.
Bomberman (U): update docs for Pass 3 symbol application
- Project OVERVIEW.md: corrected source file from main.s to prg.s/header.s/chr.s;
output name from reconstructed_bomberman.nes to reconstructed.nes;
rewritten §10 Zero-Page/RAM Symbols table to match locals.inc (with aliases
and unnamed residue documented);
fixed §4 step 1/4 to use IndirectPtrLo/Hi and SoundDelay0 names;
fixed §11 InesHeader location (now header.s);
added §2 Pass 3 summary table;
added §13 notes on legacy main.s and Main_WriteSoundDispatchIndex_C317 misnomer.
- Root OVERVIEW.md §3: updated source line count (7,553), file list, and added
Pass 3 summary.
Both builds verified byte-identical.
Pass 3 symbol application to all 19 top-level projects (2026-10-19)
Ran _tools/apply_symbols.py --write on all 19 promoted projects to substitute
symbolic names from symbols.inc (hardware registers) and locals.inc (zero-page
RAM aliases, forward references) into prg.s operands. The tool preserves the
canonical audit comments (; CPU:$XXXX PRG:XXXX AA BB) and preserves byte
identity — all projects still rebuild byte-identical to their original ROMs.
Per-project operand substitutions:
Baseball (UE) 121 (85 single + 36 hardware)
Tennis (JU) 18 (18 single)
Pac-Man (USA) (Namco) 524 (524 single; 727 multi-alias residues)
CIRCUS 118 (38 single + 80 hardware; 23 multi-alias residues)
Wild Gunman (U) 76 (11 single + 65 hardware)
Joypad Test Cartridge (U) 47 (47 hardware)
NES PowerPad Test Card (U) 89 (89 hardware)
MUSCLE (U) 220 (91 single + 129 hardware)
Lunar Pool (U) 45 (18 single + 27 hardware)
Donkey Kong (JU) 75 (17 single + 58 hardware)
Urban Champion (JU) 70 (4 single + 66 hardware)
Already had symbols applied: Balloon Fight, Bomberman, Burger Time,
Clu Clu Land, Defender 2, Donkey Kong 3, Donkey Kong Jr.
Skipped: Excitebike (JU) — inline prg.s definitions conflict with locals.inc
values and source operands don't match ROM byte encodings; automatic
substitution breaks byte identity.
Documentation updated:
OVERVIEW.md — added Pass 3 symbol notes to all 19 project entries,
added Urban Champion as section #36
TODO.md — current state now reflects complete symbol application across
the promoted tier
Verification: python3 _tools/verify_pass1.py reports all 19 projects OK/SEEDED,
all canonical comments, no truncated or unfaithful Pass 1.
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.nes (whole ROM: 16-byte header + 16,384-byte PRG + 8,192-byte CHR) |
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.nes matches rom.nes exactly (header + PRG + CHR).
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.nes, compared whole-ROM againstrom.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.