case study10 min read$1C

Reading The Debug Menu Like A Database

Getting into Marvel vs. Capcom's secret test menu was the easy part. Turning it into data meant writing an exact-glyph OCR, and every hard bug in it was self-inflicted: a threshold one unit too high clipped the bottom row of every glyph, which manufactured 100+ phantom characters and quietly corrupted two rounds of human labelling. What came out the other side is the moveattr record format -- stride and field offsets, verified against 125 decoded rows -- plus a way to drive the CPS-2 debug DIP switches that the published write-up says requires a custom MAME build. And the MSHvSF romset I said wouldn't boot was missing a 20-byte key.

cps2reverse-engineeringmarvel-vs-capcommshvsfdebug-mode
CPS-2 Reverse EngineeringPart 17 of 17
Browse all writing
On this page
Evidence Strip

Goal: Turn the CPS-2 secret test menu from something you screenshot into something you query.

Constraints: The debug text isn't in RAM in any readable form -- an offset-invariant search for it found nothing, so the font tiles aren't ASCII-ordered. That leaves reading the screen.

Approach: Exact 8x8 glyph matching (the renderer is pixel-perfect, so it's matching, not recognition), bootstrapped from a screen whose text is already known, then validated against a structural invariant the data itself must satisfy.

Result: 23 characters x 128 rows x 7 named fields, every character at 100% coverage. The moveattr record format derived and verified: stride $20, Dmg +$00, Piy +$06, Sto +$07, Snd +$08, Slp +$09, Shc +$10, Grd +$18.

Proof / Validation: Every row-index transition across all 34 captured pages steps by exactly +1 (mod $80) -- 748 of 748.

The thing I got wrong first

The obvious way to read the menu is not to read the screen at all. The text is drawn from a tilemap; find the tilemap, map tile codes to ASCII, done -- perfect text every frame, no image processing.

So I searched for it. Not naively: tile codes are almost always ASCII + some fixed base, and I didn't know the base, so I searched for strings by their consecutive differences, which is invariant to whatever additive offset the game uses. EDITER, MESSAGE, BLOCK, SENSEI, ENDING, across video RAM and main RAM.

Nothing. The font tiles aren't ASCII-ordered; the game maps characters through a non-linear table. Fine -- that's a real answer, arrived at cheaply, and it pointed at reading the picture instead.

I mention it because the negative result is what justified the whole OCR detour. Without it I'd have been reading pixels while a clean RAM route sat there.

Exact matching, not recognition

The renderer is pixel-identical: every A is byte-for-byte every other A. So this isn't OCR in the fuzzy sense -- it's a dictionary lookup on an 8x8 bitmap, with no error rate. The only hard part is building the dictionary.

I didn't want to hand-label 60 glyphs, so the atlas bootstraps itself: the SECRET TEST MENU screen has known text, so you extract its glyphs in reading order and zip them against the known characters. A line whose glyph count disagrees with its expected character count is skipped, never guessed -- a mislabelled glyph would silently poison every later decode.

That part worked. Everything after it was me debugging my own mistakes.

The bug that cost the most, and the number that was screaming

Two rounds of human-in-the-loop labelling got thrown away.

The first sheets I generated contained fragments -- edges and half-letters -- because my segmenter was cutting glyphs that contain internal gaps. Nobody can name half a letter. That was one wasted pass.

Then I "fixed" it with a per-line phase search, which chose wrong phases and sliced characters down the middle. Second wasted pass.

The actual fix was to stop being clever: a CPS-2 text layer is an 8x8 tile map aligned to the screen, so cell boundaries are multiples of 8. Trust the hardware, don't go looking for the grid.

But here's the part worth writing down. Through both of those rounds, my own tooling printed this:

110 distinct unknown glyphs
...
167 distinct glyphs

For a font with roughly 64 characters. That number is physically impossible, it was in my output twice, and I treated it as noise while building workarounds on top of it -- per-line phase search, cell-relative thresholds, ink-run segmentation, three rounds of constraint-driven relabelling. All of them compensations for one thing.

The one thing: THRESH = 80.

The font has a top-to-bottom gradient, measured at 209 / 209 / 184 / 154 / 129 / 104 / 79. The dimmest row of every glyph sits at 79. A threshold of 80 clipped it. So E lost its bottom bar and became byte-identical to F, and every clipped bottom manufactured phantom variants of characters that were already in the atlas.

Fixing that one number: 108 unknown glyphs collapsed to 21, and total distinct went 167 -> 62. Which is, finally, the size of a font.

The value had originally been 60 -- correct. I raised it to 80 myself, while chasing a colour-invariance theory that my own measurement had already rejected.

The generalisable bit

When a derived quantity is physically impossible, that is the diagnostic. Stop and find the measurement bug. Do not build workarounds on top of it -- I built four, and two of them corrupted correct human-supplied labels to make the data fit.

Validating against something the data can't fake

The reason any of this is trustworthy is that the HIT TEST screen has a structural invariant: it's a 128-row table, so the row index down a page must step by exactly +1, wrapping $7F -> $00.

That check caught three separate errors, two of which were mine:

  • a glyph read as lowercase o was the digit 0 -- it appears 12,437 times, and oo cannot be a value in a hex column
  • an F decoding as E
  • my own wrong modulus (I used mod $100 where the table wraps at $80)

It also caught me twice auto-"correcting" glyphs to satisfy the invariant, each time overwriting a correct human label. And then, when the check still failed, I swung the other way and started doubting the invariant itself.

What settled it was the error distribution. The step histogram read:

step +1: 672    step +0: 38    step +2: 38

+0 and +2 occurring exactly 38 times each is the signature of one confused glyph pair: every time a true 0E renders as 0F you get 0D -> 0F (+2) immediately followed by 0F -> 0F (+0). Perfectly paired. If the index genuinely skipped or repeated, those counts would not match to the unit.

So: the rule was right, and precisely one glyph was wrong. Measure the shape of your errors before you either trust or abandon an invariant.

Final state: 748 of 748 transitions clean, across all 34 pages.

What the data turned out to be

With a decoder you can trust, the payoff is correlation. Take a decoded HIT TEST page and go looking for those exact bytes in the ROM.

Two hypotheses died first, and they're worth recording so nobody retries them: the eight displayed columns are not a packed contiguous record, and not parallel per-column arrays. Both were searched across the whole ROM.

What they are is a 32-byte record, and every column resolves to exactly one byte offset -- checked against 125 decoded rows, with no ambiguity anywhere:

fieldoffsetfieldoffset
Dmg+$00Slp+$09
Piy+$06Shc+$10
Sto+$07Grd+$18
Snd+$08stride$20

Those are Capcom's own field names, straight off the HIT TEST header. A blob that was opaque is now a named, writable table -- including for the two characters this series is porting.

What I could not establish: which move a given row is. I assumed record index equalled the animation pattern index and tested it two ways -- comparing the damage profile of the "normals" band against the "movement" band across several characters, and scanning every index shift from -$20 to +$40 for one that separates them. Both refuted it; the best shift scored +0.13, which is noise. So moveattr has its own move-id space.

I removed the pattern names from the tool. A confidently wrong label is worse than no label, and I'd already shipped it once with them.

A roster with a ghost in it

Sweeping all 23 characters turned up something I didn't expect at slot zero:

00 Dachi        <- zero damage across all 54 normals
01-10           the roster (War Machine ... Rock Man)
11 ONSLAUGHT
12 VENOM2  13 HULK2  14 WARMACHINE2  15 CHUN LI2  16 MORRIGAN2

Dachi is 立ち -- Japanese for "standing." It sits among real character names, and it has zero attack data across every single normal, where every real character has values. That's what an unused template looks like. Three independent signals -- the name, the data, and a placeholder-looking sprite sheet -- pointing the same way.

The tail is the secret alts (Red Venom, Orange Hulk, Gold War Machine, Shadow Lady, Lilith) at $12-$16, which incidentally shows exactly where the stock id space ends -- useful when your ported characters live past it.

Driving the debug DIP switches without a custom MAME build

The original write-up is clear that you can't drive the CPS-2 debug switches in stock MAME, and ships a custom cps2.cpp for it. That's correct, and the reason is visible in the game's own code:

$000D7E  tst.w   -$3b74(a5)            ; master debug flag copy
$000D84  move.b  $8040b0.l,-$3ba6(a5)  ; DIPSWA -> work RAM
$000D8C  move.b  $8040b1.l,-$3ba5(a5)  ; DIPSWB
$000D94  move.b  $8040b2.l,-$3ba4(a5)  ; DIPSWC
$000D9C  not.b   -$3ba6(a5)            ; ACTIVE LOW

MAME's driver forces that I/O to all-ones, which inverts to every switch off. The DSWA/DSWB/DSWC ports MAME exposes are vestigial for this purpose.

But look where the values land: work RAM. So you don't need the switches at all -- you need the three bytes they end up in. Finding them takes one trick: write a distinctive value with bit 15 set to ROM $C0 (I used $8001), then scan RAM for it. The copy sits at a5-$3B74, which gives you a5 = $FF8000, and therefore:

DIPSWA = $FF445A    DIPSWB = $FF445B    DIPSWC = $FF445C

Poke those every frame and the debug features come on. I know they reach the game because my first attempt flipped the screen vertically -- SW1:2 -- which also told me the poke lands before the not.b and gets inverted, so you want the complement of the mask you actually want.

This matters more than it sounds. The menu tools enumerate characters through the stock 23-row roster tables, so they can never see a character ported into a slot past the end. The DIP features render during an ordinary match, where a ported character exists and is playable. It's the one debug facility that works on characters the menu can't even list.

The romset I said wouldn't boot

The previous post said MSHvSF was verified statically but not live, because "I don't currently have an MSHvSF romset MAME will boot."

That was wrong, and the correction is embarrassing in a useful way. I had a perfect mshvsfj set -- zero bad CRCs -- sitting in the ROM folder. It was missing one file: its 20-byte decryption key, which was also already on disk, in a different directory.

romset mshvsfj is good

MSHvSF's secret test menu boots, its HIT EDITER runs, and character cycling reaches Shuma-Gorath and two separate Sakura entries. Since MSHvSF is the source game for this port, that means the original animation data for both ported characters is now browsable with Capcom's own labels.

I'd previously tested two other MSHvSF sets that produced garbage tiles -- they're phoenix-decrypted, so MAME decrypts already-plain data into noise -- and concluded the data was wrong. The lesson: when a romset won't boot, check -verifyroms for a missing file before concluding the data is bad. The difference between "garbage" and "good" was one file I already had.

One incidental confirmation: MSHvSF's menu has STAR TEST where MvC has HIT TEST, and selecting it gives a black screen with no response -- exactly as the original write-up documents. Nice to independently reproduce someone else's dead end.

Still open

Reading the Hit Editor automatically. It draws a bright full-screen lattice in the same colour as the header text, so colour can't separate them. Geometry can -- grid lines span most of a row, glyphs never do -- and stripping them makes the screen segment correctly. But the grid crosses the glyphs, so removing or repairing those rows leaves damaged bitmaps that miss the atlas. Text-only screens decode perfectly; this one doesn't yet.

What indexes moveattr. Probably reached through the animation record rather than by pattern id -- the record's last long is already known to be a box-set index, so the same indirection is the obvious candidate.

Where this connects

The moveattr table this post decodes is the damage and hitstun data for the Shuma-Gorath port, and the MSHvSF menu now booting means its source data can be read with labels rather than inferred. The Saturn work needs the same arcade semantics for a different reason -- to recognise what the Saturn version replaced.

Written by Daniel Plas Rivera · 2,131 words · $1C

ShareXLinkedIn