Skip to content
Index / $0E

Cabinet-Faithful Spectating: Why You Can't Just memcpy a Game State Across Architectures

The sequel to the Arcade1Up protocol RE: disassembling libarcade.so's spectate consumer, discovering the cabinet's blind-apply-and-run model, proving verbatim state resume is impossible cross-architecture, and a debugging saga where the biggest bug was my own screenshot tool swapping red and blue.

Date
Jul 4, 2026
Runtime
11 min · 2,522 words
Tags
reverse-engineering, arcade1up, cps2
Slot
$0E
Contents
tip // Evidence Strip

Goal: Make our desktop spectator render a cabinet's live match the way the cabinet does — 1:1 with libarcade.so, not an approximation that merely looks plausible.

Constraints: The cabinet is 32-bit ARM (libarcade.so, armeabi-v7a). Our desktop core is ARM64/x86-64. The witness stream is a 353,796-byte FBAlpha BurnAreaScan state produced by the cabinet's C68K core — including a 68000 CPU context serialized as 32-bit host pointers. No documentation; the only ground truth is the disassembly and a human who owns the cabinet and knows what the game is supposed to look like.

Approach: Ghidra/radare2 on libarcade.so's witness-apply path (FUN_00087878 → the BurnAreaScan callback near 0x9d140). Controlled emulator experiments: self-exported states vs. real cabinet keyframes, measured by distinct-frame CRCs and pixel metrics. Every visual claim rendered, dumped, and eyeballed — then re-checked against the cabinet owner, because subagent "looks correct" verdicts turned out to be worthless.

Result: The cabinet model decoded — countdown → blind full-state apply → run forward → resync — and proven impossible to replicate verbatim across architectures: the state carries ~160 bytes of architectural registers but not the mid-frame CPU cycle-phase the cabinet resumes from, so a loaded state renders the correct scene and then freezes. Two real render bugs found and fixed along the way, plus one embarrassing one that was entirely in my analysis tooling.

Prior work: Reverse Engineering Arcade1Up's Online Fighting Protocol — the protocol stack, witness server, and P2P netcode this post builds on.

The first Arcade1Up post decoded the protocol: how to talk to the room server, subscribe to the witness stream, and receive live game-state snapshots. This post is about the much harder second half — actually playing those snapshots back the way the real cabinet does — and it's as much a story about debugging discipline as it is about emulation internals.

It starts with a correction I had coming.

The bug report that invalidated the whole approach

Our desktop spectator worked, sort of. On a mid-match join it would boot the emulator cold, detect the two teams from the keyframe, synthesize a coin-insert → character-select → FIGHT input sequence, and then replay the witnessed per-frame inputs from the start of the match, using a jitter buffer to fast-forward up to the live edge. It produced smooth 60fps motion of the right characters.

The cabinet owner watched it and said, plainly: that's not what the cabinet does.

The real cabinet, he explained, shows a countdown, then simply displays the game at the latest relayed state — no fast-forward, no character-select replay. And the same countdown reappears on a network hiccup. My whole from-boot-and-replay machinery — the character-select prefix, the input reconstruction, the jitter-buffered catch-up — was an invented workaround, not parity. I'd built something that looked like a fighting game and called it done, without ever reading what libarcade.so actually does on the receive path.

So I went and read it.

What the cabinet actually does

Inside libarcade.so, the witness-frame handler is refreshingly blunt. FUN_00087878 copies a 68-byte header, reads a state size from a byte-swapped word at header offset 0x18, allocates a buffer, and calls a BurnAreaScan-style apply callback near 0x9d140:

// libarcade.so witness apply, reconstructed
void apply_witness_state(uint8_t *frame) {
    uint8_t hdr[0x44];
    memcpy(hdr, frame, 0x44);           // 68-byte header
    uint32_t state_size = bswap32(*(uint32_t*)(hdr + 0x18));
    uint8_t *buf = malloc(state_size);
    // ... copy payload ...
    apply_callback(buf, 0, state_size); // == BurnAreaScan(buf, 0, size)
}

There is no validation. It doesn't check the 68000 program counter, doesn't sanity-check the payload, doesn't reconstruct anything. It blindly loads the entire received state — work RAM, Z80, QSound, video registers, and the 68000 CPU context — straight into the running emulator, then continues executing. Each fresher state that arrives is applied again, as a resync. The "countdown" is just the awaiting-first-state UI shown until the first snapshot lands, re-shown whenever the stream stalls.

Loading diagram...

This is elegant, and it works on the cabinet for one reason that matters enormously: the sender and receiver are the same architecture. Both are 32-bit ARM, both are the same FBAlpha build with the same C68K core. The serialized 68000 context — a c68k_struc with D[8], A[8], condition flags, and critically a program counter and memory base stored as 32-bit host pointers — is byte-identical to what the receiver's own core expects. Loading it is a verbatim memcpy. No conversion, no re-basing, no risk of a dangling pointer. Blind apply is safe only because there is no architecture boundary.

Our desktop is ARM64/x86-64. That boundary is exactly where we live.

The make-or-break experiment

The question became sharp and testable: with the state converted correctly, can our desktop core load a cabinet keyframe and run it forward, the way the cabinet does? If yes, the entire from-boot detour dies and we get true parity. If no, I need to know precisely why.

I wired up the conversion — de-base the 68000 PC as logical_pc = PC - BasePC (the two 32-bit pointer fields are 4 bytes apart in the struct, which is itself proof the blob is ARM32-native), rebuild the status register from the C68K flag encoding, load it into our added C68K core — applied a real cabinet keyframe, and stepped 300 frames.

The result was the most informative kind of failure. It rendered the exact host fight scene — correct characters, correct positions, no crash — and then froze. The 68000 program counter pinned inside a sprite-DMA copy loop and never left it across a thousand frames. The object RAM that drives the fighters never advanced; only ~123 bytes of stack churned.

The control experiment nailed the cause. I exported a state from our own running core and loaded it back through the identical apply path: 195 of 200 frames animated — perfectly. Same machinery, same code, different source. So the pipeline is correct; only the cabinet capture won't run.

warning // The cross-architecture wall

The 353,796-byte state carries about 160 bytes of architectural 68000 registers — but not the micro-architectural state: the instruction-cycle phase, the pending-IRQ nesting, the exact point mid-instruction the cabinet was at when it serialized. The captured PC sits mid-frame, inside a sprite-build routine, and resuming there re-enters a display-list loop whose exit condition depended on cycle-phase the blob doesn't contain.

On the cabinet (ARM32 → ARM32, verbatim memcpy of the live core) that phase is implicitly preserved. Cross-architecture, we can reconstruct the register file but not the phase — so the CPU derails. Verbatim blind-apply-and-run is architecturally impossible off the cabinet. Not a bug to fix: a property of copying a running CPU between two different CPUs.

That's a genuine, provable "no." The cabinet's whole trick is same-architecture verbatim resume, and we can't have it. The path forward is different: apply the state for its display, and re-enter the game's own per-frame update over that data rather than resuming the raw captured instruction — but first I had to get the display itself correct, and that's where I lost the most time to the dumbest possible cause.

The instrument was lying

The cabinet owner kept telling me the rendered frames were wrong — "discolored," "nothing is correct in color." I kept looking at my screenshots and seeing a plausible-looking fighting game. He was right; I was staring at a lie my own tooling was telling me.

Our core packs pixels as standard RGB565. The definitive proof is in the source — the FBAlpha high-color routine:

static UINT32 HighCol16(INT32 r, INT32 g, INT32 b, INT32) {
    UINT32 t;
    t  = (r << 8) & 0xf800;  // red   → bits 11-15 (high)
    t |= (g << 3) & 0x07e0;  // green → bits 5-10
    t |= (b >> 3) & 0x001f;  // blue  → bits 0-4 (low)
    return t;
}

Red in the high bits, blue in the low bits. My framebuffer-to-PNG converter decoded red from the low bits and blue from the high bits — a plain BGR/RGB swap. Every screenshot I'd produced for weeks had red and blue inverted. Megaman, who is blue, came out red; I never noticed because I don't have the game memorized the way the cabinet owner does. The live Electron window renders through the real video path, so the actual spectator colors were always correct — the discoloration existed only in my analysis PNGs, and I'd been chasing it as if it were a palette-conversion bug in the game state.

danger // Lesson: verify your instruments before you debug with them

I burned real effort — and the cabinet owner's patience — debugging a "color conversion bug" that was a two-line mistake in a vid2png.py helper nobody had checked. When a domain expert says the output is wrong and your tooling says it's fine, suspect the tooling. The screenshot decoder was never on trial; it should have been the first thing verified, not the last.

With the decoder fixed, most cabinet keyframes rendered correctly. But not all of them, and the ones that didn't revealed two real bugs the color noise had been masking.

Real bug #1: the sprites that slid off-screen

With correct colors, the cabinet owner immediately spotted what I couldn't: on some frames, the whole scene was shifted — the UI toward the lower-right, characters sliding right. Not color: geometry.

I stopped guessing and measured. The sprite coordinates in the state were in perfectly normal on-screen ranges, so the shift was happening at render time, from a global offset. The CPS-2 sprite draw offset in FBAlpha comes from here:

// cps_obj.cpp — per-frame sprite offset
pof->nShiftX = -0x40;                    // canonical default
pof->nShiftY = -0x10;
if (Cps == 2) {
    pof->nShiftX = -CpsSaveFrg[0][0x9];  // override from CPS-B "four registers"
    pof->nShiftY = -CpsSaveFrg[0][0xB];
}

CpsSaveFrg is a buffered copy of CpsFrg, the CPS-B "four registers." I dumped CpsFrg from a state that rendered correctly and from a shifted cabinet state:

correct (self-export):  70 80 80 7d 53 10 00 00 00 40 00 10 ...
cabinet keyframe:       00 00 00 00 00 00 00 00 00 00 00 00 ...  ← all zero

The cabinet's ARM32 BurnAreaScan doesn't capture the CPS-B four registers at all — they come out zero. So nShiftX/nShiftY load as 0 instead of -0x40/-0x10, and every sprite shifts right by 0x40 and down by 0x10: exactly the lower-right slide. The character sprite data is fine; the global offset that positions all of it is zeroed.

The fix is four lines, and the shape of it matters: the correct value is already the code's default (line 249). We just must not let a zeroed cabinet register overwrite it.

// Only honor the register when it's actually populated. A cabinet
// witness keyframe leaves CpsFrg zero, which would zero the sprite
// offset and slide the whole scene off the lower-right corner; keep
// the canonical -0x40/-0x10 default in that case.
if (CpsSaveFrg[0][0x9] || CpsSaveFrg[0][0xB]) {
    pof->nShiftX = -CpsSaveFrg[0][0x9];
    pof->nShiftY = -CpsSaveFrg[0][0xB];
}

Because the condition only changes behavior when the register is zero — which never happens in a real running frame — the native render is byte-identical afterward. The slide is gone; the KO screen's banners snap back to the top corners, the fighters back onto the ground.

An earlier attempt had patched CpsFrg directly in the blob and failed, which was itself instructive: the renderer reads CpsSaveFrg, the buffered copy that's refreshed from CpsFrg each frame — so the fix has to live at the read site, not the stored data.

Real bug #2: torn captures

A minority of cabinet keyframes stay broken even after the offset fix — black background, sprites fragmented. These are torn captures: the cabinet's BurnAreaScan snapshotted the CPS-A scroll registers and the graphics RAM at slightly different points in the frame's DMA, so the scroll-base registers point at graphics memory that doesn't hold the tilemap the register expects. Layers are enabled but drawing from empty memory → black. There's no coherent tilemap to recover; the data simply isn't self-consistent in the blob.

These are unrepairable on the receiver — and, importantly, they're a cabinet-side capture artifact, not a conversion bug. The correct handling is to detect a torn frame and hold the last good one rather than flash the corruption. A held clean frame for a fraction of a second is invisible; a torn frame is a glitch. This also explains why the live spectator looked persistently broken on some joins: the display had frozen on a single join keyframe, and if that one keyframe happened to be torn, you'd stare at the glitch until the match state changed.

A correction to the first post

The original Arcade1Up writeup named QSound pointer serialization — 4-byte ARM32 lpWave pointers dereferenced as 8-byte pointers — as the cross-architecture blocker, causing a SIGSEGV on load. That was true for the RetroArch file-injection path it described, and it's a real hazard in general. But this session's disassembly of the current native path found it doesn't apply the way I'd assumed: this FBAlpha QSound core stores channel positions as 16.12 fixed-point sample offsets, not host pointers, and reconstructs the one real pointer (PlayBank) from our sample-ROM base via MapBank() on every load. A 500-frame run with the Z80 and QSound live exits cleanly — no SIGSEGV. The genuine cross-architecture wall isn't QSound at all; it's the 68000 cycle-phase described above. I'd rather correct the record than let a tidy-but-wrong explanation stand.

Where this leaves the spectator

Honest status, in the spirit of the rest of this blog:

  • Protocol and witness stream: solid (see the first post).
  • Color: was never a game bug — an analysis-tool defect, now fixed.
  • Sprite positioning: real bug, root-caused to the un-captured CPS-B registers, fixed at the render read-site with no regression to native play.
  • Torn frames: understood as a cabinet-side capture artifact; handled by detect-and-hold rather than repair.
  • Verbatim blind-apply-and-run: proven architecturally impossible off-cabinet. The replacement — apply the state for display and re-enter the game's own per-frame update loop over it, resyncing on each new state — is the current line of work.

The throughline of the week wasn't a clever emulator hack. It was the same discipline the memorial-bot project learned the hard way: the only evidence that counts is what a domain expert sees on the real output. Offline metrics, subagent "looks correct" verdicts, and screenshots from an unverified tool all agreed the spectator was fine. The person who owns the cabinet and knows what Marvel vs. Capcom is supposed to look like disagreed, three times, and was right every time. Every real fix in this post came after I stopped trusting my own confident-but-wrong instruments and started rendering, measuring, and asking him to look.

Reverse engineering a proprietary system is mostly this: not the disassembly, but the humility to keep checking whether the thing you built is the thing that actually exists.

EOF · $0E · 2,522 words · Daniel Plas Rivera
Share[X][LinkedIn]