Skip to content
Index / $11

One ROM, Two Truths: Shipping the Dark Sakura Native Port

The donor chassis retires: Dark Sakura now loads, fights, and wears her own name and palette in MvC1's $2E slot with her native identity intact. Getting there took instrumenting the emulator's CPU core after static RE missed three times -- and the root causes were beautiful: a ROM whose bytes decrypt two different ways depending on how the CPU reads them, a byte-cell that had to be data and code at once, and a sprite bank bit hiding one table away.

Date
Jul 17, 2026 · upd Jul 21, 2026
Runtime
11 min · 2,407 words
Tags
cps2, 68000, reverse-engineering
Slot
$11
Contents
tip // Evidence Strip

Goal: Retire the donor chassis -- where Ryu's code drove her body -- and make Dark Sakura run native: in-fight char-id $52 = $2E, her own dispatch entries, her own tiles, her own name and palette, in a live match that doesn't crash.

Constraints: Same as ever: no source, base ROM untouched (all work is roms/modified/ build artifacts), custom 64MB FBNeo core for her 9,520 upper-region tiles (offline only). New constraint discovered mid-port: some of my patches provably were not reaching the CPU -- the debugging tool itself had to be built first.

Approach: When three high-confidence static fixes failed to move the crash, I stopped guessing and instrumented the emulator's 68000 core directly: a 1,024-entry PC ring buffer plus a "wild jump" detector that logs the exact instruction, target, and register file whenever execution leaves valid program space. Every remaining bug fell out of that log in order.

Result: Build d7091230: she selects via the secret code, loads into a live match, fights on her own animation tree, renders her sprite from her MSHvSF tiles in the dark gi palette, and the health bar and select screen both read SAKURA. Full harness runs: 0 wild jumps, 0 non-boot resets. The recipe is replicable -- Shuma-Gorath's port is now unblocked (his one flagged risk turned out to be an artifact of the same encryption model this post is about).

Proof / Validation: Headless FBNeo harness that navigates the secret code, plays a full match, and screenshots at fixed frames; instrumented-core logs (pctrace.log) asserted zero wild jumps; live A/B OBJ-RAM dumps donor-vs-native for the tile fix; palette-RAM dumps for the color fix. Honest caveats at the bottom -- there are several.

Repo / Artifacts: CHARACTER_SELECT/scripts/build_ds_native.py + ds_native_tables.py (the build), vendor/FBNeo/src/cpu/m68k/m68kcpu.c instrumentation (dev-only, stripped for release builds), analysis/native_port/, harnesses in /tmp/extcore_run/.

note // Where this sits in the series

The donor-chassis post ended with a working fighter and an admission: it was Ryu's brain in her body, architecturally capped -- the engine's hit-test re-derives attack properties from $52-keyed tables, so as long as $52 said "Ryu," her real frame data could never fully land. This post is the other road: keep $52 = $2E and pay whatever that costs. It cost four root causes, and every one of them taught me something about CPS-2 I didn't know last week.

note // Status update (July 21, 2026)

The native era matured fast after this post. Shuma-Gorath ($30) shipped native too -- his real MSHvSF code block ($0301DE..$032A3E) relocated to $3FA060 -- and the deployed build now carries 17 live-gated fixes, each verified with the visible effect plus match-liveness and stock-control regression asserts. Highlights: the knockdown/throw crash was one byte (a relocation thunk indexed its table by d1 instead of d0); native defense went from a silent ×5.94 damage multiplier (a past-the-end table read) to ×1.0; a stun-table overlap that affected all characters was fixed; five garbage-sound sites were gated; and both characters' names now render on both screens (SHUMA / SAKURA on select, SHUMA-GORATH / SAKURA on the lifebar -- Iron Spider branding gone), verified by an independent six-dimension adversarial pass. Mystic Stare's input detection works and its pose now animates its natural duration (8 anim frames over ~35, clean recover, re-castable). The eyeball projectile does not work yet: the spawn pipeline is live-proven (each cast allocates a real pool object, type $78, owned by Shuma, via the engine's own allocator) but the object has no GFX bank, animation, or velocity wired -- nothing visible spawns. Still honest and still open: both natives run Ryu-clamped physics rows at ~22 dispatch sites (a mini "RYU" tag shows under the lifebar portrait), their correct MSHvSF sounds are silenced-not-replaced, supers and other specials aren't wired, pre-fight walk is missing, a stun/KO animation stall exists, and the 64MB core still breaks Fightcade netplay. The unifying root cause behind most of the crashes deserves naming: fighter field $52 is char_index*2, every per-character table has exactly 23 entries ($00..$2C), and the secret ids $2E/$30 index past the end into whatever bytes follow -- a 203-site census of those past-end reads now exists.

Three Misses and an Instrument

The native build had been stuck for days on one symptom: she'd 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.

That's the tell worth writing down: when well-reasoned static fixes change nothing at all -- not "crash moved," not "crash changed shape," but nothing -- stop reasoning and start measuring. The Lua layer couldn't help (this FBNeo build's registerexec/registerwrite hooks never fire; only frame-boundary polling works, and the crash lives inside the frame). So the measurement had to go into the emulator itself:

/* m68kcpu.c, in the 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.

One ROM, Two Truths

Here is 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 every other character. The original instruction was:

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, and code is stored encrypted. Emulators honor this by giving the CPU two views of the same address range -- and 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.

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

The fix pattern, 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)

I'd like to say I only had to learn this once. The wild-jump logger caught me a second time the same day -- a different thunk, 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.

The Byte-Cell That Had to Be Two Things

The second root cause is my favorite bug of the project.

The per-frame state dispatch reads state_table[$06(a6)] at $00D708 and calls the handler. The table's entries for character setup 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 -- no stock id indexes past the end. For $2E:

  • 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, it turns out, is 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 next harness run produced a match where 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 one byte-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

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

The Scramble Was a Bank Bit

With the match stable, 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.

The verified facts made it a clean pincer: her tile data was correct (the custom core's upper-bank GFX chips are byte-identical between the working donor build and the native build), so the defect had to be in how the native path addressed them. 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

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 had never ported the patch. Two ppatch lines (the table has exactly six readers program-wide, all PC-relative, so the patch belongs in the decrypted view -- the lesson above, applied in reverse). Her body assembled on the next run.

Her colors took one more dig. The palette-source pointer on her fighter object was provably correct -- and the palette RAM banks her sprites sample were provably full of someone else's colors. The engine's one-time palette fill had run without ever consulting her pointer. Rather than chase the fill's ordering through another dispatch maze, 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 with the engine's brightness convention. Slot-computed, so it holds whether she's point, partner, or P2.

warning // 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.

Identity Is Just Data (If You Built It Right the First Time)

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 the part I want to advertise, because it's a payoff for discipline from weeks ago. The donor era's 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; the scripts only write descriptors and tiles.

So the native "full" build is a four-stage pipeline of independently verifiable steps:

build_ds_native.py            # program: native dispatch, tables, thunks, palette
  -> merge upper GFX chips    # her 9,520 tiles (byte-identical to donor's)
  -> build_ds_selectart.py    # select montage + grid-cell face   (data-only)
  -> build_ds_cosmetics.sh    # DARK/SAKURA wordmark + name banner (data-only)

Her select screen: montage art, grid-cell face, and her name where IRON SPIDER used to be

Coda: The Compression That Never Existed

The same encryption model closed out one more mystery, on a different character. Shuma-Gorath -- next in the port queue -- had one flagged blocker: his sprite data looked compressed (entropy 7.95 over his animation region, no known CPS-2 decompressor). That would have been 30-50 hours of codec archaeology.

It was nothing of the sort. The analysis artifact everyone had been reading was built by applying the CPS-2 decrypt over the entire first megabyte -- which fixes opcodes and scrambles the plaintext data stored in the same range. The "compressed" bytes were our own tool's garbage. In the original encrypted set, his animation tree is a perfectly ordinary table (entropy 4.43), his 11,587 tiles walk out with the exact pipeline built for her, and his idle stance renders on the first try.

One ROM, two truths -- and if your tooling only tells one of them, it will lie to you with complete confidence.

Status

SurfaceState
Select: secret code -> $2E commitWorking, deterministic
Select: her montage, grid face, SAKURA wordmarkWorking (one Iron Spider art fragment still layers beneath the montage)
Match: loads, fights, full harness run0 wild jumps, 0 non-boot resets
Sprite: her tiles, assembled, dark-gi paletteWorking
Health-bar nameSAKURA
NextShuma-Gorath, slot $30: tiles proven, build skeleton in progress -- since shipped native (see the July 21 status update above)
warning // Honest caveats

Everything above is harness-verified on one machine: a scripted headless run (secret-code navigation, one full match vs. the CPU, fixed-frame screenshots) plus instrumented-core assertions. No hands-on play session on this build yet -- the donor post's rule stands, a human oracle is the final gate. The in-fight HUD portrait is still garbled. $52 reads $00 at frame boundaries in some probes (mid-frame it is $2E; the discrepancy is understood but not yet eliminated). And the instrumentation build of the emulator is for development only -- it is stripped for anything meant to be shared.

EOF · $11 · 2,407 words · Daniel Plas Rivera
Share[X][LinkedIn]