This article remains the identity milestone it says it is. A later unified build now shows Storm
moving, performing all six standing normals and completing Lightning Storm against Gambit: ten
life debits, 112 damage, native combo counter 10 and 35 simultaneous owned class-$28 objects,
with zero invalid pointer events and zero writes to Storm's fighter fields.
The input lineage is intentionally narrower than "exact replay." Ten masks come from verified
xmvsfu replay frames 5090..5099; exactly three simultaneous-direction/SOCD rows
(5095..5097) normalize D+L+R mask $5F8 to D+R $5E8. The receipt sets
exact_wire_claim: false. The harness also
writes a diagnostic core-enable once, positions the select cursor and stabilizes P2; Storm's HUD
still says JIN. Final ROM md5: 4279ea69bb585ad92dce98319082490b; Storm capture receipt
SHA-256: 597bcadde10aa5af05e29a79ca25fd3ba9e76476a0e8b6dafefce7c0fd72bc5f.
That closes one real gameplay route, not the full moveset, audio, identity or regression matrix. Watch the scoped Storm and Magneto gameplay follow-up →
Storm from X-Men vs. Street Fighter is standing in Marvel vs. Capcom. She has her own art,
her own animation tree, and — statically — her own voice. She is sitting in Jin Saotome's chair
at id $18, third row, third column, Down Down Right from Ryu.
And the table this project spent a whole session preparing to patch — the roster-indexed table with exactly the right shape, exactly the right record size, and a fingerprint so clean it explained a documented quirk of the game — is never read at the character select screen. Not once. Not in either address space. Not on either of two independent runs.
I know that because the same capture that reported zero reads on the table reported sixteen reads on a control address in the same window, from the same tap machinery, in the same run.
- Decode a CPS-2 select grid yourself. The addressing formula, the cell geometry, the tile code stride and the per-cell palette are all here, measured, not guessed.
- Build a MAME read-tap that cannot lie to you. Including the two mistakes that make a tap report "nothing found" when the truth is "your probe is dead."
- Import PCM into a QSound ROM with no reclaim at all, and know the one 64 KB rule that invalidates a naive packer.
- Skip four landmines that each cost this project real hours, listed with the symptom you will actually see.
Skip to what you came for
- Act 1 — seating a character inside another one — the tile rule, the bank selector, the directories. Pure hex.
- Act 2 — the audio import — the id-block collision that is not a collision, and the samples that were already in the ROM.
- Act 3 — the reversal — the tap, the control, and what actually draws the grid. This is the part to read if you only read one part.
- Act 4 — the icon is drawn — flip bits, the palette base, and the transparent pen. The part with the pictures.
- Act 5 — making it a ROM patch — 76 bytes of program ROM, listed. The part you can apply yourself.
- Act 6 — what a whole character costs — 30 KB and four pointers, plus two wrong answers and the control that killed both.
- Landmines — nine things that will eat your evening.
- The number I do not trust — a 20x disagreement, stated honestly.
- Reproduce it — exact commands.
Storm's art and animation are hers. Her move logic is not. This is a phase-1 seat: the
donor's state machine is still the dispatcher, which means she stands in Jin's chair and moves by
Jin's rules. The manifest says so in its own status field:
"status": "STATIC PASS ONLY. Jin remains the dispatcher; this is an
inspection baseline, not a Storm move-logic claim."Everything below is true. None of it means "Storm is finished."
Act 1: seating a character inside another one
The expanded build maps an extra program member, mvc.exp, at CPU $500000. Storm's imported
data occupies $508000..$523006. Magneto — the first import, from an earlier session — starts at
$588000 and is untouched by this work.
- 1box shapes (Jin's own window)$3A6000–$3A6800 · 2 KBthe one stock range this build overwrites; Jin's original SHA-256 is recorded for rollback
- 2animation tree$508000–$510000 · 32 KB26,576 B, pointers relocated to 32 destination groups
- 3source code oracle$510000–$512000 · 8 KB7,720 B parked and never dispatched — this is why move logic is not hers
- 4hitboxes$512000–$513000 · 4 KB1,650 B
- 5move / attack properties$513000–$514000 · 4 KB3,074 B
- 6palette$514000–$515000 · 4 KB544 B
- 7private framedata pack$515000–$523006 · 56.0 KB57,350 B: 829 framedata blocks + 665 tile rows
- 8Magneto (previous import, untouched)$588000–$5E0000 · 352 KB
The tile rule is one line
This is the piece that makes a cross-game graphics import tractable, and it is worth stating on its own because it removes an entire class of work:
destination tile = XvSF tile − 0x10000
Exactly one bank, so in-bank tile codes are unchanged and spritelists relocate with no translation at all. Verified 15,303 / 15,304 on the shipped Magneto build.
A CPS-2 character's tile codes are 16 bits plus a per-character bank byte. If your import lands inside one bank, every code in every spritelist stays literally the same and you are only moving pixels. If it straddles two banks, you are rewriting every reference. Check this before you plan anything else.
Storm's seat:
| value | source | |
|---|---|---|
| graphics bank | 4 | storm_phase1_manifest.json → gfx.bank |
| chips | mvc.h13m mvc.h15m mvc.h17m mvc.h19m | odd H chips |
| chip CRC32 | 8d2e879e beffa427 3e02ddb8 b1c37a36 | manifest gfx.chip_crc32 |
| distinct tiles | 12,377 | manifest gfx.distinct_tiles |
| gfx bytes | 1,584,256 | manifest source_sizes.gfx_bytes |
| low code range starts at | 0x0001 | manifest gfx.low_code_range |
The upper-bank selector, and the off-by-one that looks like a wrong table
Each character's bank is selected by a byte in a directory word table:
upper-bank selector: DIR_WORD + id + 1 value = bank x 2
Magneto id $04 -> $0C (bank 6, even H chips)
Storm id $18 -> $08 (bank 4, odd H chips)The + 1 is real and it is the most common way to lose an hour here. So is the endianness:
KitRom reads $0E67C8 big-endian-consistent. A raw concatenation of the members is
byte-swapped relative to it. An off-by-one in this table does not look like an off-by-one —
it looks like you are reading a completely different table, because every value shifts to a
plausible-but-wrong neighbour.
The five per-character directories
These are the pointers that make a character a character. Write them down once; you will use them in every phase.
| directory | address | holds |
|---|---|---|
DIR_ANIM | $0E676C | animation tree root |
DIR_WORD | $0E67C8 | bank / attribute words (the + id + 1 selector above) |
DIR_BOXSHAPE | $0E67F6 | box-shape window |
DIR_HITBOX | $0E6852 | hitbox table |
DIR_MOVEPROP | $0E68AE | move / attack properties |
Jin's box-shape window is $3A6000; Captain America's is $3A1000. Storm's import overwrites
Jin's window in place, which is why the manifest carries jin_boxshape_sha256 and a recovery
note: rebuild from the immutable base zip.
Storm's source pointers on the XMvSF side, for anyone doing the same import:
| what | XMvSF address | bytes |
|---|---|---|
| code (parked, not dispatched) | $024E6C | 7,720 |
| animation tree | $0AB244 | 26,576 |
| box shapes | $357000 | 2,048 |
| hitboxes | $099FCA | 1,650 |
| move / attack properties | $08E9B6 | 3,074 |
| palette | $11EC68 | 544 |
The palette directory that eats builders
Two builders in this project read the wrong table and produced wrong colours — including Magneto's second palette, which took a while to attribute because "the second palette is wrong" sounds like an alternate-costume bug rather than a directory bug.
| game | palette directory | helper |
|---|---|---|
| MvC | $1C7268 + 4 x row | $00A486 |
| XMvSF | $1145A8 + 4 x row | $009ED6 |
192 bytes per character: 3 base lines, 3 alternate lines at +$60. Both games' copy loops are
the same shape — 24 longwords, with lea $60(a0),a0 for the alternate.
$11E83C is not the palette directory. It is what both builders were wrongly reading, and it
is close enough in shape to produce plausible garbage. If your imported character's colours are
wrong in a way that looks systematic rather than random, check which directory you are reading
before you check your palette conversion.
And one thing you must not forget on a stock-layout build
MvC's boot code at $000236 samples one byte every $FFF across eight banks and sums them.
The expected sums are stored at $0000D0, parked in the unused 68000 exception-vector area. Any
stock-layout build has to recompute them. (The expanded build sidesteps this; the stock-layout
track does not.)
phase-1 seat
What is actually hers, route by route
Pointer safety and behaviour acceptance are different columns on purpose.
| route | data seated | renders | behaviour | audio |
|---|---|---|---|---|
| idle / walk / jump art | verified | verified | donorJin's state machine drives the chains | bridgedrow rewritten, not yet heard |
| animation tree | verified | verified | donor28 of 31 groups translate by index; 3 are decisions | pending |
| hitboxes / box shapes | verified | pendingnot visually confirmed | donor | pending |
| palette | verified | verified | verified | pending |
| move logic | pendingsource code parked at $510000, never dispatched | pending | donorthis is Jin, entirely | pending |
| select grid icon | verified48 art tiles in the GFX ROMs | verifiedROM boot, no Lua or debugger writes | verified8 source entries repointed and deflipped | pendingvisual identity only; no audio claim |
Act 2: the audio import, and the collision that was not one
Every CPS-2 fighter gives each character a contiguous block of 32 sound-command ids and a fixed row that maps animation slots to them. The dispatch is short enough to read in one sitting:
; MvC sound-post routine
$0D5EF0 movem.l d0-d3/a0-a1,-(a7)
$0D5EF4 move.w $52(a6),d2 ; character id
$0D5EFE move.w $d5f30(pc,d2.w),d2 ; 23-entry offset table
$0D5F02 lea.l $d5f30(pc,d2.w),a0 ; -> this character's sound ROW
$0D5F10 add.w d1,d1 / add.w d1,d1 ; animation slot * 4
$0D5F20 move.w (a0,d1.w),d1 ; slot -> sound action id
$0D5F24 beq.b $d5f2a ; 0 = silent slot
$0D5F26 bsr.w $d5a7e ; post itNow the part that sounds like a disaster and is the opposite of one.
Storm's id block in XMvSF is $1C0–$1DF. Jin's id block in MvC is also $1C0–$1DF.
Two characters allocated the same block number in two different games is not a conflict — it is
the best possible outcome. Seven of Storm's eight in-block ids land on ids Jin already uses, and
the eighth, $1C7, is free inside that same block. So the import is close to an in-place
substitution.
Compare Magneto: his XMvSF block $260–$27F is Ryu's block in MvC, which is why the Magneto
audio builder carries aliasing, packet tagging and a Z80 hook that Storm's builder does not need
a single line of.
Row geometry: the off-by-one is structural, not accidental
The two games' rows are different lengths, and the difference is a header:
| address | length | slots | |
|---|---|---|---|
| XMvSF Storm row | $079580 | $7C | 31 slot pairs, no leading pair |
| MvC Jin row | $0D660C | $80 | 32 slot pairs, pair 0 is a zero header |
Every MvC row has that header pair and no XMvSF row does. So the copy is not slot-for-slot, it is:
ROW_SLOT_SHIFT = 1 # MvC slot i+1 <- XMvSF slot i
XV_ROW_ADDR, XV_ROW_LEN = 0x079580, 0x7C # 31 slot pairs, no leading pair
MVC_ROW_ADDR, MVC_ROW_LEN = 0x0D660C, 0x80 # 32 slot pairs, pair 0 is a headerThe row rewrite is 128 bytes at $0D660C, and because the row lives in the code region it costs
zero allocation.
PCM into a zero tail: no reclaim, one hard rule
mvc.h12m — the sample expansion the build already carries — ends in a 150,906-byte run of
zeros starting at QSound address $FDB286. Storm's clips need 97,516 bytes. So the whole
placement problem is "put 97,516 into 150,906," and the answer is not "pack them end to end."
A QSound descriptor is (bank, start, loop, end) — 8 bits of bank and 16-bit offsets.
Therefore no clip may cross a 64 KB boundary. Storm's largest clip is 32,767 B, so every clip
fits in a bank; they just cannot be packed contiguously across one.
The builder allocates first-fit-descending across whole 64 KB banks that lie entirely inside the verified zero run, and never uses a partial bank. That is why it consumes 112,796 bytes of tail to place 97,516 bytes of clips. The 15 KB of slack is the price of the rule, and it is cheaper than a clip that plays as a bank-wrapped scream.
- mvc.h12m verified zero tail150,906bytes
starts at QSound $FDB286
- bytes actually consumed112,796bytes
whole-bank allocation, no partial banks
- clip bytes required97,516bytes
9 spec clips + 1 out-of-block import
A placed descriptor, decoded, so you can recognise the format in your own ROM:
descriptor index $34C record: fe 00 00 07 50 fe 7f 3c
bank $FE
start $0000
loop $5007
end $7FFE
rate $3C
-> QSound address $FE0000, 32,767 bytesNew descriptors are written at fresh indices past MvC's live table (indices 0..817 are live; 818's record is already past the end), and each record is proven all-zero and unreferenced before it is written.
The instrument re-encode: 8 bytes in, 4 bytes out
The two games do not store note records the same way, and this is a genuine format translation rather than a copy:
XMvSF note record (8 bytes): [desc:LE16][00][a][d][s][r][00]
MvC note record (4 bytes): [desc:LE16][00][$20]
^ indexes the envelope
preset table at $8000That $20 was derived, not assumed — it was read off the 13 PCM clips that are byte-identical
in both games, which is the kind of cross-game invariant that turns a guess into a measurement.
The two source instruments Storm's sequences reference (1 and 5) use disjoint note numbers, so they merge into one new MvC instrument with the note numbers unchanged:
| value | |
|---|---|
| target instrument | 16 (first free slot) |
| pointer written at | $3B24 |
| note array at | $8130 |
| array bytes | 336 |
| unused note slots filled with | ffff0000 |
The relevant table bases, if you want to walk this yourself:
MVC_SEQ_TBL = 0x9006 # id -> 24-bit BE linear + flag byte
MVC_INST_TBL = 0x3B04 # instrument -> LE16 pointer into mvc.01
MVC_DESC_BASE = 0x5000 # descriptor index -> 8-byte record
MVC_ENV_TABLE = 0x8000 # note-record byte 3 indexes this table
MVC_HEADER = 0x3B00 # (sequence, descriptor, instrument) bases
MVC_N_IDS = 0x332
XV_SEQ_TBL = 0x9006
XV_INST_TBL = 0x4004
XV_DESC_BASE = 0x54F6
XV_HEADER = 0x4000 # expect bytes 0090f6542e40Sequences are copied verbatim except for the 1F nn instrument selector, and MvC's $9006 table
is repointed at the copies:
The finding buried in the receipts: two of these samples are already in the ROM
Five of Storm's ids sit outside her block — $147 $148 $149 $14A and $16C — and four
of them would play another character's sample. That is not a byte move, it is a listening call,
so the builder holds those four row slots byte-identical to the base and waits.
Then the receipt generator did something worth stealing: before writing anything, it hashed every clip it was about to import and searched the destination ROM for those exact bytes.
Marvel vs. Capcom already contains two of Storm's X-Men vs. Street Fighter samples, byte for byte, in the stock ROM.
| Storm clip | already in MvC at | MvC descriptor | reachable by posting | bytes |
|---|---|---|---|---|
$147 $148 $149 | QSound $21580D | $0C9 | id $133 | 10,218 |
$14A $16C | QSound $3A0000 | $163 | id $185 | 15,280 |
So four of the five out-of-block ids can be answered by posting an existing MvC id — a one-word
row edit — instead of importing a single byte of PCM. $133 is already a shared effect posted
between rows 9, 10 and 11, so using it is not stealing one character's private voice.
Three more clips in the import set turn out to be already present too — at QSound $018000
(descriptor $00A, id $15F), $058000 (descriptor $01A, id $184), and $A30000
(descriptor null, unreferenced). Hashing your import set against the destination before you write
is about ten lines of Python and it can delete most of your work.
The gate, and what it does and does not say
gate rule: every id reachable from the rewritten row must resolve to a Storm
clip or an audited equivalent, and no id outside $1C0-$1DF may be
reachable without an entry in the translation list
verdict: PASS
reachable ids: $121 $16C $1C4 $1C5 $1C6 $1C7 $1C9 $1DA $1DB $1DC $1DE
reachable out of block: $121 $16C (both in the translation list)Five members changed, thirty-one are byte-identical:
| member | why it changed |
|---|---|
mvc.01 | descriptors, Storm instrument array + pointer |
mvc.02 | copied Storm sequences |
mvc.h12m | Storm PCM written into the verified zero tail |
mvce.04a | sound row $0D660C, encrypted program member |
mvcud.04d | sound row $0D660C, decrypted phoenix mirror |
The gate is a reachability proof over the ROM's own tables. It proves no id reachable from Storm's row resolves to an unaudited sample. It does not prove the ROM boots, that QSound plays these descriptors, or that any of it sounds right. Nobody has heard this yet. It is a static verification, and I am labelling it as one.
The import spec gave id $1DC a 44,037-byte sequence span starting at address $000000 plus four
PCM clips. Measured: XMvSF's sequence-table entry for $1DC is $00000000. The id has no
sequence at all, and the "span" is just the distance from address zero to the first real sequence —
an artefact of the null pointer.
The four clips are still imported (the spec pins their SHA-256s) but no sequence was invented:
MvC's own $1DC is left untouched. The open decision is whether to zero MvC's $1DC entry to
match XMvSF's silence — which would also silence $1DC for MvC row 13, which posts it too.
Act 3: the table that was never read
Here is the setup, written the way I believed it going in.
At this point in the work, both characters still showed the donor's face in the 3x5 select grid.
The identity builder already handled the HUD name, the 32x32 HUD portrait at
$006AA6 + row x 2, the SELECT montage, the wordmark and what I then believed was the select
palette at $1D2A84 + row x 32. It did not touch the grid icon.
And there were two roster-indexed u32 tables with exactly the right shape:
$1810F2 row 2 -> $1815E6 row 12 -> $18165E
$18296A row 2 -> $1829D2 row 12 -> $182A4A
12-byte record: 0000 FFFF <u32 ptr into $33xxxx/$34xxxx> 0018 <own low word>They had a fingerprint, and the fingerprint was beautiful:
Orange Hulk's record is byte-identical to Hulk's. Red Venom's to Venom's. Gold War Machine's to War Machine's.
That is the EX-alias signature, and it explains an observable fact about the game: EX characters share a grid face. A structure that predicts a visible quirk is exactly the kind of evidence that gets a hypothesis promoted from "candidate" to "obviously it."
The plan was one live capture to decide which table was the grid cell and which was the montage, then repoint rows 2 and 12. Half a session, tops.
The capture, and the control that makes it mean something
- 1PT_A pointer table$1810F2–$181152 · 96 B24 x u32 — 0 reads
- 2REC_A records$1815CE–$1816EE · 288 B24 x 12 B — 0 reads
- 3PT_B pointer table$18296A–$1829CA · 96 B24 x u32 — 0 reads
- 4REC_B records$1829BA–$182ADA · 288 B24 x 12 B — 0 reads
- 5CTL_SELPAL (historical name; positive control)$1D2A84–$1D2C84 · 512 B16 reads at frame 558 — the tap works; palette identity retracted in Act 5
install_read_tap over $1810F2 +96 B, $1815CE +288 B, $18296A +96 B, $1829BA +288 B,
bucketing hits by record index so a single read on one row is visible rather than lost in a
total. Not just the pointer tables — the record arrays too, because a pointer table can be
read once at boot and cached.
This is the whole trick. CTL_SELPAL at $1D2A84 +512 B was the range the existing identity
builder treated as a select-palette table, and it was already known to be read on this path.
Act 5 retracts the palette identification; the 16 observed reads still make it a valid probe
control. Arming it through the same code path converts "we saw nothing" into "we measured
nothing."
68000 opcode fetches do not appear in a program read tap. An earlier probe returned
reads=0 for an address the game demonstrably executes, purely because of this. Arm program
and opcodes separately and label the keys differently, or you will debug a phantom.
Pump Service 1 (not Coin 1), start, then hold directions for 6 frames each. Detect the
select screen from RAM — game phase $FF4104 == $0004 with cursor $FF40B1 != 0 — rather than
from a frame count, so the window you report is the window the game was actually in.
Here is the tap code, close to verbatim, because the shape matters more than the addresses:
local TAPS = {
-- the two candidate structures (pointer tables + record arrays)
{ tag = 'PT_A', base = 0x1810F2, len = 4 * 24, stride = 4 },
{ tag = 'REC_A', base = 0x1815CE, len = 12 * 24, stride = 12 },
{ tag = 'PT_B', base = 0x18296A, len = 4 * 24, stride = 4 },
{ tag = 'REC_B', base = 0x1829BA, len = 12 * 24, stride = 12 },
-- positive controls
{ tag = 'CTL_BOOTSRC', base = 0x000E62, len = 0x80, stride = 0x80 },
{ tag = 'CTL_SELPAL', base = 0x1D2A84, len = 32 * 16, stride = 32 },
{ tag = 'CTL_HUDPORT', base = 0x006AA6, len = 0x40, stride = 2 },
}
_G._cap_taps = {} -- park handles in _G, see Landmines
local function arm(space, spname)
for _, r in ipairs(TAPS) do
local key = r.tag .. (spname == 'program' and '' or ('~' .. spname))
local ok, t = pcall(function()
return space:install_read_tap(r.base, r.base + r.len - 1, 'c' .. key,
function(offset, data, mask)
local slot = math.floor((offset - r.base) / r.stride)
counts[key][slot] = (counts[key][slot] or 0) + 1
if not first[key][slot] then first[key][slot] = frame end
return data
end)
end)
if ok then _G._cap_taps[#_G._cap_taps + 1] = t end
end
end
arm(prog, 'program')
if ops then arm(ops, 'opcodes') endThe result
The log is blunt about it. Fifteen consecutive measurement windows spanning entry, both picks and 2,940 frames past selection:
[select-detected f=533]
[win f=653 ph=0004 cur=12 hov=0012] entry burst
CTL_SELPAL{15:16}
[win f=772 ph=0004 cur=12 hov=0012] baseline hover
(no reads in window)
[act f=773] pick#1 (hovered)
[win f=892 ph=0004 cur=12 hov=0014] after pick#1
(no reads in window)
[act f=893] move right
[win f=1012 ph=0004 cur=12 hov=0020] after move-right
(no reads in window)
...
=== TOTALS (key slot:count@first) ===
CTL_BOOTSRC~opcodes 0:59@93
CTL_SELPAL 15:16@558Two independent runs. Both address spaces. The control fires; the candidates never do.
The EX-alias fingerprint is real. Orange Hulk really does share Hulk's record. It is just not the grid. Both tables are static and unread on this screen — they belong to some other surface (win pose, ending, VS). A structure can be genuinely meaningful and still be completely irrelevant to the thing you are trying to change.
This is the part I would want someone to tell me: the fingerprint was evidence about the structure, not evidence about the screen. I had confused "this table encodes character identity aliasing" with "this table draws the character select grid." Only one of those is testable in ninety seconds, and it is the second one.
So what actually draws it
The 3x5 grid is not sprites and it is not a roster-indexed pointer table. It is a scroll1 (8x8
tile) tilemap, built into CPS gfxram at $908000.
Read linearly with a 32-entry stride the block appears transposed, because CPS scroll1 addressing is column-major:
tilemap index = (row & 0x1F) + ((col & 0x3F) << 5) + ((row & 0x20) << 6)A naive index = ty*32 + tx walk therefore yields row = tx, col = ty. Undo that and the
geometry falls out exactly:
That +1 / +$12 stride is the real finding inside the finding: the entire grid is one
contiguous tile sheet 18 tiles wide. The cells are windows onto it, not independent objects.
You can see it directly in the dumped tilemap — each row of the sheet advances by $12, and
consecutive columns advance by 1, with the attribute word's low byte carrying the palette:
y06 409B(0008) 40A2(0008) 322D(0068) 40C4(0008) 40D6(0008) 40E9(0009) ...
y07 409B(0008) 40A4(0008) 40B4(0008) 40C6(0008) 40D8(0008) 40EB(0009) ...
y08 409B(0008) 40A6(0008) 40B6(0008) 40C8(0008) 40DA(0008) 40ED(0009) ...
^ code(attr) — attr low byte is the cell paletteThe ROM source blob for this tilemap is at $25EE90, found by searching the ROM for the live
codes.
The copy is entry-by-entry, not a verbatim DMA. So a straight memcmp of the ROM blob against
gfxram will not match, while every code matches individually. If you are hunting a tilemap
source in a CPS-2 ROM and your byte-compare comes back negative, search for the codes, not the
block.
The live cell map, measured by driving the cursor
This was not inferred from a table. A Lua script snakes the cursor through all 15 cells, and at
each settle reads the hovered character id from RAM plus the P1 cursor's pixel position — found by
scanning OBJ RAM at $708000 for the cursor's palette-10 corner pieces.
Cursor pixel positions: x = 184, 232, 280 and y = 48, 80, 112, 144, 176.
| col 0 (x=184) | col 1 (x=232) | col 2 (x=280) | |
|---|---|---|---|
| row 0 (y=48) | $16 Chun-Li * | $12 Ryu | $1A Zangief |
| row 1 (y=80) | $1E Morrigan | $14 Capt. Commando | $20 Mega Man |
| row 2 (y=112) | $1C Strider | $0E Spider-Man | $18 JIN — Storm's cell |
| row 3 (y=144) | $04 Capt. America | $0C Venom | $06 Hulk |
| row 4 (y=176) | $0A Gambit | $02 War Machine | $08 Wolverine |
* col 0 / row 0 was the snake's start cell and never re-entered. $16 is the only base-roster id
unaccounted for after the other fourteen were observed directly.
Per-cell palette, from the tilemap attribute low byte:
| col 0 | col 1 | col 2 | |
|---|---|---|---|
| row 0 | $0A | $08 | $0C |
| row 1 | $0E | $09 | $0F |
| row 2 | $0D | $06 | $0B — Jin/Storm |
| row 3 | $01 | $05 | $02 |
| row 4 | $04 | $00 | $03 |
The handoff said, from completely separate work: "Storm is Jin's cell: row 3, column 3 — Down Down Right from Ryu."
Ryu is row 0 / col 1. Down, Down, Right lands on row 2 / col 2 = Jin. The cursor snake and the navigation note agree without having consulted each other, which is the cheapest cross-check available and worth running every time.

Why this is much better news than the original plan
The original plan was "seat icon art somewhere new, then repoint rows 2 and 12 of a 12-byte-record table." That plan cannot work, because there is no such table in this path. What replaces it is strictly smaller:
At this stage, changing Storm's icon looked like overwriting 24 tiles of pixel data in the GFX
ROMs and, optionally, the cell's palette line at $0B: a pure GFX-ROM edit. Act 5 records why
that was only the prototype answer and how the permanent patch also owns eight tilemap entries and
two palette rows. The useful result survived the correction: no pointer table or new code is
required.
Act 4: the icon is drawn, and three hardware rules I did not know
Act 3 ended with "a pure GFX-ROM edit." That was the prototype conclusion, and it was not enough. Writing 24 tiles of Storm into the cell produced an icon that was recognisably Storm and visibly wrong: a square missing from the top-right corner, gold grid lines running through her face, and Magneto with both side columns gone.
Three separate bugs. Each one is a rule of the hardware that is invisible until it bites you.
4.1 The stock ROM reuses one corner tile by flipping it
Dump the tilemap entries for both cells at select time — code then attr:
STORM cell, screen (216,96) MAGNETO cell, screen (120,128)
4135/0B 4136/0B 4137/0B 4138/0B 40A8/01 4171/01 4172/01 4173/01
010D/6B 4139/0B 4174/01 40A8/21
4147/0B 4148/0B 4149/0B 414A/0B 04E1/61 417F/01 4180/01 4181/01
414B/0B 414C/0B 4182/01 04E1/01
4159/0B 415A/0B 415B/0B 415C/0B 04E1/61 418C/01 418D/01 418E/01
415D/0B 415E/0B 418F/01 04E1/01
416B/0B 416C/0B 416D/0B 416E/0B 419A/01 419B/01 419C/01 419D/01
416F/0B 4170/0B 419E/01 40A8/61Twenty-two of Storm's twenty-four tiles are unique codes with attr $0B. One is not: code
010D with attr $6B. Magneto has seven: 40A8 three times and 04E1 four times, with attrs
$01, $21 and $61.
Those are shared decoration tiles — the gold cell frame. And the attr byte is not just a palette index:
/* MAME, cps1.cpp — the scroll1 tile callback */
tileinfo.set(0, code, attr & 0x1f, TILE_FLIPYX((attr & 0x60) >> 5));
/* ^ palette ^ $20 = X flip, $40 = Y flip */The ROM draws one corner tile and gets all four corners by flipping it. $6B is palette $0B
plus X flip plus Y flip.
So when I repointed those shared tiles to private codes — correctly, so that writing art into a corner would not corrupt every other cell that borrows the same tile — and wrote un-flipped art into them, the hardware dutifully mirrored my art.
Gold pixels inside the Magneto cell. # = gold now, . = not gold, o = gold in stock and
missing in the patched build. Cell is 48 px wide.
rows 1-7 #.......................................#......o
rows 8-23 o......#.......................................#
row 24 #.......................................########
row 31 #########################################oooooooRead it against the attrs. Tile (5,0) has attr $21 — X flip — so its right-edge gold column
landed at x=40 instead of x=47. Tile (5,3) has $61 — X and Y — so its bottom border
came out on row 24 and its right column on x=40. And Storm's tile (4,0) at $6B put the
top border down on row 7.
That last one is the "missing top-right corner chunk" reported three rounds running. It was never missing. It was eight pixels lower than it should have been.
The fix is two bytes per entry instead of one — write the attr as well as the code, with the flip bits cleared:
If you repoint a tile, you own its attr. A shared tile's attr is part of how it was shared. The moment the code becomes private, every bit in that attr that existed to make one tile serve four positions is now actively lying about your art.
Symptom to watch for: your art appears, but a border, a highlight or a gradient is on the wrong side — and it is on the wrong side consistently, mirrored rather than shifted. Shifted means an addressing bug. Mirrored means an attr bug.
4.2 Finding the palette base, after retracting the first answer
Earlier in this project I wrote 16-colour palettes to $1D2A84 + row*32 and the colours came out
badly wrong. That claim was retracted. Here is the replacement, and how it was found.
Take a select-time dump of gfxram ($900000-$92FFFF), and brute-force every 32-byte-aligned
window in it against the colours a cell actually displays, which were already calibrated by
writing known pixel values and reading back the screen:
def dec(w): # CPS palette word -> RGB
br = 0x10 + ((w >> 12) & 0xF) # BRGB: brightness, R, G, B
return tuple(((w >> s) & 0xF) * 0x11 * br // 0x1F for s in (8, 4, 0))
for off in range(0, len(gfxram) - 32, 32):
pal = {dec(u16be(gfxram, off + 2*i)) for i in range(16)}
score = len(pal & cell_colours) # how many of the 16 matchTwo hits, both 15/16:
Jin's cell (palette $0B) -> gfxram offset 0x14560
Cap's cell (palette $01) -> gfxram offset 0x14420And they agree on a base, independently:
0x14560 - 0x0B*32 - 0x400 = 0x14000
0x14420 - 0x01*32 - 0x400 = 0x14000- 1OBJ (sprites)$914000–$914400 · 1 KB32 palettes x 16 words
- 2SCROLL1 — the select grid$914400–$914800 · 1 KBpal N at $914400 + N*32
- 3SCROLL2$914800–$914C00 · 1 KB
- 4SCROLL3$914C00–$915000 · 1 KB
- Apal $01 — Captain America's cell$914420 · marker
- Bpal $0B — Jin's cell$914560 · marker
- Cpal $1E — Storm (was a grey ramp)$9147C0 · marker
- Dpal $1F — Magneto (was a grey ramp)$9147E0 · marker
Do not overwrite $0B and $01. Those palettes are shared. Scan every one of the 64x32
scroll1 tilemap entries for attr & 0x1F and you get the palettes actually in use on this screen:
in use: 0 1 2 3 4 5 6 8 9 A B C D E F
free: 7 10 ... 1FSlots $1B-$1F hold five byte-identical grey ramps — obvious padding. Storm took $1E,
Magneto took $1F. No stock cell changes colour, and the two icons stop fighting each other for
someone else's fifteen colours.
4.3 Pen 15 is transparent, so you have fifteen colours
The first custom-palette build put the gold frame colour at index 15. The frame vanished — and
the pixels came back as (34,34,119) and (221,255,221), which are not in any palette. They are
the scrolling Earth in the background layer.
CPS scroll1 draws with pen $0F transparent. Which explains a detail that had been sitting
unremarked in every palette dump:
pal 00 FFC0 FEDE ... F035 F000 <- last word $F000
pal 01 FFFF FBFF ... F111 F001 <- $F001
pal 0B F000 FFC9 ... FF00 F00B <- $F00B
pal 0F F000 F5BF ... F731 F00F <- $F00FEvery palette's sixteenth word encodes its own index. It is never rendered, so the game parks a signature there. The build does the same, partly out of respect and mostly because it makes a palette dump self-labelling.
A CPS scroll1 cell has 15 usable pens, not 16. This build spends them as 14 adaptive colours for the character plus gold pinned at index 14, with the signature at 15.
If your art looks right but has holes in it that show the layer behind, you spent pen 15.
4.4 The stock icons are cut-outs, and that changes everything
Build five looked right and was still wrong, in a way that took someone else noticing to see: "his background isn't transparent."
That is not a style note. Decode each stock cell's 15 renderable pens, then count screen pixels inside the cell whose colour is not one of them. Those pixels are pen 15, and what you are seeing through them is the layer behind:
- Captain America ($01)43% of the 48x32 cell
670 of 1,536 pixels are pen 15
- Ryu ($08)33% of the 48x32 cell
- Chun-Li ($0A)25% of the 48x32 cell
- Jin Saotome ($0B)25% of the 48x32 cell
the cell Storm takes over
- Hulk ($02)24% of the 48x32 cell
- Zangief ($0C)10% of the 48x32 cell
Captain America's cell, opaque in white, transparent in blue:

So a painted background — which is what builds three through five produced — is the wrong construction, not a worse one. Two things follow immediately:
- A crop rectangle stops being the framing tool. Anything that is not the character is transparent, so the crop only has to contain him.
- The palette gets bigger. Excluding background pixels from the palette fit means all 15 pens go to the character instead of spending four or five on a backdrop nobody will ever see.
Cutting him out
The backdrop is two things: the XvSF hover panel's desaturated grey backing slab, and the starfield outside the panel. Take the union, then keep only what is reachable from the crop border:
slab = ((mx - mn) < 40) & (mx > 80)
stars = (b > r + 40) & (b > g + 40) & (r < 70) # r<70 spares Magneto's purple
bg = flood_fill_from_border(slab | stars)Border-connected is the whole trick. The same greys occur inside the character — nose highlight, eye whites, the specular on his hand — and a plain colour test punches holes straight through his face. The flood fill only takes backdrop that is actually behind him.
The first cut-out rendered a red blob on Magneto's left. Akuma's sprite sits
behind the hover panel at source x40-75, and his red hair is connected to
Magneto's outstretched hand — so "keep only the blob containing the head" does
not drop it. They are genuinely one blob.
Fix: start the window at x = 76. Worth internalising as a shape — a
connected-component filter is only as good as the window you hand it, and on a
character-select screen there is always another character parked just off frame.
Placing by head size instead of by crop
Once the background is gone, framing becomes a placement problem: measure the head with a colour test, scale the whole silhouette so the head is a chosen height in cell pixels, and drop it at a chosen cell coordinate.
MAGNETO window (76, 0, 182, 72)
head red test -> source bbox x100-139, y7-50
head_h 20 cell px at (23.5, 13.0) [cell centre x is 23.5]
measured back off the rendered frame: helmet 20 x 19, centred (23.5, 13.0)The first attempt set head_h = 16, matching Captain America's ~15 px head, and
produced a tiny Magneto floating in an empty cell. The mistake is instructive:
Cap's cell is head and raised arm, so a 15 px head there implies a large
figure. Magneto's hover art is head-plus-hand at a completely different zoom.
Match the composition, not the measurement.
Storm keeps a painted background, on purpose
Her hair is white and the panel behind her is white cloud, so the
border-connected grey test cannot separate them — it would eat her hair. A
painted background is also legitimate here: Spider-Man's web and Hulk's green
are painted too. Her crop widened from 84 to 114 px of source across the cell,
which drops her face from roughly 30 to 22 cell pixels, and her crop centre and
face centre are both source x = 100, so she stays centred.
Cutting her out properly needs a hand-drawn mask, not a colour test. That is an honest limit, not a finished job.
4.5 The verification, and one number that is not a defect
STORM gold missing 0 gold extra 35 13 colours in cell
MAGNETO gold missing 0 gold extra 0 15 colours in cell
new collateral outside the two cells: 0Storm's 35 "extra gold" pixels are skin highlights that pass the gold colour test — they are inside her face, not on the frame. Magneto is exactly zero, which is the number that matters: his frame is byte-for-byte where the stock frame was.
A whole-screen diff of this screen reports 8,414 changed pixels no matter what you do, because the Earth in the background scrolls. That number is byte-identical between the broken build and the fixed one.
Diff inside the cell rectangles only. I have now written that sentence in my own notes three times and walked past it twice.


Act 5: making it a ROM patch, 76 bytes you can type in by hand
The visual prototype up to this point used runtime pokes: a Lua script wrote tilemap entries and palettes at select+120. That proved the renderer and the art. This act replaces that prototype with ROM data, so nothing has to write the select screen at runtime.
Here is the whole thing as ROM data. 2,736 bytes across six files, of which only 76 are program ROM — the rest is pixels.
5.1 The palette table, corrected twice — and how the second one fooled me
$1D2A84 was retracted early in this project. Its replacement was found by
searching the ROM for the colours a cell displays, which turned up three hits
each, because palettes are stored as 12-bit 0RGB and the game ORs in the
brightness nibble at upload:
pal $0B -> 0x1BE71A 0x1C8A04 0x1D28A4
pal $01 -> 0x1BE5DA 0x1C8284 0x1D2764Take the last pair. 0x1D28A4 - 0x1D2764 = 0x140 = 10 x 32, and $0B - $01 = 10. A clean 32-byte stride landing exactly where a palette table should. Infer
the base at $1D2744, check the first 15 words of every palette $00-$0F,
and get sixteen straight matches.
It was wrong. Patching there changed nothing on screen.
They were the same near-miss, sixteen times. Every palette in this game starts with similar high-value colours, so a 15-word prefix comparison is nowhere near as strong as it looks — and I had already thrown away the one word that would have caught it, because the 16th word is the transparent pen and I had decided it "doesn't matter."
The test that settles it is all 16 words across all 32 palettes:
base $1BE5BA 00:Y 01:Y 02:Y 03:Y ... 1C:Y 1D:Y 1E:Y 1F:Y 32/32
base $1D2744 00:. 01:. 02:. 03:. ... 1C:. 1D:. 1E:. 1F:. 0/32Widen the test before you believe a stride. A coincidence that survives a narrow check will happily survive it sixteen times in a row.
So: $1BE5BA + N*32, sixteen words of 0RGB, N from $00 to $1F.
5.2 The patch got smaller when each cell turned out to own its palette
Count tilemap references per palette on the select screen and you get fifteen
palettes in use — $00-$06 and $08-$0F — for fifteen cells, with $07 spare.
One palette per cell. Storm's cell owns $0B; Magneto's owns $01.
Which means they can be rewritten in place, with no palette repointing and no touching the palette bits of 48 attribute words.
The only tiles on those palettes that are not inside the cell are the adjacent
gold border columns — codes $413A and $04E1, four tiles each. Sample them
off the framebuffer and they use exactly one opaque pen:
x264-271, y96-127 gold (255,204,0) x 32, everything else transparent
x112-119, y128-159 gold (255,204,0) x 32, everything else transparentSo pin gold at the index the stock palette already uses — $0A in pal $0B,
$07 in pal $01 — and those columns come out byte-identical while the other
fourteen pens are yours.
That is the difference between a 48-word tilemap edit and an 8-word one.
5.3 Finding free tile codes: two tests that don't work
The eight shared decoration tiles still need private codes. Two obvious criteria both fail on this hardware:
65,511 of the 65,536 possible 16-bit values occur somewhere in 4 MB of program ROM. Any test based on raw byte-pattern absence is dead on arrival at this scale.
Zero codes in $1000-$FFFF have all-zero graphics. CPS decodes 8x8, 16x16
and 32x32 tiles from the same region — 8x8 tile C is bytes [32C, 32C+32)
and 16x16 tile D is [128D, 128D+128) — so the region is fully packed and
"blank" is not a thing.
What does work is structural. A code is free if no 4-byte-aligned position
anywhere in the program ROM holds it as a code word followed by a plausible attr
word (attr < $100, bit 7 clear). That is a test about tilemap entries, not
about bytes, and it found $4D55-$4D61 — a run of thirteen. Eight were needed.
5.4 Locating 48 tilemap entries without decoding the block's layout
The select-grid tilemap source lives at $25EE94..$25F760 as plain (code, attr) pairs — but not in any scan order I could read off it. Row-major fails,
column-major fails; it is chunked.
Rather than reverse it, invert the problem:
- 1(code, attr) pairs$25EE94–$25F760 · 2.2 KB360 entries, chunked order
- ASTORM (4,0) — $010D/$6B$25F2A8 · marker
- BMAG (0,0) — $40A8/$01$25F328 · marker
- CMAG (0,1) — $04E1/$61, ambiguous$25F338 · marker
Dump gfxram at the select screen and read the whole 64x32 tilemap. Most
(code, attr) pairs occur exactly once, because most tiles are unique art.
Scan the block on its 4-byte stride and, whenever the pair is one of the
unique ones, you have learned that offset's (col, row). 356 of 360
entries resolve outright — no layout formula required.
The four that do not resolve are all $04E1, the shared edge tile. Take a
resolved neighbour's offset, add or subtract the 4-byte stride, and check
the (code, attr) actually sitting there before accepting it. Each resolved
to exactly one candidate.
5.5 The eight tilemap edits, in full
This is the entire non-graphics, non-palette half of the hack. Word values, at 68000 addresses, in a plain data block — no code, no relocation, no free-space hunt:
$25F2A8 010D -> 4D55 $25F2AA 006B -> 000B STORM (4,0) X+Y flip
$25F328 40A8 -> 4D56 MAG (0,0)
$25F36C 40A8 -> 4D57 $25F36E 0021 -> 0001 MAG (5,0) X flip
$25F338 04E1 -> 4D58 $25F33A 0061 -> 0001 MAG (0,1) X+Y flip
$25F37C 04E1 -> 4D59 MAG (5,1)
$25F488 04E1 -> 4D5A $25F48A 0061 -> 0001 MAG (0,2) X+Y flip
$25F4CC 04E1 -> 4D5B MAG (5,2)
$25F4DC 40A8 -> 4D5C $25F4DE 0061 -> 0001 MAG (5,3) X+Y flipRemember the program ROMs are ROM_LOAD16_WORD_SWAP: byte A of the 68000
address space is byte A^1 of the file, so a big-endian word is stored
low-byte-first. At file offset 0x5F2A8 you will see 0D 01 6B 00, and you
write 55 4D 0B 00.
5.6 Proving it, which is the part that matters
A runtime poke only ever has to be right on one screen. A ROM patch has to be right everywhere — those eight new tile codes and two rewritten palettes could be used anywhere in the game.
- Attract loop (60 frames)0frames differing
title, demo play, rankings — byte-identical
- In a match (56 frames)0frames differing
vs screen, gameplay, HUD — byte-identical
- Select screen (9 frames)9frames differing
differ only inside x121-262 y97-158 — exactly the two cell interiors
And the acceptance test for the patch itself: boot it with a script that performs zero memory writes, and compare against the runtime build.
STORM gold missing 0 extra 2 | vs runtime build: identical
MAGNETO gold missing 0 extra 0 | vs runtime build: identical
border column (Storm side) unchanged: True
border column (Magneto side) unchanged: True
Act 6: what a whole character actually costs, and the answer I got wrong twice
The icon is a face. The character behind it is a different question, and until this act the honest answer was "unknown, probably enormous." It is neither.
A full character port is about 30 KB of data copied anywhere free, plus four pointer words. No relocation pass, no fixups, no new code. Getting to that number meant being wrong twice in a row, and both wrong answers are more instructive than the right one.
6.1 The roster id is sitting in RAM at $FF40B1
Driving a select screen by counting frames is how three of my runs landed on the
wrong character without saying so. There is no need: $FF40B1 holds the roster
id of the character under the cursor. Park, read the byte, and you know exactly
where you are.
Sweeping the grid gives the map:
col 0 col 1 col 2
row 0 $16 $12 Ryu $1A
row 1 $1E $14 $20
row 2 $1C $0E $18 <- Jin = STORM'S SEAT
row 3 $04 $0C $06
row 4 $0A $02 $08
^
Captain America = MAGNETO'S SEATAll ids are even, $02..$20. $10 is in range and belongs to no cell.
I published the opposite, from misreading a parking sequence, and it cost two
runs. The consequence is not cosmetic: a right, right, down sweep just rides
down column 2, because right against the right edge does nothing. Two runs
hunting $04 never found it, never started a match, and produced a one-frame
comparison that said 0 differences — a green light from a test that had not
run.
Because it clamps, up x6 then left x6 reliably parks at r0c0. A real sweep
walks back: per row right, right, left, left, then down.
6.2 directory index = roster id / 2
Six tables sit contiguously at $0E676C..$0E6936, 23 entries each — 18 base
characters plus 5 EX. A read tap at entry granularity, run through a match,
lights up exactly one index per picked character:
cursor id $12 -> heavy index 9 $12 / 2 = 9
cursor id $1A -> heavy index 13 $1A / 2 = 13
cursor id $1E -> heavy index 15 $1E / 2 = 15Two of those three runs were cursor mis-drives — aimed at $18 and $04,
landed elsewhere. That made the result stronger, not weaker: the cursor went
somewhere unplanned three times and the index followed it every time. Which is
only true because the script prints the id it actually reached. A mis-drive that
prints nothing is a wrong answer wearing a lab coat.
| table | address | Jin [12] | Cap [2] | holds |
|---|---|---|---|---|
| D1 | $0E676C | $13EC82 | $107FF0 | move/animation scripts, 24-27 KB |
| D2 | $0E67C8 | $0006 | $0000 | u16 selector, values 0/2/4/6 |
| D3 | $0E67F6 | $3A6000 | $3A1000 | box shapes, $3A0000 + N*$800 |
| D4 | $0E6852 | $0FBAD4 | $0F90DA | 1.3-1.7 KB per character |
| D5 | $0E68AE | $0F3C2A | $0EC678 | 2.6-3.3 KB per character |
| T6 | $0E690A | $047E | $00E6 | u16 offsets, stride $5C |
Indices 18-22 alias 6, 3, 1, 11, 15 — the five EX characters pointing at their
base character's data. Independently confirms two claims this project had made
from unrelated work: $3A6000 is Jin's box-shape window, $3A1000 is Captain
America's.
6.3 Is it code or data? The two live in different address spaces
This decides everything. If a character's block is 68000 code, a port is a translation. If it is interpreted data, a port is a copy.
A 68000 opcode fetch does not appear in a program read tap — the landmine
from Act 3, now an instrument. Arm both spaces over the same range and the two
answers separate cleanly:
range program reads opcode fetches
OPS_CTL $000000..$00FFFF 451,278 76,267,698 <- control
D1_ENT $0E679C (the pointer) 792 0
D1_BLK $13EC82..$144C35 34,547 0
D3_BOX $3A6000..$3A67FF 23,914 0ops = 0 means nothing on its own. My first run of this reported zero across
the board — and it was zero because the cursor had landed on the wrong character
and I was tapping a block the game never loaded. A dead probe and a real
negative look identical.
Low ROM executes constantly, so tapping it in the opcodes space proves the machinery is alive: 76 million fetches, in every phase, while the character ranges hold at exactly zero.
So: a character's blocks are interpreted data read by a shared engine. Nothing in them is executed. The reads are action-driven, as an offset table plus per-action scripts would be — idle 3,037, walk 16,648, jump 2,252, punch 1,390.
6.4 Wrong answer #1: "the blocks are full of absolute pointers"
The block head is a clean two-level offset table (first u16 = $003E = its own
size, 31 entries). All block-relative. But the script records looked like they
carried absolute 32-bit pointers back into the block:
+3850 00 04 00 14 24 94 00 00 $00142494 = this block + $3812I counted them across all 23 blocks against controls and got what looked like a decisive result:
- 68000 code $020000 (control)21.9per KB
real code is full of absolute addresses
- All 23 D1 blocks10.4per KB
5,391 total — 50-100x the non-pointer baseline
- gfx-ish $300000 (control)0.2per KB
- box shapes $3A0000 (control)0.1per KB
I published that, plus a second argument that felt airtight: each block's hits
land in its own address range. Jin's need a high word of $0013/$0014,
Captain America's need $0010. Surely coincidence would cluster at the same
high words for every block?
6.5 Wrong answer #2, and the control that killed both
So I built a relocator: copy Jin's block to $3D0000, add the delta to every
in-range u32, repoint D1[12]. It broke — Jin desynced from stock at frame 386.
I diagnosed the overlapping-pointer resolution, fixed it, and it broke again.
At that point the reasonable-looking conclusion was "relocation is hard and needs a better pointer discriminator." Instead:
Jin (idx 12) D1 only, dest $3D0000, zero fixups 0 of 101 frames differ
Jin (idx 12) D1 only, dest $3F1000, zero fixups 0 of 101 frames differ
Jin (idx 12) D1+D3+D4+D5, 30,520 B relocated 0 of 101 frames differ
Cap. (idx 2) D1+D3+D4+D5, 33,692 B relocated 0 of 101 frames differ
CONTROL stock vs stock 0 of 51 frames differByte-identical. The pointers do not exist. My fixups were the only thing breaking Jin.
The mechanism is worth carrying to your own project. Every D1 block base has a
high word in $000F..$0017 — 15 to 23 in decimal. Those are exactly the
values you find everywhere in animation data: frame counts, durations, timers.
So two ordinary adjacent u16 fields read as one in-range "pointer":
+14E0 40 00 00 00 | 00 14 00 00 | 00 02 00 2F | 4A 04 90 03
^^^^^^^^^^^
two u16 data fields -> $00140000, "inside" Jin's blockrepeating every 16 bytes through whole regions of the block.
Both of my arguments collapse against that:
- "Each block's hits land in its own range" is circular. Every block needs a high word of 15-23 to produce an artifact, and all of those are common data values. The locality is produced by the mechanism, not by pointers.
- "The controls separate cleanly" was never apples-to-apples. Box shapes at
$3A0000and graphics at$300000need high words$3A/$30to fake a pointer, and those are far rarer as data than 15-23 are.
And the smoking gun, from logging block-relative read offsets on both ROMs so they are directly comparable:
[737] stock q=386 +14F4 reloc q=386 +14F4
[738] stock q=387 +257C reloc q=386 +0030 <- reloc re-enters the head table+$14E4 and +$14F4 are those 00 14 00 00 fields. Rewriting them to
00 3D 13 7E corrupted two data fields in every record of that run.
I went straight from "the pointers are real" to "let me build a relocator that fixes them." That experiment could only ever tell me whether my fixups worked. It could never tell me whether they were needed.
Run the null version first. Before building the machine that compensates for a thing, check that the thing is there. The zero-fixup run took four minutes and would have saved the entire detour.
6.6 So: copy four blocks, write four pointers
Jin (idx 12) Captain America (idx 2)
D1 scripts 24,500 B 26,664 B
D3 box shapes 2,048 B 2,048 B
D4 1,378 B 1,714 B
D5 2,594 B 3,266 B
---------- ----------
30,520 B 33,692 B
pointer words D1 $0E679C D3 $0E6826 D4 $0E6882 D5 $0E68DE (Storm's seat)
D1 $0E6774 D3 $0E67FE D4 $0E685A D5 $0E68B6 (Magneto's seat)D2 and T6 are u16 selectors, not pointers; they were never changed and a port that keeps the same shape of character does not need to touch them.
What is still unproven, and I would rather say it than have you find it: one
input programme per character, so a move neither performs could still hide a
dependency. The "~50 pointers into other characters' blocks" figure from the same
flawed scan is retracted and unverified. And this is all stock mvsc — the
expanded build was not used.
What that leaves for Storm is no longer a reverse-engineering problem. It is a data-authoring one: produce 30 KB in MvC1's script format. That is a different kind of hard, and a much more tractable one.
Landmines that cost real hours
Each of these has already bitten this project. They are listed with the symptom, because the symptom is what you will actually be staring at.
Symptom: your probe works for a few hundred frames and then goes silent. Silence reads exactly like "nothing found."
Cause: notifier subscriptions held in autoboot-chunk locals get collected.
Fix: park them in _G — _G._cap_taps = {} and push every handle into it. Every capture
script in this post does that on the first line of its arming code, and that is not stylistic.
Symptom: your unattended run never reaches the select screen, or reaches it inconsistently.
Cause: the EEPROM default is 2 coins per credit.
Also: grid navigation needs 6-frame holds. Four-frame holds drop steps, which produces a cell map that is subtly wrong rather than obviously wrong — the worst failure mode there is.
Symptom: the emulator dies mid-run, usually deep into a long capture, usually after you stopped watching.
Fix: the palette-RAM probe has to stay off for long runs. Design your long captures so the expensive probe is a separate, short run.
Symptom: two tiles dedupe into one and a character loses pixels somewhere unrelated to anything you changed.
Cause: deduplicating 15,304 tiles with a 64-bit hash. Two collisions in one character's art.
Fix: compare exact bytes. And remember that sharing tiles across banks is not addressable however identical the pixels are — a character's codes are 16-bit plus a bank byte.
Symptom: reads=0 for an address you can watch the game execute in the debugger.
Fix: arm the opcodes space separately. This is the flaw that made an earlier exec_tap
return a confident, completely false zero — and it is exactly why the icon-table result needed a
positive control before I would believe my own measurement.
Symptom: your gfx address formula fits every anchor tile you calibrated it on and fails on every new tile. Then some tiles appear "unwritable."
Cause: the scroll1 row index for screen pixel y is (y/8 + 2) & 0x1F, not y/8. The column
term (x - 72)/8 was right the whole time, which is what makes this so hard to see — half the
formula validates.
Fix:
def tile_entry(x, y):
col = (x - 72) // 8
row = (y // 8 + 2) & 0x1F # <-- the +2
return 0x8000 + col * 0x80 + row * 4Four separate address models were fitted and discarded before this was found. If your model fits its own anchors and nothing else, stop fitting and go looking for a constant offset.
Symptom: your art appears, but a border or highlight is on the wrong side — mirrored, not shifted.
Cause: attr & 0x60 are the X and Y flip bits (TILE_FLIPYX((attr & 0x60) >> 5)). Shared
decoration tiles are shared because they get flipped into several corners.
Fix: when you repoint, write both words — code at the entry and attr & ~0x60 at entry+2.
Tell: mirrored = attr bug. Shifted = addressing bug. They look similar at 48x32 and they are not remotely the same problem.
Symptom: part of your art is missing and you can see the layer behind it through the hole.
Cause: CPS scroll1 draws with pen $0F transparent. A 16-entry palette gives you 15
colours.
Tell: dump the stock palettes. Every one of them ends in a word encoding its own index —
$F00B for palette $0B, $F001 for $01. Nobody wastes a real colour on a self-reference; that
word is where the game parks a signature because it never renders.
Symptom: an automated cursor drive lands on the wrong character, and worse, your comparison still reports 0 differences because the run never started a match and only produced one frame.
Cause: right against the right column does nothing. A right, right, down
sweep therefore rides straight down column 2 and never visits columns 0 or 1.
Fix: up x6 then left x6 parks reliably at the top-left because clamping
is well-behaved. Then sweep each row right, right, left, left, then down.
And print the roster id from $FF40B1 every step — a mis-drive that prints
nothing is indistinguishable from a correct one.
Symptom: you build a fixup pass, it does not work, you improve the fixup pass, it still does not work, and you conclude the problem is hard.
Cause: never testing whether the fixups were needed at all. An experiment comparing "my correction" against "broken" can only tell you if the correction works — never whether the thing you are correcting for exists.
Fix: relocate with zero fixups. Diff with the patch reverted. Run the empty version of whatever you are about to build. It took four minutes here and would have saved the whole detour — and it is the same discipline as the positive control that made Act 3's zero mean something.
The number I do not trust
The ledger says removing Jin frees 297,988 bytes of exclusive PCM (435,199 with Captain America). That figure is a per-id sum.
Recomputed as disjoint claimable ranges — every PCM range reachable from an id only Jin's row uses, minus every range reachable from any other character row or from any of the 531 system ids — it is:
Jin-exclusive PCM: 1 run, 14,429 B 0x07E0000..0x07E385D (bank 126)- per-id sum (in the ledger)297,988bytes
the stock-layout qsound_pcm headroom line rests on this
- disjoint claimable ranges14,429bytes
one run, bank 126 — only ONE of Storm's nine clips fits
Both numbers deserve a caveat, and the caveats point in opposite directions:
- the budget's own method note says only the first note-on per channel is followed, so every character owns at least what was counted — which would make the range subtraction more aggressive, not less;
- some of the 531 system ids may resolve to descriptors that are not really live, which would make it less aggressive.
So 14,429 B is not a final answer either.
Solid: the two methods disagree by a factor of twenty, and the stock-layout ledger's
qsound_pcm line (+279,090 headroom, reported CLOSED) rests on the larger one.
Reopened: that ledger line. It should not be treated as closed until the ownership method is reconciled.
Not blocking: the expanded build, which puts Storm's PCM in mvc.h12m's zero tail and needs no
reclaim at all. That is what the expansion is for.
I am including this because a post full of confident hex is exactly the kind of post where a
number nobody checked survives forever. This one got caught by noticing that two figures which
should have matched did not, which is also how the boot checksum at $000236 and the icon-table
reversal were found. Three of this project's more useful findings came from the same reflex.
What is left, and how to reproduce this
Still open
The select-grid edit is not on this list anymore. It is permanent ROM data and its verifier performs zero memory writes.
- Hearing the audio. It is written and gated; it has not been played.
- Storm's move logic — now a data-authoring job, not a reverse-engineering one.
The engine reads interpreted scripts, the blocks are position-independent, and the
four pointer words are known. What remains is producing ~30 KB in MvC1's script
format. Her parked code at
$510000is unexplained by that result and may be the wrong shape for the job; check before building on it. - Reconciling the QSound ownership method above.
Build the ROM
cd "$ARCADE_RESEARCH_ROOT/CHARACTER_SELECT"
python3 scripts/build_storm_xmvsf_phase1.py # md5 6ba1e1e6cd11fcd38ff4a06d9709a388
# Permanent select-grid-only patch; emits a ROM that needs no Lua writes.
python3 scripts/patch_rom_permanent.py --rom roms/mvsc.zip \
--out build/select-grid-icons/mvsc.zip
MAGNETO_ROM="$PWD/roms/modified/mvsc_magneto_storm_phase1.zip" \
MAGNETO_MANIFEST="$PWD/analysis/storm_xmvsf/storm_phase1_manifest.json" \
bash experiments/magneto_xmvsf/run_magneto_xmvsf_direct.sh playStorm is Jin's cell: row 2, column 2 — Down, Down, Right from Ryu.
Re-run the captures
# tap totals + positive controls -> the zero-reads result
mame mvsc -rompath roms -video none -sound none -nothrottle -skip_gameinfo \
-autoboot_script cap4_master.lua
# cursor snake -> the 3x5 cell map
mame mvsc -rompath roms -video none -sound none -nothrottle -skip_gameinfo \
-autoboot_script cap7_snake.lua
# gfxram / objram / wram dumps -> the tilemap itself
mame mvsc -rompath roms -video none -sound none -nothrottle -skip_gameinfo \
-autoboot_script cap6_video.luacap6_video.lua writes gfxram.bin ($900000 +$30000), objram.bin ($708000 +$2000) and
wram.bin ($FF0000 +$10000). The tilemap you want starts at $908000 — offset $8000 into
gfxram.bin.
Every address in this post, in one table
| address | what |
|---|---|
$000236 | boot ROM checksum routine (samples 1 byte every $FFF, 8 banks) |
$0000D0 | expected checksum sums, in the unused vector area |
$000E62 | boot grid-struct copy source (used as a tap control) |
$006AA6 | HUD portrait table, + row x 2 |
$00A486 / $009ED6 | palette copy helper, MvC / XMvSF |
$0D5EF0 | MvC sound-post routine |
$0D660C | Jin's / Storm's 128-byte sound row (MvC) |
$079580 | Storm's 124-byte sound row (XMvSF) |
$0E676C $0E67C8 $0E67F6 $0E6852 $0E68AE | the five per-character directories |
$1145A8 / $1C7268 | palette directory, XMvSF / MvC |
$11E83C | the table that is not the palette directory |
$1810F2 $1815CE $18296A $1829BA | the four tapped candidate ranges — 0 reads |
$1D2A84 | live-read positive-control range, historically misidentified as the select palette table; identity retracted in Act 5 |
$1BE5BA | real ROM source for Scroll 1 palettes, + N*32; Magneto $01 at $1BE5DA, Storm $0B at $1BE71A |
$25EE90 | ROM source blob for the select-grid tilemap |
$3A1000 / $3A6000 | Captain America's / Jin's box-shape window |
$500000 | where mvc.exp maps |
$508000..$523006 | Storm's imported data |
$510000 | Storm's parked, never-dispatched source code |
$588000 | Magneto's imported data |
$708000 | OBJ RAM (cursor position scan) |
$900000 / $908000 | CPS gfxram / the select-grid scroll1 tilemap |
$914000 | PALBASE — OBJ palettes; scroll1 at +$400 + N*32, scroll2 +$800, scroll3 +$C00 |
$914420 / $914560 | scroll1 palette $01 (Cap's cell) / $0B (Jin's cell) |
$9147C0 / $9147E0 | runtime-prototype palette slots $1E / $1F; the permanent ROM patch instead rewrites cell palettes $0B / $01 in place |
$908B3A | Storm tile (4,0) attr — $6B to $0B, the X+Y flip that hid her top-right corner |
$90834E $908352 $9085CA $9085D6 | Magneto's flipped corner attrs — $21/$61 to $01 |
$180000 + 32*code | gfx byte address of an 8x8 scroll1 tile |
$FF4104 / $FF40B1 | game phase / roster id under the select cursor |
$0E676C | D1 — per-character move/animation script pointers, 23 x u32 |
$0E67C8 | D2 — per-character u16 selector, 23 entries |
$0E67F6 | D3 — per-character box shapes, $3A0000 + N*$800 |
$0E6852 / $0E68AE | D4 / D5 — per-character parameter blocks |
$0E690A | T6 — per-character u16 offsets, stride $5C |
$3CD180..$3E0000 | 77,440 bytes of free $FF (249,494 free in 8 runs total) |
$FDB286 | start of mvc.h12m's 150,906-byte zero tail (QSound address) |
$3B04 $5000 $8000 $9006 $3B00 | MvC instrument / descriptor / envelope / sequence / header tables |
$4004 $54F6 $4000 | XMvSF instrument / descriptor / header tables |
Storm is standing in Marvel vs. Capcom with her own art, her own animation, and now her own face in the select grid. She moves like Jin and she has not been heard. Both of those are named, bounded pieces of work with an address attached.
The part worth carrying away is not any single number. It is that the best-looking hypothesis in this project — a table with the right shape, the right size, and a fingerprint that explained a real quirk of the game — was killed in ninety seconds by a tap with a control on it. The tap was cheap. Believing the fingerprint would have cost a session, and the session after that would have started by wondering why the repoint did nothing.
Put a positive control on your probe. It is the only thing that turns nothing happened into I measured nothing happening.

