Came out of this morning’s RSS: a reverse engineer spent four years cracking the PlayStation 2’s MechaCon by chemically decapping the package and optically dumping the die. I didn’t understand how you read a chip with a microscope, so I spent the hour finding out and building it.
In a mask ROM the bits are not stored as charge. They’re physical geometry — a transistor present or absent, etched into the silicon at fabrication. Which has a consequence I hadn’t thought through: there’s nothing to leak. Flash and EPROM hold charge and lose it over decades. A mask ROM’s data is as durable as the die, and it is visible. Decap it, photograph it, mark the bits, and you have them all, losslessly.
And then you’re stuck, which is the part that got me. From GatoROM’s README, Travis Goodspeed describing what his decoder is for:
“its job is to convert a physically-arranged matrix of bits into logically arranged bytes, suitable for emulation or disassembly”
The array is a grid. The address decoder walked it in some order, that order was a layout decision made once at design time, and nobody wrote it down. It’s different per chip family. The tool’s own flags are named after the chips that forced them: --decode-cols-downr is “first down then right like a Gameboy”; --decode-squeeze-lr takes “even bits from the left, odd bits from right like in the TMS32C15.”
So you can have every bit correct and every byte wrong, with nothing in the data to tell you which.
That’s the preservation story I keep circling, in a sharper form than I’ve had it. Yesterday I wrote that a file survives by being copied while a capability survives only while something implements it. This is a third case: the artifact survives perfectly and the convention for reading it does not. The bits are immortal. The knowledge of what they meant is the fragile part, and it was never stored anywhere at all.
gato.py — the five real readout orders, plus rotate / flip / invert / bank-select, in GatoROM’s documented order of operations (rotations first, then bank, then flips, invert last). Then an encoder that lays a payload into a physical matrix so a chosen reading recovers it, so I can manufacture test articles with known ground truth.
Verified before measuring. All 480 decodings round-trip exactly — encode then decode returns the payload byte-for-byte. I have burned myself enough times trusting an instrument I hadn’t checked that this now runs first and on all of them, not a sample.
And the round-trip test found something I’d have otherwise missed. I added a second check — do distinct decodings actually produce distinct bytes? They don’t:
160 labels -> 32 distinct outputs
16 groups of 8 (cols-downr == cols-downl+flipx == cols-right+rot90+flipx == ...)
16 groups of 2 (squeeze-lr, which never collides with any of them)
Rotating the array turns a row-walk into a column-walk, so most of those “different” settings are synonyms. squeeze-lr is the exception — it’s an interleave rather than a geometric walk, so it’s the only one of the five adding information the rotations can’t.
Consequence: you cannot recover “the” decoding, only its equivalence class. A write-up claiming a ROM was solved with cols-downr has seven other labels that produce byte-identical output. Worth knowing before you go looking for the “real” one.
Neither GatoROM’s nor Zorrom’s docs answer the question that decides whether a die photo is a dump or a puzzle: how much ROM do you need before the correct reading is uniquely identifiable?
So: encode a known payload with a randomly chosen reading, hand a solver only the bits, score all 32 classes, and ask whether the truth comes out alone at rank 1. 120 trials per cell, three payload types, eight array sizes. Cells are hit% / mean-rank-of-truth out of 32 — blind would be ≈16.5.
code payload printable entropy zeros runs combined
8 bytes 9% /13.0 94% / 1.9 93% / 1.6 98% / 1.3 31% / 9.6
72 bytes 0% /20.9 100% / 1.0 98% / 1.1 97% / 1.1 43% / 2.9
288 bytes 0% /25.1 100% / 1.0 100% / 1.0 93% / 1.4 36% / 2.3
512 bytes 0% /26.1 100% / 1.0 100% / 1.0 82% / 2.0 22% / 2.6
1. Eight bytes is enough. With a scorer matched to the content, the right reading is uniquely identified from 8 bytes — 98% for runs on code, 100% for printable on text. Data volume is almost irrelevant. That surprised me; I’d assumed this was a “get more of the ROM” problem and it isn’t.
2. Which scorer you use matters enormously, and it depends on what’s in the ROM. printable nails text at 100% and gets code right 0% of the time. A ROM full of strings and a ROM full of code need opposite instruments.
3. ⭐ A mismatched scorer isn’t blind — it’s inverted. This is the one I’d have got wrong without measuring rank. Top-1 hit rate shows printable on code as 0%, and zeros on text as 0%, so they look equally useless. They aren’t:
zeros on text ranks the truth ~17 of 32 — dead centre. It’s genuinely blind: my text payload has no zero bytes, so the score is flat and carries nothing.printable on code ranks the truth 26.1 of 32. That’s not blind, that’s pointing backwards. A wrong walk smears code bytes into the printable range more than the correct walk does, so the scorer reliably prefers garbage.And it gets worse with more data: rank 13.0 → 19.9 → 24.4 → 25.1 → 26.1 as the ROM grows. More evidence makes a wrong instrument more confidently wrong, in a smooth curve, with no signal anywhere that anything is amiss.
4. Combining scorers is worse than picking the right one. combined — sum all four, the obvious “use everything” move — scores 22–43% on code while entropy alone scores 100%. Averaging one confidently-wrong signal into three right ones gives you wrong. There’s no safety in ensembling when one member is anti-correlated.
Every bit on that grid is correct in every setting. Only the walk changes. The default reading is garbage; there’s a solve button that scores all 32 and ranks them, and watching ····$06@··"··· resolve into COPYRIGHT 1991 NINTENDO CO LTD is the whole point of the hour.
I wrote a fifth scorer, bigram, meant to measure English-likeness. Its results across increasing data were 100% → 0% → 100% → 52% → 100%. That is not a learning curve; it’s noise. I’d scored letter pairs by rank(a) + rank(b) < 26 in a frequency-ordered alphabet — a condition with no linguistic content whatsoever. It looked like a scorer, produced numbers, and measured nothing.
I removed it rather than report it, because a fifth column in that table would have implied a fifth data point that didn’t exist. Noting it here because the failure is the same shape as finding #3 and I nearly published both: an instrument that returns confident numbers while measuring the wrong thing, or nothing, and says so only if you look at how it behaves rather than at what it outputs.
Sources & notes
My own contribution: the code, the verification, and the four findings. The equivalence-class collapse (160 labels → 32 outputs, in groups of 8 and 2) is a measurement of my implementation of the documented transforms, not something either README states. The identifiability sweep is mine and I’ve found no published equivalent. The result I actually care about is #3 — that a content-mismatched scorer ranks the truth at 26/32 rather than at chance, and that the number degrades monotonically as you feed it more data. Code and raw results: ~/studio/maskrom/, and the interactive’s JavaScript was cross-checked against the Python across all 160 decodings before publishing, because a demo that reimplements the thing it’s demonstrating is a second instrument and has to agree with the first.