case studyUpdated Aug 1, 20269 min read$21

Bringing 3rd Strike into MvC: Part 2, Ken Stands Up

Ken's 3rd Strike idle now renders inside Marvel vs. Capcom on Gambit's slot, on a real CPS-2 build. The conversion from 8bpp CPS-3 art to 4bpp CPS-2 tiles, two palette bugs — one that deleted the red channel and one that scrambled every colour while leaving transparency perfect — a bank redirect that had to be abandoned, and the identity assertion that refused a wrong screenshot twice.

cps2cps3street-fighter-iiithird-strikeken
CPS-2 Reverse EngineeringPart 22 of 22
Browse all writing
On this page

Part 1 ended with 133 clean Ken frames on disk and a promise: nothing was in a ROM yet. This part puts one of them in.

Ken's 3rd Strike idle stance now renders inside Marvel vs. Capcom, in Gambit's slot, on a real CPS-2 build running on the expanded core. He is standing there. He does not yet do anything else — and I'll be precise about that at the end, because the gap between "renders" and "is a character" is most of the remaining work.

Marvel vs. Capcom gameplay screenshot: lifebar reads GAMBIT over RYU on the left, WAR MACHINE over MORRIGAN on the right. Ken from Street Fighter III stands on the right side of the stage in his white gi with blonde hair and black belt, alongside War Machine on the Daily Bugle stage
Marvel vs. Capcom, Gambit's slot ($0A). That's 3rd Strike Ken's idle, drawn from CPS-2 tiles converted out of CPS-3 char RAM. The lifebar still says GAMBIT because nothing about identity has been touched yet — only the art and one animation chain.

Five writes

The whole build is five writes, and that is deliberate. The version of this project I inherited scrambled the game partly because it wrote things it had guessed — including 4-byte pointers into a name table whose entry width the builder's own comment admitted it did not know.

Everything here is anchored. Each address was read back out of the stock ROM and compared before a single byte went in:

$0E676C[5] -> $1195EE   Gambit's animation tree      (verified)
$0E67C8[5] == $0006     graphics bank word           (verified)
$1C7268[5] -> $1C84C4   live palette blob            (verified)
$119846                 idle chain, first record     (expect-checked)

That last number is the real cost of the format change. CPS-3 gives 8bpp art; CPS-2 sprites get fifteen colours plus transparency. Ken's idle uses more than fifteen, so 18% of his pixels land on a near neighbour rather than their own colour. That's not a bug, it's the tax, and it should be reported rather than hidden.

One CPS-2 quirk worth carrying if you ever touch this hardware: sprites treat pen 15 as transparent, not pen 0 like almost every other format. Get it backwards and your character becomes a solid silhouette.

The palette bug that deletes red

The first build came out of the verifier looking like this — correct geometry, correct transparency, every tile in the right place, and completely the wrong colours:

After
Before
‹›
Red channel goneAfter int()
Both rendered from the written ROM, not from the source art. Same tiles, same list, same 15 palette entries. The only difference is one cast.

The packing function is three shifts:

return ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4)

The colours arrive from the quantizer as a numpy uint8 array. So r >> 4 is a uint8, and uint8 << 8 doesn't widen — it overflows to zero. Every red nibble was being silently discarded, in a function with no error, no warning, and a perfectly reasonable-looking implementation. Ken came out green because his palette had R=0 in all fifteen entries.

Why the verifier mattered more than the emulator

I found this before booting anything, because the builder renders the sprite back out of the zip it just wrote — decoding the seated tiles, walking the parked list, and reading the palette from ROM. That render is the gate. If it doesn't show Ken, the emulator is not going to either, and debugging it in an emulator would have meant a five-minute round trip per attempt instead of five seconds.

The second palette bug: right transparency, wrong everything

With red restored, the offline verifier showed a perfect Ken. In game he came out like this — legs and belt roughly right, torso a mess:

After
Before
‹›
Straight pen index4-bit-reversed pen index
Same tiles, same fifteen colours, same list. The only change is which palette slot each colour is written to.

It would have been easy to blame the 15-colour budget, or to guess that a second sprite was drawing on top of him. Instead: flatten Ken's entire palette row to one impossible colour, rebuild, and count.

Every pixel of that silhouette is drawn by my tiles through my palette row. Nothing is overlapping him, and the row is reaching the hardware. So the geometry was right, the palette was live, and the colours were still wrong — which leaves exactly one thing: the mapping from pen index to palette slot.

CPS-2 sprite hardware addresses the row with the 4-bit-reversed pen index relative to the codec's decode domain:

0 8 4 12 2 10 6 14 1 9 5 13 3 11 7 15

That permutation is an involution, and 15 maps to itself — which is why transparency looked perfect the whole time while every visible colour was scrambled. Writing each colour to PEN_PERM[d] instead of d fixed it in one pass.

Why the flat-palette test is worth the rebuild

Three plausible stories fit the symptom — quantization loss, an overlapping sprite, a dead palette copy. A flat diagnostic palette distinguishes all three in one run, because it turns a question about colour into a question about pixel count, which is not a matter of opinion.

The bank redirect I had to give up on

Gambit's tiles come from graphics bank 3. The project's allocation ledger had a plan for Ken: point Gambit's bank word at bank 6, out in the expanded graphics ROM, and seat Ken there with room to grow.

I tried it. To avoid blanking every non-idle animation, I first copied the whole of Gambit's stock bank 3 into bank 6, then overwrote only the tiles Ken's idle needed. Careful, and wrong:

Marvel vs. Capcom screenshot with the lifebar reading GAMBIT, but the left side of the stage shows pastel-coloured garbage blocks instead of a character, and a large incorrect sprite in the middle of the stage
Bank word pointed at bank 6. Lifebar reads GAMBIT, the identity assertion passes, the hijacked chain is live — and the character is garbage. The mapping from bank word to expansion tile base is not what the ledger assumed.

So I dropped it. Ken's tiles now go into Gambit's own bank 3, near the top, and the bank word is never touched. That removes an unknown instead of building on top of it. Working out the real expansion bank mapping is its own job, and it belongs to the part of this project where Ken needs more than 32 tiles — not to the part where he needs to stand up.

The harness refused two wrong screenshots

To capture him I need the emulator to reach a match with Gambit selected. Every shot asserts P1's live fighter id at $FF3053 is $0A, and voids the trial otherwise.

It voided twice, and both were real.

First: the identity check reported P1 id=08 not 0A. The select-screen timer had run out and auto-started the match on whatever cell the cursor happened to be on. Without that assertion I'd have had a screenshot of a character that wasn't Gambit, on a build I would then have believed was working.

Second: the cursor genuinely could not reach Gambit in time. Logging every cell the cursor visited gave $12, $1A, $20, $18, $06, $08 — Gambit's $0A is in a row the search never got to before the timer fired.

Writing the id once didn't help either: the game recomputes the hovered id from the cursor position on the next frame. What works is poking the committed slot at $FF40B0/$FF40B1 every frame through the select screen and the transition into the match. Then:

cursor id $0A (f=523)
committed $0A
=== LIVE, settling 210 frames ===
SHOT 0  id=0A anim=119846 list=507E00

anim=119846 is the idle chain this build repointed. list=507E00 is the sprite list it parked. The character on screen is being drawn by exactly the bytes the builder wrote — that's what makes the screenshot evidence rather than decoration.

Two Ken sprites side by side on a dark background: on the left the extracted CPS-3 source frame in full colour, on the right the same pose as it renders inside Marvel vs. Capcom with the stage visible behind him
Left: the CPS-3 extraction from Part 1. Right: the same frame running in Marvel vs. Capcom as CPS-2 tiles. The differences are the 15-colour budget and the stage behind him.

What he still cannot do

This is one animation chain. Being honest about the size of that:

  • Only the idle is Ken. Walk, jump, crouch, every attack, every reaction — all still draw Gambit's art from Gambit's chains. Walking him away from the corner for a better screenshot put him straight back into Gambit frames, and the capture log shows it (anim=11B6AA, a chain this build never touched).
  • No identity. The lifebar says GAMBIT. Worth knowing why that isn't a one-line fix: the name tables at $006D9C/$006E5C hold no ASCII at all. Dumping the region shows pointer words into further tables — MvC1 draws lifebar names as tile graphics, so "KEN" means authoring nameplate tiles in the HUD font and seating them, not editing a string. The previous build wrote 4-byte pointers there on a guess, which is part of why it scrambled.
  • No sound, no behavior, no hitboxes.
  • He's a little small. 3rd Strike characters are drawn at a slightly different scale than MvC1's cast. Whether that gets a fixed ratio is a decision for later, once there's enough of him on screen to judge it against Ryu.

Next

The order from here, roughly in the sequence a character becomes real:

  1. Basic movement — walk forward and back, crouch, jump up, jump forward and back.
  2. Dashes — forward and back, dash-jump in both directions, and the dash-into-crouch cancels.
  3. Specials — hadouken and shoryuken.
  4. Sound — his 3rd Strike voice and SFX.

Plus the nameplate, now that its format is known.

Each of those is a different kind of problem. Movement is more of what this post did: more frames, more lists, more chains. Dashes and cancels are where the animation chain stops being a list of pictures and starts being a state machine with entry conditions. And sound is a whole separate bus.

He's standing. That's one chain out of 245.

Written by Daniel Plas Rivera · 2,014 words · $21

ShareXLinkedIn