Skip to content
Index / $0F

The Missing State Schema for the Memorial Bot Was a Paper, Not a Trace Session

Closing two long-open items on the alanmargolies88 behavioral clone — state perception and the four player-struct offsets we'd never wired — using a community reverse-engineering reference for Marvel vs. Capcom's EU 0.2.5 ROM.

Date
May 27, 2026
Runtime
8 min · 1,754 words
Tags
behavioral-cloning, machine-learning, fighting-games
Slot
$0F
Series · The Memorial Bot · part 2 of 3
Contents
tip // Evidence Strip

Goal: Close the two unsolved items left open in the May 25 post on the alanmargolies88 behavioral clone — state perception (the bot occasionally super-jumps at grounded opponents) and the broader question of whether the bot's RAM-slot map covers everything the model needs to fight like Alan.

Constraints: No new live RAM probes; no fork of FBNeo; the existing 7,554-replay corpus stays the source of truth. Any change has to be additive (don't break the v6 parquet schema 92 replays are already tagged against).

Approach: A community-produced reverse-engineering reference for MvC1's EU 0.2.5 ROM lands the full per-player struct field map (27 documented offsets, cross-verified against FBNeo cps_mem.cpp) plus a 432-instruction taxonomy of every place in the M68000 code that reads the character identifier. Cross-reference the paper's §3.1 player-struct table against our existing rl_bridge.lua ADDRS list. Four channels turn out to be missing: the +$2C0 32-bit move-class bitfield (§7), the +$4C chaining-bits word, the +$46 lookup-table word, and the +$BA engine-computed enemy-distance — which we'd been reading as a 1-byte "jump status" since schema v5, but the paper documents it as a signed word. Wire all four into a Phase-10 / schema-v7 block alongside the existing schema (no shift to old parquet field positions).

Progress: Phase-10 ADDRS block landed in rl_bridge.lua and mirrored in fbneo_emulator.py MEMORY_ADDRESS_NAMES. Move-class DWORD per player + facing low-bit broken out from +$B4 + corrected signed-word enemy-distance + chaining-bits word. The +$BA correction in particular invalidates a schema-v5 column we'd been training on for weeks — the byte read at 0xFF30BA is the high byte of a signed word, so its values were truncated and partially sign-flipped (positive distances ≥ 0x80 would read as negative bytes). Retrain is queued against the corpus once the recorder repopulates the four new columns.

What's actually new in this post: the framing — that the engineering blocker on a memorial-AI project was a community RE artifact we hadn't gone looking for. Build-from-reverse-engineering-papers-not-from-fork-and-grind is a pattern.

Repo / Artifacts: vendor/FBNeo/scripts/rl_bridge.lua (Phase-10 block), src/emulator/fbneo_emulator.py, DECOMP_MVC/docs/PAPER_REFERENCE_EU_025.md, DECOMP_MVC/docs/CHARACTER_ID_PATTERNS.md, DECOMP_MVC/docs/HAS_DATA_CATALOG.md.

The two open items

Two days ago I wrote about the alanmargolies88 behavioral clone — the project to train an AI that fights like a specific Fightcade player so the community can spar against his style again. That post listed two items as "not yet working":

State perception in some matches. The bot occasionally super-jumps when the human opponent is grounded — a tell that somewhere in the RAM-slot mapping (or the bot-side flip logic), the position read is misaligned.

Engaging idle opponents at long range. The model knows to attack when in range, but at mid-screen distance with the human standing still, it sometimes oscillates between walk_F and crouch without committing.

Both items have the same shape: the model is making decisions that look state-blind in specific contexts (grounded opponent, mid-range). My instinct was to write more live-RAM probes, hunt for a bad address, and patch. That's how every prior perception bug got fixed in this project.

I didn't write probes. I went looking for what other people had already written.

What the reverse-engineering reference contains

The artifact is an 81-page paper titled Marvel vs. Capcom — A Reverse-Engineering Reference for the CPS-2 Release, version 0.2.5, dated May 2026. It targets the mvsc.zip EU romset — the same ROM revision Fightcade ships and the same one my bot's emulator boots. The paper consolidates community investigation work into a structured reference, and it does several things at once that I hadn't found anywhere else:

  • §2 maps the CPS-2 hardware and the 21-chip ROM partition (program ROM encrypted, mvsc.key battery-backed, GFX/QSound plaintext, Z80 sound code split into mvc.01/02).
  • §3 documents the gameplay RAM region — four 0x400-byte player struct slots starting at 0xFF3000, globals at 0xFF4000, the camera and BG layer state. §3.1 is the per-player struct field map: 27 documented offsets, each with size, semantics, and verification source.
  • §4–6 catalogue the 22-entry character identifier space and the 432 instructions in the M68000 code that read it, classified by four indirection patterns (direct compare, per-character table lookup, +$2C0 bitmask test, shared probe routine).
  • §7 documents the +$2C0 move-class bitfield — a 32-bit field whose bits encode move-class membership (wall-jump capability, hyper armor, post-super double-jump state, character-family identity). Eleven distinct masks observed across the engine.
  • §8 is a worked example of replacing identifier 0x2C (Lilith / Alt-Morrigan) — 13 verified hex-edit patches and a community-introduced shared-probe routine at 0x013B1C.
  • §10 documents the 0.2.5 balance-patch byte-level changes — 8 of 21 ROM chips changed, 526,416 bytes diffed, 73 code edits classified.
  • §11 ships a full ROM audit with CRC32+SHA-256 per file, decryption pipeline reproducibility (591 LOC standalone cps2crypt port, 0 byte differences vs MAME's live AS_OPCODES dump), and 122 cluster-classified diff entries in the decrypted text.
  • §12 documents the investigation Lua keymap (VBlanking, watchpoints, cursor — the primary instrumentation primitives that produced everything else).
  • Appendices A–D ship the full HAS DATA catalog (130 entries grouped by purpose), the tests-if catalog grouped by character, the identifier tables, and the cheat-engine address highlights.

This isn't a Q&A thread or a partial guide. It's the kind of artifact that should exist for every emulated game that has a community of people doing memory hacks, and almost never does.

What it changes for the bot

I cross-referenced §3.1 against the bot's existing rl_bridge.lua ADDRS table — the live RAM-feature map that drives every observation the policy reads. The bot has been through nine schema revisions (Phase 4 box geometry → Phase 5 partner state + projectile pool → Phase 6 tier-1 perception channels → Phase 7 anim-next + air-jump bit → Phase 8 stage/camera fix → Phase 9 signed positions). Most of §3.1 was already covered.

Four channels weren't:

1. +$2C0 — the 32-bit move-class bitfield. This is the headline gap. Eleven documented masks across the engine: 0x80 Spider-Man wall-jump, 0x2000 Hulk armor, 0x4000 Captain Commando bench-dash, 0x40000 Wolverine berserker queue, 0x400000 Lilith Alt-Morrigan flag, 0x1000000 Onslaught boss flag, plus state-derived masks like 0x204804 for post-super double-jump and 0x214810 for post-double wall-jump. Without this DWORD wired, the bot is blind to "I have armor active right now" and "I just super-jumped, I'm in the post-super state" and "this character is Lilith specifically, not Morrigan" — all character-conditional bits the engine itself gates branches on.

2. +$4C — chaining bits. A 16-bit magic-series enable mask; one bit per cancellable target action. Set by hitting; allows the next chain-eligible normal to cancel. This is the frame-exact "what can I cancel this normal into right now" signal — the binding constraint for cloning Alan's chain decisions, the kind of signal that turns "the bot does combos but they don't look like Alan's" into "the bot does Alan's combos."

3. +$46 — lookup table. A 16-bit value indexed by the animation-element cursor that selects per-frame variant data (hitbox set, sound). Combined with the animation ID at +$34 and cursor at +$38 (both already wired as p1_anim_id and p1_anim_next in schema v5/v6), this resolves the bot's view of the opponent's exact current frame within their move, not just an opaque animation pointer.

4. +$BA — engine-computed enemy X-distance. This is the one that retroactively invalidates training data. Schema v5 (the Phase-6 tier-1 perception block, May 2026) added a field named p1_jump_status at address 0xFF30BA, read as a one-byte value. The paper documents +$BA as a signed word — the engine refreshes this every frame pre-AI as the X-distance to the active opponent. The byte at 0xFF30BA was the high byte of that signed word. Positive distances ≥ 0x80 would read as negative bytes; small positive distances would all collapse to 0; the variable name was the opposite of what the field measured.

Item 4 is a real bug. Eight weeks of training runs consumed p1_jump_status as if it were a status flag. The model would have learned something from it — the high-byte truncation isn't pure noise, it has correlation with actual distance — but treating it as a status enum is wrong by construction.

What landed

I added a Phase-10 / schema v7 block to rl_bridge.lua and mirrored it in src/emulator/fbneo_emulator.py MEMORY_ADDRESS_NAMES. Eight new columns (four per player): p1_move_class (DWORD), p1_enemy_dist_x_paper (signed WORD), p1_chaining_bits (WORD), p1_facing_paper (BYTE — the low bit of +$B4, broken out from the existing p1_stance byte that conflates facing with posture). Same for p2.

I deliberately didn't remove the schema-v5 p1_jump_status byte. The bot's existing parquet schema is v6.2; 92 replays are joined against it. Any rename or removal shifts positional consumers and breaks the trace pipeline. The new _paper suffix marks the correct-width signed reading; trainers will switch consumers in the next pass. Old field stays for back-compat.

The retrain hasn't happened yet — the new columns need to be re-recorded across the existing trace corpus first (the raw .p7.raw replays are fine; only the per-frame state-vector columns need widening). I'll trigger that as a separate job and write up the live-match diagnostic separately.

The pattern

The thing I want to flag, more than any specific address: I'd spent eight weeks treating the bot's perception gap as something I was going to solve by writing more probes and patching more 0xFFxxxx addresses one at a time. The actual move was to find the community RE artifact that had already done that work, classified the results, and published.

This isn't quite "always check the literature first" — the relevant literature for game-specific reverse engineering rarely lives anywhere indexed. It's more like "for every project that touches a community-maintained binary, assume someone has produced a structured reference and go find it before you write another probe."

For MvC1 the artifact happens to be an 81-page paper with CRC32s and Ghidra cluster maps. For another game it might be a forum thread with 200 replies of which 12 contain the answer. Either way, the cost of looking is way smaller than the cost of grinding.

The two open items from the May 25 post — state perception and idle-engage — should both close once the recorder produces v7 traces and the model retrains. I'll write the validation up separately, on real behavioural evidence, not on "the schema now has the field." Trying to claim a behavioural fix without live evidence is exactly the kind of error this project has been disciplined about avoiding.

Cross-references landed in DECOMP_MVC/docs/PAPER_REFERENCE_EU_025.md, CHARACTER_ID_PATTERNS.md, and HAS_DATA_CATALOG.md for the next person who comes to MvC1 reverse engineering and would otherwise start from zero.

EOF · $0F · 1,754 words · Daniel Plas Rivera
Share[X][LinkedIn]