# AGENTS.md ## What this repo is Forty-one AI-generated disassemblies of NES ROMs, organized by mapper. All 41 are promoted into the top-level mapper directories and all verify **byte-identical for the whole ROM** (most recently Golf (JU) and World Class Track Meet (U) on 2026-10-21); 851 more live under `_wip/` (29 mapper folders: `NROM`, `MMC1`, `UNROM`, `CNROM`, `MMC3`, `MMC5` and 23 more), all verifying **byte-identical for the whole ROM** as of 2026-10-21 (`make wip`: 851 of 851 built, 0 failures). All 41 promoted projects carry real disassemblies (Pass 1 or beyond); all 851 `_wip/` projects are **Pass 0 skeletons** (`prg.s` is a single `.incbin "prg.bin"`). 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/`, `MAPPER8/`. The 41 promoted projects are 38 under `NROM/`, plus `CNROM/World Class Track Meet (U)` (mapper 3), `UNROM/Zanac (U)` (mapper 2) and `MAPPER8/Mermaids of Atlantis (U)` (mapper 8); `Xevious (U)` and `Volleyball (U)` are mapper 0 but have a 32 KB PRG, so `NROM/` also holds the only two mapper-0 projects that are not 16 KB. `AOROM`, `MMC1`, `MMC3` and `MMC5` are empty placeholders (2026-10-21). The pre-promotion tier mirrors the same structure at `_wip//` (currently 29 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 eight 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. The `_wip/` pre-promotion tier is reached only through the separate `make wip` / `make wipclean` targets — `all`, `clean` and `` never touch it. **`WIP := _wip` at the top of the root `Makefile` is what makes those two targets work**: an empty `$(WIP)` expands the loop to `for m in /*/`, which walks the filesystem root instead of the tier. - Root `CHANGELOG.md` is the repository's **running log** of every change, across **all** the projects. It has three parts: a **Pending** list at the top - the changes to be put in the next git commit - a **History** of the commits already made, and the **milestone index** at the end, which is the per-project pass history (one section per project with a row for each of the four milestones: **Pass 1** the code/data split, **Pass 2** the labels replaced by names, **Pass 3** the semantic naming, **Promotion** the move into the top-level mapper folder). **As work lands, a line goes into Pending; the next git commit's message is written from those entries, and they then move to History under the commit they went in with.** A milestone and its milestone-index row go in the same commit - the requirement is in `PROMPTS.md` ("Pass 3: Changelog entry"), and the checklist item is part of the promotion requirements. - Root `README.md` is the index for the **promoted** tier: vectors, build fixes, and summary for the 41 promoted projects (38 under `NROM/`, plus `CNROM/World Class Track Meet (U)`, `UNROM/Zanac (U)` and `MAPPER8/Mermaids of Atlantis (U)`). **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** (851 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), `banked_dis.py` (Pass 1 tracer for a banked PRG — one segment per MMC3 bank, seeded from a Mesen CDL capture), `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), `describe_routines.py` (fills in the routine header blocks from the routine 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), `derive_clobbers.py` (derives the `; Clobbers:` block of every routine header — registers, flags and memory, with register preservation modelled), `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) make wip # repo root: build + verify every project under _wip/ 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/` under `all`, `clean` or the per-mapper targets — those only ever touch the promoted tier. The pre-promotion tier has its own explicit targets: ```bash make wip # build + verify every _wip/// make wipclean # clean the build artifacts in that tier ``` (Equivalently, `for d in _wip/*/*/; do make -C "$d"; done` still works.) ## 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: `../../_tools/` from a promoted project (`//`), but **`../../../_tools/` from a `_wip/` project** (`_wip///` is three levels deep, not two). `_tools/apply_pass2_map.py` and `_tools/derive_branch_names.py` take a `--bank-prefix` / bank-prefix argument for labels of banks that share a CPU window. 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`. 6. **Delete the old `_wip/` folder.** `git mv` moves only the *tracked* files, so the old folder survives with its untracked build artifacts (`*.o`, `reconstructed.nes`, `reconstructed.lbl`, `reconstructed.map`) and sometimes a stale `README.md`. A leftover folder counts as a project in every tier tally while having no source to build — `Xevious (U)` and `Zanac (U)` both left one behind on 2026-10-20, and both were cleaned up on 2026-10-21. ## 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 41 promoted projects now use this exact format** (standardized 2026-10-19; the last pre-canonical project, `Golf (JU)`, was converted at its Pass 2/3 and promotion on 2026-10-21, so no project in the tree uses the old `; C007: AD 02 20` spelling for its audit trail). 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 851 projects have a 16 KB PRG; 149 have 32 KB, 522 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 38 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`. - **A banked PRG needs one MEMORY area per bank, and one segment per bank.** `Mermaids of Atlantis (U)` (mapper 8, 64 KB PRG) declares `PRG0`..`PRG7` at the eight file offsets and gives each segment its own `.org` for the CPU window the bank is visible at. The bytes land at the right file offset (the image is byte identical) *and* the assembler knows the CPU address of every label, so `jsr SomeRoutine` assembles to the right operand. A flat segment cannot do both: its addresses are file offsets, and a call would encode the file offset instead of the CPU address. Labels of a bank that shares its CPU window with another bank need a prefix (`Bank4_`..`Bank7_`); `_tools/apply_pass2_map.py` and `_tools/derive_branch_names.py` take a `--bank-prefix` / bank-prefix argument for that. - **The MMC3's fixed bank is not always the last 8 KB of the PRG.** On real hardware $E000-$FFFF is the last bank, but a 64 KB MMC3 image can be built so the game expects another one: in `Mermaids of Atlantis (U)` the fixed bank is the *fourth* 8 KB bank (file $6000-$7FFF) - the text table the code reads at $F1D6 is there, and the image repeats the reset stub and the vectors at the end so it also boots on a mapper that fixes the last bank. The dump's own code settles the question: the NMI vector in the fixed bank names a handler that exists, and the copy at the end of the image names one that does not. - **Memory area sizes are hexadecimal.** `size = $127` is 295 bytes, not 127. - **226 `_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. - **Header byte 6 bit 0 is the mirroring bit and nothing else is.** `0` = Horizontal, `1` = Vertical — the convention `README.md` states and every per-project README uses. Bit 1 is the battery flag, bit 2 the trainer flag and bit 3 four-screen VRAM; each has its own row. Folding the battery bit into the mirroring value is a bug that once made 56 battery-backed `_wip/` READMEs claim `Four-screen`. 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) (Sachen) 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/`.