case study17 min read$39

Strider Hiryu Is Standing Where the Logo Was. Twenty Patches Failed First Because the Sprite Was Animated.

The American Marvel Super Heroes vs. Street Fighter fills one select-screen box with its own logo. It now holds Strider Hiryu — portrait, name plate, bust and standing figure. Getting there meant retracting three confident answers, including one that blamed the graphics ROMs for a crash caused by a bad pointer.

cps2mshvsfmarvel-super-heroes-vs-street-fighterstridernorimaro
CPS-2 Reverse EngineeringPart 45 of 45
Browse all writing
On this page

The American arcade board for Marvel Super Heroes vs. Street Fighter has one box on the character select that holds no fighter. Bottom row, middle. It shows the game's own logo.

Last time I showed that the box is not empty — it is Norimaro, the Japan-only character, switched off by a single byte in a defaults table.

This time the box holds Strider Hiryu.

The bottom-centre select-screen box, before and after. Left: the MARVEL SUPER HEROES vs STREET FIGHTER logo. Right: Strider Hiryu's portrait, in his own colours.
Same box, same ROM, on a credited select screen. Left is stock. Right is twelve tiles, one sprite-list word and one palette.

The box is not a tile, and calling it one cost three builds

The grid is built at $06CF62: index a cell-art table by col*6 + row, add the self-relative word found there, and jsr the draw routine at $001F0A with the resulting 12-byte descriptor. Every descriptor has the same shape, and only one field differs between cells — so I called that field the tile base and wrote tiles at it.

Nothing appeared. Worse, pointing it at the one patch of virgin space in bank 0 made the game reboot the moment Start was pressed.

I wrote that up as a graphics integrity check: the tile build also took several thousand extra frames to clear POST, which fit. It was wrong, and the one-variable control is what caught it — a build with the identical 64-tile write and the descriptor untouched runs perfectly.

The retraction that paid for itself

Blaming the graphics ROMs compared stock against a build that changed two things. Writing CPS-2 graphics ROMs is safe: the byte diff accounts for the write exactly, the codec round-trips, and a search across 80 candidate sum rules found no graphics checksum at all. What resets the game is the pointer.

Eleven instructions of disassembly ended every open question at once:

$001F0A: move.l  a0, $34(a6)        ; keep the descriptor pointer
$001F0E: move.w  (a0)+, $3c(a6)     ; +0
$001F12: move.w  (a0)+, d0          ; +2   ($8100)
$001F14: move.l  (a0)+, $40(a6)     ; +4   <- a 32-bit SPRITE LIST pointer
$001F18: tst.w   d0
$001F1A: bpl.b   $1f22
$001F1C: andi.w  #$7fff, d0
$001F20: movea.l (a0), a0
$001F22: move.w  d0, $3e(a6)
$001F26: move.l  a0, $38(a6)
$001F2A: andi.b  #$fe, ccr
$001F2E: rts

There is no tile field. What I had been calling "the tile word at +6" is the low word of the long at +4, and that long is the object's sprite-list pointer. Every loose end closes on that one line: the numbered test tiles never appeared because the art lives at codes the probe never wrote to, and the reset was a garbage list pointer killing the walker on the select screen's exit path. Not an integrity check. Not a bank problem. A bad pointer.

The geometry question dissolved too. There is no "N tiles wide from a base" — the list's cells carry code and attr, and attr holds the dimensions and the palette:

nx  = ((attr >> 8)  & 15) + 1
ny  = ((attr >> 12) & 15) + 1
pal =   attr        & $FF

The logo's cell is code $B61C, attr $2311 — four wide, three tall, palette $11, drawn from bank 1, row pitch 16. Rendering $1B61C as 4x3 at stride 16 reproduces the logo exactly. Banks 0, 2 and 3 and stride 4 all produce noise. That agreement against a known answer is the only reason to trust the rule anywhere else.

The portrait was not in the ROM I was reading

With the mechanism understood, the remaining job was Strider's actual face — and the first attempt put his bomb in the box. The art I seated was gameplay art, the paraglider, and it looked plausible enough in a grayscale tile render to survive until it was on screen.

So I went looking for Marvel vs. Capcom's real portrait table, found a clean one — sixteen one-cell 3x2 lists at stride 14, each carrying its own palette $01 through $10 — and could not identify which index was Strider. Four methods failed. The fourth failure is the useful one: an exact palette-bijection match, which should be trivially exact, returned 0 of 16, because that grid is drawn as a perspective trapezoid and the portraits are scaled. No pixel-identity method can work against that screenshot.

The answer was already written down, in a tool this project had used before and I did not open. PalMod's MVC_A_DEF.h lists sixteen character-select icon palettes at a stride of $20, each labelled with the character's nameStrider Hiryu CS Icon, at file offset 0x3e758, which is $1BE758 in 68k terms. Thirteenth entry. The identification problem I had declared open was a lookup.

Then the table turned out not to drive the screen at all. Repointing all sixteen entries at numbered markers changed nothing; stamping the digits over those entries' own tiles in place changed nothing; MAME confirmed it had loaded the modified set. MvC1's select grid is a scroll layer, not sprites — solid-filling the region blanked the SELECT text and the cursor and left every portrait untouched, which is exactly the control that separates "my edit didn't land" from "that isn't where the art is".

So the art was lifted from the live screen instead: the 46x30 cell interior, requantised to fifteen colours.

That requantisation was the wrong move, and it is worth being exact about why. Median-cut on a screenshot does not recover a palette; it invents fifteen colours that approximate what is on screen. And what is on screen there is not Strider's colours at all — the trapezoid scaling that defeated the bijection match also means those pixels are interpolated blends of neighbouring source pixels. So the output approximated blends. The build printed its own error the whole time — quantise err 13.7/255 — and I read that as a tolerance rather than as a symptom.

The shipped build uses the labelled palette instead: Strider's sixteen real CS-icon words, read straight out of $1BE758 and written through unchanged. Two candidates were scored against the cell before either was used — the CS Icon entry explains 1,238 of 1,380 pixels (89.7%) as exact matches, the CSP/Duo entry at $1D2F02 explains 795 (57.6%). The residual 10% is the scaling blend, which is the answer you would predict if the palette is right and the art is still coming from a scaled screenshot. It is.

Two things the codec had documented backwards

Transparency on this hardware is palette entry 15, not 0 — entry 0 is white, and it is what draws the logo's lettering. Building the mask as pen == 0 punched that lettering through the replacement art as holes.

And the pen mapping is not "15 − stored pen"; it is a 4-bit reversal (1→8, 2→4, 3→12, 4→2, 8→1). Scored against the stock logo, the reversal mismatched 1,441 of 3,072 pixels against 2,707 for the documented rule, and the per-pen table is an exact bit reversal on all sixteen values.

The palette's ROM source resisted an exact search because the game ORs $F000 into every runtime word. Masked to $0FFF, there is exactly one hit.

The full select screen with the cursor on Wolverine. The bottom-centre grid box holds Strider's portrait while the name plate reads WOLVERINE and the bust shows Wolverine.
The box does not need the cursor on it. Here the cursor is on Wolverine and Strider's portrait is already sitting in the grid — the cell art is chosen once, when the screen is built.

The name plate is stored backwards

The text under the highlighted cell is one 1x1 sprite per letter: palette $01, y = 208, twelve pixels apart, code = $5210 + (letter − 'A').

Seven attempts guessed at addresses. The eighth measured, and the first thing measuring corrected was the record layout itself. Object RAM records are (x, y, code, attr)x first. Of 226 non-zero-code records in one frame, x-first puts 208 on the visible screen; y-first puts 91. Every earlier read had the two words swapped, which is precisely why the plate "wasn't there".

The CPU never writes those codes. A glyph-filtered write tap across all of main RAM caught nine hits in 550 frames and all nine came from the random number generator, whose muls #$b0 / divs #$7fed lands in the glyph range often enough to look like a lead. The records are DMA'd, and the ROM stores each name last letter first:

+0   longword  glyph count
+4   word      $0000
+6   longword  pointer to a per-length layout block
+10  glyphs    `count` words, REVERSED

Entries are packed with no slack, so changing a name's length desynchronises every name after it. STRIDER keeps the count at eight.

The space glyph is $54EE

The plate's space is $54EE, not $5020. $5020 is a blank in two other fonts on the same screen, and assuming it was this font's space was one of the seven dead ends. What settled it was CAPTAIN AMERICA — the only name long enough to contain a space, sitting in the table as a control nobody designed to be one.

Then the standing figure refused to change

The small full-body character beside your locked-in pick is sprites. I measured its records, mapped which screen cell drew which tile, wrote Strider's art into those tiles — and the figure kept its original shape while taking on Strider's colours.

So I measured again. Same answer. I checked the tile base by stamping seven candidate offsets at once, each in its own colour, and the figure changed for none of them. I tried to fit the multi-tile geometry: every combination of horizontal flip, vertical flip, column step ±1 and row step (±16, ±width, ±(width+1), ±height) against four block-origin conventions — 384 rules. The best reproduced 4 of 18 known cell-to-tile pairs.

I wrote it up as unreachable. The pieces clearly weren't derivable from the record, so the asset must belong to the animation path, which is the full character-port problem rather than a cosmetic swap.

That was wrong, and the reason is worth the whole post.

The asset was animated, and I was reading the wrong frame

Sampling the object list every six frames for two seconds gives eleven distinct poses across 66 sprite codes. The map I had measured belonged to whichever pose was on screen in the snapshot I read — and because the probe wrote several snapshots and I read the last one, that was a different pose almost every time.

Everything downstream inherited it. The geometry looked unfittable because I was scoring a correct rule against mismatched reference data. Nine of eighteen tiles looked like "background" because they belonged to a pose that wasn't being drawn. Two dead ends, one mistake, two costumes.

The symptom, stated so you can recognise it in ten seconds

If your patched art appears somewhere on screen but the target keeps its original shape in the new colours, you are patching a frame that is not the one you are looking at. Freeze the animation before you measure anything.

Once you know it's animated, the fix stops being an import and becomes a data edit. The poses are ROM sprite lists: searching for a pose's record bytes finds nothing, because positions and palette bits are applied at runtime, but searching for its code sequence at stride 4 hits exactly once. All eleven measured poses matched lists in one region, exactly.

The pointers to those list headers then all live in a single run, sixteen bytes apart — a pointer, some constant words, a duration. That run is the animation script. Point all thirty-one rows at one pose and the figure stops moving, and a single pose's tiles become sufficient.

Re-measured on the frozen build, with every sampled frame required to agree, the map came out clean at twenty cells:

tile = code + $20000 + row * $10 + (width - 1 - column)

Offset exactly $20000, row step sixteen, horizontally flipped so the code decreases as screen x increases. It reproduces the pose's ROM list entry for entry.

That was the first rule I tried. It was never wrong. My reference data was.

The small standing figure beside the locked-in pick, before and after. Left: Norimaro in a purple suit and glasses. Right: Strider Hiryu in his red scarf and sash.
Left is stock. Right is the same twenty tiles I had been writing for twenty builds — measured once against a frozen animation instead of a moving one.

Asking the screen a question only the code can answer

The large bust came out desaturated, borrowing the donor's olive and grey instead of Strider's reds. To find out whether its palettes were safe to patch, I gave each of the five candidate palettes a saturated marker colour and looked at what each one painted. Two painted nothing inside the bust; one straddled its edge. I concluded none was exclusive to it and the route was closed.

Then I write-tapped palette RAM instead. One instruction does all 560 writes, and the routine around it says everything:

movea.l #$001FEDAE, a0      ; source table
muls.w  #$0050, d0          ; 80 bytes per character
adda.w  d0, a0
move.w  #$0027, d0          ; 40 longwords = 160 bytes = five palettes
.loop
move.l  (a1), d1
andi.l  #$F000F000, d1      ; keep the brightness nibbles
or.l    (a0)+, d1
move.l  d1, (a1)+
dbra    d0, .loop

It copies 160 bytes out of a table whose stride is 80. Entries overlap by design. The block was correct all along; only the copy length made writing all five unsafe, because the back half belongs to the next characters. The first eighty bytes — palettes zero and one — are exclusively this character's.

The large character bust, before and after the palette patch. Left: olive and grey, wearing the donor's colours. Right: Strider's red scarf and correct skin tones.
Left borrows the donor's palette. Right patches only the two palettes the loader's arithmetic proves are this character's.

The marker probe answered what does this palette paint, which is a fact about the screen. Whether a ROM block is safe to write is a fact about the loader's arithmetic. The right question, the wrong oracle.

What it costs to be sure

Every claim here is pinned to a change on screen, and the ones that only ever looked right in a render are the ones that wasted the most time. Rendering the standing figure's tiles at one candidate base produced a perfectly coherent picture of the donor, which I took as confirmation. It was coincidence: that art exists at that ROM location, but the hardware draws from elsewhere. Stamping the same tiles and running the game changed nothing, and the game is the only witness that counts.

Two harness defects nearly ate the whole project. The first: an input control hard-coded a port name that does not exist on CPS-2, and the resulting error fired inside the frame notifier where MAME swallows it silently — so every select-screen run produced no frames and looked like a failed patch. The second: cpu.state['PC'] is not a valid key on this driver, only CURPC, and it failed the same silent way.

And every "select screen" in my notes before a certain point was the attract-mode demo grid, because the coin never registered. CPS-2 wants a long coin hold: an 8-frame pulse does nothing, 120 to 150 frames gives credits.

Reusable, if you are doing this to another character

In order, because the first two are cheap and protect everything after them.

  1. Is the object list I am reading live? CPS-2 renders to an end-of-table marker, not to the end of the buffer. On one frame here the terminator sat at record 204 of 1,024 — about eighty percent of what I had been scanning was stale.
  2. Is the asset animated? If yes, freeze it before measuring anything.
  3. Where is each pose's sprite list? Search the code sequence at stride 4, not the records.
  4. What sequences them? Find the pointer run; that is the script.
  5. Only now measure the cell-to-tile map, and patch.

The instrument I should have used first

Everything above about the portrait works from a screenshot, and a screenshot of that grid is the worst available view of it: scaled, interpolated, and drawn by a scroll layer whose tile numbering is offset from the sprite numbering by an amount nobody here has measured.

Capcom shipped a better instrument in the ROM. CPS-2 development boards carried three extra DIP banks and a secret test menu — hitbox editor, throw editor, AI tester, and a raw ROM tile viewer that draws graphics 1:1 with a page index. Production boards read those switches as all-off and the menu is unreachable, which emulators reproduce faithfully.

Two things gate it, and both are needed. MAME 0.259 and newer expose the switches as a machine configuration — :HW_TYPE set to "Development (Enable Debug DIPs)", plus :DSWA, :DSWB, :DSWC. But the switches alone do nothing, because the game also tests a master debug flag held in ROM data; with it clear, the code zeroes the switches and never reads the bus. That flag sits in an unassigned 68000 exception vector slot repurposed as a config block, outside the encrypted opcode span — so turning it on is a two-byte data patch and needs no CPS-2 cipher at all.

Three things that cost a run each
  1. emu.add_machine_frame_notifier returns a subscription. Drop the reference and Lua garbage-collects it, and the notifier silently stops firing after about one frame. It looks exactly like a patch that did nothing.
  2. Errors thrown inside that notifier are swallowed by the emulator. Everything goes in a pcall with a log, or a typo reads as a failed hypothesis.
  3. P1 Button 1 is both the secret-menu gate and the menu's select button. Held across the Service Mode flip it lands on the ordinary menu root; held in a window after the flip it opens the secret menu; held a moment too long once a menu is up, it selects item 1.

The same flag address, the same window and the same switch configuration open the menu on both games measured here — the one the art comes from and the one it is going into. That is the control that says the mechanism was understood rather than fitted to one ROM.

Where it stands

The full select screen. The name plate reads STRIDER, the large bust is Strider in his red scarf, the small standing figure is Strider, and the bottom-centre grid box holds his portrait.
All four assets at once, on a credited select screen. The background is verified unchanged, not assumed — by counting a signature colour across every sampled frame and getting the same number each time.

Name plate, grid box, bust and standing figure are all Strider. Boot checksum self-located and unchanged on every build. The one-word portrait change diffs against stock in exactly one 55x43 region on all 21 frames and nowhere else.

Still borrowed: half the bust's tiles, because the other three palettes in that 160-byte window are not this character's to take. Closing that means following the second source table the loader can select, which is a fresh thread.

Still open, and this is the honest list. The art in that cell still comes from a screenshot — the palette is now exact, the pixels are not, and closing that means reading the icon's tiles out of the ROM through the tile viewer rather than off the screen. The scroll-layer tile-number offset is the same question wearing a different hat. And everything past the face — tree, animations, hitboxes, move properties, code — is untouched, which is the actual port.

He is not playable. Selecting that slot still gives you the character whose record lives there, wearing Strider's face. He is standing in the grid, named, in a game he was never in, and the box that held the logo does not hold it any more. That is all this is, and it is worth saying plainly, because a screenshot of a select screen is very good at implying otherwise.

Written by Daniel Plas Rivera · 3,666 words · $39

ShareXLinkedIn