case studyUpdated Aug 14, 202666 min read$2D

I Found the Perfect Table for Storm's Portrait. The Game Never Read It.

A 90-second control killed the theory. Following the renderer instead produced permanent Storm and Magneto icons in a 2,736-byte MvC ROM patch.

Jump to evidence
cps2reverse-engineeringxmvsfmarvel-vs-capcomstorm
CPS-2 Reverse EngineeringPart 34 of 43
Browse all writing
On this page
Editor's update — August 14, 2026: Storm now has a replay-derived gameplay receipt

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.

What you will be able to do after reading this
  • 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.
clip · loops · audio available
PERMANENT ROM PATCH // The game boots and draws both icons from patched Scroll 1 tilemap, palette and graphics data—no Lua memory writes or debugger pokes. Capture audio is available, but this clip is not evidence that either character's imported voice set or move logic is complete.

Skip to what you came for

Read this before the good part, because the good part is seductive

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.

Storm's phase-1 data inside the expanded MvC program map
8$3A0000$5E0000
  • 1
    box shapes (Jin's own window)
    $3A6000$3A6800 · 2 KB
    the one stock range this build overwrites; Jin's original SHA-256 is recorded for rollback
  • 2
    animation tree
    $508000$510000 · 32 KB
    26,576 B, pointers relocated to 32 destination groups
  • 3
    source code oracle
    $510000$512000 · 8 KB
    7,720 B parked and never dispatched — this is why move logic is not hers
  • 4
    hitboxes
    $512000$513000 · 4 KB
    1,650 B
  • 5
    move / attack properties
    $513000$514000 · 4 KB
    3,074 B
  • 6
    palette
    $514000$515000 · 4 KB
    544 B
  • 7
    private framedata pack
    $515000$523006 · 56.0 KB
    57,350 B: 829 framedata blocks + 665 tile rows
  • 8
    Magneto (previous import, untouched)
    $588000$5E0000 · 352 KB
The parked source-code oracle at $510000 is the honest centre of this diagram: Storm's original 68000 code is in the ROM, and nothing calls it.

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:

The tile placement rule

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:

valuesource
graphics bank4storm_phase1_manifest.jsongfx.bank
chipsmvc.h13m mvc.h15m mvc.h17m mvc.h19modd H chips
chip CRC328d2e879e beffa427 3e02ddb8 b1c37a36manifest gfx.chip_crc32
distinct tiles12,377manifest gfx.distinct_tiles
gfx bytes1,584,256manifest source_sizes.gfx_bytes
low code range starts at0x0001manifest 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:

The $0E67C8 byte-order trap

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.

directoryaddressholds
DIR_ANIM$0E676Canimation tree root
DIR_WORD$0E67C8bank / attribute words (the + id + 1 selector above)
DIR_BOXSHAPE$0E67F6box-shape window
DIR_HITBOX$0E6852hitbox table
DIR_MOVEPROP$0E68AEmove / 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:

whatXMvSF addressbytes
code (parked, not dispatched)$024E6C7,720
animation tree$0AB24426,576
box shapes$3570002,048
hitboxes$099FCA1,650
move / attack properties$08E9B63,074
palette$11EC68544

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.

gamepalette directoryhelper
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 a different table

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

nativeverifiedbridgeddonorpending
routedata seatedrendersbehaviouraudio
idle / walk / jump artverifiedverifieddonorJin's state machine drives the chainsbridgedrow rewritten, not yet heard
animation treeverifiedverifieddonor28 of 31 groups translate by index; 3 are decisionspending
hitboxes / box shapesverifiedpendingnot visually confirmeddonorpending
paletteverifiedverifiedverifiedpending
move logicpendingsource code parked at $510000, never dispatchedpendingdonorthis is Jin, entirelypending
select grid iconverified48 art tiles in the GFX ROMsverifiedROM boot, no Lua or debugger writesverified8 source entries repointed and deflippedpendingvisual identity only; no audio claim
The select-grid identity row is complete. Storm's character behaviour and heard-audio gates remain separate and open.

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 it

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

The collision that is not a collision

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:

addresslengthslots
XMvSF Storm row$079580$7C31 slot pairs, no leading pair
MvC Jin row$0D660C$8032 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 header

The row rewrite is 128 bytes at $0D660C, and because the row lives in the code region it costs zero allocation.

sound row, first 4 bytes of a 128-byte rewrite $0D660C · mvce.04a + mvcud.04d4 bytes · 4 changed
before3907B3CF
after27ECDBA9
Shown as the leading bytes of the row's SHA-256 before/after, because the interesting object is the whole 128-byte row: 3907b3cf... -> 27ecdba9... Both the encrypted member and the decrypted phoenix mirror are written.

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

The 64 KB rule that invalidates a naive packer

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.

Storm's PCM against the space it went into
  • 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

The gap between the second and third bars is the 64 KB bank rule, priced in bytes.

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 bytes

New 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 $8000

That $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 instrument16 (first free slot)
pointer written at$3B24
note array at$8130
array bytes336
unused note slots filled withffff0000

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 0090f6542e40

Sequences are copied verbatim except for the 1F nn instrument selector, and MvC's $9006 table is repointed at the copies:

byte patch $009006 · mvc.02 (sequence table entry for id $16C)4 bytes · 3 changed
before02A8A400
after039B4000
24-bit big-endian linear address plus a flag byte. The 55-byte sequence moved from $0239D9 in XMvSF to linear $039B40 in MvC. One entry per repointed id; eight ids were repointed.

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.

You do not need to import it. It is already in there.

Marvel vs. Capcom already contains two of Storm's X-Men vs. Street Fighter samples, byte for byte, in the stock ROM.

Storm clipalready in MvC atMvC descriptorreachable by postingbytes
$147 $148 $149QSound $21580D$0C9id $13310,218
$14A $16CQSound $3A0000$163id $18515,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:

memberwhy it changed
mvc.01descriptors, Storm instrument array + pointer
mvc.02copied Storm sequences
mvc.h12mStorm PCM written into the verified zero tail
mvce.04asound row $0D660C, encrypted program member
mvcud.04dsound row $0D660C, decrypted phoenix mirror
What the gate does not prove

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.

One spec defect, found by disagreement

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:

Why these tables looked like the answer

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

What was tapped, and what fired
$180000$1D3000
  • 1
    PT_A pointer table
    $1810F2$181152 · 96 B
    24 x u32 — 0 reads
  • 2
    REC_A records
    $1815CE$1816EE · 288 B
    24 x 12 B — 0 reads
  • 3
    PT_B pointer table
    $18296A$1829CA · 96 B
    24 x u32 — 0 reads
  • 4
    REC_B records
    $1829BA$182ADA · 288 B
    24 x 12 B — 0 reads
  • 5
    CTL_SELPAL (historical name; positive control)
    $1D2A84$1D2C84 · 512 B
    16 reads at frame 558 — the tap works; palette identity retracted in Act 5
Four warn-tone ranges at zero, one hot-tone range at sixteen, from the same machinery in the same run.
How to run a read-tap that cannot quietly return a false negative
1. Tap both candidates, at record granularity

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.

2. Tap something you already know is read

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

3. Tap the opcodes space as well as program

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.

4. Drive the machine to the state you are measuring

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') end

The 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@558

Two independent runs. Both address spaces. The control fires; the candidates never do.

Do not spend another session repointing those tables

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 palette

The ROM source blob for this tilemap is at $25EE90, found by searching the ROM for the live codes.

Why a byte-compare against gfxram fails at $25EE90

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 0col 1col 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
Two independent derivations agree

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.

Marvel vs. Capcom character select screen captured headless in MAME, showing the 3x5 character grid with the cursor on the top-middle cell
THE SURFACE IN QUESTION // stock mvsc, headless MAME 0.264, cursor settled on row 0 / col 1 at pixel (232, 48) — Ryu, the snake's start cell. Every cell in this grid is a 6x4-tile window onto one shared 18-tile-wide sheet.

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/61

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

The evidence, straight off the framebuffer

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   #########################################ooooooo

Read 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:

byte patch $9085CA · scroll1 tilemap (gfxram)2 bytes · 1 changed
before0021
after0001
MAGNETO tile (5,0): drop X flip. Same edit at $90834E, $908352, $9085D6 ($61 → $01) and $908B3A ($6B → $0B).
The general rule, because this is not specific to MvC

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 match

Two hits, both 15/16:

Jin's cell  (palette $0B)  ->  gfxram offset 0x14560
Cap's cell  (palette $01)  ->  gfxram offset 0x14420

And they agree on a base, independently:

0x14560 - 0x0B*32 - 0x400 = 0x14000
0x14420 - 0x01*32 - 0x400 = 0x14000
CPS palette RAM, inside the gfxram window
1 · OBJ (sprites)23 · SCROLL24 · SCROLL3ABCD$914000$915000
  • 1
    OBJ (sprites)
    $914000$914400 · 1 KB
    32 palettes x 16 words
  • 2
    SCROLL1 — the select grid
    $914400$914800 · 1 KB
    pal N at $914400 + N*32
  • 3
    SCROLL2
    $914800$914C00 · 1 KB
  • 4
    SCROLL3
    $914C00$915000 · 1 KB
  • A
    pal $01 — Captain America's cell
    $914420 · marker
  • B
    pal $0B — Jin's cell
    $914560 · marker
  • C
    pal $1E — Storm (was a grey ramp)
    $9147C0 · marker
  • D
    pal $1F — Magneto (was a grey ramp)
    $9147E0 · marker
PALBASE = $914000. Each layer gets 32 palettes of 16 BRGB words; scroll1 is the second block.

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

Slots $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      <- $F00F

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

Budget

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:

How much of each stock select cell is transparent
  • 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
Measured off the framebuffer against each cell's own palette. Every icon in this grid is a silhouette on a transparent field.

Captain America's cell, opaque in white, transparent in blue:

Transparency map of Captain America's select-grid cell: an opaque character silhouette surrounded by transparent background
THE CONSTRUCTION // The character is an opaque blob. The gold frame is opaque. Everything else is pen 15, and the Earth scrolls behind it.

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.

And then Akuma showed up

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.

Magneto's cell cut out to a silhouette with the Earth showing through behind him
Magneto's cell with mirrored border tiles, missing side columns and a painted background
‹›
Before: flipped tiles, painted backgroundAfter: cut out, head placed
Same 48x32 cell, same 24 tile codes. Attr bits fixed, 15 pens that belong to him, and the Earth scrolling behind.

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: 0

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

And the trap I walked into three separate times

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.

Stock Marvel vs. Capcom character-select grid with Jin in the third row right cell and Captain America in the fourth row left cell
Before // stock Jin and Captain America grid icons
Patched Marvel vs. Capcom character-select grid with Storm replacing Jin's icon and Magneto replacing Captain America's icon
After // Storm and Magneto, gold frames intact

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   0x1D2764

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

Fifteen partial matches are not fifteen pieces of evidence

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/32

Widen 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 transparent

So 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:

Test 1: 'the value never appears in the ROM'

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.

Test 2: 'the tile's graphics are blank'

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:

select-grid tilemap source block
1 · (code, attr) pairsABC$25EE00$25F800
  • 1
    (code, attr) pairs
    $25EE94$25F760 · 2.2 KB
    360 entries, chunked order
  • A
    STORM (4,0) — $010D/$6B
    $25F2A8 · marker
  • B
    MAG (0,0) — $40A8/$01
    $25F328 · marker
  • C
    MAG (0,1) — $04E1/$61, ambiguous
    $25F338 · marker
356 entries fall out of a uniqueness match. Four need one more step.
Finding a ROM offset for every entry, without knowing how the block is ordered
1. Build a (code, attr) -> (col, row) index from a live dump

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.

2. Look each unique pair up in the ROM block

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.

3. Resolve the rest from a neighbour plus the stride

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 flip
byte patch $25F2A8 · mvc.07 (file offset 0x5F2A8)4 bytes · 3 changed
before010D006B
after4D55000B
Storm's top-right tile: repointed to a private code, and the X+Y flip dropped. This one edit is the 'missing corner' from Act 4.

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

Stock ROM vs patched ROM, frame-for-frame
  • 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

Same inputs, same frame cadence, two ROMs. The only thing that changed is the thing that was supposed to change.

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
Marvel vs. Capcom character select grid with Storm and Magneto ported from X-Men vs Street Fighter, rendered from ROM data with no runtime patching
THE RESULT // No Lua, no pokes, no debugger. This is the ROM booting and drawing its own select screen.

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 SEAT

All ids are even, $02..$20. $10 is in range and belongs to no cell.

The grid clamps. It does not wrap.

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 = 15

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

tableaddressJin [12]Cap [2]holds
D1$0E676C$13EC82$107FF0move/animation scripts, 24-27 KB
D2$0E67C8$0006$0000u16 selector, values 0/2/4/6
D3$0E67F6$3A6000$3A1000box shapes, $3A0000 + N*$800
D4$0E6852$0FBAD4$0F90DA1.3-1.7 KB per character
D5$0E68AE$0F3C2A$0EC6782.6-3.3 KB per character
T6$0E690A$047E$00E6u16 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                 0
The control is the entire point

ops = 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 + $3812

I counted them across all 23 blocks against controls and got what looked like a decisive result:

Apparent 'self-pointer' density
  • 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
This chart is wrong, and the way it is wrong is the point of this act.

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:

Relocate with ZERO fixups
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 differ

Byte-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..$001715 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 block

repeating 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 $3A0000 and graphics at $300000 need high words $3A/$30 to 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.

The lesson, stated plainly

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.

1. MAME garbage-collects Lua notifier subscriptions

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.

2. The game wants Service 1, not Coin 1

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.

3. Reading CPU address registers inside a write-tap callback segfaults MAME 0.264

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.

4. A 64-bit content hash collided. Twice.

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.

5. 68000 opcode fetches do not appear in a program-space read tap

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.

6. A missing +2 in the tilemap row index — one bug, three symptoms

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 * 4

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

7. Repointing a shared tile without taking its attr

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.

8. Pen 15 is transparent

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.

9. The select grid clamps at its edges — it does not wrap

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.

10. Run the null version before building the machine that compensates

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)
The same reclaim, computed two ways
  • 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

A factor of twenty. I do not know which of these is right, and that is the honest state of it.

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.

What is solid, and what is reopened

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.

  1. Hearing the audio. It is written and gated; it has not been played.
  2. 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 $510000 is unexplained by that result and may be the wrong shape for the job; check before building on it.
  3. 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 play

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

cap6_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

addresswhat
$000236boot ROM checksum routine (samples 1 byte every $FFF, 8 banks)
$0000D0expected checksum sums, in the unused vector area
$000E62boot grid-struct copy source (used as a tap control)
$006AA6HUD portrait table, + row x 2
$00A486 / $009ED6palette copy helper, MvC / XMvSF
$0D5EF0MvC sound-post routine
$0D660CJin's / Storm's 128-byte sound row (MvC)
$079580Storm's 124-byte sound row (XMvSF)
$0E676C $0E67C8 $0E67F6 $0E6852 $0E68AEthe five per-character directories
$1145A8 / $1C7268palette directory, XMvSF / MvC
$11E83Cthe table that is not the palette directory
$1810F2 $1815CE $18296A $1829BAthe four tapped candidate ranges — 0 reads
$1D2A84live-read positive-control range, historically misidentified as the select palette table; identity retracted in Act 5
$1BE5BAreal ROM source for Scroll 1 palettes, + N*32; Magneto $01 at $1BE5DA, Storm $0B at $1BE71A
$25EE90ROM source blob for the select-grid tilemap
$3A1000 / $3A6000Captain America's / Jin's box-shape window
$500000where mvc.exp maps
$508000..$523006Storm's imported data
$510000Storm's parked, never-dispatched source code
$588000Magneto's imported data
$708000OBJ RAM (cursor position scan)
$900000 / $908000CPS gfxram / the select-grid scroll1 tilemap
$914000PALBASE — OBJ palettes; scroll1 at +$400 + N*32, scroll2 +$800, scroll3 +$C00
$914420 / $914560scroll1 palette $01 (Cap's cell) / $0B (Jin's cell)
$9147C0 / $9147E0runtime-prototype palette slots $1E / $1F; the permanent ROM patch instead rewrites cell palettes $0B / $01 in place
$908B3AStorm tile (4,0) attr — $6B to $0B, the X+Y flip that hid her top-right corner
$90834E $908352 $9085CA $9085D6Magneto's flipped corner attrs — $21/$61 to $01
$180000 + 32*codegfx byte address of an 8x8 scroll1 tile
$FF4104 / $FF40B1game phase / roster id under the select cursor
$0E676CD1 — per-character move/animation script pointers, 23 x u32
$0E67C8D2 — per-character u16 selector, 23 entries
$0E67F6D3 — per-character box shapes, $3A0000 + N*$800
$0E6852 / $0E68AED4 / D5 — per-character parameter blocks
$0E690AT6 — per-character u16 offsets, stride $5C
$3CD180..$3E000077,440 bytes of free $FF (249,494 free in 8 runs total)
$FDB286start of mvc.h12m's 150,906-byte zero tail (QSound address)
$3B04 $5000 $8000 $9006 $3B00MvC instrument / descriptor / envelope / sequence / header tables
$4004 $54F6 $4000XMvSF 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.

Written by Daniel Plas Rivera · 14,472 words · $2D

ShareXLinkedIn