- Xevious (U): Promoted from _wip/NROM/Xevious (U)/ to NROM/Xevious (U)/
- Pass 3 complete: 34.5% code coverage, 143 semantic entry points,
4 named data tables, ~20 semantic ZP aliases
- NROM mapper 0, 32 KB PRG, 8 KB CHR, horizontal mirroring
- Zanac (U): Promoted from _wip/UNROM/Zanac (U)/ to NROM/Zanac (U)/
- Pass 3 complete: 34.5% code coverage (bank 7 only), 133 semantic
entry points, 26 named data tables, 14 semantic ZP aliases
- UNROM mapper 2, 128 KB PRG (8 banks), no CHR
- Bank 7 fixed at CPU -; banks 0-6 switchable at 000-
- Bank 7 assembled separately as prg.07.s with .org
Both projects build byte-identical. Updated root README.md inventory,
per-project details, and WIP.md accordingly.
Move Zanac (U) to UNROM/ mapper directory
Zanac (U) is a UNROM (mapper 2) game, not NROM (mapper 0). Move it to
the correct mapper directory and update README.md and WIP.md references
accordingly. Update project counts in WIP.md (857->856 total, UNROM
98->97 projects).
Promote Mermaids of Atlantis (U) from _wip/ to MAPPER8/
Additional cleanup of Mermaids of Atlantis
Pac-Man (Tengen) (U) — Game Flow from Power-On
This document describes the execution flow of the disassembled Pac-Man (Tengen) (U) PRG bank (16 KB, mapped at CPU $C000–$FFFF), starting from power-on. It is based entirely on prg.s in the repository. The rebuild is verified byte-identical to the original ROM — make compares the whole reconstructed.nes against rom.nes.
Variant note: Pac-Man (Tengen) (U) and Pac-Man (USA) (Namco) are the same game, but they are different builds. This dump was disassembled from scratch; no name, map or file was carried over from the sibling dump, and the two were only compared afterwards.
Build difference from the sibling dump. The two PRGs differ in 56 bytes in 8 runs:
| Region | Tengen | Pac-Man (USA) (Namco) |
|---|---|---|
| $C3E3–$C408 | 26 spaces — the licensee field is blanked | LICENSED BY NAMCO AMERICA INC |
| $FF00–$FF16 | $FF padding — there is no boot code |
sei / cld / PPU off / double vblank wait / jmp ColdStart |
| $FFFC–$FFFD | RESET vector = $C033 | RESET vector = $FF00 |
Everything else is byte-identical, so the two dumps disassemble to the same structure with the same addresses. The CHR is identical; the iNES header differs only in byte 6 (mirroring).
Consequence for the reset path. In the sibling dump the RESET vector points at $FF00, which holds real boot code. Here it points at $C033, and the boot sequence is inline at the start of the PRG. The code is the same, just relocated:
$C033 sei / cld / lda #$00 / sta $2000 / sta $2001
$C03D lda $2002 / bpl $C03D ; wait for the first vblank
$C042 ldx #$FF / txs / lda #$00 / tay
1. Reset Vector — CPU $FFFC → $C033
| Vector | Address | Handler |
|---|---|---|
| NMI | $C0FA | NmiHandler |
| RESET | $C033 | Reset |
| IRQ | $C16A | IrqHandler (a bare rti — unused placeholder) |
Reset ($C033–$C042) performs the cold start:
| Step | Code | Purpose |
|---|---|---|
| 1–2 | sei / cld |
disable interrupts, clear decimal mode |
| 3–4 | lda #$00 / sta PPUCTRL / sta PPUMASK |
silence the PPU |
| 5 | lda PPUSTATUS / bpl |
wait for the first vblank |
| 6 | ldx #$FF / txs |
stack pointer to $01FF |
| 7 | lda #$00 / tay |
A = 0, Y = 0 |
Then:
| Routine | Address | What it does |
|---|---|---|
ClearZeroPage |
$C048 | clears $0000–$003D (62 bytes) |
ClearHighRAMPages |
$C054 | clears 9 pages from $8700 to $8F00 |
CheckCopyrightSig |
$C077 | compares the 15-byte ProgrammerSig at $C0EB with RAM at $0052 |
CopyrightMismatch |
$C086 | on a mismatch, clears all 256 zero-page bytes |
CopyCopyrightToRAM |
$C091 | on a match, copies the signature to $0052 and sets CopyrightVerified |
InitGameState |
$C0A0 | clears GameState, sets OamDmaPage = 7 |
SwapEntityBuffers |
$C0B2 | swaps two 16-byte entity buffers at $0067 and $0077 |
InitAudioAndPPU |
$C0C7 | enables the APU, initialises the sound engine, primes the PPUCTRL shadow, then jmp RequestNMI_Dispatch |
The signature is the string "HIROKI AOYAGI" + $AA + "U" — the programmer's name, not a company name.
2. NMI / Frame Heartbeat — NmiHandler ($C0FA)
NmiHandler ($C0FA):
pha / txa / pha / tya / pha ; preserve A, X, Y
lda #$1E / sta PPUMASK
lda #$00 / sta OAMADDR / sta NmiFlag
lda OamDmaPage / sta OAMDMA ; OAM DMA from $0700
jsr UpdateSprites
jsr UpdateGameLogic
jsr UploadPalette1
lda ScrollX / sta PPUSCROLL
lda ScrollY / sta PPUSCROLL
lda ActivePlayerFlag / and CocktailModeFlag / and #$01 / asl a
ora PpuCtrlShadow / sta PPUCTRL
lda #$01 / sta JOY1 / lda #$00 / sta JOY1 ; strobe the controllers
ldx #$08
ReadControllers:
lda JOY1 / and #$01 / cmp #$01 / ror Player1Input
lda JOY2 / and #$01 / cmp #$01 / ror Player2Input
dex / bne ReadControllers
ldx Player1Input / lda CocktailModeFlag / and ActivePlayerFlag / beq NmiSelectActiveInput
ldx Player2Input
NmiSelectActiveInput:
stx ActiveInput
inc FrameCounter
lda GameplaySuspended / bne NmiEpilogue
jsr RunLevelScript
NmiEpilogue:
pla / tay / pla / tax / pla / rti
The NMI reads both controllers into 8-bit shift registers and then picks one: in two-player mode the second controller wins, otherwise the first. GameplaySuspended ($FF while the game is running) gates the level script, so the script only runs when the game is not suspended.
3. Game State Dispatch
RequestNMI_Dispatch ($C23C) sets NmiFlag and waits for the NMI to clear it, then reads the game state and dispatches through GameStateDispatchTable ($C253):
ldy GameState
lda GameStateDispatchTable,y
sta DispatchPtrLo
lda GameStateDispatchTable+1,y
sta DispatchPtrHi
jmp (DispatchPtrLo)
The same indirect-dispatch shape appears three more times — at $D3CA (DispatchPlayerBehaviour_Dispatch), $D90F (DispatchGhostToTable) and $D9B6 (DispatchGhostState) — each with its own table.
4. Level Graphics
| Routine | Address | What it does |
|---|---|---|
DrawMazeGraphics |
$C27D | writes MazeTileGraphics ($C2FD) to VRAM, 16 tiles per block, 6 screens per level |
WriteAttributeTable |
$C2BE | writes AttributeTableData ($C387) — the per-screen colour attributes |
WriteBorderPattern |
$C2E2 | writes BorderPattern ($C432) — 40 bytes to VRAM $23C8 |
5. Ghosts, Fruit and Level Data
| Routine | Address | What it does |
|---|---|---|
UpdateGhosts |
$C89B | the ghost AI: picks a target, shifts the position block, places the fruit, then jmp UpdateMazeTiles |
UpdateGhostTarget |
$C8AA | finds the pellet nearest the player and records it in $028D |
ShiftGhostPositions |
$C8ED | moves the 12-byte position block $0022–$002D to $001E–$0029 |
UpdateFruit |
$C977 | places the fruit from FruitTargetTable ($C9FF) |
GetGhostDataPointer |
$C589 | reads a pointer from CharacterDataTable ($C66A) |
CopyGhostData |
$C597 | copies that data to the $024B buffer until a $FF byte |
UpdateMazeTiles |
$DAB0 | rewrites the maze tiles through MazeTileOverlayTable0–3 and MazeTileOffsetTable |
ResetGhostSlots |
$D249 | clears the per-ghost slot array at $BA |
CharacterDataTable ($C66A) holds ten pointers into CharacterNameStrings ($C67E), which is where the ROM names its characters:
$ C67E " CHARACTER"
$ C68A "!M...." "!SBLINKY"
$ C692 "!SBLINKY"
$ C69B "!AD...."
$ C6A3 "!PINKY" "\"INKY"
$ C6AB "\"INKY" "\"m...."
$ C6B3 "\"m...." "\"sCLYDE"
$ C6BA "\"m...." "\"sCLYDE" "\" 10 PTS"
$ C6C2 "\"sCLYDE" "\" 10 PTS" "\" 50 PTS"
$ C6CA "\" 10 PTS" "\" 50 PTS" "#M..."
So the four moving entities the code drives are the four ghosts — Blinky, Pinky, Inky and Clyde — and the ROM itself supplies the names.
6. Sound and the Level Script
InitAudio ($EE4B) enables the APU channels and sets up the level-script workspace: a source pointer at $F0/$F1, a working buffer pointer at $F2/$F3, a parameter-table pointer at $F4/$F5 and a state array at $FE.
RunLevelScript ($EE8F) walks the 16 eight-byte records of the working buffer, copies records around, reads 16-bit parameters with ReadScriptParam ($F082) and dispatches a command through ScriptCommandTable ($EFDD) with DispatchScriptCommand ($EFCC):
| Command | Address | What it does |
|---|---|---|
ScriptCommand_MergeMask3F |
$F00A | merges a parameter into the record with mask $3F |
ScriptCommand_MergeMaskCF |
$F022 | the same with mask $CF |
ScriptCommand_MergeMaskF0 |
$F03A | the same with mask $F0 |
ScriptCommand_StoreAt2 |
$F052 | stores a byte at record offset 2 |
ScriptCommand_StoreAt4 |
$F062 | stores a byte at record offset 4 |
ScriptCommand_StoreAt1 |
$F072 | stores a byte at record offset 1 |
ScriptCommand_Store |
$F001 | stores into the source buffer |
7. Pass 3 Data-Table Inventory
| Address | Name | Size | What it holds |
|---|---|---|---|
| $C0EB | ProgrammerSig |
15 | the copyright signature compared with RAM $0052 |
| $C253 | GameStateDispatchTable |
42 | 16-bit handler pointers indexed by GameState |
| $C2FD | MazeTileGraphics |
138 | the maze tile images |
| $C387 | AttributeTableData |
100 | the per-screen colour attributes |
| $C412 | InitialPalette |
32 | the palette copied to $0299 |
| $C432 | BorderPattern |
40 | the border/tile pattern written to VRAM $23C8 |
| $C66A | CharacterDataTable |
20 | ten pointers into CharacterNameStrings |
| $C67E | CharacterNameStrings |
~80 | the ghost and score strings |
| $C9FF | FruitTargetTable |
32 | the values the fruit placement indexes |
| $D32B | TurnOrderTable |
16 | the order in which directions are tried when blocked |
| $D33B | DirectionBitTable |
16 | direction to button-bit mapping |
| $D3CD | DirectionBehaviourTable |
248 | the handler pointers the direction routine dispatches through |
| $D4DE | AnimationFrameTable |
581 | the animation frame table |
| $D830 | CollisionTileTable |
16 | the tiles the collision check looks for |
| $D840 | CollisionPairTable |
146 | the tile pairs the collision check compares |
| $D912 | GhostUpdateTable |
121 | the pointers DispatchGhostToTable uses |
| $D9B9 | GhostStateTable |
247 | the pointers DispatchGhostState uses |
| $DBAD | MazeTileOverlayTable0 |
256 | overlay tile bytes, case 0 |
| $DCAD | MazeTileOverlayTable1 |
52 | overlay tile bytes, case 1 |
| $DCE1 | MazeTileOverlayTable2 |
256 | overlay tile bytes, case 2 |
| $DDE1 | MazeTileOffsetTable |
52 | overlay offsets |
| $DF2B | AttributeAddressTable |
610 | the VRAM addresses the sprite routine writes |
| $E45E | ScoreTextTable |
18 | the address pairs the score text is read from |
| $EFDD | ScriptCommandTable |
36 | the 16 command handler pointers |
| $F0A7 | LevelDataPointerTable |
24 | level data pointers |
| $F0BF | ScriptParameterTable |
30 | the 16-bit values the script reads |
8. Naming and Residue
Pass 1 — 4,595 lines. 1,637 instruction lines covering 3,273 code bytes (20.0%), 842 data lines covering 13,111 bytes, 25 explicit ; ---- data $XXXX-$YYYY regions, canonical audit comments throughout. Every one of the 2,479 audit comments was checked against prg.bin at its stated PRG offset: 0 mismatches.
Pass 2 complete — 227 label definitions, zero Label_ residue: 199 routine entries and 28 internal branch targets, named <Routine>_<Role>. No raw $XXXX operand remains on any jsr, jmp or branch — the four indirect jumps carry their IndDispatchXX-style names. The map is in routinemap.tsv.
Pass 3 — locals.inc holds 30 semantic zero-page aliases, each applied only at the sites where its role is proven (the addresses that carry two roles keep the neutral ZpXX name elsewhere; the per-site choice is in sites.tsv). 182 alias references are in the source, the rest of the zero-page references keep their neutral names. apply_symbols.py reports 0 substitutions pending and 0 residue — every hardware register is symbolic.
Residue (recorded, not guessed):
- Pass 1 gap — the unreached main game logic. The tracer never reaches the block at $C45A–$C55F, so it is still
.bytedata. It is code: the tracer's own decoder walks it cleanly, and the dispatch table at $C511 points into it ($C529and$C556). It holds the input handling, the 32-bit pointer copies, the direction/turn logic and a second dispatch. It cannot be entered without a guess, so it is left as data here — and the same gap exists in the promotedPac-Man (USA) (Namco)dump. Other untraced regions with the same character: $C9FF–$D248, $DBAD–$DE3C, $DF2B–$E18C, $E4C1–$E9D7, $EA53–$EB5D, $EB75–$EE4A, $F0A7–$FFFF. Re-deriving them is a Pass 1 job — a Mesen Code Data Logger capture is the tool the repo uses for it (_tools/cdl_reclass.py). - Six command handlers were recovered. The table
ScriptCommandTable($EFDD) is read by the traced instruction at $EFCC, and six of its entries point at $F00A, $F022, $F03A, $F052, $F062 and $F072 — all of which the tracer had left as data, and two of which it had mis-split across thejmp $EF61at $F01F and $F04F. Those 120 bytes are code here, decoded straight from the ROM. - 92 zero-page slots keep their neutral name;
locals.inclists every one of them with the reason. - The remaining raw absolute operands against RAM addresses stay numeric where their role is only partly traced — the same residue class accepted in
Pinball (JU)andIce Climber (U).
9. Promotion to NROM/ (2026-10-20)
Moved out of _wip/NROM/ with git mv; the build now compares the whole ROM, not just the PRG:
header.s/chr.sadded (one-line.incbins ofheader.bin/chr.bin)linker.cfggained theHEADER($0000, $0010) andCHR($0000, $2000) areas ahead ofPRG($C000, $4000)rom.nes— symlink toPac-Man (Tengen) (U) [!].nes(the original ROM name contains spaces and brackets)Makefilebuildsheader.o/prg.o/chr.ointoreconstructed.nesand compares it againstrom.nes
reconstructed.nes is 24,592 bytes, MD5 d34e7a74…28ad0 — equal to the original ROM. The header.bin / prg.bin / chr.bin splits were checked against the original before promotion: they are the ROM's first 16 bytes, its next 16,384 bytes and its last 8,192 bytes, exactly.
Build verification: make in NROM/Pac-Man (Tengen) (U)/ produces reconstructed.nes, compared against rom.nes (whole-ROM byte-identical).