Goal: Reverse engineer Arcade1Up's proprietary online multiplayer system to enable PC-based spectating and P2P fighting for Marvel vs Capcom cabinets.
Constraints: No source code. No documentation. Encrypted server IPs. ARM32 binary (libarcade.so) running FBA 0.2.97.35. Architecture-dependent QSound serialization (ARM32 C pointers in state data cause SIGSEGV on ARM64/x86_64). RetroArch file-based state injection vs. libarcade.so's in-memory BurnStateDecompress.
Approach: Wireshark captures of cabinet traffic. Ghidra analysis of libarcade.so (69 game drivers, 872 ROM file entries). Java decompilation of the Android APK (com.zuiki.mvc2). AES-CBC decryption of server configuration. Custom Python client implementing the full protocol stack (STUN, NAT traversal, KCP, game protocol).
Result: Full protocol decoded: room server (TCP 31780), witness server (TCP 32957), match coordination (TCP 41780), STUN (UDP 25371), P2P (KCP over UDP). State injection pipeline: witness data → zlib decompress → flag prefix detection → QSound ARM64 splice → RSTATE wrap → RetroArch LOAD_STATE. P2P input bridge: pynput capture → 60fps byte encoding → KCP transport → CGEvent injection. 28,402 player profiles and 1.37M ranking snapshots collected.
Repo / Artifacts: src/a1u/client/ (protocol implementation), src/a1u/emulator/ (state injection + input bridge), desktop/ (Electron app), src/a1u/api/ (REST + WebSocket API).
The follow-up — Cabinet-Faithful Spectating: Why You Can't Just memcpy a Game State Across Architectures — disassembles libarcade.so's actual spectate consumer (a blind full-state apply-and-run) and proves that resuming a cabinet state verbatim is architecturally impossible off the cabinet. It also corrects the QSound claim below: on the current native FBAlpha path, QSound channels are stored as fixed-point sample offsets and reconstructed from the local sample-ROM base — not raw host pointers — so the real cross-architecture wall turned out to be the 68000 CPU cycle-phase, not QSound serialization.
What is Arcade1Up Online?
Arcade1Up sells home arcade cabinets with online multiplayer capability. The Marvel vs Capcom cabinet runs a modified Android PCB with an embedded FBA Alpha 0.2.97.35 emulator (libarcade.so) that connects to cloud servers for matchmaking, ranking, and live game state synchronization. Players can fight opponents worldwide, watch live matches as spectators, and climb a ranked ladder from Bronze to Invincible.
The problem: everything is locked inside a proprietary ecosystem. There's no API documentation, no PC client, and no way to spectate or play from anything other than the physical cabinet. The protocol is completely custom — not GGPO, not standard RetroArch netplay, not anything documented.
Our goal: reverse engineer the entire system and build a desktop client that can spectate live matches and engage in P2P fights using the same protocol the cabinets use.
Protocol Stack Architecture
The Arcade1Up online system uses a layered protocol stack with six distinct components, each on different ports and serving different roles:
Each layer was decoded separately through packet captures, with the room server being the entry point that led us to discover the rest.
Binary Forensics: Inside libarcade.so
The heart of the system is libarcade.so — a 4.2MB ARM32 Thumb binary containing the FBA Alpha 0.2.97.35 emulator. Using Ghidra, we extracted:
- 69 game driver registrations with CRC32 ROM verification tables
- 872 ROM file entries mapping device ROM IDs (like
46.zip) to driver names (likemvscu) - BurnAreaScan state format — the serialization mechanism that writes game state to a flat buffer using
ACB_FULLSCAN | ACB_READflags
The critical discovery was the QSound serialization problem. The FBA Alpha QSound driver serializes raw C pointers:
// Inside qsnd.cpp (FBA Alpha 0.2.97.35)
static void QscnScan(INT32 nAction)
{
if (nAction & ACB_VOLATILE) {
// This serializes the POINTER itself, not what it points to
ba.Data = &QscnChannel[i].lpWave; // ARM32: 4-byte ptr
ba.nLen = sizeof(QscnChannel[i].lpWave);
BurnAcb(&ba); // Written to state buffer
}
}These 4-byte ARM32 pointers become invalid on ARM64/x86_64 where pointers are 8 bytes. Loading a cabinet-generated state on desktop RetroArch causes an immediate SIGSEGV as QSound tries to dereference an invalid pointer. Our solution: splice QSound data from a locally-generated template state (same ROM, correct architecture) while keeping the game-logic state from the witness stream.
The Witness State Injection Pipeline
Spectating works by receiving compressed game state snapshots from the witness server and injecting them into a local RetroArch instance. This is the most complex part of the system:
The 10-Byte Flag Prefix Discovery
The most elusive bug was a persistent black screen after state loading. Forensic hex dump comparison between RetroArch-generated states and our wrapped states revealed a 10-byte ASCII prefix that RetroArch's retro_serialize() prepends to the core state data:
Template state (first 16 bytes):
30 30 31 31 30 31 30 30 30 31 xx xx xx xx xx xx
'0' '0' '1' '1' '0' '1' '0' '0' '0' '1' [game data...]
Witness data (first 16 bytes):
xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx
[game data starts immediately — no flag prefix]These 10 ASCII characters ('0' = 0x30, '1' = 0x31) are boolean flags that RetroArch uses to track which memory regions have valid data. Without them, every memory region offset is shifted by 10 bytes, causing total state corruption — the screen goes black because the video registers point to garbage memory.
The fix: extract the flag prefix from our locally-generated template and prepend it to the witness data before wrapping into RSTATE format.
P2P Fighting Protocol
When two players fight, the system establishes a direct peer-to-peer connection using a multi-step handshake:
The INPUT_DATA message is only 18 bytes — a 12-byte header plus 6 bytes of payload containing two input bitfield bytes and a delay frame counter. At 60fps, this produces ~1080 bytes/second of game data per player — remarkably efficient.
Input Byte Format
The input encoding maps directly to the arcade cabinet's button layout:
Byte 1: [LP:0x01] [MP:0x02] [HP:0x04] [LK:0x08] [--:0x10] [Left:0x20] [Down:0x40] [Right:0x80]
Byte 2: [MK:0x01] [HK:0x02] [Up:0x04] [Start:0x08]Our input bridge captures local keyboard events via pynput, converts them to this byte format, and sends them through the KCP transport. Opponent inputs arrive as the same byte format and are injected into RetroArch as Player 2 keyboard events using OS-level event simulation (Quartz CGEvent on macOS).
Player Analytics: 28,402 Players
By polling the room server and collecting ranking data, we built a comprehensive database of the Arcade1Up community:
- 28,402 unique player profiles with nicknames, icons, and ranking data
- 1,376,891 ranking snapshots tracking level, wins, losses, and rank tier over time
- Match detection without an API — by monitoring room state transitions (2 players → 1 player = match ended, identify winner by checking who left)
The rank distribution across all tracked players shows a healthy pyramid:
| Rank | Players | Percentage |
|---|---|---|
| Bronze | 21,847 | 76.9% |
| Silver | 3,412 | 12.0% |
| Gold | 1,893 | 6.7% |
| Diamond | 847 | 3.0% |
| Champion | 341 | 1.2% |
| Invincible | 62 | 0.2% |
Electron Desktop App
We built a full desktop application using Electron + React + Tailwind CSS with a Cursor-inspired dark theme. The app provides:
- Arena View — Live match cards with VS displays, watcher counts, and spectate/join actions
- Dashboard — Community statistics, rank distribution charts, activity heatmaps, win rate leaders
- Leaderboard — 6 tab views (Global, Most Wins, Win Rate, Veterans, Rising Stars, Search)
- Fight View — Host or join P2P rooms with real-time session status
- Spectate View — Witness state streaming with frame counter and emulator integration
All data flows through a FastAPI backend that manages the protocol clients, emulator lifecycle, and SQLite analytics database.
System Architecture
Encryption and Authentication
Server IPs are AES-128-CBC encrypted in the Android APK's shared preferences. We decompiled the Java source to extract the encryption key and IV:
from Crypto.Cipher import AES
import base64
# Extracted from com.zuiki.mvc2 APK decompilation
key = b'...' # 16-byte AES key
iv = b'...' # 16-byte IV
cipher = AES.new(key, AES.MODE_CBC, iv)
server_ip = cipher.decrypt(base64.b64decode(encrypted_value))The authentication flow uses a device ID and session token exchanged with the room server during the initial TCP handshake. Player identity is tied to a numeric user ID (8-digit integer), with nicknames being a separate mutable field that can change independently.
Status and Next Steps
What works today:
- Full room server protocol (browse rooms, see players, watcher counts)
- Witness server spectating (live state injection into RetroArch)
- P2P fight protocol (STUN, NAT traversal, KCP, input exchange)
- Input bridge (local capture + opponent injection via pynput)
- Player analytics (28K profiles, ranking history, match detection)
- Desktop app (Electron) and web UI (FastAPI + Jinja2)
What's next:
- Rollback netcode — the current delay-based approach adds perceptible input lag; implementing speculative execution with state rollback would dramatically improve the feel
- Cross-platform state compatibility — solving the QSound pointer issue at the FBA Alpha core level rather than our splice workaround
- Mobile client — bringing the spectating experience to phones
- Tournament mode — bracket management using the existing room server infrastructure
The Arcade1Up online system is a fascinating piece of engineering — a full multiplayer fighting game platform running on embedded Android hardware, with a bespoke protocol stack that rivals the complexity of professional gaming networks. By reverse engineering it, we've opened the door to preserving and extending this community beyond the constraints of a single hardware platform.