Standardized all 18 top-level projects to canonical audit comment format: ; CPU:$XXXX PRG:XXXX AA BB Changes per project: - CIRCUS: fixed 4,427 comments (CPU:XXXX -> CPU:$XXXX) - Excitebike (JU): fixed 48 double-semicolon comments (; state $XX ; CPU:...) - 9 projects received canonical comments via new audit_comment_tool.py: Balloon Fight, Baseball, Bomberman, Burger Time, Clu Clu Land, Defender 2, Donkey Kong 3, Donkey Kong Jr., Tennis New tool: _tools/audit_comment_tool.py - Uses nesdis.py tracer as ground truth for instruction bytes/addresses - Parses source while tracking PRG offset, emits canonical comments - Handles .byte/.word/.dword directives, instruction comments (short format) - Carves PRG from rom.nes when prg.bin missing Documentation updates: - TODO.md: Updated current state, item 3 (tool built), repo conventions, tool list - AGENTS.md: Added audit_comment_tool.py to _tools/, updated audit convention Verification: verify_pass1.py reports all 18 projects byte-identical with 'canoni' format.
71 lines
9.6 KiB
Markdown
71 lines
9.6 KiB
Markdown
# AGENTS.md
|
||
|
||
## What this repo is
|
||
|
||
Thirty-six AI-generated disassemblies of NES ROMs: 17 promoted to top-level folders and 19 skeletons still under `_wip/`. The contract (stated in `PROMPTS.md` and verified by `make` in every `copilot/` folder) is **byte-identical rebuilds**: `reconstructed.nes` must match the original ROM exactly. Not for distribution (`README.md`).
|
||
|
||
## Layout
|
||
|
||
- Each project owns a folder with a `copilot/` build dir (`prg.s`, `header.s`, `chr.s`, `linker.cfg`, `symbols.inc`, `locals.inc`, `Makefile`, and a copy/symlink `rom.nes`) and a per-project `OVERVIEW.md` at the folder root (power-on flow analysis). Some older ones use `main.s` instead of `prg.s`, and a few lack `prg.bin` (carve the PRG out of `rom.nes` instead).
|
||
- Root `OVERVIEW.md` is the index: vectors, build fixes, and summary for all 36 projects. **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.
|
||
- `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 (prg.s, symbols.inc, linker.cfg, Makefile, locals.inc). `_ROMS/` is the master ROM store.
|
||
- `_tools/`: `nesdis.py` (Pass 1 tracer), `pass2.py` and `bytecode_dis.py` (Pass 2/3 emitters), `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 all projects), `audit_comment_tool.py` (adds canonical audit comments using tracer as ground truth), `splitnes.py`, `chr2gif.py`.
|
||
|
||
## Commands
|
||
|
||
```bash
|
||
cd "<Project>/copilot" && make # builds reconstructed.nes, then cmp vs rom.nes
|
||
python3 _tools/verify_pass1.py # rebuilds and re-traces every project
|
||
```
|
||
|
||
`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.** Top-level projects compare the whole ROM against `copilot/rom.nes`. The 20 `_wip/` projects only run `cmp reconstructed_prg.bin prg.bin`, so their **header and CHR are not proven** — do not describe them as byte-identical to the ROM.
|
||
|
||
## Promoting a project out of `_wip/`
|
||
|
||
1. `git mv "_wip/<name>" "<name>"` — the relative `copilot/rom.nes -> ../<name>.nes` symlink survives untouched.
|
||
2. Add `copilot/rom.nes` (a space-free copy or symlink of the original) and switch the Makefile from `cmp reconstructed_prg.bin prg.bin` to `cmp reconstructed.nes rom.nes`.
|
||
2b. A Pass-0 skeleton has no `header.s`/`chr.s` and its `linker.cfg` declares only the PRG memory area, so add all three (copy the shape from `Burger Time (U)/copilot/`: two one-line `.incbin`s and `HEADER: start = $0000, size = $0010` ahead of PRG) or the whole-ROM compare has nothing to link. `Lunar Pool (U)` is the worked example (2026-10-18).
|
||
3. Change `../../../_tools/` to `../../_tools/` in the Makefile — the depth changed by one.
|
||
4. Verify `make`, then `make regen` if a regen pipeline exists.
|
||
5. Add it to the root inventory table and re-number the `_wip` rows.
|
||
|
||
|
||
## 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 18 top-level projects now use this exact format** (standardized 2026-10-19). 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`.
|
||
- 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 `OVERVIEW.md` per-project "Build fix")
|
||
|
||
- 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` inside `copilot/`. 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 `<offset>r <n> <bytes> <source text>`. 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/`.
|
||
|