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 93.4%
  • Lua 4.1%
  • Assembly 1.5%
  • Shell 0.6%
  • Makefile 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Chad Nelson 778b85e8cf Identify a whole mechanic from its preconditions
$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.
2026-08-21 15:49:39 -06:00
docs Identify a whole mechanic from its preconditions 2026-08-21 15:49:39 -06:00
include The 32x22 array in save RAM is the screen's tile map, column-major 2026-08-21 14:05:08 -06:00
tools Identify a whole mechanic from its preconditions 2026-08-21 15:49:39 -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 Update the status figures, and say what the percentages do not mean 2026-08-21 14:25:20 -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 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

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

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.