case studyUpdated Aug 16, 202621 min read$2F

Why Can Wolverine Chain That Combo and Ryu Can't? It's 48 Bytes.

MvC's own rank table explains the difference. I built a training tool to read those rules live; the code passes headless checks, with a real Fightcade smoke test still ahead.

cps268000reverse-engineeringmarvel-vs-capcomtraining-mode
CPS-2 Reverse EngineeringPart 36 of 43
Browse all writing
On this page

I have been taking Marvel vs. Capcom apart for about two years now. Character ports, a 999-hit counter, super cancels the arcade board never had. Every one of those started the same way: open the ROM, find the table, prove what it does.

Somewhere in the middle of that I realised I had been answering a question nobody asked me, while a question people ask constantly went unanswered. Not "can you add a character" — the question is "why does my character work like this?" Wolverine chains jab into short into strong into forward all the way up. Ryu cannot chain his jab into his short. Everyone who has played this game knows that. Nobody I have ever talked to could tell me why, and the answer you get in a Discord is always some version of "that's just how the game is."

It is not just how the game is. It is a 48-byte table, one per character, and the rule that reads it is four instructions long. This post is that table — and the training mode I built so you can watch it decide, live, for whoever you happen to be playing.

Two years of taking this game apart, and the one question I had not answered
Drawing the diagram…
Every stage on the left was proven in an emulator before the next one started. The tool on the right is what happens when you point all of it at the player instead of at the ROM.
Five terms, and the rest of this post reads straight through
  • Magic series. The chain of normal attacks — jab into strong into fierce and so on. Every Versus game has one; every character's is subtly different.
  • Rank. A small number the engine assigns to each button, per stance. Not damage, not strength — a chain ordering. This is the thing nobody knew was there.
  • Notation. LP MP HP are jab/strong/fierce, LK MK HK are short/forward/roundhouse. > is a chain link. So LP > MP > HP is the standard ground series.
  • +$B7, +$4D. Byte offsets into a fighter's object in work RAM. +$B7 holds the rank of the move you are currently doing.
  • HIT TEST. A screen in the arcade board's own hidden test menu that prints per-move properties. The table behind it is in the ROM, and the tool reads it live.

Street Fighter III Has Had a Real Training Mode for Years. This Game Has Had Nothing.

3rd Strike players have 3rd_training_lua — recorded dummy slots, hitboxes, frame advantage, parry drills — running as a Lua script on the same emulator Fightcade already uses. Marvel has had what the arcade board gave it in 1998, which is a versus screen and your own patience.

The obvious move is to port the 3rd Strike script. That is the wrong instinct, and it took me about an hour of reading to see why. Roughly half of that script is about mechanics Marvel does not have — parries, red parries, the stun gauge, Super Art selection, charge partitioning — and none of it knows about the mechanics Marvel does have: a three-level hyper meter, tag, Variable Counter, Duo Team Attack, special partners, advancing guard, ground recovery rolls, air combos.

What survives the port is the architecture. How you drive a pad one frame at a time. How you record input relative to which way you are facing, so a recording still works after a side switch. How you keep a menu on screen without desyncing anything. That part I kept nearly verbatim, because it is correct and it is proven, and I am not going to re-derive a solved problem to feel clever.

Everything above that line is new. And the reason it could be new is that after two years of pulling this ROM apart for other reasons, I already had the tables. The tool did not need a frame-data spreadsheet copied off a wiki. It could ask the game.

The Whole Magic Series Is One 48-Byte Table and One Strictly-Greater Comparison

Here is the mechanism. Every character has a 48-byte rank block, reached through a pointer table, laid out as eight rows of three strengths — one row per stance, split into punches and kicks:

row $00  stand punch       row $06  stand kick
row $0C  crouch punch      row $12  crouch kick
row $18  air punch         row $1E  air kick
row $24  super-jump punch  row $2A  super-jump kick

Press a button and the dispatcher computes rank = block[row + strength], where strength is the light/medium/heavy index at +$4D, and stores the result at +$B7. Then, when you try to chain, the check permits your next button only when all three of these hold at once:

+$64 & $E0 ≠ 0      you are still inside the current move
+$84       ≠ 0      the current move CONNECTED
rank(candidate) > +$B7

Strictly greater. That one word is the entire mystery.

The rank block // $0E8048 + charid×2
123 · rank blocks45 · rank blocks67A$0E8000$0E8300
  • 1
    per-character data
    $0E8000$0E8048 · 72 B
  • 2
    rank pointer table — one long per character
    $0E8048$0E8080 · 56 B
    indexed by charid×2, which is why ids are even
  • 3
    rank blocks
    $0E8080$0E81C0 · 320 B
  • 4
    Wolverine's 48 bytes
    $0E81C0$0E81F0 · 48 B
    kicks ranked punches+1
  • 5
    rank blocks
    $0E81F0$0E82B0 · 192 B
  • 6
    Ryu's 48 bytes
    $0E82B0$0E82E0 · 48 B
    punches and kicks share ranks
  • 7
    rank blocks
    $0E82E0$0E8300 · 32 B
  • A
    the directory every character goes through
    $0E8048 · marker
One pointer table, four beats // why your chain drops
one directory

Every character reaches their chain rules through the same pointer table at $0E8048. Index it by charid × 2 — which is why every character id in this game is an even number — and you get a long pointing at that character's own 48 bytes.

eight rows, three strengths

The 48 bytes are eight rows of six: stand punch, stand kick, crouch punch, crouch kick, air, air kick, super-jump, super-jump kick. Within a row the three strengths sit at +0, +2, +4. So one byte answers "what rank is my crouching forward."

two characters, two philosophies

Ryu's block ranks his punches and his kicks identically1 3 5 in both rows. Wolverine's kicks are ranked punches plus one1 3 5 against 2 4 6. That is the only difference, and it is the whole difference.

strictly greater

Jab is rank 1. Ryu's short is also rank 1, and 1 > 1 is false, so the engine discards the input before it checks anything else. Wolverine's short is rank 2, which slots into the gap above jab — and every one of his kicks sits in a gap between two punches, which is exactly why his series zigzags all the way to roundhouse.

The two blocks, side by side

This is the engineer-lane version of the same claim. Twelve bytes from each character — rows 0 and 1, stand punch and stand kick, which is where the entire story lives. Same offsets, same stride, two different fighters:

Ryu's stand rows against Wolverine's — this is a character diff, not a patch $0E82B012 bytes · 3 changed
before010003000500010003000500
after010003000500020004000600
Before is Ryu at $0E82B0, after is Wolverine at $0E81C0. Row 0 (stand punch) is byte-identical: LP=1, MP=3, HP=5. Row 1 (stand kick) is the entire difference: Ryu repeats 1/3/5, Wolverine goes 2/4/6. Three bytes, and one of them is why your c.LK never comes out after c.LP.

Diffing two blocks is the cheapest evidence I can offer that the field means what I say it means. If rank were something else — damage, startup, an animation index — those three bytes would not fall exactly where the two characters' chains diverge in play.

What that costs Ryu, counted

Six buttons give thirty ordered pairs. Run the strictly-greater rule over both blocks and count how many of those thirty the engine will actually allow:

Wolverine gets three more legal chain transitions than Ryu — and twice the chain length
  • Ryu — legal transitions12of 30 ordered button pairs

    ranks 1 1 3 3 5 5: same-strength cross-chains are all refused

  • Wolverine — legal transitions15of 30 ordered button pairs

    ranks 1 2 3 4 5 6: every kick fits in a gap between two punches

Counted directly from the two 48-byte blocks above, stand rows, by applying rank(to) > rank(from).

Twelve against fifteen does not sound like much. The number that matters is what those transitions can be strung into, because the rule applies again at every step:

Ryu is not "worse at chains" because of some hidden property of his normals. He has three distinct ranks where Wolverine has six, and a chain is a strictly increasing walk through them. Three ranks, three steps. That is the ceiling, and it is arithmetic.

Here is the whole matrix for both, from-move down the left, to-move across the top. The upper-triangular shape falls out of the rule automatically — and the holes are where the character-specific quirks live:

from ↓ / to →LPLKMPMKHPHK
Ryu LP (1)··
Ryu LK (1)··
Ryu MP (3)····
Ryu MK (3)····
Ryu HP (5)······
Ryu HK (5)······
Wolv LP (1)·
Wolv LK (2)··
Wolv MP (3)···
Wolv MK (4)····
Wolv HP (5)·····
Wolv HK (6)······

The three bolded cells are the three bytes from the HexDiff. That is the whole difference between the two characters' ground games, written out.

What to do with this in the next hour

The rule. A normal chains into another normal only if the target's rank is strictly higher — and only after the first one has actually connected.

The habit. If your LP > LK drops on one character and works on another, you were not mistiming it. On the first character the engine never even looked; it threw the input away at the comparison. You have been practising a link the game does not have.

The drill. Open the Lab tab, print your character's rank table, and find where two ranks are equal. That pair is a chain you must stop attempting. Then find the gaps of exactly 1 — those are your tightest legal links, and they are where the confirm work pays.

So the Panel Does Not Ship a Chain Chart

The Move Lab reads the live block for whoever you are playing, reads your current rank at +$B7, checks the two gate bytes, and prints the buttons that are legal this frame. When you are not in a move it previews what would be legal if one connected.

Pick a character the tool has never seen — a hidden one, a variant, something from a modded build — and it still works, because the table is the character. There is no per-character data file in the package to go stale.

A rule this clean invites conclusions it does not support

The rank rule governs normal-into-normal chains. It says nothing about special-into-super, about which specials cancel out of which normals, or about air-series ordering beyond the two air rows. Those are separate systems with separate gates, and I have not traced all of them.

If the tool shows chains: MP HP MK HK, that is a statement about four buttons. It is not a statement that nothing else is possible from where you are standing.

Eight Columns, Not Seven

The arcade board's hidden HIT TEST screen prints a per-move property row with Capcom's own column headers: Dmg Piy Sto Snd Slp Shc Grd Mut. Our earlier offline dump of those rows carried seven of the eight.

The one that got left behind is Mut — a big-endian word at +$1A. The consumer disassembly shows Mut & $3F seeding the post-hit invulnerability timer at +$106 and closing the vulnerability gate at +$83 for that many updates.

That is the rehit window: the reason a multi-hit move hits three times instead of thirty. It is now on screen next to damage, hit-stop, hitstun, guard stun, knockdown and dizzy accumulation, read from the row the move itself latched at +$80.

The three ROM tables the tool reads live, and nothing else
1 · program data57 · the rank blocks themselvesAB$0E0000$0F0000
  • 1
    program data
    $0E0000$0E68AE · 26.2 KB
  • 2
    HIT TEST pointer table
    $0E68AE$0E68E0 · 50 B
    + charid×2 → rows of $20 bytes. Damage, hitstop, hitstun, guard stun, knockdown, dizzy, and Mut.
  • 3
    program data
    $0E68E0$0E6FEE · 1.8 KB
  • 4
    pushbox shape directory
    $0E6FEE$0E7020 · 50 B
    the box viewer's one indirect read
  • 5
    program data
    $0E7020$0E8048 · 4.0 KB
  • 6
    magic-series rank pointer table
    $0E8048$0E8080 · 56 B
    + charid×2 → the 48-byte block. Everything above is downstream of these 34 bytes.
  • 7
    the rank blocks themselves
    $0E8080$0F0000 · 31.9 KB
  • A
    Wolverine — kicks are punches+1
    $0E81C0 · marker
  • B
    Ryu — punches and kicks share ranks
    $0E82B0 · marker
Three pointer tables, all indexed the same way, all read at runtime. The tool ships no copy of any of them.

The move name I refuse to print

The ROM does contain a 320-entry pattern-name table, and it is extremely tempting — a panel that says "STAND SMALL PUNCH" instead of grp $10 reads better in every screenshot.

The join from a move id to a name index was tested and refuted during the earlier work. Two independent checks killed it: the damage profile of the supposed normals band does not match, and scanning every offset from −20to+20 to +40 for a shift that separates the bands scores essentially zero for every character.

So the panel labels the animation group, using only the nine group ids that were hand-verified against live captures, and prints the raw hex for everything else. A tool whose entire pitch is "it reads the ROM instead of guessing" loses that pitch the first time it guesses.

The Frame Meter, and the One Number Nobody Ships

The rolling per-frame ribbon is the feature every modern fighting game now has and this one had to imitate. Both fighters, one square per frame, colours following the convention SF6 taught everyone to read: startup, active, recovery on the attacker; stun on the defender.

The part nobody ships is the derived question — is my blockstring actually tight? So the tool watches for the stretch where the defender is actionable between two hits of a string, and reports its width:

What the gap number means before you have to think about it
  • 0-2 — tight2frames the defender is actionable mid-string

    nothing fast enough fits; keep going

  • 3-5 — mashable5frames the defender is actionable mid-string

    a fast normal fits in here and your string is a suggestion

  • 6+ — wide open8frames the defender is actionable mid-string

    this is not a blockstring, you are taking turns

The tool prints the measured number and the verdict. The bands are mine; the frames are counted from the defender's own stun flag.

That feature also produced the most instructive bug in the whole project, which is two sections down.

What the Research Changed, Specifically

Before writing the dummy I had an agent sweep arXiv for anything from 2024 onward that bears on how a practice tool should behave. Four findings changed the design. I am naming which, because "we read some papers" is not a claim.

The dummy got a reaction delay, and the drill got an honest clock. A dummy that blocks on frame one is not a hard opponent, it is an impossible one, and it teaches expectations no human will meet — the fairness argument for injecting delay is made directly in arXiv:2312.16010. So the reaction drill times from the first frame the dummy's option is visually distinguishable, not from the frame the sequence was queued, and it will not credit a direction you were already holding. Hold down-back forever and the tool tells you that you never reacted, which is true.

Difficulty sliders got replaced by a band. The useful task is the one that is neither already solved nor still impossible — the Zone-of-Proximal-Development rule that ProCuRL (arXiv:2304.12877) derives rather than tunes, and that FightLadder (arXiv:2406.02081) implements by sampling opponents in proportion to 1 − P(win). The finding that settled the argument for me is arXiv:2512.17882: people left to choose their own difficulty systematically choose too easy, and perform just as well when a model pushes them harder, without extra frustration. So the adaptive dummy targets a 50–70% success band, moves exactly one knob per step so you can see what changed, and unwinds by restoring your settings rather than a default.

The tendency detector uses the features that were shown to work. arXiv:2408.06818 reports 82–87% next-action prediction from relative position plus current move alone — which is precisely what a RAM reader already has, and a useful reminder that you do not need a neural network to notice that someone always presses fierce at mid range. It buckets situation by distance, and ranks habits by what they cost rather than how often they happen. It only reports. Arming the dummy against your worst habit is a separate, explicit button, because a partner that punishes everything instantly becomes unusable (arXiv:2604.25796).

The overlays learned to get out of the way. Constant cueing induces overreliance and erodes retention — the explicit finding of arXiv:2603.06253 on dynamic versus static guidance, along with the asymmetric rule: fade out fast, fade back in slowly. So the assist overlays dim as your rolling success rises and return when you start dropping things.

One result is worth recording for what it says about the field rather than the tool. The esports scoping review at arXiv:2409.19180 finds no periodization framework, no exercise taxonomy and no defined training load in esports at all. And the sweep turned up zero 2024–2026 papers on optimal combo or punish search in fighting games. A punish finder that searches the legal cancel graph for maximum damage is apparently still an open problem — and now that the chain rule is machine-readable, it is a tractable one. That is the next thing I want to build.

Two Inversions That Reading the Code Would Never Have Found

I had an agent review this adversarially, twice — once after the first version and once after the research features. The first pass found 17 real defects. The second found 18 more. Two are worth writing down, because both read as perfectly sensible code and both were exactly backwards.

The adaptive ladder ran in reverse. The combo counter at +$120 belongs to the attacker. Crediting the dummy's combo as the player's success meant the tool made the dummy easier when you played well and harder when you were being comboed. A difficulty curve pointed precisely the wrong way, in eight lines that anyone would have signed off.

The gap detector measured the inverse of a gap. The first version counted frames while the defender was in blockstun — which is the definition of the string working. A perfectly tight blockstring reported a wide-open gap. A genuine 20-frame hole reported nothing at all. A gap is the stretch where the defender is actionable, so the state machine had to start counting when blockstun ends.

Both now have regression tests that assert the direction, not just the magnitude. A test that only checked "a gap was measured" passes happily on the broken version, which is the actual lesson.

The rest were more ordinary and no less real: a settings write that shelled out to mkdir on every menu keypress, which on Windows is a multi-second stall per button press; character-id flicker outside a match wiping recording slots and hammering the disk every frame; "infinite meter" writing 255 bars into a field that holds 0 to 3; and a reaction-delay bug where any non-zero value meant the dummy never blocked at all.

None of that is exotic. All of it was invisible until something ran the code and checked the number against what it should have been.

Status, Honestly

August 16 self-audit

What is read from the ROM, what is derived, and what is still a heuristic

Headless verification only. Nothing here has been run on real Fightcade yet.

nativeverifiedbridgeddonorpending
routesource of the valueheadless testlive on hardware
chain legality (rank rule)verifiedlive rank block + the three gate bytesverifiedpending
move properties incl. MutverifiedHIT TEST row at $0E68AE + charid×2verifiedpending
hitbox geometryverifiedper-object table at +$6Cbridgedpending
frame data (startup/active/recovery)bridgedmeasured from state transitions, not read from a tableverifiedpending
blockstring gapbridgedderived from the defender's stun flagverifiedpending
damage scaling readoutbridgedbase Dmg against life actually lost; the exact ROM walk is not pinned hereverifiedpending
blocked-vs-hit discriminationpendingstun with no life drop — inferred, no flag foundverifiedpending
advancing-guard button pairpendingnever confirmed; ships as a menu optionbridgedpending
Verified means the value comes from a table or rule we proved. Bridged means it is derived from proven inputs by a stated method. Pending means it is a heuristic, or has simply not been run yet — and the whole right-hand column is pending until I sit down at a real Fightcade session.

195 headless checks pass under both Lua 5.1 and LuaJIT, against a stubbed emulator API that models the RAM layout and the joypad contract. That catches logic, arithmetic, state machines and draw-call safety. It cannot catch a wrong address, and it cannot tell me the hitboxes land on the sprites. That is the next session, not this one.

The standing caveat for anything that writes to work RAM: this is a single-player tool. Running it during a netplay match will desync your opponent.

References

  • 3rd_training_lua — the 3rd Strike training script whose architecture the input, recording and menu layers are built on.
  • peon2/fbneo-training-mode — carries dammit's marvel-hitboxes.lua, the source of the mvsc camera transform and box profile.
  • arXiv:2312.16010 — delay mechanisms and fair evaluation against fighting-game agents.
  • arXiv:2304.12877 and arXiv:2406.02081 — curriculum by proximal difficulty, and opponent sampling by inverse win rate.
  • arXiv:2512.17882 — people choose too-easy difficulty for themselves.
  • arXiv:2408.06818 — next-action prediction from position and current move.
  • arXiv:2603.06253 — dynamic versus static guidance, and why constant cueing erodes retention.
  • arXiv:2409.19180 — the esports training-science scoping review, and the gap it documents.

Written by Daniel Plas Rivera · 4,575 words · $2F

ShareXLinkedIn