# AGENTS.md ## What this repo is Thirty-six AI-generated disassemblies of NES ROMs, organized by mapper. 35 are promoted into the top-level `NROM/` and all verify **byte-identical for the whole ROM** (most recently Donkey Kong Jr. Math (U), Ice Climber (JE)/(U), Lode Runner (U), Pinball (JU), Popeye (JU) (PRG 1), Excitebike (JU), Raid on Bungeling Bay (U), Raid on Bungeling Bay (U) [a2], Pac-Man (U), Pac-Man (Tengen) (U), Pyramid (U), Hogan's Alley (JU) and Mario Bros (JU) on 2026-10-20); 857 more live under `_wip/` (30 mapper folders: `NROM`, `MMC1`, `UNROM`, `CNROM`, `MMC3`, `MMC5` and 24 more), all verifying **byte-identical for the whole ROM** as of 2026-10-20. All 36 promoted projects carry real disassemblies (Pass 1 or beyond); 856 of the 857 `_wip/` projects are **Pass 0 skeletons** (`prg.s` is a single `.incbin "prg.bin"`) and only `Golf (JU)` has a disassembly. The contract (stated in `PROMPTS.md` and verified by `make` in every project folder) is **byte-identical rebuilds**: `reconstructed.nes` must match the original ROM exactly. Not for distribution (`README.md`). ## Layout - Projects live in a folder under **their mapper directory**: `NROM/`, `AOROM/`, `CNROM/`, `MMC1/`, `MMC3/`, `MMC5/`, `UNROM/`. Everything promoted so far is mapper 0 (16 KB PRG), so all promoted projects sit in `NROM/` — `AOROM`–`UNROM` are empty placeholders (2026-10-20). The pre-promotion tier mirrors the same structure at `_wip//` (currently 30 folders: `_wip/NROM/`, `_wip/MMC1/`, `_wip/UNROM/`, `_wip/CNROM/`, `_wip/MMC3/`, `_wip/MMC5/` and `_wip/MAPPER/` for every other mapper). AGENTS.md files written before 2026-10-20 say "top-level folder"; that location is now `//`. - **Each project folder is flat — there is no `copilot/` subfolder** (flattened 2026-10-20; commit history, root `README.md` and `TODO.md` predate the flatten and still say `copilot/...` — mentally strip the prefix). Every file sits in the project folder: `prg.s`, `header.s`, `chr.s`, `linker.cfg`, `symbols.inc`, `locals.inc`, `Makefile`, the `prg.bin`/`header.bin`/`chr.bin` splits, a built `rom.nes` copy/symlink of the original ROM, and a per-project `README.md` (power-on flow analysis — the file was called `OVERVIEW.md` before 2026-10-20). Bomberman (U)'s legacy `main.s` pre-standardization duplicate was removed 2026-10-20 (recoverable in git history) — every project's build source is `prg.s`. A few projects lack `prg.bin` (carve the PRG out of `rom.nes` instead). - Root `Makefile` builds and verifies every project under all seven mapper directories: `make` (all), `make clean`, or `make ` for one tier, e.g. `make MMC3`. It loops with quoted sub-make invocations because project names contain spaces and parentheses, which break make-target names. It is non-parallel; each project's own `Makefile` verifies byte-identity before "Byte-identical ROM verified." is printed. - Root `README.md` is the index for the **promoted** tier: vectors, build fixes, and summary for the 35 projects under `NROM/`. **Keep it in sync** when a project changes — its DK Jr. entries drifted badly before being corrected, and its vector column later turned out to have five rows wrong from copy-paste duplication. The **pre-promotion tier** (857 projects under `_wip/`) is indexed in `WIP.md`, which carries its layout, its quirks and its open work. - `TODO.md` is the handoff: open work per project, plus the gotchas that cost real time. Read it before starting anything non-trivial. - `PROMPTS.md` is the workflow contract. `default_project.tar.gz` is the empty-project skeleton (flat: prg.s, symbols.inc, linker.cfg, Makefile, locals.inc). The master ROM store used to be `_ROMS/`; it was dissolved on 2026-10-20 and every ROM now lives in its own project folder under `_wip///`, so the ROM and its skeleton are side by side. - `_tools/`: `nesdis.py` (Pass 1 tracer), `pass2.py` and `bytecode_dis.py` (Pass 2/3 emitters), `apply_symbols.py` (substitutes `symbols.inc`/`locals.inc` names into `prg.s` operands), `trace_stage_payload.py` (py65-based runtime tracer), `cdl_reclass.py` (Mesen-CDL-driven code/data split fix), `verify_pass1.py` (rebuild + re-trace check across the top-level projects), `audit_comment_tool.py` (adds canonical audit comments using tracer as ground truth), `repair_audit_comments.py` (repairs damaged audit trails — shifted/`??` comments — from the ca65 listing, validated against `prg.bin`), `apply_pass2_map.py` (applies an address→name Pass 2 map), `derive_branch_names.py` (derives the internal branch-target names), `apply_zp_aliases.py` (per-site zero-page alias substitution), `name_data_region.py` (names a data block, splitting the `.byte` row when needed), `resolve_raw_branches.py` (turns `bXX *+$NN` operands into labels), `align_audit_comments.py` (restores the canonical column-35 audit comment), `make_skeleton.py` (creates a byte-identical Pass 0 build skeleton for a project folder — splits the ROM, writes the ca65 sources, the linker config, the Makefile and the project README; refuses to overwrite an existing `prg.s` without `--force`), `splitnes.py`, `chr2gif.py`. ## Commands ```bash make # repo root: build + verify every project (all mapper dirs) cd "NROM/" && make # one project: builds reconstructed.nes, then cmp vs rom.nes python3 _tools/verify_pass1.py # rebuilds + re-traces the top-level projects ``` `make`'s default target includes the byte-identity `cmp` — a build that doesn't print "Byte-identical ROM verified." is a failure. Requires `ca65`/`ld65` on PATH. **There are two build tiers, and the difference is real.** Promoted projects (top-level `NROM/` etc.) compare the whole ROM against `rom.nes` in the project folder. Since 2026-10-20 the `_wip/` projects do the same — they carry `header.s`/`chr.s`, a HEADER/PRG/CHR `linker.cfg` and a `rom.nes`, and `make` compares the whole ROM. The real difference between the tiers is now **content, not plumbing**: a `_wip/` project may still be a Pass 0 skeleton (`prg.s` is a single `.incbin "prg.bin"`), so it is not promotable until it carries a real disassembly. The root `Makefile` deliberately does **not** cover `_wip/`. Build the tier explicitly: ```bash for d in _wip/*/*/; do make -C "$d"; done ``` ## Promoting a project out of `_wip/` 1. `git mv "_wip//" "/"` — everything is already flat, so nothing needs to move inside the folder. 2. The whole-ROM build is already in place (`header.s`/`chr.s`, the HEADER/PRG/CHR `linker.cfg` and `rom.nes`), so there is no Makefile or linker work left to do — promotion is `git mv` plus the disassembly work. This was still a manual step before 2026-10-20; `Lunar Pool (U)` (2026-10-18) and `Donkey Kong (JU)` (2026-10-19) are the worked examples, and DK (JU) also fixed a header-byte mirroring error in its project `README.md` (Vertical → Horizontal from byte 6 = `$00`). 3. `_tools/` paths: **now a no-op.** All project folders are the same two levels deep (`//` and `_wip///` alike), so `../../_tools/` is correct in both tiers. 4. Verify `make`, then `make regen` if a regen pipeline exists. 5. Add it to the root `README.md` inventory and remove its row from `WIP.md`. ## Irreversible conventions (do not "clean up" past these) - Every instruction carries a trailing audit comment with its CPU address, PRG offset and exact emitted bytes. **The canonical format is `; CPU:$C123 PRG:0123 AD 21 03` — all 33 top-level projects now use this exact format** (standardized 2026-10-19; Donkey Kong (JU) confirmed canonical at its promotion the same day). This is the byte-identity audit trail — preserve it even while renaming labels or restructuring files. The earlier gap (6 variants, 1,013 uncommented lines in Excitebike) was closed 2026-10-19 via `_tools/audit_comment_tool.py`; Excitebike's shifted/`??` damage was re-anchored 2026-10-20 via `_tools/repair_audit_comments.py` (listing-based, self-validating against `prg.bin`). - The audit comment starts at **column 35**: `nesdis.py` pads each instruction to 31 characters before the `; `. A Pass 3 substitution that lengthens an operand pushes the comment right, so **re-align after substitutions** with `_tools/align_audit_comments.py` (whitespace-only — it cannot change the bytes). - Unreached/unresolved bytes stay as data (`Label_/Data/` `.byte` or `.incbin` blocks with ROM provenance), never deleted or reformatted. - Routine headers follow the template in `prg.s`: global + local description, parameters, returns, clobbers. - Two separate symbol files: `symbols.inc` = NES hardware constants only; `locals.inc` = zero-page and forward references. Unresolved zero-page bytes keep neutral `ZpXX` names rather than speculative names. Proven semantic names may sit *beside* the generated `ZpXX` entries as aliases. - Pass discipline: Pass 1 = code/data split (stop for review), Pass 2 = label replacement, Pass 3 = semantic naming of data tables/structures. Do not skip straight to guessy rewrites. When a pass is only partly achievable, record the residue and why rather than inventing names. - `make regen` (where it exists, e.g. MUSCLE) must keep reproducing `prg.s` byte-for-byte on the standard library alone. Re-check it after any `nesdis.py` change. ## Hard-won quirks (details in root `README.md` per-project "Build fix") - **The `_wip/` tier is mostly a banked mapper with a PRG image larger than the 6502's address space.** Only 2 of its 857 projects have a 16 KB PRG; 151 have 32 KB, 523 have 128 KB and one (`Action 52 (U) [!]`) has 1.5 MB. Those projects link their MEMORY areas at **.nes file offsets** (HEADER at `$0000`, PRG at `$0010`, CHR after it) rather than at CPU addresses, which is both the only option and the honest model for a banked mapper. The 39 older `_wip/` projects keep the CPU-address layout (`$C000`/`$4000` or `$8000`/`$8000`) because their PRG really is one contiguous window. See "Two linker layouts" in `WIP.md`. - **The mapper comes from the iNES header**: byte 6's upper nybble is mapper bits 0-3, byte 7's upper nybble is mapper bits 4-7. `_wip/` uses the repo's board vocabulary where it exists (`NROM`, `MMC1`, `UNROM`, `CNROM`, `MMC3`, `MMC5`) and `MAPPER` for every other mapper. - **ld65 writes the output file in MEMORY *declaration* order, not address order.** The areas must be declared in the order the bytes appear in the `.nes` file: `HEADER, PRG, CHR, TRAILER`. - **Memory area sizes are hexadecimal.** `size = $127` is 295 bytes, not 127. - **227 `_wip/` projects have no CHR at all** (the mapper supplies CHR RAM), so they have no `chr.bin` and no CHR area. Six have trailing bytes the header does not account for, reproduced by a `TRAILER: size = $007F` area — and the two `Wall Street Kid` dumps have 64 KB of trailing graphics data whose header size fields are simply wrong. Also: **Pass 3 naming expectations for promotion are in `PROMPTS.md`** — every branch/jump target must carry a ROM-specific semantic name (no `Label_CXXXX`, no raw `$XXXX` operands), and comment/audit alignment is re-done after substitutions. - The flat disassembler mislabels `bne` as `bpl`; fixes use `bne *-$XX` to reproduce the original bytes (Clu Clu Land, Defender 2, Excitebike). - Zero pages are reused across modes; aliases may be required in `locals.inc` (e.g. `RandomStateAlt = $E4` beside `RandomState0 = $54`). - Donkey Kong Jr. is architecturally different: reset entry is $C66E, the reset path idles forever in an LFSR loop, and the entire frame state machine runs inside NMI. Its `DeferredTaskHandlerTable` entry 0 deliberately mirrors the IRQ vector word at $BFFE. - IRQ vectors often point at unused/data targets in these games — verify before assuming a handler is broken. - Unlicensed titles break static-trace assumptions: Pyramid (U) pokes a mapper unlock at `$4100` (beyond the register space) despite a mapper-0 header, and dispatches through `jmp ($0002)` built from RAM — trace via its two queue tables (`$C3A6`, `$CCD5`), and do NOT fall-through-seed gaps that follow `rts`/`jmp`/`brk` (that misclassifies data as code). - ROM filenames contain spaces **and parentheses** (e.g. `M.U.S.C.L.E. (U).nes`); builds `incbin` a space-free `rom.nes` in the project folder. Keep that copy/symlink working and don't rename it. Note that a name with spaces/parens is also what makes FCEUX hang headless — symlink it to a clean name and run under Xvfb. - `.gitignore` contains `ROMS/*` but the actual directory is `_ROMS` — the pattern matches nothing, so ROM binaries stay tracked. Check before committing large blobs. ## Tracer and toolchain gotchas (2026-10-17) Full detail and reasoning in `TODO.md`. These are the ones that silently corrupt results rather than failing loudly. - **Undocumented opcodes silently truncate a trace.** A tracer that knows only the 151 official opcodes halts on the first one used as ordinary code. Joypad Test Cartridge died at `$C09E` on `DCP $3C20` (`$CF`) and filed **3,256 bytes of real code as `.byte`**. `nesdis.py --undocumented` decodes all 105 (default **off**, so existing `make regen` pipelines stay byte-stable). - **ca65 V2.19 implements none of the undocumented opcodes**, so they cannot be written as mnemonics. `nesdis.py` emits them as explicit `.byte` with the disassembly in the comment — that keeps them in the code region, so both the split and the audit trail survive. ca65 is also happy to accept plain `nop`, which is official (`0xEA`). - **Vectors are not in the iNES header.** They are the three little-endian words at the *end* of the 16 KB PRG (`$FFFA–$FFFF` in the `$C000` mirror). Header bytes 0–9 carry only sizes, mirroring and mapper. Parsing them from `header.bin` as if they were header fields yields garbage. - **RESET and IRQ are usually the same address.** The semantic-name map must not overwrite on collision, or the primary entry gets renamed to `IrqHandler`. Use `setdefault` in RESET/NMI/IRQ priority order. - **Control-flow targets must win over fall-through.** A fall-through decode whose *operand* spans an address that another path branches to is the wrong decode — no assembler branches mid-instruction. Mario Bros had a `bne` at `$C4BA` targeting `$C528` swallowed by a `NOP #$A5` at `$C527`. - **Branch targets inside a surviving instruction must be pruned** from the target set, or the emitter's linear walk consumes those bytes as an operand, the label is referenced but never defined, and **the file does not assemble at all**. - **A plain vector trace reaches only ~15% of a real game.** Almost everything sits behind computed dispatch. Look for `jmp ($xx)`, and for dispatchers that pop the table address with `pla` — that form cannot be followed from the dispatcher because the pointer is only knowable in the caller's frame (Mario Bros `$CD9E`). Probing a suspected data region linearly with `nesdis.py --probe` is cheap and settles whether it is code; `--auto-seed` plus a couple of `--extra-seed` addresses took Mario Bros from 14.6% to 72.2%. - **When emulating to trace, NMI must fire once per frame, not per `$2002` read.** The main loop polls `$2002` to sync on vblank, and each read clears bit 7, re-arming NMI within the same scanline. Guarding on the vblank flag alone gave **40,900 NMIs across 3,000 frames** and flattened coverage at 26%. - **Controller input goes on joy1 = `$4016`** with a real latch/shift register. The poll routine's `lda $4016,X` with X=1 lands on `$4017`, but only bits 0–1 are compared, so driving `$4017` consumes nothing and changes no coverage at all. - **`ca65 -l` listings contain no source line numbers** — they emit `r `. You cannot key listing entries back to source lines, and the listing prints `rr rr` for relocatable words, so byte values must come from `prg.bin`. - **py65 is not a build dependency.** Unpack on demand (`pip download --no-deps py65`, then `unzip` into a lib dir; `PY65LIB` env var points at it). `/tmp` is volatile — a reboot wiped it mid-session. Anything reusable belongs in `_tools/`.