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

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 tiles | 12,377 |
| distinct after internal dedup | 12,374 |
| already present in surviving MvC art | 45 |
| new slots needed | 12,329 |
| slots Jin frees in that bank | 12,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 itEvery 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:
| row | id block | in-block ids used | |
|---|---|---|---|
MvC Jin $18 | $0D660C | $1C0–$1DF | 24 |
| XvSF Storm | $079580 | $1C0–$1DF | 8 |
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 CLOSEDThe 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.
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:
- 18 display records$5BF2B0–$5BF2F0 · 64 Bu32 $00000006 + cel pointer; record 8 carries $00008006
- 2loop pointer$5BF2F0–$5BF2F4 · 4 Bu32 $005BF2B0 — back to the head
- 3$FFFFFFFF sentinel$5BF2F4–$5BF2F8 · 4 Bunused fill; the walker pins it anyway
- 4owner router$5BF300–$5BF326 · 38 B38 bytes of 68000 — routes ids $18 and $04 to the private chains
- 5ticker router$5BF330–$5BF35C · 44 B44 bytes — the per-frame chain advance
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.
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.
$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 | $0E3594 — stock table | $5BF2D0+8k — private chain |
| bank / palette | $02 / $0C | $08 / $0C |
anchor x, y | slides $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.

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

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.