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.
9.6 KiB
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/symlinkrom.nes) and a per-projectOVERVIEW.mdat the folder root (power-on flow analysis). Some older ones usemain.sinstead ofprg.s, and a few lackprg.bin(carve the PRG out ofrom.nesinstead). - Root
OVERVIEW.mdis 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.mdis the handoff: open work per project, plus the gotchas that cost real time. Read it before starting anything non-trivial.PROMPTS.mdis the workflow contract.default_project.tar.gzis 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.pyandbytecode_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/
git mv "_wip/<name>" "<name>"— the relativecopilot/rom.nes -> ../<name>.nessymlink survives untouched.- Add
copilot/rom.nes(a space-free copy or symlink of the original) and switch the Makefile fromcmp reconstructed_prg.bin prg.bintocmp reconstructed.nes rom.nes. 2b. A Pass-0 skeleton has noheader.s/chr.sand itslinker.cfgdeclares only the PRG memory area, so add all three (copy the shape fromBurger Time (U)/copilot/: two one-line.incbins andHEADER: start = $0000, size = $0010ahead of PRG) or the whole-ROM compare has nothing to link.Lunar Pool (U)is the worked example (2026-10-18). - Change
../../../_tools/to../../_tools/in the Makefile — the depth changed by one. - Verify
make, thenmake regenif a regen pipeline exists. - Add it to the root inventory table and re-number the
_wiprows.
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/.byteor.incbinblocks 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 neutralZpXXnames rather than speculative names. Proven semantic names may sit beside the generatedZpXXentries 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 reproducingprg.sbyte-for-byte on the standard library alone. Re-check it after anynesdis.pychange.
Hard-won quirks (details in root OVERVIEW.md per-project "Build fix")
- The flat disassembler mislabels
bneasbpl; fixes usebne *-$XXto 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 = $E4besideRandomState0 = $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
DeferredTaskHandlerTableentry 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 throughjmp ($0002)built from RAM — trace via its two queue tables ($C3A6,$CCD5), and do NOT fall-through-seed gaps that followrts/jmp/brk(that misclassifies data as code). - ROM filenames contain spaces and parentheses (e.g.
M.U.S.C.L.E. (U).nes); buildsincbina space-freerom.nesinsidecopilot/. 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. .gitignorecontainsROMS/*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
$C09EonDCP $3C20($CF) and filed 3,256 bytes of real code as.byte.nesdis.py --undocumenteddecodes all 105 (default off, so existingmake regenpipelines stay byte-stable). - ca65 V2.19 implements none of the undocumented opcodes, so they cannot be written as mnemonics.
nesdis.pyemits them as explicit.bytewith 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 plainnop, 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–$FFFFin the$C000mirror). Header bytes 0–9 carry only sizes, mirroring and mapper. Parsing them fromheader.binas 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. Usesetdefaultin 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
bneat$C4BAtargeting$C528swallowed by aNOP #$A5at$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 withpla— 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 withnesdis.py --probeis cheap and settles whether it is code;--auto-seedplus a couple of--extra-seedaddresses took Mario Bros from 14.6% to 72.2%. - When emulating to trace, NMI must fire once per frame, not per
$2002read. The main loop polls$2002to 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 =
$4016with a real latch/shift register. The poll routine'slda $4016,Xwith X=1 lands on$4017, but only bits 0–1 are compared, so driving$4017consumes nothing and changes no coverage at all. ca65 -llistings 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 printsrr rrfor relocatable words, so byte values must come fromprg.bin.- py65 is not a build dependency. Unpack on demand (
pip download --no-deps py65, thenunzipinto a lib dir;PY65LIBenv var points at it)./tmpis volatile — a reboot wiped it mid-session. Anything reusable belongs in_tools/.