case studyUpdated Aug 1, 20266 min read$21

Bringing 3rd Strike into MvC: Part 3, Six Chains and a Self-Loop

Ken now renders across six movement states in Marvel vs. Capcom — idle, both walks, crouch, and two jumps. Getting there meant live-tracing which animation chain each input actually drives, finding out the engine picks by facing rather than by direction key, and finding the one longword that decided whether a correctly-seated frame appeared at all.

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

Part 2 got Ken's idle stance rendering in Marvel vs. Capcom — one animation chain out of 245. This part takes it to six, which sounds incremental and is really three separate problems: knowing which chain an input drives, finding out the engine doesn't choose the way you'd assume, and finding out why a correctly-seated frame can still never appear on screen.

Six Ken sprites in a row on a dark background: standing idle, walking forward, walking backward, crouching, jumping straight up curled, and jumping forward, all in a white gi
Six movement states rendered out of the written ROM — idle, walk forward, walk back, crouch, jump up, jump forward. 161 tiles, one shared 15-colour palette. All six confirmed rendering in game.

You cannot seat what you cannot name

The project I inherited had a CHAIN_MAP.md with 245 rows, every one marked UNVERIFIED_LIVE. That isn't a map. So before seating anything I built one by measurement: a harness that holds each movement input in a real match and logs every (anim, list) pair the engine actually enters, with how long it held.

The first run was nearly useless. The CPU opponent interrupts constantly, so hit-reaction chains appeared under every action and outscored the real ones. The fix was blunt: hold the opponent out of range every frame.

With the noise gone the structure fell out immediately: movement chains cluster in $1199xx$11A5xx, and the chains that had been contaminating everything cluster in $11B6xx$11C2xx and are hit reactions. That separation is why differencing against a neutral baseline works at all.

The engine picks by facing, not by direction

Two of my traces disagreed about which chain was "walk forward" and which was "walk back". Not noise — they genuinely produced different answers.

The engine selects by forward or backward relative to which way the character is facing, not by which direction key is held. My traces had the opponent on opposite sides, so "press right" meant forward in one and backward in the other. Both were correct for their run.

What this breaks if you miss it

Any directional row from a trace is only meaningful together with where the opponent was. Merge two traces naively and you get a map that is confidently, silently mirrored. The clean trace pins facing by holding the opponent to one side, which is what makes its labels authoritative.

The one longword that decided everything

With six chains identified I seated a Ken frame on each and repointed the first record. Then only three of six rendered.

The obvious explanation is that the missing three are transient — records the engine passes through too fast to catch. That story dies on the numbers: jump_up captured at 5.3% dwell while walk_fwd missed at 8.7%. Whatever separated them, it wasn't how long the chain was live.

The answer was one longword. Each animation record is 16 bytes, so a record's +16 is the next record's first field. Dumping that field across all six:

crouch     $11A402   +16 = 0011A402   <- pointer, back to itself
jump_up    $119D9A   +16 = 00119D8A   <- pointer
idle       $119846   +16 = 00000003   <- an ordinary record
walk_fwd   $11A4C2   +16 = 00008002   <- an ordinary record
walk_back  $1199DA   +16 = 00000006   <- an ordinary record

When that field is a ROM pointer, the chain loops. Crouch and jump_up loop back onto their own first record natively — and those were exactly the states that showed up. The others run straight past the single frame I seated, back into Gambit's art.

Part 2's standing build worked for precisely this reason, and I hadn't understood why at the time: it wrote a self-loop. Writing one for each chain that doesn't already have one took it from three to six in a single rebuild:

SHOT idle       anim=119846 list=507E00
SHOT walk_fwd   anim=11A4C2 list=507ECE
SHOT walk_back  anim=1199DA list=507F84
SHOT crouch     anim=11A402 list=508046
SHOT jump_up    anim=119D9A list=5080F0
SHOT jump_fwd   anim=119F42 list=508152
CAPTURED 6 states

Every line there is gated: the harness only shoots when the live animation pointer equals the chain that state was seated on, and asserts P1's fighter id is $0A every frame. A shot cannot be a Gambit frame taken by accident.

Marvel vs. Capcom gameplay screenshot with the lifebar reading GAMBIT, showing Street Fighter III Ken standing in his idle stance in a white gi on the stage
In game, on the seated idle chain. The lifebar still reads GAMBIT — identity work is deliberately untouched, since those were the guessed writes that scrambled the original build.

The palette got worse, and that's the interesting part

Every Ken frame draws through Gambit's single 16-entry palette row. So the fifteen colours have to be fitted across all six frames at once, not per frame — quantizing each independently would give each an optimal palette the others then have to live with.

That's a real regression in fidelity, and it gets worse with every state added. It's the first hard constraint this project has hit that more care won't solve — it needs either a second palette row or a smarter shared quantization, and that decision is coming.

Bounds

  • One frame per state, not animation. Each state holds a single pose; walking looks like gliding. Animating them means walking each chain's records and seating a frame per record.
  • The self-loops are destructive. Looping a chain on its first record overwrites the next record's first field. For a single-frame seat that's the intended trade; real animation needs those records preserved and repointed instead.
  • 20 chain rows are still CANDIDATE. They survive the baseline filter in one run only. I'm not promoting them on one witness — that's the mistake the original map made 245 times.
  • Nothing about identity, sound, or attacks has been touched.

Next

Attacks and specials — hadouken and shoryuken — which is where a chain stops being a list of pictures and starts having entry conditions. Then his 3rd Strike voice and SFX.

Six chains down. He walks, crouches, and jumps, all in his own art.

Written by Daniel Plas Rivera · 1,217 words · $21

ShareXLinkedIn