case studyUpdated Aug 8, 202662 min read$13

Marvel vs Capcom Had No Room for a 24th Character. I Added Two.

The game had 23 character slots. Dark Sakura and Shuma-Gorath run beyond them, with their own code—and every table past the old limit waiting to break something.

Jump to evidence
cps268000reverse-engineeringencryptionemulator-instrumentation
CPS-2 Reverse EngineeringPart 10 of 43
Browse all writing
On this page

These games had been left alone for years. Marvel Super Heroes vs. Street Fighter came out in 1997, Marvel vs. Capcom in 1998. I played them a lot. But nobody was doing big modifications to them. Nobody was adding new characters. Adding real content to a game that hadn't been touched in decades. So I jumped in.

What got me started was sprite edits. I'd see someone in the community recolor a character and think, that's cool, they put something in there. I can do that too. So I started with color changes. Then I wanted to do more.

What I actually wanted was to give the community something nobody had. New characters. Something people could play with and imagine with.

A lot of us, as kids, wanted to see characters from other games show up in these ones. And we were sad when a new release came out and Sakura wasn't in it. Storm wasn't in it. Why did they remove them? We want them back. As kids you don't realize there are memory limits, that they couldn't ship every character even when they were capable of building them. So I felt it was my duty to do something about that. To fulfill the thing a lot of people wanted and never got.

You are never going to see Sakura fight Strider in this game, because that matchup never happened. Marvel vs. Capcom 2 put them on the same screen later, but it isn't the same polish, the same mechanics, the same physics. I wanted to know what she'd actually be like here.

There were moments I wanted to quit. I'd have it running across all the code and it would exhaust the token limit. I was pushing huge amounts through, reading raw code, trying to decipher it, and it ate tokens. Weeks of this. It made me want to give up.

Then I'd think: nobody else has been doing this at this level. Nobody has been willing to sit through the pain. Watching prompts run forever, diving into every chip to see what's in it, which stages, which features, which character data, how the memory values change, what the Lua script shows you. I wanted to make something I could leave behind, that other people could actually use.

That's why this exists. The rest of this post is how it was done.

If you got here from LinkedIn: welcome. The proof you came for is at the bottom of this page, and the Jump to evidence button above will take you straight to it. Everything in between is how it actually got done, and the road is the good part.

I didn't begin at the destination. The first playable version took the cheaper route: Ryu's state machine drove her while a hook drew Dark Sakura on top. It looked convincing enough to record, but the engine still knew it was Ryu. And honestly? A costume wasn't the port I wanted to claim. So I retired that donor architecture and made the engine dispatch her native identity instead.

Watch the wrong turn first. This is the capture that started the project:

Where it started: donor-era Dark Sakura in MvC1. In this early capture she is riding Ryu's engine underneath. The rest of this post is how that crutch got removed.YouTube ↗

That video is the donor era. What shipped since is the native port, and its most visible payoff is simple: her own name, in slots that used to say IRON SPIDER. You'll see it at the bottom.

My contribution was directing the reverse engineering, deciding which hypotheses were worth building, choosing what entered the cumulative patch chain, and setting the proof standard. Claude Code agents were tools inside that process: they searched, proposed patches, drove harnesses, read captures, and tried to refute leading explanations. They were confidently wrong more than once. Nothing below counts because an agent said it worked. It counts because the emulator ran it, the visible effect appeared, the match stayed alive, and a stock control remained unchanged.

How long this actually took
Drawing the diagram…
Each stage was gated in the emulator before the next one started. None of this was one prompt.
Six terms, and you can read everything below
  • Donor vs native. Donor: the character is a costume over another fighter's running code (Ryu's). Native: the engine runs her own code under her own id. This post is the native story; the earlier donor-chassis build is fully superseded by it.
  • Char-id. The engine's number for a fighter. The stock roster is ids $00 through $2C (23 characters). Dark Sakura is $2E, Shuma-Gorath is $30. Both past the end.
  • $52. The field on every in-fight fighter object that holds its char-id. Native means $52 stays her own value on every frame.
  • Two views. CPS-2 encrypts opcode fetches only, so one ROM address returns different bytes to a code fetch than to a data read. This single fact causes half the bugs below.
  • Arena. The unencrypted ROM region where everything I wrote lives: relocated code, copied tables.
  • Thunk / trampoline. A few instructions of glue that redirect a stock code path into the arena and back.

Three ground rules framed the whole port, plus a fourth I learned the hard way:

  • No source. Everything below is binary reverse engineering against the shipped ROM.
  • The base ROM stays untouched. Every build composes its patches onto a pristine source and writes a separate output ROM set.
  • A custom 64MB FBNeo core hosts the new characters' upper-region tiles. Offline only, because it breaks Fightcade netplay.
  • Discovered mid-port: some patches provably were not reaching the CPU. The debugging tool itself had to be built before the port could continue.

Let me give you the moment that defines the whole project. One address in a CPS-2 ROM handed the emulator a valid function pointer. My own relocated code read that same address, got $B5E9C085 (ciphertext), and jumped straight into it.

Dark Sakura and Shuma-Gorath are real, finished fighters in a different 1998 Capcom arcade game, Marvel Super Heroes vs. Street Fighter. Making them run their own code in MvC, rather than a costume over someone else's brain, cost:

  • four distinct root causes, one per subsystem,
  • an emulator I had to instrument mid-project, and
  • one master defect behind nearly every crash: their IDs fall off the end of every table that describes a fighter.

The most useful thing I learned is the first one: one address in a CPS-2 ROM can return two completely different values, and if your tools only ever show you one of them, they will lie to you with total confidence.

A Costume Is Cheaper, and the Engine Can Always Tell

Why not just reskin an existing character? Fair question. It's cheaper, and it genuinely works. But the engine can always tell, and I want to show you what that costs, because the price list explains every decision that follows.

There is a cheaper way to make a ported character fight, and it works: remap her in-fight char-id to a real, complete character (Ryu) so his state machine drives her, while a hook forces her own animation on screen. That was the donor chassis, the build in the video up top.

It has one ceiling. The engine's hit-test and property lookups re-derive attack data from $52-keyed tables, and under the donor $52 says "Ryu" on every frame. Her frame data can be composed onto the screen, but never be fully authoritative to the engine.

There's a fourth option above native: seating a character's own physics rows. When I first wrote this section I called it structurally blocked. It isn't — her walk, jump and gravity rows have since been seated through per-id thunks, and the project has proven a relocated 25-row directory end to end on one table family. What is true, and what the master defect below actually forces, is narrower: you cannot widen a table in place, because the 23-row directories tile with zero slack. You can redirect the reader, or relocate the whole directory and repoint every reader of it. Both are expensive; neither is impossible. Native means the engine dispatches each character as herself: her in-fight char-id ($52) stays her own value end to end, she runs her own relocated code and animation trees, and there is no other character's brain underneath, paying whatever that costs.

Three fields on the fighter object do almost all the damage, and each one gets its own root cause below, in this order:

Three fields do almost all the damage
Drawing the diagram…
Field $52 is the one that decides whether a character is herself or someone else. It also indexes every 23-row table in the game.

Three High-Confidence Fixes Moved the Crash Zero Frames

Here's why this port needed a debugging tool that didn't exist yet, and why "well-reasoned" and "true" are not the same thing.

The build was stuck for days on one symptom: Dark Sakura would commit, load, reach the intro, and 15 frames into the match the game would soft-reset to attract mode.

Static analysis produced three high-confidence candidate crash sites. A multi-agent workflow ranked them; the top pick scored 0.82. I built and tested all three.

None of them moved the crash by a single frame.

Where that 0.82 came from: this port was directed, not typed

The reverse engineering here ran through Claude Code, Anthropic's agentic coding tool, across several model generations. The loop: fan out parallel hypothesis agents on one symptom, then hand the leading hypothesis to a separate agent prompted to refute it (adversarial verification), then terminate everything in the emulator. Agents drove FBNeo through Lua harnesses, captured fixed-frame screenshots, and read those screenshots back themselves (live-execution ground truth). "Live-gated" throughout this post means exactly that: no fix counts until it has been run, the screenshot has been read, and the match is confirmed still alive.

That rule exists because the agents were confidently wrong more than once, and plausibly so. The "compressed sprites" blocker below was our own tool's garbage, and a "satellite objects" model for one of Shuma's attacks was built and shipped before the real defect turned out to be a missing hitbox and a hit-test rewrite. The ranked-candidates step you just read produced three well-argued patches worth zero frames. What separated the useful output from the confident nonsense was never the model. It was refusing to count anything that hadn't run.

That's the tell worth writing down. When well-reasoned static fixes change nothing (not "crash moved," not "crash changed shape," but nothing), stop reasoning and start measuring.

Lua couldn't help: this FBNeo build's registerexec and registerwrite hooks never fire, only frame-boundary polling works, and the crash lives inside the frame. So the measurement went into the emulator itself: a 1,024-entry PC ring buffer plus a wild-jump detector, writing a program-counter trace to disk:

/* inside the core's 68000 execute loop, dev builds only */
ring[ri++ & 1023] = REG_PC;              /* last 1024 instruction PCs */
 
/* wild-jump detector: valid program space -> RAM/out-of-ROM */
if (cur_wild && prev_ok)
    fprintf(log, ">>> WILD-JUMP: instr @%06X jumped to %08X | a0=%06X a1=%06X d0=%08X\n", ...);

Rebuild time: eight seconds. Time to first root cause after weeks of static guessing: one run.

The Same Table Address Returns Ciphertext to the Wrong Kind of Read

This is root cause number one, the "two views" trap from the terms box, springing for the first time.

Here's the log line that cracked the port open:

>>> WILD-JUMP #1: instr @23C018 jumped to B5E9C085 | a1=018DF0 d0=00000028

$23C018 is inside my own thunk, the master character dispatch I'd written to route char-id $2E to her code. Its "stock" path reproduced the original table read for everyone else. The original:

movea.l $18df0(pc,d0.w),a0    ; PC-relative read of master_table[id]
jmp     (a0)

My thunk, relocated far away, reproduced it the only way relocated code can, absolutely:

lea     $18DF0.l,a1           ; same table, absolute addressing
movea.l (a1,d0.w),a0
jmp     (a0)                  ; ...jumps to $B5E9C085. Ciphertext.

Same address. Same table. Different bytes.

CPS-2 encryption applies to opcode fetches: data in the ROM is stored plaintext, code is stored encrypted. Emulators honor this by giving the CPU two views of one address range. Critically, PC-relative operand reads go through the fetch (decrypted) view, while absolute data reads go through the raw view.

The original PC-relative read saw the decrypted table. My absolute read saw the encrypted bytes of that same table, and jumped to the ciphertext.

One address. Two views. One crash.
Drawing the diagram…
The stock PC-relative operand read receives a decrypted pointer. My relocated absolute read receives ciphertext from the same table.

So every character except Dark Sakura (everyone on the thunk's stock path) dispatched through garbage. She loaded fine; the match died when her partner did.

The fix, once you see it, is mechanical: copy the table, at build time, from the decrypted view into unencrypted arena space, and point the thunk at the copy.

MASTER_COPY = 0x23C300          # unencrypted data arena
for cid in range(0x00, 0x32, 2):
    v = table_entry_from_decrypted_view(cid)
    if cid == 0x2E: v = DS_ENTRY          # her slot
    emit(v)

The logger caught me again the same day: a different thunk, the same absolute-read-of-encrypted-table mistake, crashing the match the moment the fighters unfroze. Same fix, second table copy, eleven minutes. That's the difference an instrument makes. The first instance took days of static analysis to not find. The second took one log line.

Every address in this post lives in one of two arenas: stock encrypted program space, or the unencrypted arena where relocated code and copied tables go.

Copy the table. Relocate the code.
Drawing the diagram…
At build time, the decrypted table and Shuma's code move into unencrypted arena space. At runtime, the dispatch thunk reads the table copy instead of the stock ROM.

One Byte-Cell Had to Be a Valid Pointer and Valid Opcodes at Once

My favorite bug of the entire project, and I want you to appreciate just how cursed this is: a spot in the ROM that genuinely has to be two irreconcilable things at the same time.

The per-frame state dispatch reads state_table[$06(a6)] at $00D708 and calls the handler. Entries are indexed by char-id, and for $2E the slot lands at $00D7E8, exactly one entry past the table's end. What lives at $00D7E8? The first instructions of the state-0 handler.

The table runs flush into the code it dispatches to.

For stock characters this is fine, because no stock id indexes past the end. For $2E, one byte-cell now has to be two things at once:

  • Read as data (raw view), $00D7E8 yields $525BC845: encrypted opcode bytes interpreted as a pointer. Wild jump, crash.
  • Patch those bytes so the data read returns a valid pointer, and you've corrupted the opcodes of the state-0 handler, which turns out to be the per-frame driver spine for every fighter in the game.

My first "fix" stubbed the handler with rts. The crash vanished, and so did all motion: the timer ticked and nothing else moved, a 1.5% pixel delta across 180 frames. I had cured the disease by stopping the patient's heart.

The real fix respects that the cell genuinely must be two things, in two views:

  1. Keep the data-view seat (the $2E setup read gets its valid pointer), accepting that the handler's first 8 bytes are corrupt as code, and
  2. Redirect state_table[0] to a thunk that executes the displaced original instructions (recovered from the clean decrypt of the stock ROM), then jumps back into the intact remainder:
state0_thunk:                   ; the 3 displaced instructions, verbatim
    tst.b   $CA(a6)
    beq.s   .to806
    subq.b  #1,$CA(a6)
    bne.s   .to806
    jmp     $00D7F4             ; resume stock, past the corrupt bytes
.to806:
    jmp     $00D806
Keep the pointer. Rescue the instructions.
Drawing the diagram…
A decrement to zero resumes at $00D7F4. A counter already at zero, or still nonzero afterward, takes $00D806. The overwritten 8 bytes at $00D7E8 remain data.

Next run: 99.6% pixel delta. The world moves again.

Two fixes for the same crash. Only one makes the world move.
  • rts stub1.5% of frames changed

    the crash stops and nothing else on screen moves; it did not fix anything, it removed the code that was failing

  • state-0 thunk99.6% of frames changed

    almost every frame differs, because the character is now actually doing something

Pixel delta across the same 180 harness frames, same inputs, same crash site.

Her Sprite Rendered as Animated Confetti Because One Bank Bit Was Missing

The match was stable; now she had to look like herself. Two graphics defects stood in the way, and (you'll notice the theme by now) each was one measurement away from obvious.

Her native select screen: montage art, grid-cell face, and her name where IRON SPIDER used to be
After the bit-12 fix and the palette stamp: montage art, grid-cell face, her name where IRON SPIDER used to be

Her sprite rendered as... confetti. Recognizably animated confetti (her animation tree was driving tile indices frame by frame), but the tiles themselves were random slices of other characters' art.

Her tile data was provably correct: the custom core's upper-bank GFX chips are byte-identical between the working donor build and the native build. So the defect was in addressing. A live A/B dump of OBJ RAM, same animation record, both builds:

donor : tile=9526  Y-word=10C9  attr=0120
native: tile=9526  Y-word=00C9  attr=012C
                          ^                 Y bit12 missing

One bit. That was the entire scramble.

The composer converts the fighter's GFX-bank byte ($47) into OBJ Y-word bank bits through a 12-entry word table, and the stock table maps her bank ($08) to $0000: lower-32MB bank 0, MvC's own tiles.

The donor build had quietly patched that table's upper half to set Y bit12, the custom core's "upper 32MB" flag, and I never ported the patch. Two ppatch lines did it. The table has exactly six readers program-wide, all PC-relative, so the patch belongs in the decrypted view. Root cause 1, in reverse. Her body assembled on the next run.

Her colors took one more dig, and one more live A/B measurement, this time of palette RAM:

  • Her fighter object's palette-source pointer was provably correct.
  • The palette RAM her sprites sample was provably full of someone else's colors.
  • Conclusion: the engine's one-time palette fill had run without ever consulting her pointer.

Rather than chase the fill's ordering, the fix rides the state-0 thunk I already own: per frame, gated on her palette pointer (a condition no other object can satisfy), copy her 48 dark-palette words into her slot's banks. Slot-computed, so it holds whether she's point, partner, or P2.

Tradeoff, on the record

The per-frame palette stamp overrides hit-flash palette effects on her while she's in state 0. Cosmetic, known, accepted for now. The right long-term fix is hooking the fill's source selection instead of overwriting its output.

The Last Mile Cost Almost Nothing, Because Identity Was Built as Data

Now the part that felt like getting paid. The last mile (her face on the select screen, her name in gold, "SAKURA" on the health bar, the dark gi) cost almost nothing, and that's a payoff for discipline from weeks earlier.

The select-art, wordmark, and name-banner machinery was all built as content-agnostic, data-only post-processors: the hooks live in the shared base ROM, and the later stages only write descriptors and tiles. Adding a new identity is emitting bytes, not touching code.

So the native "full" build is four independently verifiable stages, each consuming the previous stage's output and emitting its own patch manifest:

1. program stage    native dispatch entries, character tables, thunks, palette
2. GFX merge        her 9,520 tiles into the upper chips (byte-identical to donor's)
3. select-art       select montage + grid-cell face            (data-only)
4. cosmetics        DARK/SAKURA wordmark + name banner         (data-only)

One extraction pipeline, two characters, one destination region. This is the whole reason the 64MB core exists. (Shuma's 11,587-tile count is measured and reported in his own write-up; hers is the 9,520 above.)

The arithmetic that forced the 64MB core
  • Free in stock MvC5,649tiles

    and no single bank holds more than 2,542 of them

  • Dark Sakura9,520tiles
  • Shuma-Gorath11,587tiles
  • Both together21,107tiles

    roughly four times the free space, with nowhere contiguous to put it

Free-space figures measured while costing a Magneto import.

Those free-space numbers came out of real measurement, counted while costing a Magneto import. The expansion was never a nice-to-have. The math does not close without it.

And because identity is data, the second character's identity came almost for free:

Shuma-Gorath's select cell before and after: his name seated by the same data-only pipeline
Same pipeline, second character // select screen
Shuma-Gorath's lifebar before and after: SHUMA-GORATH seated by the same data-only pipeline
Same pipeline, second character // lifebar

Her select-screen idle is a build, not a picture

Hover her on the select screen and she moves. I want you to catch that, because it's easy to scroll past: that little animation is not a picture somebody pasted in. It's an idle chain rebuilt from her own poses, seated in a measured run of free tile cells, with a budget checked before a single tile moved. Same deal for Shuma.

Dark Sakura's animated select-screen idle, shifting her stance in a loop
Her select-screen hover idle, rebuilt pose by pose from her own art. This is the shipped asset, not a mockup.
Six frames of Shuma-Gorath's select panel idle animation in a row
Shuma's select-panel loop, six records end to end.

And the name banners over those panels were composed offline first, checked against the grid, and only then written into the ROM as data:

The D.SAKURA select-screen name banner composed from the game's letter tiles
D.SAKURA, composed before it ever touched the ROM
The SHUMA-GORATH select-screen name banner composed from the game's letter tiles
SHUMA-GORATH, same pipeline, second identity

The VS splash was lying too

One more surface, and I didn't even have it on the list at first. The pre-fight VS splash builds its portraits from its own table, and that table had never heard of either character. Shuma's panel came up as a blank magenta hole. Sakura's slot showed Spider-Man, a whole different fighter, with full confidence.

After
Before
‹›
BeforeAfter
The VS splash. Drag to compare: a blank panel becomes Shuma's own art.
The pre-fight VS splash showing Spider-Man's portrait standing in Dark Sakura's slot
Shuma's panel got fixed. Hers did not, and this is still what the tip shows: the splash presents committed id $2E as $0E, so Spider-Man stands in her slot. One of nine Iron Spider-era surfaces itemized and still open.

Every one of these surfaces is small. That's the point I want you to take from this section: the finale you're scrolling toward is not one big trick. It's dozens of small, verified pieces like these, each one seated and gated, stacking up until the game itself stops arguing.

Shuma-Gorath Runs a Verbatim Copy of His Own MSHvSF Code

Dark Sakura proved the recipe. Shuma-Gorath ($30) tested whether it generalized, and it did, in a stronger form.

Native Shuma-Gorath, live in an MvC1 match. His own relocated MSHvSF code, no donor underneath.YouTube ↗

Where her port leans on shared engine paths, his relocates an entire block of his real MSHvSF code ($0301DE..$032A3E) verbatim into arena space at $3FA060 and re-enters it through trampolines, so his logic runs as his own code rather than being fitted onto anyone else's spine.

He came with his own blocker-that-wasn't: his "compressed" sprites were actually our own decryption tool's garbage, the same one-ROM-two-truths trap as root cause 1. Shannon entropy over his animation region settles it (both figures measured and published in his own write-up):

Same bytes, two readings, one of them lying
  • Read through the decrypted view7.95bits per byte

    indistinguishable from compressed or encrypted data; the reading that cost weeks

  • Read raw, the way the hardware reads data4.43bits per byte

    ordinary sprite data, and all 11,587 tiles came through the existing pipeline

Shannon entropy over his animation region. Opcode fetches are decrypted, data reads are not.

7.95 bits per byte looks like a codec. 4.43 looks like a table.

His flagship special, Mystic Stare, went through the full arc on camera. Pose, launch, impact:

Shuma-Gorath in his Mystic Stare pose
1 // the Mystic Stare pose animates
Native, live: Shuma-Gorath fighting on his own animation tree in an MvC1 match; the white orb rising in front of him is the Mystic Stare eyeball mid-flight
2 // launch: the eyeball is a real, moving object
The Mystic Stare eyeball reaching the opponent's side of the screen
3 // impact side of the flight

It now spawns all six eyeballs in an open hexagonal ring, his authentic count, verified live on the build where the ring landed. (The current tip carries that lineage forward, but the ring itself has not been visually re-confirmed on it; the last capture landed during the opponent's intro.) A later build in the chain replaced the rigid formation with a target-relative orbit, its phase seeded in object field $1E. The hitbox is the part I have to describe carefully, because my notes argue with themselves. In the ROM it is there: on the tip, the eyeball's box geometry reads (-16, 16, -16, 16) and the approach frames carry box-set $54, both of which I read straight out of the shipped file. Whether it lands in a live match is not settled — the attach/pulse/explode work went through a pool leak and a contact-protocol correction (MvC does not report object hits into these objects' $84 the way MSHvSF does), one of my own notes still ends on "mechanism works end-to-end, still not tip," and the tip's own suite reads atk=0 on both Stare strengths. Calling that fixed would be exactly the kind of upgrade-without-evidence this post is supposed to refuse. It needs one live session, and it has not had one:

Earlier six-eyeball build: the eyeballs in a fan formation
Intermediate build // six eyeballs, fan formation
Six eyeballs in an open hexagonal ring
Verified live // hexagonal ring; orbit landed in a later build; the box is in the ROM, whether it connects is unresolved

That missing hitbox has a counterpart that is closed, and the way it closed is a correction I owe this post. Mystic Smash is the attack from the callout above, the one whose "satellite objects" model got built and shipped before the real defect turned out to be a missing hitbox.

An earlier version of this section printed the fix as two bytes: nop out the branch at $3FB75A so the ball always rolls in its spiky form. That patch shipped. Then it got reverted, because it was wrong about the source game — in MSHvSF the ball rolls smooth until it makes contact and only then goes spiky, so blind-nopping the branch skipped the state the original spends most of its time in.

The real defect was a chicken-and-egg. The smooth roll's box set carried atk=$0000, so contact could never be reported from a pure-smooth roll — and the roll tick masked off the very bit that would have reported it:

Mystic Smash: the hit test that actually shipped $3FB76412 bytes · 9 changed
before102E008467060200007F664C
after4A2E0084660000524E714E71
move.b $84(a6),d0 / andi.b #$7f,d0 / bne.s becomes tst.b $84(a6) / bne.w. The mask was deleting bit 7 — the hit bit. Read out of mvsc_spikehit.zip and mvsc_stable1.zip.

Three changes ride with it: $3FB75A goes back to 6602 so the default is the smooth roll, the smooth roll's records get box set $57 so they carry a real attack value ($0045), and the on-hit path swaps to the spiky sub. On the current tip the suite measures the box live: sp.Smash.LK atk=49, sp.Smash.MK atk=57 frames.

I'm leaving the wrong version described rather than quietly swapping the bytes, because a HexDiff is the most falsifiable thing on this page. It is the post saying here are the exact bytes, go check. Printing a reverted patch in one costs more than any stale status row.

Mystic Smash: the smooth rollid $30 group 22 slot 9, record $3F8508CONFIRMED IN GAME
idx 00/4 · 4t (67.1 ms)
frame0 / 3
hold4t · 67.1 ms
rotation4 frames, 16 ticks
tiles64 / 64 from his own seat at $800000
bank5 — sprite codes read with +$10000
palette$3F1F44, where $1C7268[$30] points

Four frames at four ticks each, which is one complete rotation of the eye-sphere and reads as a rotation only when it turns: any single frame of this is just a green sphere. This is the form the paragraph above puts back as the default.

shuma-mystic-smash.png — id $30 group 22 slot 9, record $3F8508, mvsc_stable1.zip (md5 f9988ba4dbeae347adaca75b87f99ce1)

Mystic Smash: the spiked formid $30 group 22 slot 5, record $3F84A8PARTIAL
idx 00/2 · 5t (83.9 ms)
frame0 / 1
hold5t · 83.9 ms
records2
tiles38 / 38 from his own seat at $800000
palette$3F1F44, where $1C7268[$30] points
distance from the smooth roll4 slots, same group

The whole spiked form, complete and correctly timed, sitting four slots from the smooth one in the same group — the art was never the missing piece on this move. What is not settled is when the ball is in it: the known-issues list further down has it staying spiky after it passes the opponent, where MSHvSF swaps back to the smooth roll. Hence PARTIAL on a chain with nothing wrong in it.

shuma-mystic-smash-spiked.png — id $30 group 22 slot 5, record $3F84A8, mvsc_stable1.zip (md5 f9988ba4dbeae347adaca75b87f99ce1)

Its object allocator ($3FAE) wasn't found by static matching (that failed) but by tracing a live Ryu Hadouken through the emulator until the allocator revealed itself. His full story is its own article: Shuma-Gorath, Whole.

Nearly Every Crash Was One Table, Read One Entry Past Its End

If you take one engineering fact away from this post, make it this one. It's the master defect, and naming it was the single most useful thing I did all project.

Both natives kept tripping crashes that felt unrelated: a knockdown crash here, ×5.94 damage there, corrupted stun, a pinned camera, garbage sounds. They share one root cause.

Any per-character table // field $52
1 · 23 valid entries, ids $00 to $2C4A$000000$000040
  • 1
    23 valid entries, ids $00 to $2C
    $000000$00002E · 46 B
    Every per-character table is exactly this long.
  • 2
    id $2E reads past the end (Dark Sakura)
    $00002E$000030 · 2 B
  • 3
    id $30 reads past the end (Shuma-Gorath)
    $000030$000032 · 2 B
  • 4
    bytes of the following table
    $000032$000040 · 14 B
    Zero slack: it starts on the very next byte.
  • A
    last valid entry, id $2C
    $00002C · marker
One strip, four beats // the master defect
the table

Every per-character table in the engine has exactly 23 entries, ids $00 through $2C. They are packed with zero slack. The next table begins on the very next byte.

the index

Fighter field $52 is char_index * 2, and it indexes straight into that table. Two bytes per entry, so the last stock fighter (id $2C) reads the last valid row.

past the end

The secret ids $2E and $30 are past the end of all of them. Two ids, past the end of every per-character table in the game, and nothing in the engine stops the read.

what comes back

So each lookup reads whatever bytes happen to follow the table into memory: the following table's own rows. That is the crash family below, and it is why the tables cannot simply be widened in place. Every fix is either a reader-side clamp or a full relocate-and-repoint of the directory.

Every native bug with a numeric fingerprint traces to a specific past-end read:

  • Defense read a past-end damage-scale entry, so natives took ×5.94 damage (55 per hit); clamped back to ×1.0 (9 per hit).
  • Stun overlapped the next table, corrupting dizzy state for every character, not just the natives.
  • Knockdown/throw crashed on a relocation thunk that indexed its jump table by d1 instead of d0. One byte (0x3D359, d1 -> d0).
  • Camera pinned on a past-end super-jump bound; five sound sites leaked garbage (an Onslaught-theme fragment, wrong SFX).

A 203-site census of those past-end reads was the first attempt, and it is worth knowing why it wasn't enough: the scanner matched only move.w $52(a6),dN, so it missed 158 further sites that index through a different address register. Two of the misses were the camera bug, and one was the garbled HUD portrait. The census that supersedes it is in the July 28 update below — 185 tables, 1,758 read sites. What both agree on is why a "seat his real physics numbers" port is walled: the 23-row tables are packed with zero slack, so ids $2E and $30 physically alias the next table's first rows. Overwriting to "fix" them corrupts the whole roster. Every fix is either a reader-side clamp or a relocation of the whole directory with all its readers repointed. What you cannot do is widen a table where it sits.

Every Fix Gated on a Wolverine Super-Jump

Now the discipline that holds all of this together, the part that keeps dozens of stacked binary patches from silently eating each other.

The deployed build is the tip of a cumulative fix chain, each fix gated live before the next stacks on top of it. (The chain stood at 18 fixes when this section was first written, at an earlier milestone; it has since grown through the eyeball ring, the smash spikes and hitbox, the win-crash fix, audio, physics rows, and the air normals to today's tip.) The rig is a headless FBNeo harness: scripted secret-code navigation, a full match, fixed-frame screenshots, plus the instrumented core's program-counter trace asserting zero wild jumps.

"Gated live" is specific here: a fix passes only when its visible effect is confirmed and the match is still alive ($FF4000 == $0800) and a stock control is unchanged (Wolverine's super-jump reads 102/102/102/103).

An early gate that checked only its target once passed a silent regression: a counter on a live engine field was quietly aborting matches while the target signal still looked right. That is why the liveness and control asserts are non-negotiable now.

Six of those fixes had names: red palette on hit, super-jump camera, five sound sites, Iron Spider gone, the pose animating, the first eyeball in flight. The nine unnamed links between them got the same treatment, one gate each: taunt crash, entrance stall, damage ×5.94 back to ×1, stun table overlap, knockdown d1 -> d0, select cursor box, names on the select screen, names on the lifebar, Mystic Stare input. The lineage has since continued past that point (six-eyeball fan, hexagonal ring, smash spikes and hitbox, win-crash fix, idle-floater clear, Shuma's voice table, air physics, moveset dispatch, eyeball orbit, NYC layers, parity, air kicks, air directionals) to the current tip.

One fix worth pausing on.

45px to 82px, and a number I had attached to the wrong fix.

Sakura's jump was 45px and now reads 82px. The clean fact underneath is that MSHvSF Sakura's jump record (idx 21) is MvC Ryu's (idx 9), byte for byte — so clamping her to Ryu's row is exact, not an approximation. But I originally credited the 82px to the physics-row pass, and the gate table says otherwise: her apex reads 82 both before and after that pass. What the physics pass actually changed was her walk speed, 2.5079 to 4.0159 px per frame. The jump was fixed earlier, by a different patch, and a deployed +$100 hook is still sitting on top of the byte-identical record. The old "she barely jumps" complaint is fixed on the deployed build. Both natives jump; Shuma's apex measures 102 against stock Ryu's 103, so he launches a hair lower, not harder. One open delta: Shuma's ascent gravity is heavier than his floatier authentic MSHvSF feel.

The Same Bug Twice More: Specials, and the Roll That Ate Her

The section above called the master defect "one table, read one entry past its end." Two more failures have since resolved to exactly that, and one of them is the reason her specials have never worked.

Detection works. Execution was never ported.

Her command detectors fire correctly ($e6 latches 0A for Hadouken, 02 for Tatsumaki), and then nothing happens. $e6 latching sets action state b0=$2A, which dispatches through $00E714:

$00E714  st.b     -$3fc4(a5)
$00E718  move.w   $52(a6),d0        ; $52 = CHARACTER ID
$00E71C  add.w    d0,d0
$00E71E  movea.l  $e726(pc,d0.w),a0 ; per-character special handlers
$00E722  jmp      (a0)

The table at $00E726 holds exactly 23 longs, ids $00..$2C, and ends at $00E782, which is the next dispatcher's own code. Zero slack. Dark Sakura is id $2E, so the read runs one row past the end and returns $302E0052: the opcodes of the routine that follows. The engine jumps into data.

Shuma is $30, two rows past, and would jump to $40207B, an odd address, which on a 68000 is an address error. That's the tell that he never reaches this dispatcher at all, and it explains something that had confused me for a while: his specials work through animation redirects and custom object handlers instead. There is no working b0=$2A path anywhere in this ROM to copy from.

Following the same chain in MSHvSF shows what should have been ported. (Note the field shift: MvC's $b0 and $e6 are MSHvSF's $a0 and $d6, both in the +$10 band, which is why searching the source for $b0(a6) returns nothing at all.)

$00C412  b0 dispatch -> b0=$2A -> $00D3AE
$00D3AE  move.w $52(a6),d0 / movea.l $d3bc(pc,d0.w),a0 / jmp (a0)
$00D3BC  26-row table  (MvC's has 23)  -> id $2A = DARK SAKURA -> $05F8F0
$05F8F0  moveq #0,d0 / move.b $d6(a6),d0 / jmp $5f8fe(pc,d0.w)
$05F8FE  57-entry move table, 46 routines, 28,672 bytes

We had ported the 2,628-byte detector block and none of the 28,672 bytes behind it. Detection worked because detection was all that was there.

Two useful things fell out of building the fix

The full 28,672 bytes don't fit; measured free space across both arenas is 7,888. But the four specials that actually latch in-game total 1,508 bytes, and there's a trap in measuring that: a routine's decoded extent stops at its own trailing phase table. Hadouken decodes to $060230, and $060230 is the table its own lea $60230(pc),a0 reads. The next-target span is the correct measure because it includes them.

Then the better idea: copy the span contiguously rather than four routines separately. That preserves every internal PC-relative displacement (the handler's jmp $5f8fe(pc,d0.w), the 57-entry move table, each routine's phase table), so none of them need fixing up. The unused routines ride along as dead weight and the arena has room. Fewer moving parts beat a smaller payload.

The second surprise was a trap I walked straight into, and it is this post's own thesis biting me. mvcud.03d in the romset is a decrypted program ROM: byte-swapped, it matches the plaintext dump exactly. So I patched it, and the patch had no effect whatsoever. FBNeo's mvsc driver loads mvce.03a/04a; mvcud.03d belongs to the separate mvscud set and is never loaded. I had found a real decrypted ROM and patched a file the emulator does not read. One address, two truths, again — this time the two truths were two romsets.

The fix that works decrypts mvce.03a+04a, patches $00E71E, re-encrypts, and asserts the round trip. The geometry is the lucky part: the 6 bytes at $00E71E (movea.l d16(pc,d0.w),a0 plus jmp (a0)) are exactly the width of jmp TRAMP.l, and the trampoline does an absolute lookup into a copied, extended table. And the adjacent claim in an earlier version of this post — that low program space had been off-limits until now — was also wrong: the parity chain had already been patching the encrypted region across 75 runs.

It builds, boots, and does not hang. When I first wrote this section I could not tell you whether the trampoline executed at all, because the counters I used to check were sitting in RAM the game writes. That is now measured, and the answer is yes — her execution driver runs. It just runs out of a verbatim copy of MSHvSF's, with its field offsets only partly remapped and its calls untranslated, which turned out to be its own category of disaster. The July 28 afternoon update below is that story.

The Hadouken, as her own tree plays itid $2E group 22 slot 23, record $3D4B0CPARTIAL
idx 00/9 · 1t (16.8 ms)
phasewind-up
hold1t · 16.8 ms
frame0 / 8
chain9 records, 15 ticks, loops to frame 6
tiles223 / 223 from her own imported bank
palette$3DEC94, where $1C7268[$2E] points
projectile chainnone found in her object groups

The chain returns to frame 6, so the thrust is a three-frame hold that repeats while the handler runs rather than a pose the engine freezes on — and a still cannot tell those two apart. This is the throw and only the throw. Her object groups hold debris and spark particles; no fireball chain turned up in them, which is the open item this section and the status table both state.

sakura-hadouken.png — id $2E group 22 slot 23, record $3D4B0C, mvsc_stable1.zip (md5 f9988ba4dbeae347adaca75b87f99ce1)

She rolled off the stage, and it was the same bug again

This one arrived as a plain bug report from watching a test run: after a trip she rolls right and vanishes. So I measured it:

d=477   b0 36->22   X=123   Xvel=56   anim=3D2EA8    <- trip
   ...  b0=22 still  X=554 -> 747, Xvel=56, 1,200+ frames

She enters b0=$22 (HOLD), Xvel=56 is never cleared, and she slides right into the stage clamp at X=747 and stays. Deterministic, same frame every run, and identical on the pre-specials build, so it predates that work entirely.

Leaving b0=$22 requires the animation advance $1dd8 to return carry-set, which happens only when the chain ends. Walking every chain in her tree with the loader's real rule finds exactly one that reaches the bad record: Char $24 sub 4. GETUP sub 4, the roll. And:

group $24 base $3D07EC     group $26 base $3D07F4     (8 apart)
group $24 sub 4 reads its offset word at gb+8 = $3D07F4 = group $26's BASE

Group $24 has only four subs. Sub 4 runs past the end into group $26's subtable and borrows its sub-0 offset $264C, and the 8-byte difference between the two bases is the misalignment. From $26's base that offset gives $3D2E40, which walks clean: 4 frames per record, box index $0000, terminating properly on ctrl=$8004 whose high byte routes to the carry-setting path. From $24's base it gives $3D2E38, which yields garbage box indices, then ctrl=$85F2 (a 1,522-frame hold), then walks out of tree range.

One past-end read, three symptoms: stuck state, continuous rightward motion, disappearance.

Three wrong explanations before the right one

I called this a chain that loops forever. Then a runtime 4-byte drift. Both were inferences from partial data, stated with more confidence than they'd earned. What settled it was walking the chains using $1dd8's actual rule (next = rec+16, or the long at rec+16 when the control word is negative) instead of reasoning about what the structure probably looked like.

The instrumentation was worse. My first two automated runs reported "NOT REPRODUCED" while the bug was on screen. $22 wasn't in the set of states I was watching, so the 1,217 frames where she sat stuck were never sampled, and I was checking for an out-of-range coordinate when the real signal was a long dwell in one state. Then a counting probe told me the trampoline was reached; running the same probe against a build containing no counting code at all returned byte-identical numbers. The counters were sitting in RAM the game uses. A control run costs one launch and would have caught it immediately.

Update, July 28: One Defect Wearing Six Hats, and the Week It Took Off the Mask

Everything else in this post still describes real work, but the frame has changed. A four-source census (arcade MvC1, arcade MSHvSF, the Dreamcast MvC1 port whose plain SH-4 names the arcade's data structures, and the Saturn MSHvSF recompile) finally enumerated the whole per-character surface. The numbers reframe the project:

That last row is the thesis of this whole post, now with its census: MvC sizes every per-character directory at exactly 23 rows, they tile with zero spare bytes, and ids $2E and $30 index past the end of each one. Every symptom that looked like a separate bug (the supers, the boxes, the roll, the crashes) was this one shape.

The week's landed fixes, each boot- and live-gated:

  • One specials crash chain is dead. Capcom left their own crash handler in the ROM. It records the fault, then wipes the evidence and resets, which is why crashes looked like clean resets. Freezing it exposed the chain: an unmasked 2-bit flip field dispatching through four identical jump islands. All four are now masked word-wide, and on that build her fireball took zero exceptions. That sentence was true of the one input I had gated. Within hours the same build was found to warp her fireball to the ceiling and to reset the board on dp+P. See the afternoon update below — I am leaving this bullet as written, dated, because the correction is the point.
  • The "she flies up during supers" bug was not Spider-Man's code running (the live trace refuted that theory). It was her y-position being snapped to 0 (the stage ceiling) on the super's latch frame by past-end metadata. A guard restores it; her punch super now plays grounded, in her own animations.
  • A latent crash nobody had hit yet: the import had overwritten the header of a live character-pair matrix, so throws involving four stock characters would crash. Found by the census, restored byte for byte, directory properly relocated.
  • The animation-VM wedge that froze every fight on the NYC stage about 15 seconds in was a Shuma reaction chain running off its terminal record. A character bug wearing a stage costume. Fixed at the chain start, the only redirectable seam. A second NYC defect landed the same day — a lineage fork that double-shifted the underground tier into empty skyline, reverted in six bytes — and both are written up in the NYC stage post.
One defect, six hats
Drawing the diagram…
Every symptom that looked like its own bug was the same past-end read wearing a different mask.

What genuinely still doesn't work, stated plainly as of the morning of July 28 — the afternoon update below revises several of these: the fireball casts but never completes, the kick super never latches (a detection gap), supers deal no damage yet, both characters are silent (sound is a mapped-but-unbuilt subsystem), and the Iron Spider-era select-screen surfaces are itemized but not yet swept.

Update, July 28 (Afternoon): Five More Resets, and a Gate That Didn't Exist Yet

The section above was written at lunchtime. By the end of the day the chain had five more links on it, four of them closing crashes that were live in the build I had just called clean. The tip is now mvsc_stable1.zip, md5 f9988ba4dbeae347adaca75b87f99ce1, and every number in this section was measured on that file.

Mystic Smash also reset the board, and the fix was one byte

The status table above has said "Mystic Smash: fixed" for a while. It was fixed, in the sense that the attack box works. It also reset the board every time the ball passed close to the opponent, and no gate I owned could see it.

The proximity helper I had seated for it is MSHvSF's own anim-swap code, and I had hand-computed a bne.b displacement in it. I got it wrong by two bytes. The branch cleared its own rts and landed in $FFFF filler, which on a 68000 is a LINE-F exception, and Capcom's crash handler did what it always does: recorded the fault, wiped the evidence, reset the board.

To prove it I built a deliberately broken version with the crash handler frozen, so the machine would stop instead of tidying up. It stopped, twice, deterministically:

!! EXCEPTION CAUGHT at f=1764, cast 1, btn=lk
exception 9  LINE-F
faulting PC = $3FCB4A
d1=00000022  d2=FFFF004E  a0=00FF3400  a1=003FCB58

$3FCB36 + 2 + $12 = $3FCB4A. The arithmetic of my own mistake, printed back at me. The fix is the displacement byte at $3FCB37, $12 -> $10, so the branch lands on the block's rts instead of two bytes past it. One byte. The second one-byte fix in this post, and like the first one it took an instrument to find, not an argument.

Three more changes ride along, because the crash was sitting next to a worse defect that had never fired. The per-opponent height table the helper reads had only four authored rows, and the index arithmetic reaches $34 against Venom — reading code bytes as a height, and an odd value there would have been an address error. It now has a full 256-byte table with a row for every id, and the index is forced even before use.

Mystic Smash proximity helper: the branch that overshot its own rts $3FCB371 byte · 1 changed
before12
after10
Two bytes past the end of the block is $FFFF filler. LINE-F, exception 9, board reset. Positive control: the frozen dump above, on a build with Capcom's crash handler disabled.
The frame the emulator stopped on at the moment of the LINE-F exception: Shuma-Gorath mid-Mystic-Smash, the ball still in flight at the lower left, the HUD and the timer frozen at 98
The frame the machine stopped on, with Capcom's crash handler disabled so it would stop instead of tidying up. Timer frozen at 98, the smash ball still in flight at the lower left. Venom is off-camera; the log has him as p2id=$000C and the fault at f=1764. With the handler running, this frame does not exist — it is a reset to attract mode and nothing to look at.

The gate on the fixed build ran the identical schedule and survived all nine casts, including the exact condition that used to crash — a second already-latched proximity frame executing through the repaired branch:

cast 1 (lk) survived: b0=36 anim=003F705C p2id=000C spikyFrames=21 latchMaxRun=2
cast 6 (hk) survived: b0=00 anim=003F56E0 p2id=000C spikyFrames=32 latchMaxRun=51
cast 8 (mk) survived: b0=00 anim=003F5680 p2id=000C spikyFrames=47 latchMaxRun=52

A user reported stage corruption. The stages were innocent, and it is the best story of the day

The report was that stock stages had scroll-tile corruption, with a photograph: camera stuck at the bathhouse ceiling, HUD strips displaced across the middle of the screen, fighters shoved to the bottom of the frame.

I swept twelve stage ids. They came back byte-identical to virgin stock — not "looked fine," identical: the same stage rendered from the fix lineage and from an untouched base ROM produced matching capture files. The stages were never corrupted. The photograph was of a crash in progress, and the crash was Dark Sakura's dragon punch.

Her dp driver ends with a verbatim copy of MSHvSF's tail: move.w #$385,d1; jmp $AC954. In MSHvSF that is a sound request. In MvC, $AC954 is mid-instruction — it is the third word of a move.b $30(a0),$30(a6) that starts at $AC950. Decoding from there gives a 6-byte ori.b, which leaves the program counter on $AC95A, which is $004B, which is ori.w to an address register, which is an illegal instruction.

That is this post's whole thesis in a costume I hadn't seen before. One address, two truths — except this time the two truths are two games. The bytes at $AC954 are a perfectly sensible instruction boundary in the game she came from, and the middle of an instruction in the game she is running in. Nothing about the address looks wrong. You cannot find this by reading her code; you can only find it by executing it and watching where the program counter goes.

The fix replaces the jump with an rts — which is MSHvSF's own skip path in that same tail, so it is proven-equivalent control flow rather than a guess — and retargets seventeen calls whose correct destinations were voted by the translator's own sibling retargets (138 votes for one of them) and then checked instruction-for-instruction against the MSHvSF routines. Twenty-seven bytes.

The run after it was the first in the entire lineage to survive DP_P, and then kept going: QCB_P, QCB_K, J_HP, QCF_K, QCF_P, all clean, RESULT SCRIPT_COMPLETE. The dp gate reads PASS 3/3 dp casts latched+recovered.

The same bathhouse stage rendering normally with an intact HUD, Dark Sakura airborne at the left mid-dragon-punch
A match mid-crash: the camera stuck at the bathhouse ceiling, HUD strips displaced across the middle of the screen, both fighters shoved to the bottom of the frame
‹›
The 'corrupted stage'After the 27-byte fix
Same stage, same character, same input. Left: what the user photographed — the camera stuck at the ceiling, HUD displaced, fighters at the bottom of the frame. That is not a broken stage, it is the death throes of an illegal instruction. Right: a healthy airborne shououken on a clean E. Honda bathhouse.

The fix that its own gate killed, and stayed in the chain anyway

Between those two I built a third patch. The theory was that her dp crash was the same class as the hadouken warp I had just fixed, so I NOP'd the thirteen remaining call sites that matched the pattern.

It changed nothing. The full-kit harness still reported EJECTED mph=0000 at step DP_P, and the frozen crash dump from the trap build was byte-identical to the one before it: same exception code, same PC $AC95A, all fifteen registers the same. Those thirteen sites were never on the dp path.

This is the same shape as the three high-confidence fixes near the top of this post that moved the crash zero frames, happening again, three months later, to someone who had already written that section. I kept the build in the chain, because NOP'ing verbatim foreign calls is defensible hardening on its own terms, and I put a correction at the top of its build script: *** OUTCOME CORRECTION (2026-07-28, later the same day) ***. The alternative was to quietly drop it and let the chain read as five fixes that all worked.

The wedge nothing could have caught, found by writing a gate that didn't exist

After the dp fix, every gate I owned passed. So I wrote a new one, for a path no harness had ever exercised: her hadouken in the air.

It found a silent wedge on the first run. Air qcf+HP latched, entered a two-record loop, and held it for 364-plus frames, absorbing every input. No crash. No exception. No warp — the earlier hardening pass had already NOP'd the calls that would have made it visible. A liveness check sees a perfectly alive match; a wild-jump detector sees nothing; the match timer keeps ticking. It is invisible to every instrument in this post.

The cause was the same one the ground driver had: the phase's only exit was an MSHvSF failsafe countdown, seeded to 45,711 frames. The fix is six bytes, reusing the ground driver's already-gate-passed recipe — count 32 frames instead. Air qcf+HP now completes in about 50 frames, the second air fireball fires (inputs are no longer being eaten), and a third air special lands clean after it.

The lesson is not the six bytes. It is that a build which passes every gate you own is a statement about your gates.

Three instruments that lied with total confidence

That is the theme of this whole post, so here are three fresh instances, all found in the same day's work, all now neutralized:

  • The 19-scenario Shuma suite cannot catch a proximity bug, by construction. Its dummy opponent is pinned, and a pinned dummy takes the hit — which sets a flag that skips the proximity helper entirely. The Mystic Smash reset lived inside a code path the suite structurally cannot reach. It survived every previous battery not because it was subtle but because nothing had ever run it. The unpinned crash harness now has to ride alongside it, permanently.
  • The crash-family heartbeat reports "HUNG" on an idle fighter. It samples every fourth byte of three narrow memory slices, and an idle character does not change them. On one build it cried HUNG about a stock control character whose own trace ring showed the CPU advancing its animation pointer every single frame.
  • The round-end diagnostic false-FAILs any round longer than 1,800 frames, because the fields it watches legitimately never change mid-round. It had only ever passed because the CPU reliably knocks out an idle Shuma inside the window. It was measuring the CPU's aggression, not the round-end machine.

Where that leaves it, precisely

The regression pass closes with a numbered list, on the principle that a complete known-issues list is worth more than a hopeful one: anything a playtest turns up that is not on it is a new bug worth reporting.

Dark Sakura: no projectile object appears on any fireball (ground or air) — the casts play and recover, nothing spawns; dp+P plays no voice line, deliberately, because the MSHvSF voice tail is stubbed along with the rest of the deferred sound channel; qcf+KK never fires; back-walk engages about 64 frames late, and I do not yet know whether that is inherited or something this lineage introduced.

Shuma: the Mystic Smash ball stays spiky after passing the opponent, where the original swaps back to the smooth roll — a fidelity gap, not a crash; and the proximity height table uses a modal row for non-Shuma opponent ids, which could be about 16 pixels off for unusually tall or short opponents and was invisible in every gate.

And one liability I want on the page rather than in a file: 44 verbatim MSHvSF call sites remain stranded in her code window, at 12 addresses I have not mapped. Forty-one are jsr — they execute junk and return, which is survivable and has been. Three are jmp, which is the exact class of the two defects above: control leaves and does not come back. None of them fired in any gate this pass. That is not the same as safe. There is a census file listing every one of them, and the honest description is that this build has a known, enumerated, unfixed debt sitting inside its most-used character.

What Still Doesn't Work

Here's the part I'd want you to read first if you're skeptical of everything above. You should be; I was.

Both native characters are a hybrid: their own code and animations, with their own walk rows (WALK_A-D), jump rows (JUMP_J1-J4, MSH idx5 data) and gravity now seated, but residual Ryu physics clamps remain at other dispatch sites, which is why a mini "RYU" tag still shows under the lifebar portrait. Specifically:

One of the open defects, caught on camera: the blue boxshape-mirror columns rising from the ground during a superjump. Root known (VRAM mirror at $922000), fix not yet shipped.
Open defect, on camera: blue boxshape-mirror columns during a superjump. Root known (VRAM mirror $922000), fix not shipped.
  • The moveset is incomplete, and the honest number is the identity gate. An earlier version of this list claimed the donor build had more moves wired than the native. A measured 29-move battery refuted that: zero moves confirmed lost. What's real is the moveset identity gate, last run on build mvsc_airdir (eight builds behind today's tip): pass=11, FAIL=6. The supers, Devitalization, the stare and smash strength variants, and two command normals still collapse into other moves.
  • Audio is per-character now. Shuma has his own voice table ($0D5EF4 routes through helper $23C222 to table $23C852, samples in the 16MB QSound expansion). Dark Sakura's wrong sounds are still silenced, not replaced.
  • Specials execute; the projectile does not exist. Detection latches, and on the tip execution runs too: her ground hadouken latches b0=$2A, walks its phase machine, and recovers to neutral grounded on every frame. What no fireball does is spawn anything — the spawn triple in her driver is deliberately NOP'd, because the calls in it were verbatim MSHvSF addresses that mean something else in MvC. Wiring a real projectile is a known, scoped item, not a mystery.
  • The post-trip roll: root-caused, no longer reproducing, and I can't fully explain that. Char $24 sub 4 reads past the end of a four-sub table into the next group's, and the 8-byte base difference misaligns the chain into a 1,522-frame hold with velocity still applied. That was the diagnosis, and a fix build for it exists in the chain. On the tip the symptom is gone: forcing 859 knockdown and getup frames, she enters b0=$22, slides X=133 to 538, and exits after roughly 23 frames, staying on stage the whole run. The honest status is "not reproduced," not "fixed" — the harness never demonstrably walked the sub-4 path, and a separate note in my own files offers a competing root cause for the same symptom (a past-end velocity-pair read giving -56.0 px/frame) that I have never reconciled with the animation-chain story above.
  • Supers: Shuma's fire and complete, and I can't yet prove they're his. On the tip, sp.Devit.hcbHK, su.ChaosDim.qcfPP and su.ChaosSplit.qcfKK all run to completion with the match alive. That is a real change from "the builds have not started." But Devitalization measures atk=26 rx=77 — the identical pair to st.HK and cmd.b+HK — so this suite cannot distinguish his super from a heavy kick, which is precisely the collapse class the identity gate exists to catch. "Fires and completes" is the whole claim. Dark Sakura's are further back: her punch super plays grounded through phases 2-4-6-8 and recovers; her qcf+KK still never fires. Sekika (air d+MK) and Upward Stare (air u+HP) landed on the tip.
  • Pre-fight walk-back is missing. (The win/KO crash is fixed: $07D950 clamped through thunk $23D4C8, and the win-condition probe reads win=1 survived=1. The dizzy softlock now exits.)
  • Blue vertical glitches rising from the ground are the boxshape VRAM-mirror columns at $922000. Known root, poke-proven, unfixed.
  • The 64MB core breaks Fightcade netplay. These are offline characters until that changes.
  • Everything is harness-verified (scripted headless run plus instrumented-core assertions). The in-fight HUD portrait is still garbled. The instrumentation build of the emulator is dev-only and stripped for anything shared.

Surface-by-Surface Status

SurfaceState
Dark Sakura $2E: secret commit, loads, fights nativeWorking. On the tip: full-kit move battery RESULT SCRIPT_COMPLETE, dp gate PASS 3/3 (each cast latched and recovered grounded), fireball gate PASS on both twin paths. (Her dp+P reset the board as recently as 2026-07-28 — that is what these gates were written to catch.)
Shuma-Gorath $30: relocated code block, loads, fights nativeWorking (block $0301DE..$032A3E -> $3FA060)
Names both screens (SHUMA / SAKURA select; SHUMA-GORATH / SAKURA lifebar)Working; Iron Spider gone; 6-dim adversarial pass
Shared master defect (past-end 23-entry reads)Superseded census: 185 tables, 1,758 read sites, 88 of 185 covered. (The earlier 203-site figure scanned only one addressing form and missed 158 sites, including the camera bug and the HUD portrait.) Fixes are reader-side clamps or full directory relocation
Cumulative live-gated fix chainTip is mvsc_stable1.zip, md5 f9988ba4dbeae347adaca75b87f99ce1, built 2026-07-28 17:33. Most rows were re-confirmed on this tip by the July 28 battery; rows that were not say so themselves
Mystic Stare eyeballsSix, in a hexagonal ring (verified when the ring landed; not visually re-confirmed on the tip); target-relative orbit landed in a later build; box geometry (-16,16,-16,16) and box-set $54 are seated in the tip ROM, but the tip suite reads atk=0 on both Stare strengths and my own notes disagree about whether contact registers live — unresolved, needs one live session; freezes in hitfreeze
Mystic Smash spikes + hitboxFixed, but not the way this post first said. The two-byte nop was reverted; the shipped fix is a rewritten $84 hit test at $3FB764 plus a box-set repoint. Tip measures sp.Smash.LK atk=49, sp.Smash.MK atk=57. Also reset the board on close contact until July 28 — see the afternoon update
Win/KO crash + dizzy softlockFixed ($07D950 clamp thunk $23D4C8; win-condition probe reads win=1 survived=1)
Shuma's own voice tableWorking ($0D5EF4 -> helper $23C222, table $23C852, 16MB QSound)
Air normalsj.MK / j.HK fixed (0 -> 11/14 attack frames); Sekika (d+MK) and Upward Stare (u+HP) landed
Specials execution (b0=$2A -> 23-row table, id $2E past-end)Executing on the tip: ground qcf+P latches the $2A driver, runs phases 2->4, recovers grounded (yMin=yMax=$01C8); dp+P latches and recovers 3/3; air qcf+P and air dp complete. Still missing: no projectile object spawns on any fireball, qcf+KK never latches, and 44 stranded verbatim-MSHvSF call sites remain in her exp window
Post-trip roll (Char $24 sub 4 past-end, 8-byte base delta)Root-caused and pre-existing. A roll-fix build exists and is in the chain. On the tip it no longer reproduces (ds_roll FORCE=1, 859 knockdown/getup frames: she enters b0=$22, slides, and exits after ~23 frames, on-stage throughout). But the harness walked group $24 sub 0 and forced getups, so the specific sub-4 path was never demonstrably taken — this is NOT REPRODUCED, not resolved, and I cannot tell you why it stopped
Sakura SFX / pre-fight walk / identity-gate FAILs (pass=11 FAIL=6, measured on mvsc_airdir, not re-run on the tip)Open. Shuma's supers now fire and complete on the tip but cannot be distinguished from his heavy kick by this suite; Dark Sakura's punch super plays grounded and recovers, her qcf+KK never fires; her dp+P is deliberately silent (the MSHvSF voice tail is stubbed)
Boxshape mirror columns ($922000), garbled HUD portrait, 64MB core breaks netplayOpen

A move either has attack frames or it does not, and that is measurable without any judgement about whether it "feels right". Both air kicks were reading zero. The animation played, and the engine registered no attack at all, because the move code asked for animation records that this character's tree does not have:

Zero attack frames, then eleven and fourteen
  • j.MK before the dispatch fix0frames the attack box is live

    the animation played and the engine registered no attack at all

  • j.MK after11frames the attack box is live
  • j.HK before the dispatch fix0frames the attack box is live
  • j.HK after14frames the attack box is live

That is the shape of the remaining work, and why the identity gate read pass=11 FAIL=6 the last time it ran: the six failures are moves that collapse into other moves rather than crashing, which is precisely the class of bug a liveness check can never catch. That gate has not been re-run since build mvsc_airdir, and re-running it on the tip is the single most useful measurement left on this list.

The Frontier Is the Hybrid: Their Code, Ryu's Physics Rows

What's left isn't "does she load." It's that both characters still carry residual Ryu physics clamps. The walk, jump, and gravity rows are seated from MSH data now, but the packed tables that can't be rewritten (they physically alias the next character's) keep every remaining fix reader-side. The plan: keep driving Ryu's working move skeleton with each native's own animations and hitboxes, closing the gap without touching the packed tables. Building the supers (researched, not started) and porting Sakura's MSHvSF SFX are the other two open fronts, and the eyeball ring (orbiting now) has a box in the ROM that has never been proven to connect in a live match.

If you've done relocation-heavy binary porting, one question I keep turning over: when a data structure is packed with zero slack and relocating it means repointing every reader you can find, is reader-side redirection always the answer, or is there a cleaner discipline I'm missing? I'd genuinely like to hear how you'd approach it.

She Stands in the Game as Herself

Everything above was the road. This is where it ends up.

Dark Sakura's select cell: IRON SPIDER before, SAKURA after
Dark Sakura's select cell: before
‹›
BeforeAfter
Select screen. Drag to compare: IRON SPIDER becomes SAKURA.
Dark Sakura's lifebar: IRON SPIDER before, SAKURA after
Dark Sakura's lifebar: before
‹›
BeforeAfter
Lifebar. Drag to compare: IRON SPIDER becomes SAKURA.

Drag those sliders. That's the whole project in two images. Dark Sakura is in Marvel vs. Capcom as herself: her in-fight id stays $2E on every frame, her own code, animation tree, tiles, palette, and name running in a game whose 23-character tables simply stop before her slot. Shuma-Gorath stands next to her the same way. The public build remains an offline emulator project on a custom 64MB FBNeo core, not a stock-board or Fightcade-compatible release, and the caveat list above is real. But she's there. She selects, she loads, she fights, she wins, and the game calls her by her name.

One ROM, two truths. If your tooling only tells you one of them, it will lie to you with complete confidence. The port started working the day I stopped trusting the view my tools handed me and went and measured the other one.

August 8 Character Lab: selection success is not move parity

The reusable secret-menu harness now gives Dark Sakura a pinned 37-lane repair manifest. Health is now read from object offset +$270; a move only passes when a seated opponent loses health from a full-life control, an active attack shape appears, and the expected state survives through recovery. Under that stricter contract, Dark Sakura remains a real native roster entry—but several gameplay paths are still unfinished.

Marvel Super Heroes vs Street Fighter HIT EDITER showing source row 15 SAKURA
SOURCE ORACLE // exact menu glyphs identify hexadecimal row $15 as SAKURA. HIT needs 21 cursor steps; CATCH starts one row later and needs 20. The capture proves source-table identity, not destination gameplay.

The current fail-closed ledger reports 1/37 required lanes closed. That one closure is only the written “flight is not applicable” waiver. The source HIT and CATCH captures are evidence inputs, not gameplay passes. On the destination build, CATCH enumerates only MvC's stock 22-row cycle and never exposes native id $2E; the automated HIT scan returns to the boot/title flow instead of producing a valid editor receipt. Editor addressability is therefore an explicit failed integration lane.

August audit

Dark Sakura's native identity passes; projectile, fire effect, voice, and back-walk timing do not

Latest live observations, not inferred table coverage.

nativeverifiedbridgeddonorpending
routeidentity + loadstate + motioncombat consequenceeffect + audio
select / fight / winnativenativeverifiedbridged
standing + crouching normalsnativebridgedbridgedprior live consequence; current per-normal gates remain openbridged
projectile routenativebridgedpendingprojectile child still absentpending
dragon-punch routenativebridgedbridgedpendingno voice cue
QCF+KK fire routenativebridgedbridgedpendingfire presentation absent
back walknativependingvisible start delaybridgedbridged
relocated code closurenativepending44 stranded call sites remain to auditpendingpending
Native describes ownership of the relocated character path. It does not turn an unverified projectile, cue, or timing route green.

The open Sakura failures are now carried as named gates: ground and air fireball animations recover without spawning a projectile child; qcf+KK never reaches a distinct source super route; the dragon-punch voice remains deliberately silent because its source tail is stubbed; back-walk can hold roughly 64 frames before moving; and 44 stranded source call sites remain to audit. The new rule for Sakura, Shuma and Zero is the same: animation addressability, state lifetime, shape id, move-property id, child-object spawn, damage, reaction and sound dispatch are separate gates.

References

  • Relocating Shuma-Gorath's Real Code: the second character's full story, code relocation, the eyeball, the live-Hadouken allocator hunt
  • The Donor Chassis: Running Dark Sakura on Ryu's State Machine: the donor chassis this native lineage superseded (the video at the top of this post is that build)
  • Binary Forensics: Recovering an Unreleased Character: the secret-slot discovery this inherits
  • State-Machine Injection: the dispatch-layer techniques the thunks build on
  • CPS-2 Maximum ROM Expansion: the 64MB core whose upper GFX region holds both characters' tiles
  • Bringing NYC to Marvel vs Capcom: the two-tier stage that shares this build chain, and the two defects that made it look broken
  • The native build chain: a program stage that seats the character tables and relocates the code block, a table-generation module it shares with the second character, and a projectile stage that wires the eyeball spawn, each emitting its own patch manifest
  • The analysis notes behind each root cause, and the emulator harnesses that gate the builds (scripted secret-code navigation, per-frame state logging)
  • The PC-ring and wild-jump instrumentation, patched into the emulator core's 68000 execute loop (dev builds only)
  • Master dispatch copy $23C300; state-0 thunk on $00D708 and $00D7E8; Shuma block $3FA060; eyeball allocator $3FAE, trampoline $23CF70, EYE_HANDLER $3FC8C0
  • 68000 Programmer's Reference Manual (Motorola)
Editor's update — August 9, 2026: MSHvSF's health fields are mapped

Source-side damage for Dark Sakura was unmeasurable until now, because nobody had located Marvel Super Heroes vs Street Fighter's runtime health fields — experiments/devmenu/run_cps2_secret_dump.sh has shipped LIFE_OFFSET := -1 for the mshvsf preset since it was written, and reusing MvC's offsets as a near-enough guess is forbidden in this tree.

Measured and confirmed by direct observation: fighter objects at $400 stride from $FF3800, health at object +$25B, its recoverable partner at +$251 ($A apart, the same structure MvC uses at +$271/+$27B). Full health is 144, identical to MvC.

First source damage numbers for her ground normals, reproduced across five sessions: stand LP 3, stand MP 6, stand HP 11–13, crouch HP 9–11, stand LK 2, stand MK 3–4.

This is what a parity gate needs to compare against. It does not by itself close a lane — her destination build still has to be measured and matched.

See the behaviour gate harness.

Written by Daniel Plas Rivera · 13,723 words · $13

ShareXLinkedIn