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.
28 KiB
Pac-Man (USA) (Namco) — Game Flow from Power-On
This document describes the execution flow of the disassembled Pac-Man (USA) (Namco) 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 |
| Source file | copilot/prg.s (14,332 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 | $C10E | NmiHandler |
| RESET | $C033 | Reset |
| IRQ / BRK | $C17E | IrqHandler (just rti — unused) |
Additional ROM data at $FFF6–$FFF9:
- $FFF6/$FFF7 = $ECEC (little-endian), used by
DrawMazeNametableas a source pointer ($00/$01) - $FFF8/$FFF9 = $C10E (little-endian)
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 → $C033
Reset ($C033)
On power-on or reset:
ldx #$00 / stx PPUCTRL— disable PPU rendering/NMI.sei / cld— disable maskable interrupts, clear decimal mode.dex / txs— initialize stack pointer to $FF.nop x3— three NOP padding bytes.- Wait for two VBlanks —
@WaitForPpuloop: set PPUMASK=$06, poll PPUSTATUS bit 7 twice (X counts down from 2). This is the PPU warm-up delay. - Clear low RAM —
@ClearLowRam: zero $0000–$003D. - Clear RAM pages —
@ClearRamPages: using indirect pointer at $00/$01, clear pages starting at $0100 upward until high byte reaches $08 (effectively clearing $0100–$07FF). PPUMASK=$06, scroll=$00 — enable background + sprites, reset scroll.- Warm-start signature check —
@CheckRamSignature: compare RAM at $0052 against the 15-byte string"HIROKI AOYAGI"plus$AA,$55(stored atRamSignature, $C354–$C366):- If all 15 bytes match → warm start: skip full RAM clear, go to
@SignatureAlreadyPresent. - If any byte differs → cold start:
@InitializeRamSignatureclears all of zero page, then copies the signature string to $0052–$0060.
- If all 15 bytes match → warm start: skip full RAM clear, go to
- Set $64=$01 (some flag), set gameState=$00.
- Set $45=$07, then check cocktail/active-player flags at $46/$47. If both nonzero, perform a 16-byte swap loop (
@SwapLoop) swapping bytes between $67,y and $77,y for Y=0..15. APUCTRL=$1F,APUFRAMECNT=$C0— enable all APU channels, set frame counter mode.jsr InitializeAudioEngine($E1F8 region) — clear audio request/channel state.jsr $EEB4— audio/sound engine init.- Set PPUCTRL=$88,
ppuCtrlShadow=$88 — enable NMI, $2000 nametable base. - Set $48=$FF (gameplay suspended flag), clear $023F and $024B (PPU update buffers).
- Set $36=$01 (some state flag).
jmp MainEntry— transfer control to the main program.
RamSignature ($C354): .byte "HIROKI AOYAGI", $AA, $55 — developer credit / warm-start validation signature.
4. NMI / Frame Heartbeat — NmiHandler ($C10E)
The NMI fires once per frame and is the central real-time driver:
- Save registers (A, X, Y on stack).
- PPUMASK=$1E — enable background + sprites with specific emphasis settings.
- OAM DMA: write $00 to OAMADDR ($2003), $00 to $40, then
lda $45 / sta OAMDMA($4014) — DMA from page $45 (i.e., $4500–$45FF) to sprite RAM. jsr FlushGameplayPpuUpdates($DE3B region) — flush pending PPU gameplay update blocks at $023F and $0245.jsr ReadNearbyNametableTiles— read nametable tile data around Pac-Man (likely for collision/tunnel logic).jsr UpdatePalettes— upload palette data from RAM buffers $0299 and $02A9 to PPU palette $3F00/$3F10 when those buffers are marked active ($0F marker).- Restore scroll: write $41 and $42 to PPUSCROLL (two writes — horizontal then vertical).
- Construct PPUCTRL:
lda $47 / and $46 / and #$01 / asl / ora $43— builds the PPUCTRL value from shadow/flag bits, writes to PPUCTRL. - Controller strobe: write $01 then $00 to JOY1 ($4016).
- Read both controllers — 8 iterations each:
- Controller 1: read JOY1, mask with #$03, compare with #$01, rotate into $4D (
currentInput). - Controller 2: read
APUFRAMECNT($4017), mask with #$03, compare with #$01, rotate into $4E.
- Controller 1: read JOY1, mask with #$03, compare with #$01, rotate into $4D (
- Select active controller: X =
currentInput($4D) by default; ifcocktailModeFlag($46) &activePlayerFlag($47) both nonzero, X =newlyPressedInput($4E). Store X topreviousInput($4F). inc frameCounter($4B) — increment per-frame counter.- $48 gate: if $48 (
gameplaySuspended) != 0, skip to@NmiDone. jsr InitializeAudioChannels— scan and activate audio channel slots.@NmiDone: restore registers (Y, X, A),rti.
The label IrqHandler appears at the rti following NMI's register restore — it is the same code address, just an alternate entry label for the IRQ vector (which points to the same rti).
UpdatePalettes
Conditionally uploads 16-byte palette blocks from RAM $0299 (to PPU $3F00) and $02A9 (to PPU $3F10) when the corresponding buffer marker byte equals $0F. After upload, resets the marker and PPU address to $3000.
5. Main Entry — MainEntry ($C573)
After Reset, control transfers to MainEntry. This is the foreground state-machine entry:
$40=$01— set the NMI sync flag (NMI will clear it each frame).@WaitForInitialNmi— spin until NMI clears $40 to $00 (synchronize to first VBlank).- PPUCTRL=$88 / ppuCtrlShadow=$88 — enable NMI.
- PPUMASK=$00 — disable rendering.
- Clear OAM page — fill $0700–$07FF with 0.
cocktailModeFlag=$00, then call:jsr ClearGameplayNametablesjsr InitializeNametableAttributesjsr InitializeNametableDatajsr $E419— additional init (address-named).
- Initialize palette buffers — copy 32 bytes from
TitleScreenPaletteto RAM $0299–$02B8. - Set
actorStatusDisplay($6C–$6F) = $2D, set $023F=$FF,gameplaySuspended=$FF. - Clear $47, $46, $42, $41,
stateTimer($4C), $87, $88. - PPUCTRL selection: if
gameState($3F) != 0, use $8A; else use $8A as well (both paths end up with $8A; thebneis effectively a no-op in current code). Write to PPUCTRL andppuCtrlShadow. MainDispatchLoop($C645):$40=$01, spin until NMI clears it (@WaitForNmi).Y = gameState($3F), load 16-bit handler address fromMainStateHandlerTable($C267) into $10/$11.jmp ($0010)— indirect jump to the state handler.
MainStateHandlerTable ($C267–$C26E) — 4 entries, indexed by gameState as a byte offset (even values $00, $02, $04, $06):
| Offset | Handler | Description |
|---|---|---|
| $00 | InitialStateHandler |
Title/input-wait state |
| $02 | PlayerSelectionState |
1P/2P selection |
| $04 | State04Handler |
Substate dispatch machine |
| $06 | TimedStateTransition |
Timed state transition |
6. Main State Handlers
TimedStateTransition ($C27A) — state offset $06
- Decrement
$0Fcountdown. If nonzero, return toMainDispatchLoop. - When zero: set
gameState=$02, return toMainDispatchLoop. - This is a timed transitional state: another state places a duration in
$0Fand selects state $06; this handler counts down before advancing to state $02.
InitialStateHandler ($C27A region, actually at $C27A? No — at $C27A is TimedStateTransition; InitialStateHandler is at $C27A? Let me re-check)
Actually from the listing:
TimedStateTransitionis at $C27AInitialStateHandleris at $C27A? No. Let me re-read:
Line 730: TimedStateTransition: is at $C27A (based on comment "$C27A-$C2A5: Timed State Transition Handler" — wait, the comment says $C27A but the code shows dec $0F at line 731. Then line 740: ; $C27A-$C2A5: Initial/Input State Handler and line 766: InitialStateHandler:.
So there's a discrepancy in comments. The actual code:
TimedStateTransition=dec $0F / bne MainDispatchLoop / lda #$02 / sta gameState / jmp MainDispatchLoop— this is state $06's handler.InitialStateHandler= checks controller bits $0C; if set → gameState=$06, $0F=$5A, jump MainEntry. Else increment $42; if $42 reaches $F0 → reset $42, ppuCtrlShadow=$88, gameState=$06, $0F=$5A. Otherwise return to MainDispatchLoop.
PlayerSelectionState ($C546 region) — state offset $02
- If $87|$88 != 0 →
@AdvanceTimeout(16-bit timeout counter). - Else (first entry): copy
PlayerCountSelectionDisplayData($C507–$C50E) to RAM $024B until $FF terminator. Capture controller bits $04 and $08 intopacmanRequestedDirection($50) andpacmanDirection($51). @AdvanceTimeout: increment 16-bit counter at $87/$88. If high byte reaches $02 → reset counter, set gameState=$04, jump MainDispatchLoop.@CheckSelectionInput: if controller bit $04 changed → store aspacmanRequestedDirection, reset counter, set $87=$01, toggleactivePlayerFlag($47) between 0/1, update display marker tiles at $024D/$0251 fromPlayerCountSelectionMarkerTiles($C50F–$C511).@CheckConfirmInput: if controller bit $08 changed → store aspacmanDirection. If bit $08 now active → cleargameplaySuspended,jsr $EEB4, jumpGameModeEntry.
PlayerCountSelectionDisplayData ($C507): $22,$0A,$5C,$00, $22,$4A,$20,$FF — copied to $024B on entry.
PlayerCountSelectionMarkerTiles ($C50F): $5C,$20,$5C — selection marker toggle data.
State04Handler ($C52E region) — state offset $04
- If controller bits $0C set → gameState=$02, jump MainEntry (restart).
- Else:
Y = $87(substate offset), load 16-bit handler fromState04SubstateTable($C52E), jump indirect via $10/$11.
State04SubstateTable ($C52E) — 12 entries indexed by even offsets $00–$16:
| Offset | Handler |
|---|---|
| $00 | State04Initialize |
| $02–$12 | State04TimedSubstate (shared, 6 entries) |
| $14 | State04LongDelay |
| $16 | State04FinalPhase |
State04Initialize($C573): sync to NMI via $40, PPUCTRL=$08, disable rendering, clear $88, call$C5D8,InitializeState04PpuData($C608),LoadState04DisplayData($C5A6), advance $87 by 2, PPUCTRL=$88, return to loop.State04TimedSubstate($C589): increment $88; at $30 frames → clear $88, call$C5A6, advance $87 by 2.State04LongDelay($C5A6): increment $88; at $80 frames → advance $87 by 2 to substate $16.State04FinalPhase($C5D7): sets $47=$01, tail-jumps toGameModeEntry.SetSelectionFlagAndContinue($C5D7):activePlayerFlag=$01,jmp GameModeEntry.
LoadState04DisplayData ($C5A6)
Uses $87 as offset into CharacterIntroDisplayDataTable ($C687), copies $FF-terminated stream to RAM $024B. Optional secondary copy of 16 bytes from CharacterIntroSpriteData based on substate offset.
CharacterIntroDisplayDataTable ($C687) — 10 pointers to display streams for characters/nicknames (SHADOW, BLINKY, SPEEDY, PINKY, etc.) plus scoring legend.
7. Game Mode Entry — GameModeEntry ($C328)
Reached from SetSelectionFlagAndContinue (state $04 final phase). This transitions from the attract/selection sequence into gameplay:
- PPUCTRL=$88, ppuCtrlShadow=$88.
- Wait for VBlank.
- PPUMASK=$00, gameState=$00, cocktailModeFlag=$00, $69=$00, $4A=$00.
jsr ClearGameplayNametables,jsr DrawGameplayHudLabels,jsr DrawGameplayScoreFields.- If
gameplaySuspended!= 0 → roundNumber=$01 (continue existing game). - Else → clear 32 bytes starting at roundNumber ($67–$86), set roundNumber=$03, $77=$03, clear $0600/$0601.
- Common init: roundDisplayValue=$FF, $78=$FF, $0245=$FF, ppuCtrlShadow=$88, PPUCTRL=$88.
GameModeFrameSync($C360):$40=$01, wait for NMI clear ($@WaitForNmi`), then:- If
gameplaySuspended!= 0 AND controller bits $0C set → gameState=$02, jump MainEntry (restart). - If
stateTimer($4C) != 0 → decrement, return to@WaitForNextFrame. - Else
@DispatchState:Y = gameState, load handler fromGameModeStateTable($CAD7) into $10/$11, indirect jump.
- If
GameModeStateTable ($CAD7) — 8 entries indexed by even offsets $00–$0E:
| Offset | Handler |
|---|---|
| $00 | GameModeState00 |
| $02 | GameModeState02 |
| $04 | GameModeState04 |
| $06 | GameModeState06 |
| $08 | GameModeState08 |
| $0A | GameModeState0A |
| $0C | GameModeState0C |
| $0E | InitializeGameplayTransitionState |
GameModeState04 ($CAE9) — pause/per-frame handler
- If $060F != 0 → return to
GameModeFrameSync. - Else: edge-detect controller bit $08 using $49 as previous state. On new press → increment $4A, install pause display commands at $024B, toggle pause display via bit 0 of $4A.
- When not paused and bit 0 of $4A clear → call gameplay helper chain before returning to
GameModeFrameSync. - Adjacent data at $CB5B contains tile values for "PAUSE" text — confirms this is the pause-state handler.
8. PPU / Rendering
DrawMazeNametable ($E2E4)
Draws the maze nametable using compressed data:
- PPU destination selected based on cocktail mode ($47 & $46): $2000 or $2080.
- Source pointer from
MazeDataPointerBytesat $FFF6–$FFF7 ($ECEC → points to ROM data). - Each row: set PPU address, decode 2-bit-run-length-encoded bytes from the source (2 bits = repeat count-1, 6 bits = tile value), write $16 (22) bytes per row.
- Advance PPU destination by $20 per row.
FlushGameplayPpuUpdates ($DE3B)
- If $48 (
gameplaySuspended) == 0 → process update block at $023F (6 bytes + terminator to PPU). - Else → jump to
UpdateAnimatedGameplayPpuFields. - Block at $0245 processed similarly if nonzero.
9. Audio System
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 and work values, scan 16 eight-byte slots through ($F2).
- Slots with values $01–$04 activate a channel: copy 4-byte parameters through indirect pointer $F6.
- Falls through to runtime command processing at $EF2D.
UpdateAudioChannels ($EF2D):
- For each active slot: decrement countdown, fetch command byte from sequence table, decode command ($F0+ = special, $C0+ = delay, else = note/frequency), update APU registers ($4000–$400F).
UpdateAudio ($F8DE):
- APUFRAMECNT=$C0, update Pulse 1, Pulse 2, Noise, Sound Effect channels.
- Clear $FF–$FB; if $07FF != 0 → APUDMALOAD, decrement $07FF.
Audio data tables at $FF00–$FFF5 (period/mask tables), $FF64–$FFEA (sequences/instruments), $F450–$F49A (runtime command data).
10. Zero-Page / RAM Symbols
| Address | Symbol | Use |
|---|---|---|
| $00–$01 | workPtr0/1 | General indirect pointers |
| $02–$09 | workValue0–5 | General scratch values |
| $0A | pathCompareValue | Maze pathfinding |
| $0B | pathFallbackValue | Maze pathfinding |
| $0C | legalDirections | Maze pathfinding |
| $10 | dispatchVector | State dispatch indirect pointer |
| $12–$15 | arithmeticValue/Operand | 16-bit arithmetic |
| $1A–$2D | pacmanMotion–ghost4Motion | Actor motion records (4 bytes each) |
| $2E–$2F | pacmanTargetRow/Column | Pac-Man maze target |
| $32 | pacmanSpriteFrame | Pac-Man sprite frame |
| $33 | ghostSpriteFrames | Ghost sprite frames |
| $39 | ghostDirectionFlags | Ghost direction/control state |
| $3F | gameState | Main gameplay state |
| $40 | — | NMI sync flag ($01=pending, NMI clears to $00) |
| $41 | ppuCtrlShadow | PPUCTRL mirror |
| $42 | — | Second PPUSCROLL write |
| $46 | cocktailModeFlag | Cocktail cabinet mode |
| $47 | activePlayerFlag | Active player (0/1) |
| $48 | gameplaySuspended | Gameplay suspended flag |
| $4B | frameCounter | Per-frame counter |
| $4C | stateTimer | State countdown timer |
| $4D | currentInput | Controller 1 input |
| $4E | newlyPressedInput | Newly pressed input |
| $4F | previousInput | Previous input state |
| $50 | pacmanRequestedDirection | Requested direction |
| $51 | pacmanDirection | Current direction |
| $61 | highScoreDigits | High score digits |
| $67 | roundNumber | Round number |
| $68 | roundDisplayValue | Round display value |
| $6A | pelletsRemaining | Pellets remaining |
| $6B | extraLifeAwarded | Extra life flag |
| $6C–$6F | actorStatusDisplay | Actor status display |
| $70 | playerScoreDigits | Player score digits |
| $F0–$F7 | audioRequestPtr…audioWorkValue | Audio engine pointers/values |
| $F8 | audioChannelFlags | Audio channel flags |
| $FC | audioRequestIndex | Audio request index |
| $FD | audioChannelOffset | Audio channel offset |
| $FE–$FF | audioIndirectPtr | Audio indirect pointer |
11. Data Tables
| Label | Address | Description |
|---|---|---|
CopyrightText |
$C004 | "COPYRIGHT 1980 1993 NAMCO LTD. ALL RIGHTS RESERVED" |
RamSignature |
$C354 | "HIROKI AOYAGI" $AA $55 — warm-start signature |
MainStateHandlerTable |
$C267 | 4 main state handler pointers |
State04SubstateTable |
$C52E | 12 state-$04 substate handler pointers |
GameModeStateTable |
$CAD7 | 8 game-mode state handler pointers |
CharacterIntroDisplayDataTable |
$C687 | 10 display data pointers for character intro |
TitleScreenTileBlock |
$C326 | 6 rows × $17 bytes of title tile data |
TitleScreenPpuRecords |
$C3B0 | 9 PPU update records for title screen |
TitleScreenPalette |
$C43F | 32-byte initial palette |
TitleScreenAttributeData |
$C45F | $18 attribute bytes |
PlayerCountSelectionDisplayData |
$C507 | 8 bytes for player-count display |
PlayerCountSelectionMarkerTiles |
$C50F | $5C,$20,$5C marker toggle |
CharacterIntroPalette |
$C667 | 32-byte character intro palette |
CharacterIntroDisplayDataTable |
$C687 | Character intro display pointers |
CharacterNicknameHeaderData |
$C69B | "CHARACTER ; NICKNAME" display data |
MazeDataPointerBytes |
$FFF6 | $EC,$EC,$0E,$C1 — maze data pointer ($C10E) |
| Audio tables | $FF00–$FFEA | Period/mask tables, sequences, instruments |
12. Summary of Execution Flow
Power On / Reset
│
▼
Reset ($C033) ───────────────────────────────────────────────┐
│ sei, cld, disable PPU, wait 2× VBlank │
│ clear RAM $0000–$003D, clear pages $0100–$07FF │
│ PPUMASK=$06, scroll=$00 │
│ check warm-start signature "HIROKI AOYAGI" + $AA,$55 │
│ valid → skip full clear; invalid → clear + save sig │
│ set flags, optional 16-byte swap ($67↔$77) │
│ APUCTRL=$1F, APUFRAMECNT=$C0 │
│ jsr InitializeAudioEngine, jsr $EEB4 │
│ PPUCTRL=$88 (NMI enabled), $48=$FF (suspended) │
│ jmp MainEntry │
│ │
MainEntry ($C573) │
│ $40=$1, wait for NMI to clear it │
│ PPUCTRL=$88, PPUMASK=$00, clear OAM │
│ clear cocktail/player flags, $41/$42/$4C/$87/$88 │
│ init nametables, attributes, palette buffers │
│ select PPUCTRL ($8A), set ppuCtrlShadow │
│ │
MainDispatchLoop ($C645) ─────────────────────────────────────┤
│ $40=$1, wait for NMI │
│ Y = gameState ($3F) │
│ load handler from MainStateHandlerTable ($C267) │
│ jmp ($0010) → state handler │
│ │
├─ State $00: InitialStateHandler │
│ ├─ controller bits $0C → gameState=$06, $0F=$5A │
│ │ → jump MainEntry (restart sync) │
│ └─ else inc $42; if $42==$F0 → reset, gameState=$06 │
│ │
├─ State $02: PlayerSelectionState │
│ ├─ 1P/2P selection via controller bits $04/$08 │
│ ├─ timeout ($87/$88 reaches $0200) → gameState=$04 │
│ └─ confirm (bit $08) → clear suspended, jsr $EEB4, │
│ jmp GameModeEntry │
│ │
├─ State $04: State04Handler │
│ ├─ controller bits $0C → gameState=$02, restart │
│ └─ Y=$87 → dispatch via State04SubstateTable ($C52E) │
│ ├─ $00: State04Initialize (PPU setup, $87+=2) │
│ ├─ $02–$12: State04TimedSubstate ($30 frames) │
│ ├─ $14: State04LongDelay ($80 frames) │
│ └─ $16: State04FinalPhase → $47=$1, │
│ jmp GameModeEntry │
│ │
└─ State $06: TimedStateTransition │
│ dec $0F; if nonzero → MainDispatchLoop │
│ else gameState=$02 → MainDispatchLoop │
│ │
└─ loop back to MainDispatchLoop │
│ │
GameModeEntry ($C328) [from SetSelectionFlagAndContinue] │
│ │
├─ PPUCTRL=$88, wait VBlank, PPUMASK=$00 │
├─ clear gameplay state, HUD labels, score fields │
├─ if not suspended: clear player state, roundNumber=$3 │
│ else: roundNumber=$1 │
├─ common init: roundDisplayValue=$FF, PPUCTRL=$88 │
│ │
GameModeFrameSync ($C360) ────────────────────────────────────┤
│ $40=$1, wait for NMI │
│ │
├─ if suspended && controller bits $0C → gameState=$02, │
│ jmp MainEntry (restart) │
│ │
├─ if stateTimer != 0 → dec, return to @WaitForNextFrame │
│ │
└─ Y = gameState → dispatch via GameModeStateTable ($CAD7)│
│ │
├─ $00: GameModeState00 │
├─ $02: GameModeState02 │
├─ $04: GameModeState04 (pause/input handler) │
│ ├─ $060F != 0 → return to frame sync │
│ ├─ edge-detect controller bit $08 ($49) │
│ │ → new press: inc $4A, install pause cmds │
│ │ → bit 0 of $4A toggles pause display │
│ └─ not paused, bit0 of $4A clear → gameplay │
│ helper chain → return to frame sync │
├─ $06–$0C: GameModeState06/08/0A/0C │
└─ $0E: InitializeGameplayTransitionState │
│ │
└─ loop back to GameModeFrameSync │
│ │
└─ (eventually enters gameplay via $0E handler) │
│
NMI ($C10E) [once per frame] │
│ │
├─ save A, X, Y │
├─ PPUMASK=$1E │
├─ OAM DMA ($4500–$45FF → $0200–$02FF) │
├─ jsr FlushGameplayPpuUpdates ($DE3B) │
├─ jsr ReadNearbyNametableTiles │
├─ jsr UpdatePalettes ($3F00/$3F10 from $0299/$02A9) │
├─ restore scroll ($41,$42 → PPUSCROLL) │
├─ construct PPUCTRL from $47,$46,$43 │
├─ strobe JOY1, read both controllers (8 bits each) │
│ → $4D (current), $4E (newly pressed) │
├─ select active controller → $4F (previousInput) │
├─ inc frameCounter ($4B) │
├─ if $48==0: jsr InitializeAudioChannels │
├─ restore registers, rti │
│ │
IrqHandler ($C17E): rti (unused) │
│
Audio Engine (from NMI via InitializeAudioChannels/$EEB4) │
├─ 16 eight-byte channel slots scanned │
│ │ values $01–$04 activate a channel │
│ │ 4-byte parameters copied via $F6 pointer │
├─ UpdateAudioChannels ($EF2D): │
│ │ decrement countdowns, fetch command bytes, │
│ │ decode ($F0+=special, $C0+=delay, else=note), │
│ │ write APU registers $4000–$400F │
└─ UpdateAudio ($F8DE): 4 channels + SFX + DMA trigger │
13. Notes
- Build verification:
makeincopilot/producesreconstructed.nes, compared whole-ROM againstrom.nes. - Vectors confirm NMI at $C10E, RESET at $C033, IRQ at $C17E (a bare
rti, unused). - The foreground main loop (
MainDispatchLoopat $C645) is strictly NMI-synchronized via$40: NMI clears it each frame, and the main code spins waiting for it to clear before dispatching to the next state handler. GameModeEntry($C328) is a second, separate state machine entered from the state-$04 final phase. It has its own frame sync ($40) and its own 8-state dispatch table (GameModeStateTableat $CAD7), leading into gameplay initialization.- State $04 contains a nested secondary state machine (
State04SubstateTableat $C52E) with 12 substate offsets, implementing a timed character-introduction sequence (GHOST names, nicknames, scoring legend). - The warm-start signature
"HIROKI AOYAGI"at $0052 allows the game to preserve RAM across a soft reset. - Many routine labels are address-based (
State04TimedSubstate,LoadState04DisplayData, etc.) reflecting the automated first-pass disassembly; semantic naming is provisional. symbols.inc(embedded at the top ofprg.s) provides NES hardware register names, controller masks, and extensive zero-page symbol definitions.