case studyUpdated Aug 15, 202616 min read$2A

Storm Fits Inside Jin. I Measured That Before Writing a Byte.

Storm needs 12,329 tiles. Jin frees 12,873. That 544-slot margin is the difference between a stock-size plan and another expansion—and only the start of the audit.

cps2reverse-engineeringxmvsfmarvel-vs-capcomstorm
CPS-2 Reverse EngineeringPart 32 of 43
Browse all writing
On this page
Editor's update — August 15, 2026: Storm's select-screen presence is now a measured contract

A new final section extends the measurement discipline this article is about to the one surface it never covered: the character select screen. Storm's grid-side body turned out to be a private eight-record animation chain plus a multiplexed grid actor whose commitment to Storm is distinguishable from the montage that cycles native characters — and all of it is now bound by a fail-closed live harness with a per-frame evidence ledger. On August 15 her selected-body window passed live for the first time, in the same run that passed Magneto's. No acceptance is claimed: the candidate is still a candidate and the program's receipts gate it. Jump to the measurement →

Editor's update — August 14, 2026: Storm now has a replay-derived gameplay receipt

This article remains the measurement 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 →

Editor's update — August 13, 2026: the select-grid identity is now in the ROM

This article remains the measurement pass it says it is. The next day's expanded-build work seated Storm's art and animation and turned her select-grid identity into permanent ROM data; Magneto's grid cell was patched in the same pass. That phase-1 artifact still ran on Jin's move logic, and its written audio import remained statically verified rather than confirmed by ear. Read the bounded follow-up and its no-runtime-write proof →

The house in one inspection card

Goal: Replace Jin at id $18 in Marvel vs. Capcom with X-Men vs. Street Fighter's Storm — inside the stock ROM layout. No expanded members, no extra chips, no expansion-aware driver. If she does not fit in what removing Jin frees, she does not ship.

Graphics: Storm's art resolves to 12,377 source tiles, 12,374 distinct after internal dedup, 45 of them already present byte-identically in surviving MvC art. New slots needed: 12,329. Slots Jin frees in the same graphics bank: 12,873. Margin: +544. She is the only one of the two imports that fits without reclaiming anything.

Audio: every CPS-2 fighter gives each character a contiguous block of 32 sound-command ids and a fixed 128-byte row that maps animation slots to them. Storm's block in XvSF is $1C0–$1DF. Jin's block in MvC is also $1C0–$1DF — seven of Storm's eight in-block ids land on ids Jin already uses, and the eighth ($1C7) is free inside that same block. Her PCM demand is 97,516 bytes against 501,325 available.

Animation: the MvC animation-tree parser reads all 19 XvSF trees unchanged — same 16-byte records, same spritelist pointer offset, same jump-flag bit. Storm's tree is 26,576 bytes against Jin's 24,498; the 2,078-byte excess sits inside 110,998 bytes of measured data headroom.

What is not true yet: nothing is built. This is the measurement pass. The allocation ledger is still OPEN — not on Storm, on Magneto's graphics bank — and it refuses to report ready_to_build while any proof is outstanding.

Marvel vs. Capcom has a Jin. It is Jin Saotome from Cyberbots, and he is the character I am deleting. That framing matters more than it sounds, because the interesting question in a stock-layout port is never "can I draw Storm" — it is "does Storm fit in the hole Jin leaves."

The previous Magneto work answered that question by building first and measuring when something broke. This time the order is reversed.

The rule that makes this hard

The build has one constraint that removes every easy answer: the ROM keeps its stock member layout. Twenty-one members, the same sizes, the same names, loaded by an unmodified driver. No mvc.h11m and friends, no expansion, no "we'll just add a chip."

That means the port is a pure exchange. Everything Storm needs has to come out of what Jin gives back, and the only honest way to know that is to count both sides before touching anything.

Horizontal bars showing Storm's graphics demand at 96% of freed slots, audio at 19% of available bytes, and animation tree at 2% of headroom
Three resources, three measurements, one axis: what Storm needs as a share of what removing Jin frees.

Graphics: +544 slots, and that is the whole margin

Both characters' art was reduced to content hashes and compared per graphics bank — no reliance on which tile slot "belongs" to whom, only on what the bytes are.

Storm → Jin's bank
source tiles12,377
distinct after internal dedup12,374
already present in surviving MvC art45
new slots needed12,329
slots Jin frees in that bank12,873
margin+544

544 slots is about 4% of the requirement. It fits, and it fits without reclaiming a single disputed slot — which is exactly why Storm is the import I would build first, and why Magneto (who is 1,495 slots short in his bank) is the one still holding the ledger open.

The single-bank rule is what makes this a per-bank question rather than a total-size question: a character's tile codes carry only 16 bits, and bank bits come from a per-character table byte, so every one of Storm's tiles must live in the same 65,536-slot bank. Jin's bank is bank 3. Storm goes in bank 3 or nowhere.

Audio: the block that was already hers

This one was a genuine surprise.

Disassembling MvC's sound-post routine at $0D5EF0 shows how every character's audio is dispatched:

$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

Every character owns a fixed 128-byte row of 32 four-byte slots(id, variant id) — reached through an offset table whose alternate characters alias earlier rows. X-Men vs. Street Fighter has the identical structure at $079462 with 124-byte rows.

Reading both games' tables out gives the blocks:

rowid blockin-block ids used
MvC Jin $18$0D660C$1C0–$1DF24
XvSF Storm$079580$1C0–$1DF8

Storm and Jin were allocated the same block number in their respective games. Seven of her eight in-block ids fall on ids Jin already uses; the eighth, $1C7, is unused inside that block. So her audio import is close to an in-place substitution: replace the sequences and samples behind those ids, rewrite Jin's row, done. The row is fixed size and lives in the code region, so it costs zero allocation.

That is not luck I can take credit for. It is a consequence of both games assigning id blocks in roster order, and Storm and Jin happening to sit at compatible positions. But it does mean Storm cannot leak another character's voice by construction — which, as the Magneto post now documents at length, is not a hypothetical failure mode.

Resolving every id through the sequence → instrument → descriptor → PCM chain gives the sizes: Storm needs 97,516 bytes of sample data; removing Jin frees 297,988 bytes of PCM that no other character and no music cue references, and there are 66,126 more bytes of trailing padding behind that.

Animation: the parser reads both games unchanged

The biggest unknown in any cross-game character import is whether the animation structures are the same shape. That is answerable without building anything: run one parser over both games and see where it stops agreeing.

The MvC walker — 16-byte records, spritelist pointer at +4, bit 15 of +2 as the jump flag — parses all 19 XvSF trees and 22 of 23 MvC trees, with mode-2 spritelists over 99% dominant in both. The port is relocate-and-translate, not rewrite.

There is one real delta. Every MvC character has 31–33 top-level animation groups; every XvSF character has 28. Aligning the five characters that exist in both games (Ryu, Chun-Li, Zangief, Gambit, Wolverine) by group shape and letting them vote gives a map that is unanimous where it matters:

  • MvC groups 0–21 ↔ XvSF 0–21, one-to-one, 5/5 agreement on every single one
  • groups 22–25 match at 4/5
  • MvC 26, 27, 28 have no XvSF source — the extras
  • MvC 29, 30 ↔ XvSF 26, 27

So 28 of 31 groups translate by index and three are decisions. Storm's tree itself is 26,576 bytes against Jin's 24,498 — a 2,078-byte excess against 110,998 bytes of headroom.

The honest caveat: shape agreement is evidence of order, not of meaning. Five characters agreeing that group 7 lines up says the containers match; it does not prove group 7 does the same job in both engines. That needs one behavioural confirmation before phase 5 leans on it.

What the measurement pass also caught

Two things surfaced that would have been ugly to discover later.

A boot ROM checksum nobody had noticed. MvC's boot code at $000236 samples one byte every $FFF across all eight 512 KiB banks and sums them, and the expected values are stored in the ROM at $0000D0 — parked in the unused 68000 exception-vector area. They match the canonical image exactly. Sixty-three of those sample points fall inside the free space this build intends to spend, so recomputing and rewriting that table is now a mandatory build step rather than a surprise in the service menu.

A 256-byte "free" run that is not free. Of eight all-0xFF runs in the program image, seven are genuinely unreferenced. The eighth, $3B1100–$3B1200, is the allocated but empty record for Onslaught in a live per-character table. Claiming it would have been a silent corruption. It is excluded, and the supply is 244,398 bytes against a 133,400-byte requirement.

The ledger says no, and that is the point

All of this feeds one artifact — stock_allocation_ledger_v1.json — where every number is read out of a receipt file rather than typed, and each line carries demand, supply, headroom and a status. It ships with a validator that exits non-zero when the ROM is not buildable, so later phases gate on an exit code instead of on prose.

line                       demand       supply     headroom  status
code                            —       15,514            —  CLOSED
program_data              133,400      244,398     +110,998  CLOSED_PENDING_PROOF
gfx_bank3_storm            12,329       12,873         +544  CLOSED
gfx_bank0_magneto          14,789       13,294       -1,495  OPEN
qsound_pcm                222,235      501,325     +279,090  CLOSED

The first time I ran the validator it caught me: program_data was marked CLOSED while still carrying an outstanding proof obligation. Numbers working is not the same as evidence landing, so there is now a CLOSED_PENDING_PROOF state and the status derivation respects it.

Storm's line is closed. Magneto's graphics line is open by 1,495 slots, with 2,075 candidate slots identified and one proof outstanding before they can be claimed. The ledger reports OPEN and refuses ready_to_build, which is the correct answer today.

What proves it, when it is built

The acceptance instrument is not a wishlist. It is 240 combo cases extracted from twelve ALLTIME-BEST xmvsfu matches — real elite play, calibrated against FBNeo's own input decode, with 113 of the cases containing a super. Each case carries per-frame stick and button state and is played back into MvC through its own controls, so the port gets exercised by what top players actually did rather than by anything I would have thought to script.

clip · loops
Not a scripted probe. This is 819 frames lifted verbatim from an ALLTIME-BEST xmvsfu match — Scorpion90's inputs, 127 button presses, 19 fireball motions, 8 double-QC supers — replayed into stock Marvel vs. Capcom through the game's own controls. Recorded headless in MAME with the tape player driving P1.

Storm is not built. Nothing is written. But every question that could have stopped her has been asked and answered with a number, and the one that is still open belongs to somebody else.

Storm's select-screen contract, measured live

The whole article above is a measurement pass for Storm the resource consumer — tiles, PCM, tree bytes. This closing section, added August 15, applies the same rule to Storm the select-screen citizen: measure the object model first, then let a fail-closed harness bind every field of it, every frame, on the record.

Eight records, a loop, and a sentinel

The select repair gave Storm a private SELECT chain at $5BF2B0 — the same structure as Magneto's 73-record chain, scaled to her eight frames at a 6-frame hold:

candidate ROM, $5BF2A0–$5BF360 (mvc.exp view)
1 · 8 display records45 · ticker router$5BF2A0$5BF360
  • 1
    8 display records
    $5BF2B0$5BF2F0 · 64 B
    u32 $00000006 + cel pointer; record 8 carries $00008006
  • 2
    loop pointer
    $5BF2F0$5BF2F4 · 4 B
    u32 $005BF2B0 — back to the head
  • 3
    $FFFFFFFF sentinel
    $5BF2F4$5BF2F8 · 4 B
    unused fill; the walker pins it anyway
  • 4
    owner router
    $5BF300$5BF326 · 38 B
    38 bytes of 68000 — routes ids $18 and $04 to the private chains
  • 5
    ticker router
    $5BF330$5BF35C · 44 B
    44 bytes — the per-frame chain advance
Every byte from $5BF2B0 to $5BF35C is hash-pinned in the runner's preflight; a single drifted duration or loop target refuses the launch.
Her chain, its loop, and the code that owns both characters' select bodies
1. Records: duration 6, eight cels, every header checked

Each record is u32 duration, u32 cel. All eight read $00000006 — hold six frames — and their cels sit contiguously at $515000–$515162, every one a valid mode-2 cel with a sane part count. Her whole idle is eight poses at ten frames a second.

2. Termination is explicit, twice

Record 8's duration word carries the $8000 end flag; the next u32 is the loop pointer back to $5BF2B0; the u32 after that is an $FFFFFFFF sentinel. The static walker validates all three — flag, target, sentinel — because the difference between a record and the bytes after the records is exactly where a naive walk goes wrong.

3. The routers behind the sentinel own both characters

$5BF300 and $5BF330 hold the owner and ticker routers: 82 bytes of 68000 that read the work object's id at +$52 and drive whichever private chain matches. Their exact bytes are pinned by hash in every preflight — the select-screen driver is part of the launch contract.

One actor, two jobs — and how the harness tells them apart

The subtle part of Storm's select presence is not the bench side; it is the left grid actor — a single class-$54/subtype-$06 work object ($FF5DEA in the measured runs) that multiplexes two jobs. Before a commitment it plays the montage: cycling native characters using their stock catalogue records. After P1 locks Storm, the same object pins to her private chain and stays there. The unfiltered observation census caught it mid-switch, and the two roles are byte-distinguishable:

montage role (measured)committed role (measured)
id at +$52$0012 (Ryu, cycling)$0018 (Storm, held 40/40 frames)
record at +$34$0E3594stock table$5BF2D0+8k — private chain
bank / palette$02 / $0C$08 / $0C
anchor x, yslides $0060 → $00B8($00B8, $0010) — parked

So the acceptance oracle does not ask "does the left panel show Storm" — a montage frame could. It requires the actor's live record to be a member of the pinned private chain with r32(record+4) equal to the pinned cel, the full field vector to hold (side $00, slot $00, flip $01, bank $08, palette $0C, state $0002, FSM word zero), the same work address to persist across the window, and at least two distinct chain cels to render-bind — animation proven, not asserted. A montage can satisfy none of that for more than a few frames; her chain satisfies all of it for as long as the window runs.

Character select frame 914: Storm's committed body rendered on the left grid-actor side during the partner phase, with the select grid and both teams' labels visible
Figure 1 — an observation-run capture (f=914): the left grid actor rendering Storm's committed body from her private chain. The arena census behind this frame is what separated the montage from the commitment.

The window that had never run

Until August 15 no attempt had ever reached Storm's committed-body checkpoint — every earlier version failed before both selectors finished choosing. That run got there, and the gate passed on its own terms. The unedited ledger, one row per window frame, verdict withheld until the last one:

age 0  record=$5BF2D0 idx 4 cel=$5150E8 render_bound=true  distinct_cels=0  ← present, first frame
age 1  record=$5BF2D0 idx 4 cel=$5150E8 render_bound=true  distinct_cels=1
age 3  record=$5BF2D0 idx 4 cel=$5150E8 render_bound=true  distinct_cels=1
age 4  record=$5BF2D8 idx 5 cel=$515112 render_bound=false distinct_cels=1  ← chain advanced (6-frame hold)
age 5  record=$5BF2D8 idx 5 cel=$515112 render_bound=false distinct_cels=2
age 6  record=$5BF2D8 idx 5 cel=$515112 render_bound=true  distinct_cels=2  ← LATCH: owner pass

Every field of the measured contract held on every one of those frames: side $00, slot $00, flip $01, bank $08, palette $0C (and palette delta $0C — the rendered submission's own palette row, recovered by inverting the renderer transform), state $0002, FSM word zero, anchor ($00B8,$0010), the same work address $FF5DEA throughout, and a record that is a member of her pinned private chain with a live r32(record+4) equal to the pinned cel. Six frames of held contract, two chain cels rendered, one unique owner, one unique coherent OBJ run.

Marvel vs Capcom character select at frame 920: Storm's committed body rendered on the left of the grid while the SPECIAL PARTNER panel is open
Figure 2 — the latch frame (f=920) from the run's own hardened GD capture. Her committed body on the left, drawn from record $5BF2D8 / cel $515112 while the partner phase is up. This is the frame the ledger above describes.

Her point-dwell gates passed in the same run with the same contract — rendered actor and chain work resolving to the same cel at all three checkpoints ($515000, $51509C, $515000), with two distinct cels across them, so her idle is proven to move, not merely to exist.

The run as a whole still sealed FAIL, and none of it was hers: the sampler collapsed Magneto's three point-dwell captures onto consecutive frames, a defer budget came up one frame short of a portrait zoom, and a global end-of-selection gate turned out to have an unreachable precondition. All three are scheduling faults in the instrument, all three are measured in the seal, and the successor changes only those. The full apparatus and revision history live in his post →

The preprint apparatus, shared with his post

The full acceptance program — revision history v8 through v11, the fail-closed pipeline (freeze → two byte-identical preflights → independent review → single launch → seal), the per-frame pre-verdict vector ledger, and the hash-pinned artifact list — is documented once, in the closing section of the Magneto article. Storm's constants ride the same instrument: her chain, cels, contract fields and the shared routers are re-derived from the staged candidate on every preflight, the pins must be byte-identical twice on the operator's machine and once more in an independent cloud re-derivation, and every run seals — pass or fail — with hashes. The measurement pass this article opened with now runs all the way to the select screen; the receipts, not this prose, say when she is done.

Written by Daniel Plas Rivera · 3,567 words · $2A

ShareXLinkedIn