- Python 95.9%
- Lua 2.4%
- Assembly 1.1%
- Shell 0.3%
- Makefile 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| docs | ||
| include | ||
| tools | ||
| traces | ||
| .DS_Store | ||
| .gitignore | ||
| bank7.cfg | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
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 1–6 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
docs/architecture.md— how the ROM is put together: banking, the object tables, graphics upload, sound, save formatdocs/toolchain.md— how the tools work, and the mistakes that shaped themdocs/tracing.md— emulator tracing, and what it settled
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 countedcmp #$81as a reference to$0081and produced two confident, wrong rows. Mode13_SaveGamenamed GameMode$0Dunder one convention and$13under 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 0–6 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.