case studyUpdated Aug 8, 202633 min read$19

Capcom Hid a Character Laboratory Inside CPS-2. We Automated It.

Retail CPS-2 ROMs still contain Capcom's HIT, CATCH, AI, and art tools. I automated the hidden lab—and made it reject character ports that only look finished.

Jump to evidence
cps2reverse-engineeringmarvel-vs-capcomxmvsfmshvsf
CPS-2 Reverse EngineeringPart 15 of 43
Browse all writing
On this page
Evidence Strip

Goal: Turn the development tools Capcom left in retail CPS-2 ROMs into a reusable character-port laboratory: source game in one column, destination game in the other, every claim tied to a receipt.

Constraints: The menu changes across games, internal object layouts are not universal, encrypted program and plaintext data views can be confused, and a beautiful editor screenshot still does not prove a move works in a match.

Approach: Enable the real debug master in a disposable ROM, enter through normal controls, drive each tool, capture pixels plus live RAM and referenced tables, exact-match Capcom's glyphs, then compare address-independent source and destination events.

Result: One bounded run now produces screenshots, raw RAM, both point fighters and partners, an exact input/event timeline, world-space boxes, projectile objects, QSound writes, live-pointer dumps, global match state, indexed story evidence, exact version settings and a signed receipt. A separate 33-lane completeness ledger refuses to call the whole character done while any move, reaction, voice, throw victim, AI behavior or ending is still open.

Proof / Validation: The August 7 v4 smoke gate decoded the real menu and recorded 820 state rows, 6,560 resolved box rows, 4,100 five-object rows and 939 QSound/phrase observations. MSH's different ten-line menu and shadowed font now pass their own exact adapter. The empty completeness template correctly reports INCOMPLETE: 0/33.

This is now the canonical account. It consolidates the original menu discovery and the later screen-harvesting notebook into one maintained system: discovery below, automated laboratory up front, and corrections kept where the evidence changed.

August 8: three troubled ports became reproducible repair ledgers

The lab now has version-pinned profiles for Zero, Dark Sakura and Shuma-Gorath. Each profile expands the generic rooms into its real named specials and supers, verifies the destination ZIP hash before a run, preserves prior evidence as OBSERVED, and refuses to turn that evidence into an accepted move. The present result is intentionally severe:

PortRequired lanesClosed nowWhat the new editor audit established
SVC Zero → MvC371MvC HIT/CATCH reach the replaced slot but still identify 10 ROCK MAN; SVC source truth must come from Neo Geo runtime traces
Dark Sakura → MvC371MSHvSF source row $15 is captured; destination CATCH exposes only the stock cycle and HIT returns to boot/title
Shuma-Gorath → MvC361MSHvSF source row $05 is captured; destination has no native $30 editor row and its HIT scan fails closed

Every “1” is the same written waiver: the source fighter has no persistent flight mode. None is a gameplay pass.

MSHvSF HIT EDITER showing row 15 SAKURA
Dark Sakura source // hexadecimal row $15
MSHvSF HIT EDITER showing row 05 SHUMATGOLUS
Shuma source // hexadecimal row $05
MvC HIT EDITER on the Zero destination build still showing the stock row 10 ROCK MAN
CROSS-HARDWARE JOIN // Capcom's destination editor can inspect the replaced slot; it cannot provide SNK's source semantics. The profile joins this receipt to the SVC runtime oracle without pretending they are the same format.

This first multi-character pass caught two reusable harness hazards. MSHvSF prints ids in hexadecimal, while automation navigates by cursor-step count. It also enters HIT and CATCH on different initial rows. Dark Sakura therefore requires 21 HIT steps but 20 CATCH steps; Shuma requires 5 and 4. A single shared selector silently captures the next character. Second, unmapped MSH/MSHvSF runtime fields—including the push selector—are now written as NA instead of borrowing MvC's offsets. A missing measurement is safer than a precise-looking wrong one.

The house: from hidden foundation to occupancy permit

The easiest way to understand the system is as a house inspection. Capcom poured the foundation; the retail game still contains the rooms its developers used. The project had been peering through windows. The harness adds a repeatable entrance, labels every utility line and refuses to issue an occupancy permit because the wallpaper looks right.

Foundation — Capcom's own vocabulary

HIT EDITER names animation families, plays and single-steps them, exposes the current graphics pointer and shows eight collision-slot ids. CATCH EDITER runs the thrower and victim as a paired CATCH/HOLD program. ENEMY SENSEI exposes idle, wake-up and library decision tables. The other rooms browse endings, messages, scroll layers, portraits and move-property matrices.

That makes the menu unusually valuable for a cross-game port: it tells us what the source engine thought an animation was, not merely where some plausible bytes happen to sit.

X-Men vs Street Fighter HIT EDITER showing Magneto on the left and Wolverine on the right with animation and hit fields
SOURCE ROOM // XMvSF identifies row 07 as MAGNETO and exposes its animation, pointer and HIT-slot state. The census captured 37 HIT rows.
X-Men vs Street Fighter CATCH EDITER showing Magneto and Wolverine catch and hold records
PAIRED ROOM // A throw is not one animation. CATCH and HOLD advance together, with separate offsets for thrower and victim.

Framing — a disposable, attributable build

The source ZIP is never patched in place. A builder makes a deterministic temporary copy, changes only the debug master, hashes the result and records the exact program member and before/after bytes. The runner launches a uniquely named, locally signed FBNeo copy, refuses to overwrite an existing evidence directory, enforces a wall-clock limit and removes only the process it created.

For encrypted source sets, the lab can synthesize a valid 20-byte CPS-2 key while preserving the stored program bytes. That closes a nasty failure mode: a Phoenix/plaintext program can look useful in a disassembler while the emulator applies decryption again and boots garbage. Program opcodes and program data are two views of the same address space; the receipt records which one was used.

Capcom SECRET TEST MENU listing HIT EDITER, CATCH EDITER, ENEMY SENSEI and the remaining development tools
POSITIVE CONTROL // Unless exact glyph matching finds this menu plus the requested room, the run fails. FBNeo's ordinary hardware test screen cannot impersonate success.
Marvel Super Heroes SECRET TEST MENU with HIT EDITER, CATCH EDITER, scroll tools, ENEMY SENSEI, ENDING TEST and MESSAGE TEST
A SECOND FLOOR PLAN // MSH has ten entries in a different order and draws a shadowed font. The adapter maps semantic room names to the real cursor positions and trains a run-local exact atlas; it does not borrow MvC's numbering or fuzzy-match the screen.

Plumbing — measure the running engine, not just the screen

The menu screenshot is the human-readable layer. Under it, the harness samples the live object and follows the pointers it is actually using:

StreamWhat is recordedWhy it matters to a port
Stateaction, animation, list, position, velocity, special/cancel/chaining fields, real hitstun, recovery and knockbackFinds the first frame where destination behavior diverges from source
Collisionpush, four vulnerability, throwable and two attack ids resolved to world-space rectanglesSeparates a correct pose from a correct interaction
Childrenprojectile owner, type, action, list, motion, hit gate and attack idsDistinguishes a real EM Disruptor/Hyper Grav child from a donor projectile
AudioQSound-bus and configured phrase-ring writes with caller PCLocates command routing without pretending the right sample was heard
Pointersreached animation, sprite, hit-row and shape-table bytes with hashesProves which imported records the engine consumed
Entitieseditor plus mapped P1/P2 point and partner objects, life, red life, meter, push, stun, recovery and knockbackStops an opponent or partner reaction from disappearing behind a clean P1 trace
Inputs + eventsexact harness bitmask transitions and run-length semantic field edgesPairs cause with effect and locates the first divergent frame
Globals + narrativephase, timer, stage, assists, indexed ENDING/MESSAGE/KAO screens and OCRCarries the port beyond its movelist into intros, branches, quotes and endings

The Fightcade AI work supplied several important corrections. Object +$B4 in MvC is a pushbox/movement selector, not stance or facing. +$7C is the defender's throwable region, not the thrower's active grab range. A persistent $8000 attack-slot id is a potential throw, not a damaging strike. Those distinctions are now encoded in the harness instead of left to whoever reads the trace.

The Magneto integration added another safeguard: a special is no longer recorded as one opaque pass or failure. Its trace is a chain of custody:

  1. command recognized;
  2. parent fighter enters the intended action;
  3. allocator returns a valid object without corrupting the parent register;
  4. child class and subtype reach the intended dispatcher;
  5. child update, collision and owner fields remain valid;
  6. teardown frees the child; and
  7. the parent recovers to neutral without leaving the match.

That split turned EM Disruptor from a vague visual failure into a precise result. The destination accepts the source route and allocates a live class $001C, subtype $04 child at $FFD47A for the Magneto parent at $FF3000. Even an ablation handler that immediately frees that child cannot save the fighter: the parent is cleared after its animation step and before the mapped neutral helper. The projectile renderer is therefore not the current blocker; the parent special-state handoff is. Hyper Grav, Flight, Magnetic Blast, Tempest and Shockwave remain failed lanes until their own complete chains pass.

This investigation also exposed a CPS-2 trap that a reusable harness cannot leave implicit. A secondary-object table is read through the program's raw data view, while the handler itself is fetched through the decrypted opcode view. The build gate now verifies both, disassembles every new word branch to its exact target, and checks the raw dispatch pointer again after any later decrypt/re-encrypt pass. Patching only the opcode image can produce convincing bytes in a file and encrypted-looking nonsense at runtime.

Finally, visual attribution is now object-aware. The recorder identifies the point fighter, partners and child objects before assigning an on-screen component to Magneto. That correction proved a supposed sprite-assembly defect was Chun-Li's teammate entrance, while Magneto's own hypnotic multi-hand idle was advancing correctly. Optional emulator memory hooks were not stable in this FBNeo build, so the authoritative diagnostic path uses tiny ROM-side trace wrappers that save and restore registers without changing condition codes, then writes a bounded receipt to unused CPS graphics RAM.

One verified menu-only smoke run
  • Resolved box slots6,560rows

    eight slots for each of 820 sampled frames

  • Player-state samples820rows

    action, pointers, motion, cancels and reactions

  • QSound / phrase observations939rows

    useful routing evidence; explicitly not proof of audible imported audio

  • Projectile rows0rows

    correct for a menu-only control run

The receipt hashes every stream and embeds the exact version preset. Counts are observations, not quality scores.

Wiring plan — offsets belong to versions, not folklore

The first reusable draft still had one dangerous convenience: it defaulted every game to MvC's layout. That is now gone. MvC uses the verified projectile table at $FFDF1A, stride $158, and push selector +$B4. XMvSF uses $FFE3D8, stride $150, and +$A4. Its phrase-ring watch is disabled because that address is not yet mapped. MSHvSF's unknown projectile and phrase fields are also disabled rather than filled with the nearest game's values. MSH now has a verified menu and story-index adapter, but its live fighter, projectile and phrase offsets stay disabled until its specific revision is mapped.

Every run writes these choices into lab_config.tsv and the JSON receipt. A future researcher can replay the measurement without reconstructing a forgotten shell command—or discover immediately that two supposedly comparable traces used different maps.

Inspection — compare semantics, not addresses

Absolute pointers cannot match after a port; the data moved. The comparator therefore reduces each stream to portable event signatures: state transitions, rectangle geometry, projectile motion/hit fields and sound commands. It reports shared, missing and extra events plus the first divergent sequence position.

Its status is always TRIAGE_ONLY. A strong correlation tells us where to look. It does not establish command recognition, contact, damage, sprite assembly, sample banking or play feel.

For Magneto, the source oracle now distinguishes EM Disruptor's child type $001C, Hyper Grav's $0020, persistent Flight state and Magnetic Tempest's multi-child $0068 pattern. Those are precise destination targets. They are not claims that the current MvC build has reached them.

Room schedule — define the whole character before admiring one move

The harness previously answered whatever question I happened to ask. That is useful for debugging and dangerous for completion: an impressive super can distract from a missing crouch reaction, back throw, hurt voice or win branch. The new manifest turns “full character” into 33 required lanes.

WingWhat the permit now requires
Identityhighlight, confirmed selection, animated pose, logo, HUD, portrait, palette and both facings
Movementidle, crouch, walks, dashes, jumps, super jump and character-specific air modes such as Flight
Combatall 18 normal families, helper-assisted normals, every strength/air special and super, commands, source animation, boxes, damage, stun, cancels and reactions
Defense + throwshigh/low/air block, pushblock, knockdown/recovery, active catch, throwable region, paired HOLD and victim release
Audio + AIcomplete voice/effect matrix and ENEMY SENSEI decision families
Narrative + regressionintro, rival branches, win/lose quotes, ending, return edge and a stock-roster match

Each lane names the evidence it requires. OBSERVED means a real editor or runtime artifact exists. ACCEPTED is legal only when every required kind points to an existing, hashed PASS artifact. WAIVED needs a written reason. The empty template reporting 0/33 is a positive feature: no green cells are manufactured from absence.

The thirty-third lane is normal.helper_assisted. It exists because a move can be an ordinary fighter input and still own a second object: Mega Man appearing with Rush during normal match play is not the Rush Drill hyper and cannot be accepted by testing Rush Drill. The lane requires the fighter animation, helper owner/child lifecycle, collision, damage, stun, victim reaction, sound and cleanup together.

Selection and idle lanes now add a visual-cadence receipt. It hashes a bounded character crop, the relevant palette slice and a profile-owned tile range among OBJ records intersecting that region. The tile filter is what prevents a moving stage behind the fighter from impersonating character motion. On Magneto's select screen, all six crops change with the starfield while the same 21 pose records produce one object hash, so the lane correctly fails frozen. PASS_MOTION_OBSERVED still does not mean the motion is correct: source cadence, sprite assembly, ground origin and palette semantics remain separate acceptance requirements. Magneto is the first useful negative control—the destination changes many pixels because the wrong state and an unrelated teammate component move, yet its source-idle-parity gate correctly remains failed.

The story wing — ENDING TEST is not just a novelty screen

An ending is another state machine. ENDING TEST supplies indexed records; MESSAGE TEST supplies text ids; KAO TEST supplies faces and palettes; the scroll rooms supply backgrounds and camera layers; QSound supplies the scene transitions. The harness now catalogs those surfaces into a separate narrative receipt.

Marvel Super Heroes secret ending test displaying indexed ending values beside the menu
STORY INDEX, NOT STORY COMPLETE // MSH's ENDING TEST exposes two changing values. The capture proves the selector is live. The launch chord, branch conditions, localized messages, portraits, audio and clean return still need their own mapped gates.

The target deliverable is a graph: match/opponent conditions enter an intro or rival branch; message and portrait nodes advance; scroll and audio events fire; the ending reaches credits or title and terminates cleanly. Three screenshots from one branch cannot certify that graph, so the receipt keeps branch, locale and ending completeness false until every required edge is replayed.

Machine room — where AI helps, and where it is not allowed to vote

The data is now shaped for machine assistance without outsourcing truth. The comparator compresses repeated frames, compares transition pairs, reports frame-span differences and points to the first source/destination semantic divergence across P1, P2, partners, children, inputs, globals and story indexes. That foundation can support several higher-leverage tools:

  • sequence alignment that tolerates a different frame rate while preserving event order;
  • clustering unknown actions by animation, boxes, projectile family and QSound signature;
  • active learning that schedules the uncaptured move or reaction with the highest coverage value;
  • static/dynamic call-graph ranking from a changed RAM field back to likely 68000 writers;
  • pixel-level assembly checks for missing pieces, ground origin, facing and palette; and
  • story-graph reconstruction from ending/message ids and observed branch transitions.

An AI model can summarize a divergence, propose the next probe or rank suspicious code. It cannot change TRIAGE_ONLY to PASS, waive a missing super, or call a silent QSound command audible. That boundary keeps the automation useful precisely when it is wrong.

Occupancy permit — the evidence ladder

GateHonest conclusion
Menu and requested room decodePASS_CAPTURE: the tool opened and data was collected
Imported pointers and rows are traversedThe engine reached the intended data
Boxes/projectiles correlateGeometry or child behavior is close enough for targeted testing
Named command, contact and outcome passThat one move lane works under the published setup
Audible source/destination sample gate passesThe correct voice/SFX is present, banked and heard
Full isolated battery plus stock regression passesThe published moveset can be called complete

This is the ground-breaking part for the project: the lab makes partial success useful without letting it masquerade as completion. A failed Magneto special is no longer “it still feels like Captain America.” It becomes “the command never leaves the donor route,” “the right group runs but no $001C child exists,” or “the QSound command fires but the imported sample never becomes audible.” Each is a smaller, testable engineering problem.

The current Magneto receipt is a good example of that honesty. His exact XMvSF palette, MAGNETO HUD identity and private hypnotic idle now pass. His select pose is still static, and attack/effect assembly, specials, persistent Flight, supers, throws, source voices and QSound effects remain open. The harness improved the diagnosis; it did not promote unfinished behavior to a finished port.

The implementation and reproducible commands live with the research code in the maintained CPS-2 Character Lab document. The rest of this article preserves how the hidden foundation was found and what the first full roster harvest taught us.

Archaeology: finding the foundation

A question came in from a different direction entirely: could Street Fighter Zero (CPS Changer) help this project? It reportedly has a debug mode that unlocks a lot of the game.

The answer was no, twice over -- it's CPS-1 hardware, and a structural test I ran across six Capcom fighters found the per-character animation directory in every CPS-2 fighter and nothing in the CPS Changer ROM at any threshold. Wrong hardware, wrong data.

But chasing that down surfaced the thing that mattered: a survey of CPS-2 debug switches listing the games that have them. Marvel Super Heroes vs Street Fighter and Marvel vs Capcom are both on it. The games I've been reverse-engineering all along ship with a debug mode.

The Search That Failed, And Why That's The Interesting Part

The write-up gives the master debug flag as a word at ROM $C0, set to $FFFF -- for X-Men vs Street Fighter "and most Marvel games". It also notes X-Men: Children of the Atom uses $D24 instead. So the address varies, and I couldn't just assume it.

First pass, static. In the data view, $C0 is a zeroed word in both games -- consistent with a defaulted-off flag, but weak evidence; plenty of words are zero. So: find the code that reads it. I searched every absolute-addressing form -- absolute short (4A78 00C0, 3038 00C0, ...), absolute long (... 000000C0).

Nothing. In either game.

I wrote that up as "unconfirmed, resolve it live." Which was the right call, but for a slightly wrong reason -- I'd framed it as maybe these games use a different address. The real problem was my search. Here's what the game actually does:

000EAA  movea.l #$c0, a0            ; debug block base into a register
000EB0  tst.b   $4(a0)              ; sub-flag at $C4
...     move.b  $5(a0)..$f(a0), $79xx(a5)
000F2C  move.w  (a0), -$3b74(a5)    ; the MASTER FLAG word at $C0
000F30  tst.w   -$3b74(a5)
000F34  bpl.b   $f94                ; bit 15 clear -> skip the entire debug path

The flag is read register-indirect through a0. The literal $C0 appears once, as an immediate operand to movea.l, and every actual access is (a0) or $4(a0). No absolute-addressing pattern could ever have found it.

That's the generalisable bit: a negative result from a pattern search is only as strong as the addressing modes the pattern covers. I'd have kept believing "no reference exists" indefinitely.

Letting The Game Answer

The fix is to stop asking the ROM and ask the running machine. MAME's Lua API can install a read tap over an address range and fire a callback on every access:

local space = cpu.spaces['program']
local tap = space:install_read_tap(0x0, 0x1FFF, 'lowrom', function(offset, data, mask)
  local e = seen[offset]
  if not e then e = { n = 0, pc = pc() }; seen[offset] = e; order[#order+1] = offset end
  e.n = e.n + 1
  return data
end)

Tap the whole low-ROM region, log which addresses get read and from which PC, run the boot and attract sequence headless. One 59-second run:

$0000C0  reads=1        first_PC=$000F2E
$0000C2  reads=1        first_PC=$000EC4
$0000C4  reads=4        first_PC=$000EB2
$0000C6  reads=3        first_PC=$000ED6
$0000C8  reads=114      first_PC=$0006E2
$0000CA  reads=2        first_PC=$000EE8

There it is. $C0 is read exactly once, from $000F2E -- and it isn't alone. $C0 through $CF form a whole debug flag block, read by a cluster of code in $000EB2-$000F2E. Disassemble there and the structure above falls out immediately: $C0 is the master word, $C4-$CF are per-feature sub-flags that get copied into work RAM.

Only bit 15 of $C0 matters. The test is tst.w followed by bpl -- branch if plus. $FFFF works because it happens to set the top bit, not because the value itself is special.

The debug block // sixteen bytes at the bottom of the ROM
123 · per-feature sub-flags$0000C0$0000D0
  • 1
    master flag word
    $0000C0$0000C2 · 2 B
    Only bit 15 is tested — tst.w then bpl. Clear it and the entire debug path is skipped. Read exactly once, from $000F2E.
  • 2
    second word
    $0000C2$0000C4 · 2 B
    Also read once, from $000EC4.
  • 3
    per-feature sub-flags
    $0000C4$0000D0 · 12 B
    Copied into work RAM at $79xx(a5) by the cluster at $000EB2–$000F2E. Read counts over one 59-second boot: $C4 four times, $C6 three, $C8 one hundred and fourteen, $CA twice.
No absolute-addressing search could have found any of this: the literal $C0 appears once, as an immediate to movea.l, and every access after it goes through a0.

And both games have it, in near-identical code:

What's Behind The Door

Set the flag, toggle Service Mode with P1 Button 1 held, and:

Marvel vs Capcom SECRET TEST MENU listing HIT EDITER, CATCH EDITER, ENEMY SENSEI, ENDING TEST, MESSAGE TEST, SCRL MOVE TEST, three SCRL BLOCK entries, KAO TEST, HIT TEST and EXIT
SECRET TEST MENU // twelve entries Capcom left in the retail ROM
The HIT EDITER screen in CHAR SELECT MODE, showing character and pattern names, Char Gp, PosX/PosY, CG_Cnt, Now_Cnt and CG_Ptr, with HIT 0 through HIT 7 hitbox slot bars and a character sprite centred on a debug grid
HIT EDITER // character slot, pattern and live geometry exposed by the game

Twelve entries: HIT EDITER, CATCH EDITER, ENEMY SENSEI, ENDING TEST, MESSAGE TEST, SCRL MOVE TEST, three scroll-layer viewers, KAO TEST, HIT TEST, EXIT.

HIT EDITER is the one I care about, and it is almost comically on-the-nose for this project. Per side it displays:

  • The character/debug-slot name, in plain text. The ROM's 23×16 name table at $0DBE72 literally supplies 00 Dachi. Interpreting dachi/tachi as Japanese “standing” terminology is plausible, but the binary supplies neither kanji nor the English gloss STAND FOOTWORK; that expansion is interpretation, not a shipped second label. Pattern names come from the separate $0E12CC directory, so Dachi must not be presented as a particular animation name.
  • Char Gp -- the animation group index. My first instinct was that this is the L1 index of the two-level animation tree I've spent this series walking. It isn't, or at least not straightforwardly -- see the name-table section below, where the arithmetic refuses to line up.
  • CG_Ptr. -- the live graphics pointer. That is the spritelist pointer each 16-byte animation record carries at +4.
  • CG_Cnt. / Now_Cnt. -- frame counters, with the total in parentheses.
  • HIT 0-HIT 7 -- all eight hitbox slots, as bars.

The screen exposes character and pattern labels beside the live group, pointer, counters and geometry. Those are related name spaces, not one interchangeable animation-name table.

Does The Saturn Port Have It Too? No.

The obvious follow-up, given the Saturn port is a source-level recompile of this same codebase: did the debug menu come along for the ride?

It would be a reasonable bet. The Saturn disc is more open than the arcade board, not less -- and this is worth stating precisely, because the two are often conflated. CPS-2 encrypts opcode fetches only, so its data tables are already plaintext in the raw ROM; only code is scrambled. The Saturn disc has no encryption at all. Its SH-2 code and its data are equally in the clear. What it has instead is compression -- PAC containers, an LZ variant, and the bit-packed sprite codec -- which is an obstacle you can simply undo, and I already have the tools that undo it.

So I searched all of it: 414 disc files plus every decompressed section from every PAC -- 552 files in total -- for HIT EDITER, CATCH EDITER, KAO TEST, ENEMY SENSEI, MESSAGE TEST, STAR TEST, DIPSW, and the engine's error strings.

One hit. NO CG.., in MM.BIN. That's an engine error string, not a menu -- and it's the same one already noted in the original audit.

The debug menu did not survive the port. Capcom compiled the game for retail Saturn hardware; the dev tooling stayed behind. Which is a small disappointment and a useful fact: for anything that needs Capcom's own labels, the arcade ROM is the only source, and the Saturn is downstream of it.

The Better Prize: The Labels Are Just Sitting In The ROM

Hunting those strings meant first finding them in the arcade ROM, to know what to search for. They're plain ASCII. And they aren't alone.

At $0E1308 in Marvel vs Capcom sits a 256-entry, 20-byte fixed-width table of animation names, in order:

group   0  STAND FOOTWORK          group  45  STAND SMALL PUNCH
group   1  FRONT WALKING           group  99  DAMAGE HEAD SMALL
group   2  BACK  WALKING           group 108  DAMAGE ASHIBARAI
group   7  VIRTICAL JUMP           group 115  BUTTOBI +90.0
group  13  VIRTICAL SUPER JUMP     group 127  KIRIMOMI DM UP
group  23  SIT DOWN                group 132  UKEMI
group  28  STAND GUARD             group 134  PIYO STAND

MSHvSF has the same table at $0BBC78. And MvC carries a character table at $0DBE72 with Capcom's internal names -- 00 Dachi, 08 RORIROROOL, 0A COMANDO, 0E STR, and, wonderfully, 05 GAMBIT? with the question mark shipped in the retail ROM. The 2 suffixed entries (12 VENOM2, 13 HULK2, 15 CHUN LI2) line up exactly with the duplicate rows I'd already found structurally in the tree directory.

So the labelled ground truth doesn't need the debug menu at all. It's a static table, extractable in seconds, for both games. tools/anim_names.py dumps it.

Where I got ahead of myself

Above I said Char Gp is the L1 index of our animation tree. Checking it properly, that doesn't hold: the name table has 256 entries, but each character's tree has 31 L1 groups (33 for one, 62 and 29 for two bosses). I then guessed the names might index flattened (group, sub) pairs -- 31 groups times ~8 subs is suggestively close to 256 -- but that fails too. Actual flattened sub counts per character run 268 to 551, mean 504. Neither model fits.

What's solid: the table exists, it's complete, it's ordered exactly like a fighting game's animation set, and the Hit Editor renders one of its strings. What's not established is the mapping from name index to tree coordinates. That mapping is the real remaining work, and I'd rather say so than ship a tidy sentence that happens to be wrong.

The Switches Are A Different Story

The same source documents three banks of debug DIP switches at $8040B0-$8040B2 -- grid overlay, hitbox display, slow motion, stop, invincibility -- which existed only on Capcom dev hardware. MAME 0.287 does expose :DSWA, :DSWB and :DSWC ioports, and I initially assumed those were them.

They aren't. With the master debug flag set, the game genuinely reads $8040B0 and $8040B2 (222 and 111 reads in a headless run). But those addresses return $FF no matter how MAME's DSW ports are set -- the production CPS2 driver hardwires the region to all bits, which is exactly what the researcher who found the switches described. Mainline MAME deliberately doesn't emulate a dev board it doesn't have.

The workaround is the same trick as before, in reverse: instead of tapping reads to observe, tap reads to substitute. A read tap over $8040B0-$8040B3 can hand the game whatever switch values you like, no custom driver required:

_TAP = cpu.spaces['program']:install_read_tap(0x8040B0, 0x8040B3, 'devdip',
  function(offset, data, mask)
    local v = banks[offset - 0x8040B0 + 1]   -- active low: clear a bit to enable
    if (mask & 0xFF00) ~= 0 then return (v << 8) | (data & 0xFF) end
    return (data & 0xFF00) | v
  end)

Switch 1:1 -- "grid overlay and some values" -- lights up immediately:

Marvel vs Capcom attract-mode fight with the developer debug grid overlaid across the screen and a column of hex coordinate and velocity readouts down the left edge
SW 1:1 // dev grid and live value readouts, via a read tap in stock MAME

Honest limit: I did not get hitboxes to render. The published switch map is documented against X-Men vs Street Fighter, and SW 2:2 produced nothing visible in MvC's attract demo. It may be a different switch here, or it may need real gameplay rather than the demo. Unverified, and labelled that way.

All Twelve Sections, And What Each One Is For

Getting in is one thing; knowing what the menu can do is another. The controls are undocumented, and -- this is the part that cost me time -- they are different in every section. So rather than guess, I opened each of the twelve entries, fired sixteen input combinations at it, and diffed every result against a baseline screen. Whatever changed tells you what that input does.

1. HIT EDITER    2. CATCH EDITER   3. ENEMY SENSEI   4. ENDING TEST
5. MESSAGE TEST  6. SCRL MOVE TEST 7/8/9. SCRL BLOCK
A. KAO TEST      B. HIT TEST       C. EXIT
SectionControls foundWhat it gives you
HIT EDITERUp/Down = animation group; Left/Right = animation within it; B6+Up/Down = character; B1 = play; B4+direction = move the spriteCapcom's own name for every animation group, plus CG_Ptr and frame counts
CATCH EDITERRight = next CATCH/HOLD pair (not Down); Left = back; B6+Up/Down = character; B1 play, B2 stepThrow data -- and it displays a pair
ENEMY SENSEIB1 = TAIKI TABLE, B2 = OKIAGARI TABLE, B3/B4 = LIBRARY TABLEThe CPU AI tables -- idle behaviour, wake-up behaviour
MESSAGE TESTUp/Down and Right step the message indexMessage/string bank browser
SCRL 1/2/3 BLOCK, SCRL MOVEUp/Left step valuesStage scroll layers
KAO TEST, HIT TEST--Graphical; they render, but there is no text to read
ENDING TEST--The cursor lands on it but the select never takes under automation

Two of those repay the effort immediately.

CATCH EDITER advances on Right. I had swept it with Down and produced thirty-one identical screenshots, then written a confident note that its control mapping was "unresolved." It wasn't unresolved; I was pressing the wrong button.

And ENEMY SENSEI exposes the CPU AI tables. Nothing I've read about this menu mentions them. For a character port that's the behaviour layer you eventually have to supply, and here it is, named.

Throws are a two-character interaction

CATCH EDITER doesn't show a move. It shows a pair:

LEFT   15 SAKURA   CATCH 00   Char 20 00   Now_Cnt 00/35
RIGHT  01 CYCLOPS  HOLD 00    Char 22 00   Now_Cnt 00/00

The thrower runs CATCH (group $20) while the victim simultaneously runs HOLD (group $22), each with its own relative-position offsets. That reframes what porting a throw means: it isn't just the thrower's animation, it needs a working HOLD on every opponent she can throw. The ROM alone would not have told me that.

The number the menu shows is the number the code indexes

Sweeping the roster produced a bridge I'd been deriving the hard way. The editor's character number, doubled, is the engine's internal char-id. Dark Sakura displays as 15 and the engine indexes her at $2A; Shuma displays as 05 and indexes at $0A. Free translation between what's on screen and what's in the disassembly.

With controls mapped and the screen readable, the editors stop being screens and become an export format: sweep both editors for both ported characters, OCR every frame, parse the fixed field layout, write JSON. Seventy-six records at full field coverage, one character per 53 seconds. The lattice-removal and exact-glyph debugging work is preserved in the archived harvest notebook, while the maintained pipeline and conclusions now live here.

It earned its keep by explaining a bug

The point of naming things is that names let you check work. HIT EDITER calls Dark Sakura's animation group $2C SPECIAL 00. Independently, with no OCR involved, her fireball routine in the source ROM ends:

moveq #$2c,d0
jmp   $1175e.l

Requesting that exact group. A screen read through a hand-built glyph atlas and a disassembly of encrypted arcade code, arriving at the same answer from opposite directions. That convergence is what made it safe to trust the next step -- which found that her special-execution code had never been ported at all, because the engine indexes a 23-row table with a character id one row past the end. That story is in the native port post.

Both Games, Every Character: What The Sweep Found

With the controls mapped and the lattice killed, the editors become an export format. So I swept all 26 characters through both editors in both games -- 104 emulator runs, 3,224 screenshots, 1,981 parsed records.

Two findings, and the second one is the reason this project exists.

The animation vocabulary is shared

Both games use exactly 30 animation group ids, and the sets are identical -- there is no group in MSHvSF absent from MvC, or the reverse. A ported character's groups therefore map 1:1 by id; the taxonomy is common and only the per-character sub-tables differ. Mean groups reached per character: 28.2 in MSHvSF, 29.3 in MvC.

That is genuinely good news for porting. The hard part was never translating what a group means between games.

The menu confirms the roster ceiling, independently

MvC's editor cycles 23 characters and then wraps -- char23 returns to the first entry. MSHvSF's cycles 26.

That is the same 23-vs-26 gap I found by disassembling the per-character handler tables: MvC's at $00E726 holds 23 rows, MSHvSF's at $00D3BC holds 26. Two completely independent methods -- reading a debug screen through a hand-built glyph atlas, and disassembling encrypted arcade code -- landing on the same pair of numbers.

Every past-end bug in this port descends from that difference. The specials dispatch reads one row off the end of a 23-row table. The getup roll borrows an adjacent group's offset because the subtable it wants isn't there. Same root, different symptom.

What this dataset is, and what it is not

Every record is frame 0 of its group (Now_Cnt 00/NN). This is a group inventory -- Capcom's name, Char id, CG_Ptr, total frame count.

It is not a hitbox dataset. The HIT_0..7 slots captured are frame 0 only and change as an animation plays, so nothing here describes a move's reach, its box geometry through active frames, or how far it travels. B1 plays an animation and B2 steps one frame; neither was used. A per-frame capture costs roughly 20-30x these 104 runs, which makes it a targeted job -- specials, catches, the getup chain -- not a roster-wide one.

The one thing the menu cannot do

MvC's HIT EDITER cannot reach the ported characters at all. Its cycling is bounded to the stock ids $00-$2C, so Dark Sakura ($2E) and Shuma-Gorath ($30) never appear in it.

Which is a neat, slightly annoying symmetry: the debug tool is limited by the exact same roster ceiling as the engine it debugs. A source-vs-port comparison through the menu is therefore impossible for the two characters I most wanted it for -- the source side comes from MSHvSF's editor, but the port side has to come from static tree reads.

Extending the editor past 23 would fix that, and is on the list. It needs the editor's own character-stepping code located first, which is not where constant-matching says it should be.

Getting 104 runs to actually finish

Worth recording, because the first three attempts produced almost nothing.

MAME instances hang at 0.0% CPU and never honour -seconds_to_run -- one sat for 1 hour 59 minutes, another for 21, each silently blocking the queue while I assumed the sweep was merely slow. The fix is to bound every run externally on wall-clock time and kill it (macOS has no timeout, so that is hand-rolled).

And never guard concurrency with pgrep: mine slipped through the gap between runs, so both games ran simultaneously and starved each other -- which is what made the MvC runs look like a navigation failure when the navigation was fine all along. One script, both games, sequential, behind an atomic mkdir lock. After both fixes: 15-21 seconds per run, zero hangs, 104 for 104.

What This Changes

Some honest scoping, because the project rule is no false success claims.

Verified live: MvC, MSH, MSHvSF and XMvSF expose the family of tools described here. The current harness carries separate MvC/XMvSF runtime presets, MSH's different semantic menu map and exact font adapter, and conservative fail-closed profiles where fields remain unmapped. The source XMvSF Magneto census contains 37 HIT and 37 CATCH screens.

Correction preserved: the first MSHvSF attempt was reported as “no bootable set.” The real problem was one missing 20-byte key; two Phoenix-decrypted sets then produced garbage because the emulator decrypted already-plain code. That failure is why the current pipeline fingerprints the program member, key path and disposable output instead of treating a ZIP filename as provenance.

Now solved: the editor's live CG_Ptr, animation/list pointers, hit rows and shape-table bases are sampled together. The name table is no longer floating separately from the tree it describes. World-space rectangles are resolved from the shape ids, while active strike and potential-throw semantics stay separate. P1, P2 and partner timelines, exact inputs, semantic events, global state, narrative catalogs and a fail-closed complete-character ledger now travel with the same receipt.

Still open by design: version-specific active catch-range generation, MSHvSF's projectile pool, XMvSF/MSHvSF phrase rings, and audible sample acceptance. Unknowns are explicit configuration gaps, not borrowed MvC addresses.

There's also a quieter benefit for the Saturn work, and it got larger a day after this post went up. I wrote here that the Saturn "re-authored the sequencing." It didn't -- the arcade animation tree is carried onto the disc and the Saturn engine walks it, compacted from 16-byte to 6-byte records. Which makes Capcom's own labels for the tree far more valuable than a cross-check: they name the same structure on both machines, so mapping a name to a tree coordinate on the arcade side maps it on the Saturn side too.

Two MAME-Lua gotchas, so you don't lose the hour I did

Drive ioport fields with field:set_value(v). Assigning field.field = 1 doesn't error -- it silently kills the frame notifier, so your script appears to run and then does nothing.

Snapshots need a real video target. -video none is great for tracing but produces no images; use -video soft -window when you want screenshots.

Where this connects

The animation tree this menu labels is the same structure the Shuma-Gorath native port relocates into MvC -- and, as the Saturn post's correction now records, the same one the Saturn port carries and walks. Three machines, one tree. The detour that started this -- "can the CPS Changer version help?" -- was a dead end on its own terms, and still worth taking.

Written by Daniel Plas Rivera · 7,233 words · $19

ShareXLinkedIn