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.
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 outa6 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
rtsAn 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.
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:
| register | player one | player two |
|---|---|---|
$804000 | bit 0 right, bit 4 LP, bit 5 MP, bit 6 HP | same bits, high byte |
$804010 | bits 0, 1, 2 = LK, MK, HK | bits 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 -> 2666Each 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:
| gate | control | defended |
|---|---|---|
| parry | 166 damage | 0 |
| Mega Crash | 204 damage, 0 bars | 178 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/frameThree 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.