The cartridge memory map and its transfer engine, measured against a real ROM library.
6 layouts, 289 header combinations replayed across 2,781 retail cartridges, 0 disagreements, 8 transfer channels, 707 tests, 100% statement and branch coverage, no dependencies
from mapper import LOROM, resolve
print(resolve(LOROM, 0x7E0900).region)
print(resolve(LOROM, 0x008000).region)work-ram
rom
The first of those two looks exactly like a cartridge bank and is not.
pip install git+https://github.com/gufranco/snes-mapper-python.gitPython 3.12 or newer. Nothing else.
To work on it instead:
git clone https://github.com/gufranco/snes-mapper-python.git
cd snes-mapper-pythonEverything a caller touches. Nothing else is public.
| Call | Does | Returns |
|---|---|---|
read(rom) |
Scores every candidate place a header could sit and returns the best | a Header |
score(rom, at) |
What one candidate place scores, before choosing between them | int |
board(found, size) |
Which map a cartridge of that size is on, from size rather than from the chipset byte | a layout name |
resolve(kind, address, fast=False, banks=None, save=False) |
Where one address lands and what reaching it costs | a Resolution |
MODELS |
Every layout this package covers, one key each | a mapping |
layout_named(name) |
The layout a name or an alias means | a Model |
channel_of(address) |
Which transfer channel an address configures, or nothing when it is outside the window | int or None |
Engine() |
The eight channels and the register that arms them | an Engine |
engine.plans() |
What every armed channel would move, without moving anything | a list of Plan |
interleave(logical) / deinterleave(built) |
Between the order the console reads and the order some dumps store | bytes |
bank_count(size) |
How many whole banks an image of that size holds | int |
snes_to_file(bank, addr, banks) / file_to_snes(offset, banks) |
Between an address and a position in a file | int / a pair |
has_copier_stub(rom) / stub_by_length(size) |
Whether an image carries the extra $200 bytes a copier prepends |
bool |
| Attribute | Is |
|---|---|
header.at / header.shift |
Where the header was found, and how far a copier stub moved it |
header.mapping / header.declared |
The byte that names a layout, and whether it is in range to be believed |
header.layout |
The layout, from the byte when it can be believed and from the offset when it cannot |
header.rom_bytes / header.ram_bytes |
The declared sizes, in bytes |
header.checksum / header.complement / header.checksum_agrees |
The pair, and whether they are consistent |
header.fast / header.battery / header.coprocessor |
Whether it asks for the faster bus, carries save memory, carries a chip |
header.chipset / header.country / header.title |
The remaining declared fields |
resolution.region |
One of rom, work-ram, save-ram, registers, open-bus |
resolution.offset |
Where in the image that address reads from, or nothing when it is not cartridge |
resolution.cycles |
Master clocks the access costs: FAST 6, SLOW 8, XSLOW 12 |
plan.addresses() |
Every address a transfer would touch, in the order it touches them |
Nothing here executes anything. It answers where an address lands and what a transfer would touch. Running code belongs to the processor packages, and mixing the two would make each harder to test.
Every defect in a year of this work lived in one layer, and it was not a processor.
A replay harness read its script from banks $7E and $7F, believing they were cartridge. A ROM survey saw no transfers at all because it watched the display's registers when the transfer registers belong to the processor. A channel index was masked with three bits when the field is a whole nibble. Channels that were never armed were read as live, so the survey reported transfers that never happened.
Not one of those is a CPU bug. Every one is the same question asked wrongly: which address is this, and who is allowed to read it?
Make that question something you assert rather than something you remember.
The map is a table, not a rule. The transfer registers are named constants, not literals copied from a datasheet at 2am. The channel index comes from the nibble the hardware uses. And plans() returns only channels the enable register actually selected, so an unarmed channel cannot pretend to be a transfer.
Correctness comes from a real library: 2,781 retail cartridges, parsed for every distinct combination of header fields they declare, with each combination checked against the layout and addresses it produces, and then every cartridge read again and held to the case that recorded it.
|
Work RAM is decided before cartridge, because its banks look like cartridge banks and are not. |
The same byte costs 6 or 8 master clocks depending on the half of the space and what the header asked for. |
|
|
289 header combinations covering all 2,781 retail cartridges, each re-read by digest on every run. |
from mapper import deinterleave, interleave
image = bytes(range(256)) * 1024
print(deinterleave(interleave(image)) == image)True
Some dumps store every bank's upper half first and every lower half afterwards. The console never sees it; it is an artefact of how the dump was taken. A patch written at a known address into an interleaved image lands half a bank away, in another bank's data, and nothing complains.
from mapper import LOROM, resolve
print(resolve(LOROM, 0x7E0900).region)
print(resolve(LOROM, 0x001000).region)work-ram
work-ram
They sit in the middle of the bank numbering and read like any other address. A patch written to one, or a script read from one, silently goes somewhere else. The bottom eight kilobytes of every low bank mirror the same work RAM, which is the second of those two lines and catches the same mistake a second way.
from mapper import CHANNEL_BASE, ENABLE
print(f"{ENABLE:#06x} {CHANNEL_BASE:#06x}")0x420b 0x4300
Not $21xx. A hook placed on the display's window sees every graphics write and not one transfer, and the symptom is a survey that reports a machine doing nothing.
from mapper import channel_of
print(channel_of(0x4370))
print(channel_of(0x4380))7
None
There are eight channels, so & 7 looks right. It folds $4380 onto channel zero instead of reporting that it lies outside the window.
from mapper import ENABLE, Engine
engine = Engine()
engine.write(0x4304, 0x7E)
print(engine.enabled)
engine.write(ENABLE, 0x01)
print(engine.enabled)[]
[0]
Registers hold whatever they last held, so walking all eight channels reports transfers that never happened.
from mapper import layout_named
print(f"{layout_named('lorom').header_at:#06x}")
print(f"{layout_named('hirom').header_at:#06x}")
print(f"{layout_named('exhirom').header_at:#06x}")
print(f"{layout_named('wholebank').header_at:#06x}")
print(layout_named("mode20").name)
print(layout_named("hirom").resolve(0xC00000).region)0x7fc0
0xffc0
0xffc0
0x7fc0
lorom
rom
| Layout | Header at | Notes |
|---|---|---|
lorom |
$7FC0 |
A 32 KB page per bank in the upper half. Aliases: lo, mode20, 20 |
hirom |
$FFC0 |
A whole bank per bank, save memory windowed into the lower banks. Aliases: hi, mode21, 21 |
exhirom |
$FFC0 or $40FFC0 |
The high layout with its two halves swapped. Aliases: exhi, mode25, 25 |
wholebank |
$7FC0 |
A whole 64 KB per bank below the window, from an interleaved image. Reaches 12 MB. Aliases: whole, wholebanks, interleaved |
The header itself recognises sa1 and spc7110 as declared layouts, because real cartridges declare them and a census must count them. They are not resolvable here yet, since neither has a corpus behind it, and a layout with nothing backing it would be a guess rather than a measurement. That is the difference between the six a header can name and the four an address can be resolved against.
exhirom is hirom with its two halves exchanged. Banks $80-$FF carry the first four
megabytes and banks $00-$7D, which include the ones holding the reset vector, carry
everything past them.
from mapper import layout
print(f"{layout.resolve('exhirom', 0x00FFC0).offset:#08x}")
print(f"{layout.resolve('hirom', 0x00FFC0).offset:#08x}")0x40ffc0
0x00ffc0
Four megabytes in, which is why an extended cartridge keeps a header there, against a plain high cartridge that never reaches past four megabytes at all.
The ceiling is layout.EXHIROM_BYTES, eight megabytes: four through $80-$FF and four
through $00-$7D, with the very top reachable only through banks $3E and $3F because
$7E and $7F are work RAM and never leave the console. An image larger than that is not
addressable by this layout however it is declared, so a ninety six megabit file is built
around a different map rather than a wider one.
Every layout above spends part of a bank on something other than cartridge, which is why
none of them gets past eight megabytes. The whole-bank map spends nothing below its
window: banks $00-$BF each carry a full 64 KB, so 192 banks of cartridge fit and the
image holds all of them. The file stores every bank's upper half first and every lower
half afterwards, which is the interleaving mapper/image.py already
converts between.
from mapper import WHOLEBANK, WHOLEBANK_BYTES, bank_count, resolve
banks = bank_count(WHOLEBANK_BYTES)
print(banks)
print(f"{resolve(WHOLEBANK, 0x008000, banks=banks).offset:#08x}")
print(f"{resolve(WHOLEBANK, 0x400000, banks=banks).offset:#08x}")
print(f"{resolve(WHOLEBANK, 0xC04D6A, banks=banks).offset:#08x}")192
0x000000
0x800000
0xa04d6a
The first is the upper half of bank $00, exactly where plain LoROM puts it. The second is
the lower half of bank $40, one whole image further in. The third comes through the window
the high banks open.
It is named for its shape rather than for a chip. The S-DD1 boards were the first to need it and are where it was first measured, but nothing in the arithmetic mentions a coprocessor, and the traffic mostly runs the other way now: taking a chip out of a cartridge by baking its answers into a lookup table makes the image larger and leaves the map alone, so an expansion lands here declaring no chipset at all. A cartridge on this map may carry a coprocessor, may have had one removed, or may never have had one.
Important
The size is an equality, not a floor. layout.WHOLEBANK_BANKS is 192 because the window reads its lower halves from the run belonging to bank $80, so its topmost byte sits 191 + banks half-banks into a file that is 2 * banks half-banks long. Those meet at exactly 192, and anything smaller has the window addressing bytes past the end. resolve refuses a smaller bank count rather than returning an offset outside the image.
That is also why board() reads size and never the chipset byte. Star Ocean's
retail six megabyte cartridge declares S-DD1 and is not on this map: it reaches past
the low layout by having the chip switch banks, which is a different mechanism. Reading the
chipset as the signal would have pointed its window a megabyte past the end of the file.
from mapper import Header, board
plain = Header(0x7FC0, bytes(32))
print(board(plain, 12 * 1024 * 1024))
print(board(plain, 4 * 1024 * 1024))wholebank
lorom
A stale chipset byte therefore costs nothing here. An expansion built from a cartridge that had a coprocessor often still claims it, because clearing the field is a separate act from removing the part, and this never reads the field.
Measured across 2,781 retail cartridges from every region, and nothing else. A modified release, a translation and a prototype can each carry an edited header, and a header read out of one describes somebody's edit rather than a cartridge that was manufactured.
| Layout | Cartridges |
|---|---|
lorom |
2,157 |
hirom |
572 |
sa1 |
42 |
exhirom |
6 |
spc7110 |
4 |
1,701 ask for the faster bus and 957 carry battery-backed save memory. 2,780 of
2,781 carry a checksum consistent with its own complement. Headers were found at three
distinct offsets, two of them past a copier stub that shifts everything by $200.
Note
A further 8 files carry no readable header. They are counted as refused rather than guessed at, because inventing a layout for them would put fiction into a corpus of facts.
Ten mapping bytes exist and all of them are 0x2x or 0x3x. A title of twenty two
characters overflows its twenty one byte field and writes its last letter over the field
that follows, which is this one. Contra III leaves an S, Krusty's Super Fun House and
Space Football leave an E, and the low nibble of a letter names a layout no cartridge has.
from mapper import Header
found = Header(0x7FC0, b"CONTRA3 THE ALIEN WARS" + bytes(10))
print(f"{found.mapping:#04x}")
print(found.declared)
print(found.layout)0x53
False
lorom
0x53 is S, the last letter of the title. Because it is out of range the byte is not
consulted, and the layout comes from the place the header sits, which is the signal that
survives.
13 of the 2,781 carry an overflowed byte. Reading the low nibble of one gave 51 retail
cartridges in a wider library the wrong layout, the wrong bus speed, or both. A byte that
is in range is believed even where it disagrees with the offset, because exhirom puts
its header where hirom does whenever the image is small enough that the far copy would
sit past the end of the file.
A header is thirty two bytes in which a cartridge describes how it is built.
| Field | What it is | Ships? |
|---|---|---|
| Mapping, chipset, ROM and RAM size, country | Facts about a physical object | Yes |
| Counts of how many cartridges share a combination | A measurement | Yes |
| The title | A name rather than a measurement | No |
| Anything outside the header | The game | Never read |
Facts and functional elements sit outside what copyright reaches, per 17 U.S.C. 102(b) and Feist. conformance/census.py reads only those thirty two bytes, records no title, and never reads a byte outside the header.
conformance/corpus.json then carries every distinct combination the library contains, together with the layout it produces and what fourteen probe addresses resolve to under it. A model that gets a rare mapping byte wrong fails against the combination that names it.
Important
This is how the repository is built, not legal advice. The rule it follows: publish behaviour, never content.
python3 -m conformance.census "/path/to/roms" census.json 2781 cartridges read, 8 refused, from /path/to/roms
289 distinct header combinations
written to census.json
A recording nothing can reproduce is a recording nobody can check, so the recorder ships alongside the recording. Anyone holding the same cartridges can rebuild the file and confirm it byte for byte.
python3 -m conformance.record "/path/to/roms" conformance/corpus.jsonTwo cartridges with identical header fields at different offsets are two cases rather than one, because where a header sits is what names the layout whenever the byte that should name it is a letter left behind by an overflowing title.
Put copies you already own in cartridges/, or point SNES_CARTRIDGE_DIR
at a library. Subdirectories are walked, so an existing collection can be pointed at whole.
Every file is checked against all four of its digests before it is read: sha256 decides,
and the other three are confirmed too, because a manifest that publishes a crc32 beside a
sha256 and never looks at the crc32 is publishing decoration.
Nothing in that directory is shared. cartridges/README.md lists
every filename and its four digests, and a digest reconstructs nothing.
| Evidence | What it settles | What it cannot |
|---|---|---|
Nintendo's manual, and arithmetic shown from it in conformance/hardware.json |
The two bus speeds, and therefore the fast and slow access counts | The extra-slow count, which the manual does not print and which is marked unverified |
| Header combinations read out of 2781 real cartridges | What a combination means on shipped hardware | Combinations no cartridge carries |
| Digests of every cartridge read | That the file read was the file named | Nothing about whether that release is the one you meant |
What this does not model, stated so nobody inherits a stronger belief: the CPU, and therefore how many accesses an instruction makes; refresh and DMA stalls; and anything about what happens once an address is reached, which belongs to whatever answers there.
Why is header detection a score rather than a test?
Because the header has no fixed home. It sits at a different offset depending on the layout it describes, so you cannot know where to look without already knowing the answer. Every candidate is scored on four independent signals and the best wins. That is what every emulator does, and it is why two of them occasionally disagree about an unusual cartridge.
Each signal earns its point by measurement rather than by argument. The declared size counts when it lands between 8 and 13, because across 7,314 cartridges that band holds the byte at the right offset 98.85% of the time and at a wrong offset 3.18% of the time. Widening it either way was measured and costs more than it buys: no cartridge declares 14 and fifteen wrong offsets do, and reaching down to 7 gains seven real cartridges at the price of twelve more wrong ones.
Does this emulate anything?
No, and deliberately. It answers where an address lands and what a transfer would touch. Executing code belongs to the processor packages, and mixing the two would make each harder to test.
Why are SA-1 and SPC7110 recognised but not resolvable?
Because recognising them is a measurement and resolving them would be a claim. Real cartridges declare both, so a census has to count them. Neither has a corpus behind its address mapping yet, and a layout with nothing backing it does not belong in a table of answers.
Every distinct combination of header fields the library declares is replayed against the layout and the addresses it produces: 289 combinations, 0 disagreements. The corpus runs with no cartridge anywhere on the machine, because it carries the measurements rather than the files they were taken from.
python3 -m conformance.corpus 289 header combinations from conformance/corpus.json
measured across 2781 cartridges
289 agreed, 0 did not
With a library present, the sweep reads every file in it and holds each cartridge to the case that recorded it:
python3 -m conformance.against_cartridges 2781 cartridges read from cartridges
2781 agreed, 0 did not
That sweep is skipped rather than passed when no cartridge is present, so a run that proved nothing never reads as a run that proved something. CI attempts it on every push and annotates the skip.
conformance/hardware.json holds what Nintendo printed about
bus timing, with the sentence it came from, and shows the arithmetic from those figures to
the two access counts this package returns. The third count, for the controller-port
region, follows from no document and is marked unverified rather than presented beside
them.
conformance/divergences.json holds every place two
sources part, with what would settle it, and OPEN-QUESTIONS.md
carries every place fidelity here is a claim rather than a measurement.
python3 -m coverage erase
for file in $(find mapper conformance -name '*.test.py' | sort); do
python3 -m coverage run -a "$file"
done
python3 -m coverage report| Command | Description |
|---|---|
ruff format . |
Format |
ruff check . |
Lint |
mypy |
Types, at strict |
python3 -m coverage report |
Coverage, which fails below 100% |
python3 -m conformance.corpus |
Replay the corpus |
python3 -m conformance.speed |
The throughput floor |
python3 -m conformance.census <dir> <out> |
Census a library you own |
Everything under conformance/ runs as a module rather than as a script. Run as a script,
its own directory goes on the import path and a file there shadows any standard library
module of the same name.
python3 mapper/doctor.py says what is actually on this machine: every layout, a header read out of an image built on the spot, and whether the cartridge library this repository cannot carry is here and holds anything. It is run as a file rather than with -m so that it still runs when the package itself will not import, which is the case it exists for. Its report is what an issue asks for, because a report is only as good as what it says about the machine that produced it.
Tests sit beside the module they cover, named <module>.test.py. Coverage is 100% of
statements and branches, enforced by pyproject.toml. Types are mypy at
strict. Commits follow Conventional Commits, and
releases are cut from main by semantic-release.
Important
While the version is below 1.0.0, the public interface may change on a minor release.
AGENTS.md is the document for an agent working here.
FAMILY.md is the standard this repository shares with the rest of the
family, kept identical in every member above the marker at the end of its shared part.
mapper/
__init__.py the package
errors.py everything this package raises, importing nothing from it
header.py the thirty two bytes, and finding which candidate place holds them
layout.py where an address lands and what reaching it costs
transfer.py the eight channels, and planning what one would move
image.py where a byte sits in a file, which is not where the console sees it
models.py what each layout is
version.py rewritten by the release job and by nothing else
conformance/
corpus.py replays every real header combination
corpus.json 289 combinations covering 2,781 retail cartridges
record.py rebuilds that corpus from a library, so it can be checked
census.py takes a census of a library you own
cartridges.py identifies a supplied cartridge by all four of its digests
against_cartridges.py reads every cartridge present and holds each to its case
hardware.json what Nintendo printed, with the sentence
divergences.json where sources part, with a status and a severity
links.py the weekly check that every cited address still answers
speed.py the throughput floor
cartridges/ a library you supply, ignored by git, never shared
cartridges.manifest.json 2,778 retail cartridges, four digests each, no content
| Suite | File | Covers |
|---|---|---|
| Header | mapper/header.test.py |
Candidate places, copier stubs, every field, scoring |
| Layout | mapper/layout.test.py |
Regions, mirrors, speeds, offsets, whole-space coverage |
| Transfer | mapper/transfer.test.py |
Registers, the nibble index, arming, planning, wrapping |
| Models | mapper/models.test.py |
The catalogue, aliases, resolution |
| Image | mapper/image.test.py |
Interleaved dumps, windowed banks, and that every conversion is its own inverse |
| Errors | mapper/errors.test.py |
That every exception has one home and one name |
| Corpus | conformance/corpus.test.py |
The whole shipped set, coverage, reporting |
| Recorder | conformance/record.test.py |
Rebuilding the corpus, and that rebuilding twice writes the same file |
| Census | conformance/census.test.py |
Walking a library, the tally, and that no title is recorded |
| Cartridges | conformance/cartridges.test.py |
The manifest, all four digests, where a library is looked for |
| Sweep | conformance/against_cartridges.test.py |
Every cartridge present, against the case that recorded it |
| Speed | conformance/speed.test.py |
The floor, and that a run too fast to time is not read as infinitely fast |
| Family | conformance/family.test.py |
That this repository still matches the standard every member carries |
This repository carries no documents and no cartridges. Every claim is traced to something published elsewhere, or to a measurement taken from files a reader supplies themselves.
| Document | Publisher | Read on | Read via | Redistributable |
|---|---|---|---|---|
| Super Nintendo Entertainment System Development Manual, Book I | Nintendo | 2026-08-20 | The text archived at archive.org | No |
The sentences taken from it are in
conformance/hardware.json, each with the table it came from.
That record carries the reading rather than a digest of the file, which is the one place
this repository falls short of the family standard and is recorded as such in
OPEN-QUESTIONS.md.
| Source | Used for |
|---|---|
| A retail cartridge library the author owns | The 2,781 headers behind conformance/corpus.json. Nothing from it is committed, and cartridges.manifest.json carries only digests |
CITATION.cff is kept in step with the released version by the same script that stamps the package, so the version it names is the version that shipped. GitHub renders it as a Cite this repository button.