- Python 93.4%
- Lua 4.1%
- Assembly 1.5%
- Shell 0.6%
- Makefile 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
$F23C is the clearest case in the ROM of a mechanic that can be read off its gates. It does nothing unless GameMode is 5, the player is in a level or on one of six specific overworld screens listed in a 6-byte table, $53 is clear, EntryState_0657[12] is set, ObjPhase's top bits are not $40, and $64 is zero. Then it copies the player's ObjDir into $0F and calls ReadScreenTileAtObj -- the tile ahead -- requiring $F4 in a level or $8D..$98 on the overworld. If that passes it needs the *held* direction to equal the facing direction, so a sustained press rather than a tap, and only then allocates a slot and spawns object type $5F one tile away through four-entry offset tables indexed by direction 0-3. Two details finish it. Type $5F's behaviour handler is the bare rts at $FC94, so the spawned object runs nothing of its own and whatever moves it is elsewhere. And $64 holds its slot, doubling as the interlock that stops a second one starting -- named PushObjSlot. Sustained input into a specific tile, spawning an adjacent object with no behaviour of its own, one at a time, on six named screens. That is a push and the routine is named TryStartTilePush -- but the commit and the docs both say plainly that "push" is the reading of those facts rather than something the instructions state. This was the largest remaining lever: 7 callers and 19 derived labels. Auto-stem 26.5% -> 25.8%, branch labels in unnamed routines 728 -> 709. |
||
| 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 195 bytes |
| Typed data | 58 351 bytes |
| Unused filler | 35 526 bytes (27 % of PRG) |
| Named memory operands | 69 % |
| Fully named labels | 18 % (a further 41 % named but retaining an address) |
Run python3 tools/coverage.py and python3 tools/labelstats.py for current
figures. Naming is the remaining work: the structure is fully mapped, and about
180 routines still carry their address instead of a name.
Two notes on reading those percentages. Roughly a third of the operands that
remain unnamed are $00-$0F — the scratch and pointer bytes that dozens of
unrelated routines borrow — so naming them would assert a meaning they do not
have, and the honest ceiling is well short of 100 %. And the label figure moves
down whenever a previously invisible routine is surfaced, which has happened
twice: 176 routines that only another bank calls 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 (48 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
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.
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 extra checks —
unclassified runs, all-$FF gaps outside named padding, and call targets not
classified as code. Run it after structural changes.
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.
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.