Files
disasm/AGENTS.md
T
tmerritt 2ddaf102a0 Audit comment standardization + new tool (2026-10-19)
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.
2026-10-19 09:07:14 -04:00

9.6 KiB
Raw Blame History

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

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 .incbins 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/.