A byte-exact disassembly and reverse-engineering toolchain for an 8-bit console game — a 128 KiB cartridge ROM for the NES, on an SNROM board with the MMC1 mapper, published in 1986. The target is identified by hash rather than by title: sha1 4671517d72d09799403f6c672cd2b395933e926e size 131088 bytes
  • Python 95.9%
  • Lua 2.4%
  • Assembly 1.1%
  • Shell 0.3%
  • Makefile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Chad Nelson 7fdac27439 Say plainly that this was written by an AI
The repository nowhere disclosed how it was produced, and
annotations.py opened with "Hand-maintained knowledge base", which was
never true of that file. Both are fixed.

README gains a "How this was produced" section, linked from the
documentation index and from a banner at the top of all three docs. It
states what was written by the AI (tools, docs, annotations, commit
messages), notes that the Co-Authored-By trailers cover roughly one
commit in seven and understate it, and then splits the honest part in
two: what is checked by machine, and what is not.

The second half is the one worth reading, so it names five failures this
project actually had rather than describing the risk in the abstract --
the regex that read `cmp #$81` as a reference to $0081, the
decimal/hex Mode13_ collision, the two annotations that each took their
bound from the other, the guard written narrowly enough to miss its own
motivating case, and the two one-off scans whose findings were artifacts
of the scan. Two were caught by a machine check, one by ca65 refusing a
duplicate label, and two by re-reading the diff. That ratio is the
summary.

docs/toolchain.md gets the long form: why the method attacks
classification before naming (one is falsifiable by machine and the other
is not), why "a name must not outrun the evidence" is doing more work
than it looks like, and a note that "hand-written" throughout these
documents has always meant "asserted rather than derived", not "written
by a human".

The licence section now says the work is AI-generated and that some
jurisdictions may treat it as uncopyrightable, so a reuser can make that
call up front instead of discovering it later.

No code changed; make is still byte-identical.
2026-08-23 13:06:48 -06:00
docs Say plainly that this was written by an AI 2026-08-23 13:06:48 -06:00
include The one place the ROM writes into its own relocated program 2026-08-22 10:53:43 -06:00
tools Say plainly that this was written by an AI 2026-08-23 13:06:48 -06:00
traces Reach the death flow; identify health bytes; fix make verifying stale asm 2026-08-20 10:53:11 -06:00
.DS_Store Run the dispatch-case pass before the attribution pass, not after 2026-08-21 14:35:36 -06:00
.gitignore State the real reason the generated assembly is not committed 2026-08-21 13:21:01 -06:00
bank7.cfg Rename to bank7; remove trademarked names; fix the first-build-after-clone bug 2026-08-21 13:03:06 -06:00
LICENSE Add MIT License 2026-08-20 08:50:54 -06:00
Makefile Rename to bank7; remove trademarked names; fix the first-build-after-clone bug 2026-08-21 13:03:06 -06:00
README.md Say plainly that this was written by an AI 2026-08-23 13:06:48 -06:00

bank7

A byte-exact disassembly toolchain for a 1986 NES cartridge — 128 KiB of PRG on an SNROM board with the MMC1 mapper. The build reassembles to a bit-for-bit identical ROM, which is the correctness gate for every change: if make reports OK, the analysis has not altered the game.

sha1  4671517d72d09799403f6c672cd2b395933e926e
size  131088 bytes

The project is named after bank 7, the 16 KiB PRG bank this cartridge maps permanently at $C000. It holds the engine every other bank calls into, so it is where most of the work starts. The target is identified by hash rather than by title.

Status

Every byte of the ROM is accounted for.

Classified 100 % of real content — no unknown bytes
Code 37 189 bytes
Typed data 58 357 bytes
Unused filler 35 526 bytes (27 % of PRG)
Named memory operands 76 %
Fully named labels 35 % (the other 65 % named but retaining an address)
Labels with an auto-generated name none

Run python3 tools/coverage.py, tools/labelstats.py and tools/operands.py for current figures. Naming is the remaining work, and it is now all of one kind. No label is called sub_A0A8 or dat_83D3 any more — every one says something about what it is, and 65 % of them keep the address alongside because the address is still the most specific thing known. Moving a label from that group to the fully-named group usually means reading the routine — though not always: 121 dispatch cases and the 34 tables that hold them carry no address at all, because each table is attributed to the routine it sits inside. That containment is a structural fact rather than a reading.

The operand ceiling is about 81 %, not 100 %

Half of the operands that remain unnamed should stay that way, and that is measured rather than estimated. tools/operands.py recomputes the split on every run; of 2 243 bare operands:

count share
$00-$0F — general scratch and pointer bytes 1 593 71 %
array bases referenced more often indexed than not 137 6 %
genuinely nameable 513 23 %

The first group is borrowed by dozens of unrelated routines, so a name would assert a meaning it does not have. The second are buffer bases whose element meaning depends on the caller — zero page $18, for instance, is written through $00-based indexed stores and read through $18-based ones, so it is not an array with a fixed purpose at all.

Naming everything in the third group and nothing in the first two lands at 81.5 %. That is the target worth aiming at.

The second group is not fixed, either — it has shrunk by 150 over four rounds, and the ceiling moved with it, 79.7 % → 80.5 % → 80.8 % → 81.5 %. Zero page $72, $73, $86 and $87 were counted as permanently-bare array bases and are slots 2 and 3 of ObjX and ObjY; a scan for sites that pair across the $14 between the two families then found slots 5 and 11 as well; and $0420$0425 turned out to be slots 16 of ObjSpeedFrac, which a routine already named for that had been asserting all along. A heuristic that says "leave this alone" is a default, not a verdict.

The figures here are lower than the ones this section used to quote — 86 % from 3 063 operands — because they now come from a tool with a written-down definition instead of a hand count that left out jsr/jmp targets and the indirect forms. The conclusion is unchanged, and it is the conclusion that matters: rather less than half of what is left is real work.

One more note on the label figures: they move down whenever a previously invisible routine is surfaced, which has happened twice. 176 routines reached only from another bank had no label at all, because a label is created by an in-bank reference and they had none.

Getting started

You supply the ROM. Place a copy at baserom/baserom.nes; it is verified by hash before anything else runs, so a wrong or altered dump fails immediately rather than producing quiet nonsense.

Requirements: Python 3.8+, and cc65 for ca65 and ld65. Mesen is optional, for the emulator tracing below.

make            # regenerate the assembly, assemble, and verify against the ROM
make asm        # regenerate the bank sources only
make clean      # remove build output

A successful build ends with:

OK  byte-identical to baserom  (131088 bytes, sha1 4671517d…)

How it works

tools/annotations.py is the knowledge base — addresses, table extents, record sizes, symbol names, and the reasoning behind each. Everything else derives from it plus your ROM. Three techniques got the coverage to 100 %, in increasing order of power:

Static analysis. A recursive-descent disassembler that follows bank switching, resolves the ROM's dominant control-flow idiom — a dispatcher that reads a jump table placed inline after its own call site — and classifies every byte as code, operand, data or unknown.

Runtime tracing. A purpose-built 6502 / MMC1 emulator records which addresses execute and which are read. Automated Mesen sessions drive real gameplay for the same purpose. This resolves what static analysis cannot: pointers built at runtime, and tables reached only through save RAM.

Revision diffing. Comparing two published revisions of the same ROM. Bytes the publisher changed are necessarily live; bytes blanked in the later revision are content that was withdrawn.

Layout

baserom/         your ROM (not distributed, not committed)
include/         hardware registers, the SRAM map, generated RAM symbols
src/             generated assembly — bank sources, header, relocated program
tools/           the toolchain (49 files; see docs/toolchain.md)
docs/            findings and method
traces/          runtime coverage data (addresses only)
bank7.cfg        ld65 memory and segment layout

Everything under src/ is build output, regenerated from your ROM plus annotations.py and excluded from git. traces/trace.json is tracked and holds only address ranges — which bytes executed, which were read.

Documentation

All of it, and the analysis it records, was written by an AI — see How this was produced below before relying on a claim.

The documentation keeps its retractions. Several conclusions here were wrong and were overturned by better evidence, including one that was retracted and then reinstated when the retraction itself proved mistaken. Three measurement errors are recorded the same way. A record showing only successes would misrepresent how the analysis went, and the wrong turns are often more useful than the conclusions.

How this was produced

The tooling, the analysis and this documentation were written by an AI — Claude, working in directed sessions with the repository owner, who supplies the ROM, sets the direction and reviews the result. That is not a footnote about one file; it covers tools/, docs/, annotations.py and the commit messages. It is stated here because a reader judging whether to trust a claim about $FAEF should know how the claim was arrived at.

Roughly one commit in seven carries a Co-Authored-By: Claude trailer. That understates it, and the history has not been rewritten to correct it; treat this section as the accurate record and the trailers as incomplete.

What that means in practice cuts both ways, and the second half is the part worth reading.

What is checked by machine. Every claim that can be reduced to bytes is. make reassembles the whole ROM and compares hashes, so a wrong extent, a misclassified byte or a bad table bound fails immediately and visibly. On top of that, tools/gaps.py runs twenty-three invariant checks that byte-identity cannot see — including several that exist because an earlier claim here turned out to be wrong. A conclusion that survives both has been tested against the cartridge, not against anyone's confidence.

What is not. Names and prose are judgement, and judgement is where a language model fails in a particular way: fluently, and in the shape of a real answer. Every one of these came from this project and is written up in docs/toolchain.md:

  • A scan that matched $([0-9A-F]{2,4}) against source text counted cmp #$81 as a reference to $0081 and produced two confident, wrong rows.
  • Mode13_SaveGame named GameMode $0D under one convention and $13 under another — two different handlers, four lines apart in the same table.
  • Two annotations each took their bounds from the other, and the pair read as settled for as long as neither was checked.
  • A guard was written narrowly enough to miss the exact case that motivated it.
  • Two one-off scans produced findings that were entirely artifacts of the scan; both are recorded as negative results rather than deleted.

The countermeasures are the repository's shape: a hard byte-identity gate, a growing set of invariant checks, a convention that a name must not outrun the evidence — so a label keeps its address rather than acquiring a plausible one — and a documentation record that keeps its retractions instead of tidying them away. None of that makes the judgements right. It makes them checkable, and it tells you which ones have been checked.

If you are reusing this work, the annotations in tools/annotations.py each carry the reasoning that produced them. Read the reasoning, not just the name.

Contributing

The rule that matters: make must still report OK. Byte-identity is cheap to check and catches most classes of error immediately.

It is necessary but not sufficient. Emitting a called routine's bytes as data assembles to exactly the same ROM, so tools/gaps.py carries twenty-three extra checks — unclassified runs, all-$FF gaps outside named padding, call targets not classified as code, and twenty more. Run it after structural changes.

Most of them exist because a claim in this repository turned out to be wrong, and the newest one earned its place immediately: it found the consumer of a 92-byte table whose annotation recorded three separate failed searches for one.

One of them is worth knowing about before you write an annotation: named RAM written only through an index. A scan for sta <address> cannot see a block copy that starts a few bytes earlier, and a claim built on one has been wrong twice here. The check lists every named symbol in that position.

Two conventions worth knowing:

  • Derive extents; do not guess them. Prefer a bound the ROM states — a loop counter, an index mask, an exact fit to the next structure — over a plausible round number. Where a claim is weaker than usual, the annotation says so.
  • A name should not outrun the evidence. A field used one way in one routine and another way elsewhere gets a name describing the use it was derived from, and a note saying so. Where nothing is known, the label keeps its address rather than acquiring a plausible-sounding name.

Emulator tracing

Optional, and useful for anything reached only at runtime. tools/mesen/ contains a self-driving session that plays the game, dumps Mesen's code/data log, and merges it into traces/trace.json.

python3 tools/mesen/setup.py     # read the note below first
tools/mesen/run_auto.sh

setup.py is the only thing here that writes outside the repository. It edits Mesen's own settings.json — enabling script I/O access, raising the script timeout, registering the session script — because the automated run silently does nothing otherwise. Your settings are copied to settings.json.bak-decomp first. It will also install a battery save into Mesen's Saves/ if you have placed one at saves/bank7.sav. If you would rather not have a script touch your emulator configuration, make those three changes by hand; the script prints exactly what it sets.

Cartridge

Mapper MMC1 (SNROM), iNES mapper 1
PRG ROM 128 KiB — 8 × 16 KiB banks
CHR 8 KiB CHR RAM — no CHR ROM, so tile data lives in PRG and is copied to VRAM at runtime
PRG mode 3 — bank 7 fixed at $C000, banks 06 switchable at $8000
Save 8 KiB battery-backed SRAM at $6000

One consequence shapes much of the toolchain: 4 720 bytes stored in bank 1 are code assembled for $6C90. The game copies them into save RAM and executes them there, so they cannot be disassembled where they are stored.

License

The tooling, documentation and annotation database are original work, released under the MIT License — see LICENSE. They are also AI-generated, in the sense set out in How this was produced: written by Claude under the repository owner's direction, who holds and grants the licence. Some jurisdictions treat machine-generated text as uncopyrightable, which would put those parts in the public domain rather than under the MIT terms; either way nothing here is withheld from you, and the disclosure is on the page so you can make that call yourself rather than discover it later. This is a description, not a legal opinion.

That covers this repository's contents only. The game is not covered by it and cannot be: its code, data, graphics, text and audio remain the property of their copyright holder. None of it is included — no ROM data is committed, and the build requires you to supply your own copy. The MIT grant does not extend to what make produces from your ROM, and could not.

Use a ROM you are legally entitled to. Nothing here will produce one, and the build refuses to run without one.

Two points that are easy to run together: none of the game's data is committed here, which is a statement about this repository, and the generated assembly is kept out of git for maintenance reasons, which is a choice rather than a requirement. Comparable projects commit their full disassembly as source and use the ROM only to verify the build matches; this one is stricter by preference, because a committed copy could only drift from annotations.py while the byte-identity gate already lets anyone reproduce it exactly.

What this repository contains of the original

Measured, so anyone can re-run the checks:

ROM, save or binary files, in any commit, ever none — every blob is valid UTF-8 with zero NUL bytes
Game text strings none — the docs describe what each store holds without reproducing it
Hardcoded game data in the tooling none — extents are derived by reading the ROM at build time
Verbatim data or code excerpts none in the documentation or the commit history

The one exception is deliberate: comments in tools/annotations.py cite short byte sequences and instruction pairs as the evidence for a conclusion, because a claim about where a table ends is not checkable without them. Those citations come to roughly 80 bytes in total — 0.06 % of the cartridge — and none of them is game text, graphics or audio.

This is a description of the contents, not a legal opinion.