case studyUpdated Aug 2, 202614 min read$23

Teaching Marvel vs. Capcom to Parry, and to Break Its Own Infinites

Marvel vs. Capcom has no answer to a combo already in progress, which is why its infinites are infinites. I added two defensive options the engine was never written with: a 3rd Strike parry and a Tatsunoko vs. Capcom style Mega Crash, charged in recoverable red life exactly as TvC charges it. Both intercept the same single instruction, and finding it meant discovering that a harness this project trusted had been reading the wrong word in memory for months.

cps2marvel-vs-capcomreverse-engineeringparrymega-crash
CPS-2 Reverse EngineeringPart 24 of 24
Browse all writing
On this page

Marvel vs. Capcom has a well known problem. Once the first hit lands, the defender is a spectator. There is no reversal, no burst, no escape. That is not a bug, it is simply what fighting game defence looked like in 1998, and it is the reason the game's infinites are infinites rather than merely long combos.

Capcom fixed this themselves, eventually. Tatsunoko vs. Capcom's Mega Crash costs two super bars and roughly ten percent of your life, can be triggered while you are being comboed, and blows the opponent away. It is the same family as Guilty Gear's Burst, BlazBlue's Burst, and Dragon Ball FighterZ's Sparking escape.

I wanted both that and a 3rd Strike parry inside Marvel vs. Capcom. Not as animations, which is the easy half, but as rules the engine actually follows.

Why both features are the same problem

A parry and a burst feel like different mechanics. Mechanically they are the same question asked at the same instant:

at the moment damage is about to be applied to me, is there a reason it should not be?

For a parry the reason is that you moved toward the attack. For a burst the reason is that you paid two bars and some life. Everything else is bookkeeping.

That means neither feature needs a per frame hook, a state machine, or any new engine loop. Both need exactly one thing: the instruction where damage is applied. Find it and both mechanics are a handful of bytes. Miss it and neither is possible at all.

So the entire project reduces to one address.

Three wrong answers, and why they were wrong

The obvious approach is to find the health field, watch what writes to it, and read off the address. This project already had a harness that located health automatically. It snapshots the player struct, lands a hit, and reports whichever value dropped. It had been reporting player struct plus $7C for months, and everything downstream trusted it.

$7C is not health.

Across two hundred and eighty nine measured frames of a character being beaten, $7C went up, from 128 to 130. Every damage figure that harness ever printed read the wrong word. Worse, a document elsewhere in the same repository had already recorded $7C as flag like and noted it "never falls like health". The correct observation was written down, and the broken tool kept running beside it.

The second candidate was more convincing, which made it more dangerous. Field $E2 starts at 128, and a before and after comparison shows it at 96 afterwards. A clean drop of 32. It looks exactly like a health bar.

It toggles. $E2 moves between 128 and 96 continuously during normal play. Sample it at two moments and you can produce any answer you want, including a very tidy one.

Both failures have the same root, and it is worth stating plainly because it generalises well beyond this:

A difference between two instants is not a decrease.

Health has a property that neither impostor has. While you are being beaten, it can only ever fall. So the test is not the size of the drop, it is monotonicity. Sample every word in the struct every single frame and discard any offset that ever rises, no matter how good its before and after picture looks.

Why two of three candidates survived a diff and died to monotonicity
Loading diagram...
A before and after comparison cannot distinguish a fall from a fluctuation.

One field survived that filter across the whole four hundred byte struct. Player struct plus $270, maximum 144, falling to zero as the character is knocked out and never once rising.

It also sits directly beside the super meter fields that were already known, which is the kind of neighbourhood that makes you believe a result. The earlier searches had missed it for an embarrassing reason: they scanned $30 through $FE, and $270 is nowhere near that window.

One caveat on "never once rising", because it is true only inside the window it was measured in. That scan ran across four hundred and twenty frames of continuous beating, and health cannot rise while you are being hit. Over a longer window it does rise, because $270 is the recoverable half of the life bar. The monotonicity test is still the right filter, it just has a precondition: sample it while the character is under pressure and nothing else is allowed to heal them. That recovery turns out to be the key to the burst, and it comes back below.

The instruction

With the right address, one write hook gave up the entire routine immediately.

$01B740  4A6E 0270    tst.w   $270(a6)      ; defender health
$01B744  6758         beq.s   ...           ; already dead
$01B746  6100 01F2    bsr.w   ...
$01B74A  6100 1714    bsr.w   ...
$01B74E  9B6E 0270    sub.w   d5,$270(a6)   ; damage applied, here
$01B752  6E4A         bgt.s   ...           ; did they survive it
$01B754  0828 0005 0016  btst #5,$16(a0)
$01B75C  426E 0270    clr.w   $270(a6)      ; knocked out

a6 is the defender, a1 is the attacker, d5 is the damage. One shared routine drives both players, so a single hook covers everyone and every character.

There is a constraint hiding in that listing. The hook has to fit in four bytes, because that is the width of sub.w d5,$270(a6) and anything wider would shift every instruction after it. bsr.w is exactly four bytes. It reaches plus or minus thirty two kilobytes, and there is exactly one run of filler in that range: forty eight bytes of zeros at $013CB2.

The other constraint is subtler and would have been a miserable bug to chase. Look at $01B752. The instruction immediately after the one being replaced is a conditional branch that reads the flags from the subtraction. Any replacement must leave those flags intact or the knockout path silently breaks, and it would break in a way that only shows up on the hit that should have ended a round.

On a 68000 neither bsr nor rts touches the condition codes. So as long as the displaced subtraction is the last flag setting instruction in the routine, the branch downstream never notices anything happened.

The parry

Sixteen bytes.

cmpi.b  #$04,$15(a6)     is the defender moving toward the attacker
bne.s   .normal
moveq   #0,d5            parried, this hit does nothing
.normal:
sub.w   d5,$270(a6)      the instruction we displaced
rts

An honest note about the trigger, because I nearly wrote this up wrong. I initially described $15 as an input latch that reads $04 on a forward tap. It is not an input latch. Probing it against held directions showed forward reading +4 and back reading −3, which makes $14 a signed X velocity, not a record of what you pressed.

That difference matters for balance. This parries when you are moving forward, which means holding forward parries continuously, and a real 3rd Strike parry demands a fresh tap inside a window of a few frames. As implemented this is closer to an autoguard than a parry. Calling it finished would be overselling it, so I am not.

The Mega Crash

The burst is the same hook with a price attached.

One hook, two defensive options
Loading diagram...
Both mechanics answer the same question at the same instant.

Reading the inputs is where this got interesting. A correlation sweep across work RAM found twelve bytes at struct +$D0 through +$ED that track button presses perfectly, across many cycles of a square wave. Perfect correlation, and completely useless.

They correlate because pressing a button starts an attack. They are attack state, not input state. A burst has to be readable while the defender is in hitstun and cannot attack at all, which is precisely the condition where those fields say nothing. A tempting correlation that answers a different question than the one you asked is the same trap as $E2, wearing better clothes.

The raw hardware ports do not have that problem. They read identically whether or not the character is allowed to act, and they turned out to be perfectly clean, active low, with player two on the high byte of the same registers:

registerplayer oneplayer two
$804000bit 0 right, bit 4 LP, bit 5 MP, bit 6 HPsame bits, high byte
$804010bits 0, 1, 2 = LK, MK, HKbits 4, 5, 6 = LK, MK, HK

Seventy eight bytes, and the shape follows Tatsunoko vs. Capcom directly: two super bars from the bar count at +$254, fourteen points of life from +$270, and the attacker forced into a knockdown state so the combo ends.

The life cost is recoverable, which matches TvC exactly. I first wrote that it could not be, on the assumption that Marvel vs. Capcom had no red life system. It does. A character regenerates the red portion of their bar while tagged out, which is basic knowledge to anyone who has actually played the game and which I should not have assumed my way past.

Correcting that assumption turned out to matter, because the monotonic scan had already found two fields falling 144 to 0 and I had taken the first one and ignored the second. A beating cannot separate them. A tag can, and the signal is the exact mirror of how health was found: there the test was never rises, here it is does rise.

        P1 +$270   P1 +$27A
f180       107        119     damage taken
f540       108        119     $270 begins climbing
f750       113        119     recovering toward $27A
 
$270 rose on 6 sampled frames.  $27A rose on none.

So +$270 is current life and it regenerates back up to +$27A, which is the permanent floor, the red boundary itself. The burst already subtracts from +$270, which means it was spending recoverable life the whole time. The implementation was right and my description of it was wrong, which is a much better failure than the reverse.

If this ever needs to be harsher than TvC rather than equal to it, the dial is obvious now: take the cost from +$27A instead and it becomes permanent.

What this does to infinites

The point of a burst is not that it wins the interaction. It is that the interaction exists.

An infinite in a game with no defensive option is a solved position: land one hit, win the round. An infinite in a game with a burst is a resource problem for both players. The attacker has to decide whether to keep going into a burst or cut the combo short. The defender has to decide whether this combo is worth two bars and a tenth of their life, or whether to save it.

That is a real decision in both directions, and it is what separates a long combo from a lost round. It does not delete infinites. It prices them.

Both of them run now, and the detour was my fault

Everything above was written while I believed patching CPS2 code required a decrypted set, which sent me to MAME. That was wrong, and the correction is the most useful thing in this post.

Only $000000 to $100000 is encrypted. Everything from $101000 upward is plaintext. That is why every patch this project has ever shipped sits at $3FCxxx and never needed crypto. So the routine body goes at $3F1000 and is simply written in the clear.

That leaves five words inside encrypted space: the four byte hook and a six byte trampoline. FBNeo implements decryption only. But the cipher is a bijection on sixteen bits at a fixed address, and the address enters only through a & 0xFFFF, so there is no need to invert anything. Try all 65,536 candidates and keep the one that decrypts to what you want:

$01B74E  6100 -> 6AFF     bsr.w $013CB2
$01B750  8562 -> 28A4
$013CB2  4EF9 -> DE05     jmp $3F1000
$013CB4  003F -> 94CE
$013CB6  1000 -> 2666

Each had exactly one solution, which is what a bijection should give. The encryptor self tests against 1024 known ciphertext and plaintext pairs from the real ROM and refuses to emit anything if one of them fails.

Both mechanics now pass differential gates:

gatecontroldefended
parry166 damage0
Mega Crash204 damage, 0 bars178 damage, 4 bars spent

The gate that passed while the feature did nothing

The parry passed a gate long before it worked, and the way it lied is worth more than the fix.

That first gate poked $15 to $04 every frame. It proved the hook zeroes damage when the field reads 4. It never asked whether playing the game produces 4. Watching the actual instant of damage:

  hit    vel($14)  $15  $B0  port$804000
    1         0    00   36   FFFE
    4         3    03   36   FFFE
    6         0    00   36   FFFE

$B0 = 36 is hitstun. At the moment a hit lands, the defender's velocity is whatever the attack imposed, never what they pressed. $15 read $00 on five of six hits and $03 once. Never $04. The trigger could not fire in play, ever.

Meanwhile the input port read FFFE on all six: forward was held the whole time. The input was always there. I was reading the wrong field.

Poking the trigger tests the mechanism, not the mechanic.

Pushblock is physics, not a state

The parry as built made damage vanish silently, which reads as a bug rather than a mechanic. The game already ships the right feel in its own advancing guard, so the plan was to find pushblock's state and reuse it.

Four probes found nothing, because there is nothing to find. Measured in a versus match with blockstun verified rather than assumed:

attacker drift, blocking WITHOUT the button pair:  0.20 px/frame
attacker drift, blocking WITH    the button pair:  0.70 px/frame

Three and a half times the shove, and no $B0 value unique to it at all. Pushblock is pure physics. Every probe hunting for a state was looking for bookkeeping the game never does. So the parry now simply reproduces the shove: ten pixels off the attacker, in the away direction.

Three harness traps, all of which produced confident nonsense

Each of these made a probe report something false rather than fail loudly.

In a one player game, player two is the CPU. joypad.set on P2 is overridden, so the "defender" fights back instead of blocking. Every early pushblock run was really watching a CPU brawl. Anything where the defender must obey needs a versus match.

Unrecognised input names are silently ignored. 'P2 LP' does nothing in this driver; 'P2 Weak Punch' works. Two harnesses used the dead names, which is why the Mega Crash gate reported FAIL while the patch was correct, and why most pushblock pairs pressed nothing at all.

Seat both fighters. Pinning only the attacker leaves the defender out of range in versus, so nothing connects and every block reads zero damage. Zero damage looks identical to a working defence. The control taking damage is the only thing that makes the zero mean anything.

Stated plainly: what still does not work

The parry triggers on holding toward, not on a tapped window, so it behaves closer to an autoguard than to a 3rd Strike parry. Narrowing it needs an input history the engine does not appear to keep.

The pushblock effect sprite is not recoloured. The shove is reproduced, the visual is not, because that needs the spawn site rather than the physics.

Neither of these is blocked. Both are simply not done.

What I would take from this

The engineering result is that two mechanics Marvel vs. Capcom was never written with fit in ninety four bytes between them, because both reduce to one question asked at one instruction.

The more useful result is the one about measurement. This project shipped a health field that was never health, it shipped it for months, it printed damage numbers from it into test reports, and a correct contradicting note sat in the same repository the whole time. It survived because a before and after diff is a very persuasive way to be wrong.

The fix was not a better search. It was a stricter property. Health can only fall, so demand that it only ever falls, on every frame, and let the impostors disqualify themselves.

Written by Daniel Plas Rivera · 2,973 words · $23

ShareXLinkedIn