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.
25 KiB
Baseball (UE) — Game Flow from Power-On
This document describes the execution flow of the disassembled Baseball (UE) 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 | Horizontal |
| PRG bank start | CPU $C000 (.org $C000) |
| Linker output | reconstructed.nes, byte-identical to rom.nes |
Interrupt vectors (CPU $FFFA–$FFFF)
| Vector | Address | Handler | Notes |
|---|---|---|---|
| NMI | $C046 | NMI |
Runs once per frame; the primary frame driver |
| RESET | $C40B | Reset |
Cold-start initialization |
| IRQ / BRK | $C43E | IRQ |
Just rti (unused) |
2. Reset — CPU $FFFC → $C40B
Reset ($C40B)
On power-on or reset:
sei/cld— disable maskable interrupts, clear decimal mode.- Clear CPU RAM $0000–$07FF — one 256-byte page at a time using the indirect pointer at $00/$01, repeatedly writing $2000 to PPUCTRL during the clear.
jsr InitializeSpriteSortTables($C7B1) — set up the sprite sort/index tables at $0377, $038C, $0399.jsr DisablePpuRendering($E96C) — turn off PPU output.- Disable APU — write $00 to APUSTATUS ($4015) to silence all channels, then select the five-step frame-counter mode and inhibit frame-counter IRQ via JOY2 ($4017).
jsr ExpandRecordPointerList($C8BB) with inline data — builds record pointer structures.lda #$1A / sta PpuMaskShadow— initial PPU mask shadow value.jmp InitializeTitleScreen($C74A) — RESET does not return; it transfers directly to the title screen.
3. NMI / Frame Heartbeat — NMI ($C046)
The NMI fires once per frame and is the central frame driver. It does not simply return; it performs substantial per-frame work:
- Save X and Y into
NmiSavedX($0D) /NmiSavedY($0E). - Read PPUSTATUS ($2002) to reset the PPU address latch.
- OAM DMA — write $02 to OAMDMA ($4014) to copy $0200–$02FF to sprite memory.
- Process the PPU command queue at $0300 — walk variable-length packets (write packets to $2007, read packets discarded), terminating on a zero-length entry, then reset the queue head (
stx $0300). - Apply scroll — write
ScrollX($DF) andScrollY($E0) to $2005. - Apply PPU registers — write
PpuMaskShadow($DE) to $2001 andPpuCtrlShadow($DD) to $2000. jsr InitializeAudioEngine($F9D0) — initialize/advance the audio engine.- Latch controllers — strobe JOY1 ($4016) with $01 then $00.
- Read both controllers — sample JOY1/JOY2 eight times each, shifting button states into
ControllerPressed1($15) /ControllerPressed2($16). - Update controller state — compute newly-pressed edges (
Pressed = (Pressed & ~Held) | raw) and updateControllerHeld1/2($11/$12). - START-button branch:
- If START pressed and
SessionActive($1E) is set →jsr ProcessQueuedObjectEventwith command $0B. - If START pressed and session is not active → set
TransitionActive($1D) andGameplaySuspended($EF), thenjmp InitializeNewSession($C49C).
- If START pressed and
NMI_CheckState— ifNmiStateFlag($1F) is nonzero, skip ahead and restore registers.- Otherwise
jsr QueueInlineObjectEvent($EB55) with inline command bytes$05,$21,$C1, thenjsr ProcessQueuedObjectEvent($EB2A) with command $0B. inc FrameCounter($E3).jsr CalculateControllerAssignments($C87E) — derive per-player control state.- Store derived input into
InputState1/2($13/$14) andInputAux1/2($17/$18). - Restore X, Y, A,
rti.
Because the NMI does controller reading and event dispatch, the main loop largely waits on the NMI-driven queued-event system rather than polling hardware directly.
4. Title Screen — InitializeTitleScreen ($C74A)
Shared entry point used by RESET and by post-match/transition paths:
jsr InitializeQueuedEventSystem($EB9D) — clear the $0600–$06FF event page, the queue count ($E2), active-event slots, and setCurrentObjectIndex($E1) = 1.GameplaySuspended($EF) = 0;TransitionActive($1D) = 1.PpuCtrlShadow= $80; disable PPU rendering; fill PPU memory (FillPpuMemoryAfterValueSavedat $E985 with value $25).jsr ProcessInlinePpuStream($EA63) twice — streams title PPU data (inline records$8D,$F8and$00,$C0).jsr EnableNmiAndWaitForFrame($E978) — enable NMI and synchronize to a frame.- Enable APU channels ($4015 = $0F), set timing byte $FD = $08.
RefreshTitleScreen($C77A) loop:- Update
ModeFlags($40). jsr QueueModeIndicatorTiles($E5CF).- Wait for controller input release (SELECT/$20) and for the frame counter / delay byte $1B to elapse.
- Update
- On exit:
TransitionActive= 0,GameplaySuspended= 1, clear audio ($4015), re-init the event system, queue an inline command$03,$88,$C4, thenjmp InitializeSessionDisplay($C4AA).
Attract mode — RunAttractMessageLoop ($C43F)
Before reaching the title input loop, Reset can flow into RunAttractMessageLoop, which:
- Clears
NmiStateFlag, selects object event bank 1. - Draws encoded six-character messages via
CopyInlineDisplayRecord($EA17) with inline text bytes. - Exchanges the object event bank ($EB4E), increments
NmiStateFlag, and loops back to itself — this is the idle attract display until the player presses START.
5. Session and Match Initialization
InitializeSessionDisplay ($C4AA)
PPU shadow setup, disable rendering, fill PPU memory with $26, then process an inline PPU stream ($F2,$F4).
InitializeMatchState ($C4B9)
- Clears base position and acceleration fields ($C94F/$C943).
- Sets
$053BandPositionSignState($B7) to $80. jsr EnableNmiAndWaitForFrame.- Player/team selection:
Random0To5($C850) →PlayerOneSelection($34).- Repeatedly
Random0To5until a value different from player one →PlayerTwoSelection($35).
- If
GameplaySuspendedis set, skip side selection; otherwise deriveActivePlayerSide($4B) from mode flags or a random bit. - Queue inline object events
$03,$19,$C8and$02,$19,$C8, setPlayerSelectionActive($44) = 1. WaitForPlayerSelection($C4FF) spins until$43(SelectionState) becomes negative, then queues$03,$3E,$C4and falls intoCompleteMatchSetup($C50C).
CompleteMatchSetup ($C50C)
FillSelectionBackgroundBlocks($C85A),QueuePlayerSelectionDisplay($C7DD),InitializeRandomBitfields($C899),QueuePlayerValueDisplay($CF8B).- Queue inline command
$09,$37,$CE, then enterInitializeRoundState($C51E).
6. Round / Gameplay State Machine
InitializeRoundState ($C51E)
Round-setup entry point:
- If gameplay is not suspended and
RoundStateIndex($33) < $12, select round music cue; otherwise reset round counters. EnableRoundAudio($C52A): enable APU channels ($4015 = $0F), set $FD = $08.ResetRoundCounters($C536): clear scores ($67/$68) andRoundStateIndex.jsr InitializeObjectsFromTable($D06B) — initialize objects from the table at $D0A0–$D131.- Set
$053B/PositionSignState= $80. - Store a $3C-tick delay, draw an 8-character message, store another $3C delay.
SelectRoundMusicCue($C566): choose a music delay value ($20 or $40) based onRoundStateIndexparity → store in $FD.PrepareRoundObjects($C573): queue player-selection display, queue random-state record, clearMatchCompleteFlag($45) andSecondarySequenceState($64).
ResetGameplayState ($C57F)
- Clear
InningOrSharedScore($66),BaseMotionActive($5C). - Mark four state entries ($0598–$059B) inactive ($FF).
jsr QueueRoundNumberDisplay($CF67), re-init objects, set sign state to $80.- Store a $B4-tick delay.
Round setup continuation points
ResumeRoundSetupAtRandomDirection($C5A4): queue random-direction record, clear primary/transition sequence state, setStateSequenceActive($46) = 1, clear state field pairs ($03,$23 via $CF51).ResumeRoundSetupAtSelectionDisplay($C5BA): clear the 8-character field, clear $0597–$05A6, clear the 38-byte motion field range $056F–$0594.- Based on
RoundStateIndexand score comparison ($67 vs $68), either jump toCompleteMatchState($C6F7) or finish the reset.
FinishGameplayReset ($C5F1)
InitializeDefaultObjects($D030), clear sign state, select event bank 1 → enterRunRoundStateLoop($C5FE).
RunRoundStateLoop ($C5FE) — main per-round loop
This is the core gameplay loop:
- If
PendingStateTransition($60) is set →InitializeObjectStateLookup($C8A3). - If either
PendingStateTransitionorStateSequenceActive($46) is nonzero → dispatch state-specific handlers via queued inline commands ($0D,$77,$D4,$0F,$81,$D3). RunPrimaryRoundHandlers($C619):InitializeSecondaryActors($D633),InitializeConditionalObject($D126).- Clear working state:
PendingScoreIncrement,BaseMotionPhase,BaseMotionEventCount,TrajectoryResult,PendingStateTransition,SpecialSequenceState,ObjectTransitionState,BaseMotionActive,ActionSequenceCounter,TrajectoryEvaluationEnabled, and several others. - Clear base acceleration and position fields.
- Queue three inline object events (
$25,$5A,$D8,$0E,$66,$D1,$1F,$76,$CA). jsr UpdateObjectEventBank_Exchangewith command $08.- Walk the 22-entry
RoundEventSlotTable($C6E1) in reverse, dispatching each viaDispatchNextQueuedEvent($EB66). - Write one inline character ($3F, value $28) to the PPU.
ApplyPendingScoreUpdate($C67E): if a score increment is pending and no state transition,jsr UpdateCountersAndDisplay($CFA6), clear the increment.StartPostRoundDelay($C68D): wait forPendingEvent($6F) to clear, then count down delay counter $48.- If match is not complete and no sequence is active →
jmp ResumeRoundSetupAtSelectionDisplay($C5BA) to start the next round. ProcessPostRoundEvents($C6A5): dispatch event slots $0D and $0F, store a $0A-tick delay, decrement a counter based onRoundStateIndexparity.CheckPostRoundContinuation($C6C5): if match complete →AdvanceRoundNumber($C6CC); otherwisejmp ResumeRoundSetupAtRandomDirection($C5A4).AdvanceRoundNumber($C6CC): update counters/display, incrementRoundStateIndex; if index reaches $12 →InitializeResultsPosition($C708); otherwiseSelectRoundMusicCue($C566) to begin the next round.
7. Per-Frame Object and Motion Updates
UpdateFrameObjectsAndTimers ($C122)
Called each frame to drive object motion:
- Reset PPU queue offset, OAM write offset, and
FrameState20($20). - Queue/process inline object events ($07,$64,$C2 via bank, then command $01).
- Decrement per-object timers at $063F,X (30 slots); when a timer reaches zero, queue a decoded object event.
- Decrement four short countdowns at $05AD,X.
- If a transition is active, jump to
CommonEngineExit($EB6A); otherwiseIntegrateAllObjectPositions($C15B).
IntegrateAllObjectPositions ($C15B)
- For 36 object slots (X stepping by 2, each logical object uses two indices):
- Sign-extend and add horizontal delta ($050B/$050C,X from $056F/$0570,X).
- Sign-extend and add vertical delta ($053C/$053D,X from $0570/$0571,X).
UpdateBaseMotionSequence ($C18D)
- Integrate velocity/position for three base object indices ($00, $31, $62).
- Reflect base motion values when $056C is negative (negate $056B/$056C and halve $0569/$056A).
- Apply doubled horizontal/vertical velocity adjustments to $0507/$0508 and $0538/$0539.
jsr EvaluateTrajectoryResult($CAD2).- Advance
BaseMotionPhase($5D); when motion settles, clear base acceleration/position fields. UpdatePositionSignState($C228): compute a signed position delta relative to a $0A threshold, track sign changes inPositionSignState($B7), and update rendering flags.
IntegrateObjectVelocityAndPosition ($C3CA)
Signed fixed-point position integration used by the three base motion call sites: shifts the signed 16-bit velocity right twice (sign-preserving), adds to position ($0507/$0508), then adds the result to the secondary position ($0509/$050A).
8. Rendering and OAM
BuildFrameOam ($C2DE)
- If
TransitionActiveis set: copy an 88-byte sprite/OAM template from $F94D into shadow OAM at $0200, setOamWriteOffset($3F), and jump toTerminatePpuUpdateQueue. - Otherwise, if
PlayerSelectionActive($44) is set: copy an 8-byte OAM template from $C331, position two sprites from player selection coordinates (scaled ×16 + $50), choose tiles based onModeFlags. - Otherwise:
SortObjectsByY($C339) — sort object indices by signed 16-bit Y coordinates ($0538/$0539) using the index table at $0377/$0378, then render each sorted object viaRenderSpriteRecordList($E77B).
FinalizeOamAndPpuQueue ($C386)
- If
ObjectListFlag($C7) is set, skip status OAM; otherwise copy 20 bytes from $EBC2 into shadow OAM and select two extra bytes from $EBBC based on player selections. TerminatePpuUpdateQueue($C3AA): write $00 to terminate the PPU command queue at $0300,Y.- Hide unused sprites by writing $F0 to each remaining OAM Y coordinate (stepping by 4).
jsr AdvanceRandomState($E5F4), thenjmp CommonEngineExit($EB6A).
9. Batting and Pitching
Pitching (RunPitcherWindupSequence, $C6044)
RunPitcherWindupSequenceanimates the pitcher windup (frames selected viaSetWindupFrame).SelectPitchAction($C6139): reads controller input to choose the pitch action.SelectPitchType($C6178): searchesPitchTypeThresholdTable/PitchDirectionThresholdTable($C5973/$C5980) to determine pitch type and direction.AnimatePitchStart($C6229),UpdatePitchMovement($C6321): apply pitch trajectory and controller-based horizontal/vertical input.- Pitch tables:
PitchVelocityBiasTable($C6399),PitchDelayTable($C6402),PitchRenderFlagTable($C6405).
Batting (SelectBatterAction, $C6414)
WaitForBatterAction($C6422): wait for batter input.UseBatterControllerInput($C6464): map controller to batter action slot.SelectActionFromObjectState($C6590) withObjectActionLookupTable($C6502).DispatchActionType($C6614): route to the appropriate action handler.
Object motion and collision
MoveControlledObject($D371): apply controlled horizontal/vertical offsets (ControlledHorizontalOffsetTable/ControlledVerticalOffsetTableat $D384/$D38C).TestObjectCollision($D38B): object-vs-object collision testing.TestCurrentObjectInput($D7D7): resolve controller input for the current object.- Geometry helpers:
CalculateCurrentObjectGeometry($D7AE),CalculateGeometryDifference($D566),BuildGeometryVector($D74D), extensive trigonometry/vector math routines ($D5xx–$D8xx).
10. Audio Engine
InitializeAudioEngine ($F9D0)
Initialize the APU: write DMC settings, configure pulse/triangle/noise channel presets, write pulse periods from tables ($FA00–), set up audio lookup data.
UpdateAudioEngine ($FB05)
Per-frame audio update:
- Clear/finalize primary audio effects.
- Update pulse 1 and pulse 2 effects (preset effects, descending effects, state-change effects, phase effects).
- Update triangle pitch sweeps.
- Dispatch music and noise (
DispatchMusicAndNoiseat $FB65): update music sequencer, noise envelope, timed triangle effects, DMC output.
Music
StartMusicSequence($FD34): initialize a music sequence by finding its bank and copying the descriptor.- Channels: pulse 1 ($FD96), pulse 2 ($FDCF), triangle ($FE0B).
- Music sequence data tables at $FE60–$FFD0 (
MusicSequenceData_FE60…MusicSequenceData_FFD0). - Sound effect presets and noise/triangle envelopes throughout the $F9xx–$FE2x region.
Audio is advanced from the NMI via jsr InitializeAudioEngine ($F9D0) each frame; the broader UpdateAudioEngine ($FB05) is called from the event system.
11. Object Event System (the main-loop engine)
The game is driven by a queued event system rather than a simple polling loop:
InitializeQueuedEventSystem($EB9D): clear $0600–$06FF, queue count $E2, active slots, set current object index.QueueInlineObjectEvent($EB55) /QueueDecodedObjectEvent($EB2A): push an event onto the queue.ProcessQueuedObjectEvent($EB2A): walk the queue and process active events.DispatchNextQueuedEvent($EB66): pop the newest event, save its continuation address on the hardware stack (SP = $FF), andrtsinto it — this is how control transfers between event handlers.CommonEngineExit($EB6A): mark the current object inactive; if the queue is empty, wait (beq DispatchNextQueuedEvent_Wait) polling $E2; otherwise disable NMI bit 7 and dispatch the next entry.
The main gameplay loop (RunRoundStateLoop) and most state handlers ultimately pass through CommonEngineExit, which blocks until the NMI or another producer queues the next event. This makes the NMI the real-time heartbeat and the main loop an event consumer.
12. PPU Streaming Helpers ($E9xx–$EBxx)
A family of routines drives PPU updates from inline byte streams embedded in the code:
DisablePpuRendering($E96C),EnableNmiAndWaitForFrame($E978).FillPpuMemoryWith26/FillPpuMemoryAfterValueSaved($E983/$E985): fill PPU memory with a byte value.DecodeInlineParameters($EA09),ReadInlineWord($EA28),CopyInlineBytesToZeroPage($EA45).WritePpuControlFromCarry($EA6E),SetPpuAddressFromInlineRecord($EA97),WritePendingPpuByte($EABF),WriteInlinePpuBlock($EAC9),PPUWriteString($EAFB).CopyInlineDisplayRecord($EA17): draw an encoded text record (count byte followed by character data) — used heavily for titles, scores, round numbers, and announcements.ProcessInlinePpuStream($EA63): process a stream of PPU commands.
Many routines are followed by .byte inline data that must not be disassembled as instructions; the disassembly marks these explicitly.
13. Match Completion and Results
CompleteMatchState ($C6F7)
- Increment
RoundStateIndex. - If
InningOrSharedScore($66) is nonzero →InitializeResultsPosition; otherwise draw a single encoded character ($32). InitializeResultsPosition($C708): set sign state to $80, draw a 10-character message ($25,$10,$0A,$16,$0E,$25,$1C,$0E,$1D,$25), set timing $FD = $4, dispatch event slot 2, queue inline$02,$88,$C4.ResultsDelayLoop($C737): repeat a $3C-tick delay 10 times.- Disable APU ($4015 = $00), clear
SessionActive($1E), then fall intoInitializeTitleScreen($C74A) to return to the title.
14. Summary of Execution Flow
Power On / Reset
│
▼
Reset ($C40B) ───► clear RAM $0000-$07FF, init sprite sort, disable PPU/APU
│
▼
jmp InitializeTitleScreen ($C74A)
│
├─ init event system, draw title screen
├─ EnableNmiAndWaitForFrame (NMI now runs each frame)
├─ WaitForTitleInputRelease (wait for START + input release)
▼
jmp InitializeSessionDisplay ($C4AA)
│
▼
jmp InitializeMatchState ($C4B9)
│
├─ Random0To5 → PlayerOneSelection ($34)
├─ Random0To5 (≠ P1) → PlayerTwoSelection ($35)
├─ derive ActivePlayerSide ($4B)
└─ WaitForPlayerSelection (spin until $43 < 0)
│
▼
CompleteMatchSetup ($C50C)
│
▼
InitializeRoundState ($C51E)
│
├─ enable audio, reset counters, init objects from table ($D06B)
├─ draw messages, select music cue
▼
ResetGameplayState ($C57F) → FinishGameplayReset ($C5F1)
│
▼
RunRoundStateLoop ($C5FE) ◄───────────────────┐
│ │
├─ InitializeObjectStateLookup │
├─ RunPrimaryRoundHandlers │
│ ├─ InitializeSecondaryActors │
│ ├─ InitializeConditionalObject │
│ ├─ walk RoundEventSlotTable │
│ └─ dispatch queued events │
├─ ApplyPendingScoreUpdate │
├─ StartPostRoundDelay (wait $48 ticks) │
├─ ProcessPostRoundEvents │
├─ CheckPostRoundContinuation │
│ ├─ match complete → results → title │
│ └─ AdvanceRoundNumber → next round │
└─ eventually → CommonEngineExit ($EB6A) │
wait for queued event ($E2) │
dispatch next event (rts into it)│
└────────────────────────────────┘
NMI ($C046) [every frame]
├─ PPU command queue flush ($0300)
├─ OAM DMA ($4014)
├─ scroll ($2005), PPU mask/ctrl ($2001/$2000)
├─ InitializeAudioEngine ($F9D0)
├─ controller latch + read (JOY1/JOY2)
├─ controller state (pressed/held edges)
├─ START branch → new session or command $0B
├─ QueueInlineObjectEvent + ProcessQueuedObjectEvent
├─ FrameCounter++, CalculateControllerAssignments
└─ restore registers, rti
15. Key Zero-Page / RAM Symbols
| Address | Symbol | Use |
|---|---|---|
| $00–$01 | IndirectPtrLo/Hi | RAM-clear indirect pointer |
| $0D–$0E | NmiSavedX/Y | NMI register save |
| $0F | DerivedControl0 | Derived control input |
| $11–$12 | ControllerHeld1/2 | Currently held buttons |
| $13–$14 | InputState1/2 | Current input state |
| $15–$16 | ControllerPressed1/2 | Newly pressed buttons |
| $17–$18 | InputAux1/2 | Auxiliary input |
| $19–$1A | RandomStateLo/Hi | PRNG state |
| $1B | TransitionCounter | Transition delay |
| $1D | TransitionActive | Transition in progress |
| $1E | SessionActive | Session/game active flag |
| $1F | NmiStateFlag | NMI state flag |
| $20 | FrameState20 | Frame state |
| $21 | SelectedPitchAction | Current pitch action |
| $33 | RoundStateIndex | Current round/inning index |
| $34–$35 | PlayerOne/TwoSelection | Selected teams |
| $36–$39 | ObjectScreenX/Y (+Hi) | Object screen coordinates |
| $3E–$3F | PpuQueueOffset / OamWriteOffset | PPU/OAM write positions |
| $40 | ModeFlags | Display/mode flags |
| $42 | CurrentObjectArrayOffset | Current object slot |
| $43 | SelectionState | Selection state |
| $44 | PlayerSelectionActive | Player selection active |
| $45 | MatchCompleteFlag | Match finished |
| $46 | StateSequenceActive | State sequence running |
| $48 | DelayCounter | General delay |
| $4B | ActivePlayerSide | Active player (0/1) |
| $4D–$50 | RandomState0/1, RandomBits0/1 | Randomization state |
| $53–$56 | SavedBaseX/Y (Lo/Hi) | Saved base coordinates |
| $5C | BaseMotionActive | Base motion active |
| $5D | BaseMotionPhase | Base motion phase |
| $5E | BaseMotionEventCount | Base motion event count |
| $5F | TrajectoryResult | Trajectory result |
| $60 | PendingStateTransition | Pending state change |
| $66 | InningOrSharedScore | Inning / shared score |
| $67–$68 | PlayerOne/TwoScore | Scores |
| $69–$6A | EventWrite/ReadIndex | Event queue indices |
| $6F | PendingEvent | Pending event |
| $7E | ActionSequenceCounter | Action sequence counter |
| $83 | ObjectApproachCount | Approach counter |
| $86 | TrajectoryEvaluationEnabled | Trajectory eval enabled |
| $8E–$90 | RandomHorizontal/VerticalMotion | Random motion |
| $9D | CurrentActionState | Current action |
| $9F–$A1 | SecondaryActorState0–2 | Secondary actor states |
| $A2 | ObjectSequenceState | Object sequence state |
| $B7 | PositionSignState | Position sign tracking |
| $C7 | ObjectListFlag | Object list flag |
| $C9–$CA | SpriteSortBaseIndex / SpriteArrayEnd | Sort table bounds |
| $DD–$E0 | PpuCtrl/MaskShadow, ScrollX/Y | PPU/shadow/scroll state |
| $E1 | CurrentObjectIndex | Current object being processed |
| $E2 | ScriptDepth / queue count | Queued event count |
| $E3 | FrameCounter | Frame tick counter |
| $EF | GameplaySuspended | Gameplay suspended |
| $F6–$F7 | SoundPtrLo/Hi | Sound pointer |
| $FE | PendingAudioFlags | Pending audio flags |
| $FF | RenderStateFlags | Render state flags |
16. Notes on the Disassembly
- The source is 21,624 lines and thoroughly annotated with symbolic names for nearly every routine, table, and data block — there are no generic
Label_Cxxxxplaceholders. - Numerous routines are followed by inline
.bytedata blocks consumed byQueueInlineObjectEvent,CopyInlineDisplayRecord, orProcessInlinePpuStream; these are intentionally left as raw bytes and marked in comments. - The PRG is a single 16 KB bank at $C000–$FFFF; calls to $Dxxx/$Exxx/$Fxxx targets are all within the same bank (no bank switching).
- Build verification:
makeincopilot/producesreconstructed.nes, compared whole-ROM againstrom.nes.