# Pyramid (U) (Sachen) — Game Flow from Power-On This document describes the execution flow of the disassembled **Pyramid (U) (Sachen)** 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`. **This is an unlicensed Sachen release.** The iNES header claims mapper 0, but the boot code writes to `$4100` — past the end of the NES register space. On the real cartridge those pokes are a mapper-unlock/protection sequence. That mismatch is recorded, not explained. --- ## 1. Reset Vector — CPU $FFFC → $FF00 | Vector | Address | Handler | |--------|---------|---------| | NMI | $D72E | `NmiHandler` | | RESET | $FF00 | `Reset` — the boot chain, at the very end of the PRG | | IRQ | $FF00 | `Reset` — the same address; `sei` is never undone, so the IRQ never fires | **`Reset`** ($FF00) is the cold-start chain, and it is unusual in that the whole thing lives in the vector page: ``` Reset ($FF00): ldx #$FF / txs / sei / cld lda #$00 / sta PPUCTRL / sta PPUMASK lda #$30 / jsr BootPokeMapperUnlock jsr BootCheckStackBytes bcc Reset_Loop ; the check failed - take the other sequence lda #$10 / jsr BootPokeMapperUnlock lda #$30 / jsr BootPokeMapperUnlock jsr BootUnlockDelay / jsr BootUnlockDelay lda #$10 / jsr BootPokeMapperUnlock jmp BootChainEntry Reset_Loop ($FF2F): ; the alternate poke sequence lda #$20 / jsr BootPokeMapperUnlock lda #$30 / jsr BootPokeMapperUnlock lda #$20 / jsr BootPokeMapperUnlock ... ``` The two sequences differ in the values poked: `$30 $10 $30` on the carry-set path, `$20 $30 $20` on the carry-clear path. ### The mapper unlock **`BootPokeMapperUnlock`** ($FF49) is one instruction plus a delay: ``` BootPokeMapperUnlock: sta $4100 ; the unlock poke BootUnlockDelay: ldy #$04 BootUnlockDelay_Loop: ldx #$CE BootUnlockDelay_Inner: dex / bne BootUnlockDelay_Inner dey / bne BootUnlockDelay_Loop rts ``` The delay is the protection: the console latches `$4100` and the ROM waits for the mapper to settle. ### The "AV" check **`BootCheckStackBytes`** ($FF57) compares two RAM bytes against the letters **A** and **V**: ``` BootCheckStackBytes: lda #$41 / cmp $0120 / bne BootCheckStackBytes_Fail lda #$56 / cmp $0121 / bne BootCheckStackBytes_Fail sec / rts BootCheckStackBytes_Fail: clc / rts ``` `Carry` set means the check passed and the first poke sequence runs; `Carry` clear means the alternate sequence runs. **`BootSeedStackBytes`** ($FF69) writes `$41`/`$56` into `$0120`/`$0121` itself, so the check is satisfied on the next pass. **`ClearCheckerBytes`** ($FF74) writes `$00` into both again. ### The real entry **`BootChainEntry`** ($FF3E) is the hand-off into the game: ``` BootChainEntry: lda #$30 / jsr BootPokeMapperUnlock jsr BootSeedStackBytes ; seed the "AV" checker bytes for next time jmp BootColdStart ; the real init, at $C800 ``` **`BootColdStart`** ($C800) is the game's own entry: it sets up the stack, waits for two vblank phases, clears RAM, resets the sound engine and initialises the PPU. --- ## 2. The dispatch architecture The game dispatches through **two levels** of indirection. ### The task table — `jmp` trampolines The entry points live in a table of `jmp` instructions rather than of addresses. Four such tables sit inside the data regions: | Table | Address | Entries | Task | |-------|---------|---------|------| | `TaskDispatchTable1` | $C01E | 3 | $C1C9, $C1D0, $C1D7 | | `TaskDispatchTable2` | $C02A | 6 | $C1EF, $C205, $C201, $C225, $C23A, $C294 | | `TaskDispatchTable3` | $C042 | 2 | $C34A, $C43E | | `TaskDispatchTable4` | $C054 | 5 | $C585, $C5AB, $C614, $C662, $C6A3 | and the first ten entries of the table are code of their own, at $C000–$C01B (`Task_PlayVramScript`, `Task_CopyBlockToCommandBuffer`, `Task_UploadPalette1`, …). The tracer cannot see any of this: a `jmp` inside a data region is just four bytes to it, so **all sixteen targets had to be seeded by hand**. ### The queued-event dispatchers Two dispatchers jump through the zero page with `jmp (ScriptPtrLo)`: | Dispatcher | Site | Vector table | Entries | |------------|------|--------------|---------| | 1 | `DispatchTask1` ($C392) | `TaskVectorTable1` ($C3A6) | 9 entries — the ninth, $D820, is a **junk slot** and stays data | | 2 | `DispatchTask2` ($CCB8) | `TaskVectorTable2` ($CCD5) | 8 entries, preceded by the 8-byte `TaskIndexTable2` at $CCCD | Both build the pointer with the same idiom — read a 16-bit entry from the table into $02/$03, then `jmp ($0002)` — and both terminate the static trace. --- ## 3. The VRAM command list The engine never writes the PPU directly. It assembles a **byte stream** in the buffer at $0700 and flushes it: - `$FE` — the next two bytes are a VRAM address - `$FD` — end of list - anything else — a byte for $2007 `PlayVramScript` ($C063) walks a script and builds that list; `FlushVramCommandList` ($C127) plays it back. `FinishCommandList` ($C11B) writes the `$FD` marker and waits for `CommandListFlag` to clear. --- ## 4. The NMI handler `NmiHandler` ($D72E) is the per-frame entry: it advances the frame counter, runs `ActorUpdate` ($D50A) and `SoundTick` ($DF80), then restores the registers. Two byte counters at $D75C/$D75D accumulate the frame total, and `DrawNumber` ($D79D) turns that into the score digits on the PPU. --- ## 5. Pass 3 Data-Table Inventory | Address | Name | Size | What it holds | |---------|------|------|---------------| | $C01E | `TaskDispatchTable1` | 9 | three `jmp` trampolines | | $C02A | `TaskDispatchTable2` | 18 | six `jmp` trampolines | | $C042 | `TaskDispatchTable3` | 6 | two `jmp` trampolines | | $C054 | `TaskDispatchTable4` | 15 | five `jmp` trampolines | | $C3A6 | `TaskVectorTable1` | 16 | the first dispatcher's eight handler addresses | | $C52A | `PaletteIndexTable` | 12 | the palette slot indices | | $C605 | `SoundDeltaTable` | 15 | the 16-bit deltas the sound engine adds and subtracts | | $C6E7 | `ZeroPadding` | 281 | zero fill up to the level data | | $C95A | `SpriteTileTable` | 77 | the tile indices of the sprite sheet | | $CAAA | `ActorAttributeTable` | 73 | four-byte actor attribute records | | $CBC1 | `ActorSizeTable` | 27 | the actor size and speed classes | | $CCCD | `TaskIndexTable2` | 8 | the second dispatcher's per-slot index | | $CCD5 | `TaskVectorTable2` | 16 | the second dispatcher's eight handler addresses | | $CD97 | `ActorParameterTable` | 13 | per-actor behaviour parameters | | $D021 | `BrightnessRampTable` | 9 | the brightness ramp `$0F $14 $28 $3C $50 $64` | | $D0C5 | `ActorTypeTable` | 14 | the actor type codes | | $D1AA | `ActorOffsetTable` | 8 | the per-actor table offsets | | $D2AB | `ActorSpriteTable` | 20 | the sprite indices per actor | | $D320 | `LevelLayoutTable` | 129 | the per-level byte table | | $D467 | `ActorBehaviourTable` | 15 | the actor behaviour records | | $D4EE | `ActorInitialStateTable` | 28 | the actors' initial state | | $D556 | `ActorSpeedTable` | 21 | the actor speed steps | | $D5D5 | `ActorSpriteOffsetTable` | 93 | the per-actor sprite offsets | | $D69C | `ValueRampTable` | 16 | the value ramp | | $D72D | `ActorFlagByte` | 1 | a single flag byte | | $D7F3 | `LevelScriptData` | 1,703 | the packed level script | | $DEBB | `SoundSampleTable` | 16 | the sound sample descriptors | | $DF30 | `MusicPeriodTable` | 80 | the 16-bit note periods | | $E276 | `SoundEnvelopeTable` | 2,808 | the instrument and envelope ramps | | $EDBF | `SoundSamplePool` | 2,770 | the sample and instrument pool | | $F95D | `ZeroPadding2` | 1,443 | zero fill up to the vector page | | $FF7D | `VectorPagePadding` | 131 | zero fill up to the vectors | --- ## 6. Naming and Residue **Pass 1** — 8,253 lines. 3,130 instruction lines covering 6,493 code bytes (**39.6%**), 632 data lines covering 9,891 bytes, 3,762 canonical audit comments. The byte accounting is exact: 16,384 bytes, no duplicates, nothing missing. **Pass 1 correction (2026-10-20).** The committed split was a strict subset of the correct one: **828 bytes** of code were still marked as data, because the tracer cannot follow a `jmp` that sits inside a data region. Re-tracing with the sixteen `jmp`-table targets, the `$C800` boot mainline and six further code starts seeded by hand recovers them, and a five-byte stub at $DF89 (`lda $33 / jmp SoundPlayEffect`) that sits in a hole between two routines. The re-trace loses **no** committed code byte. **Pass 2 complete** — 463 label definitions, **zero `Label_` residue**: 431 routine entries and the 32 data blocks, named from this dump's own code. **No raw `$XXXX` operand** remains on any `jsr`, `jmp` or branch. The map is in `routinemap.tsv`; the internal branch-target names are derived mechanically from the branch that reaches them by `_tools/derive_branch_names.py`. **Pass 3** — 24 semantic zero-page aliases, each applied only where its role is proven (`locals.inc`, `zp_aliases.inc`). 57 slots keep their neutral `ZpXX` name, and **every one of them is listed with the reason** in `locals.inc`. `apply_symbols.py` reports **0 substitutions pending and 0 residue**; no hardware register is left as a raw literal. **Residue (recorded, not guessed):** - The zero page is reused heavily — 410 references still carry a neutral name. The largest group is `$45`–`$49` (105, 69, 33, 22 and 11 references), which are the general-purpose scratch of the shift and arithmetic helpers; one name would not describe their other uses. - 482 absolute operands point at RAM ($0700, $0200, $0400, $0436, …). Those are data addresses, not hardware registers, and stay numeric. - `sta $4100` at $FF49 stays a raw operand. It is outside the `$2000-$4017` range that `PROMPTS.md` asks to be symbolic, and no `symbols.inc` name would describe a mapper unlock. --- ## 7. 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.s` added (one-line `.incbin`s of `header.bin` / `chr.bin`) - `linker.cfg` gained the `HEADER` ($0000, $0010) and `CHR` ($0000, $2000) areas ahead of `PRG` ($C000, $4000) - `rom.nes` — symlink to `Pyramid (U) (Sachen).nes` - `Makefile` builds `header.o` / `prg.o` / `chr.o` into `reconstructed.nes` and compares it against `rom.nes` - `make regen` reproduces `prg.s` and `locals.inc` from `prg.bin` and re-applies the naming, so the split stays reproducible `reconstructed.nes` is 24,592 bytes, MD5 `aa285ff1…6c59e2` — 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/Pyramid (U)/` produces `reconstructed.nes`, compared against `rom.nes` (**whole-ROM byte-identical**).