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.
timeline
2024-2025 : Character ports : find the table, prove it, move it
Early 2026 : 999-hit counter : no ROM expansion, three hooks
Mid 2026 : Super cancels : roster-wide, 21 identities
Along the way : The chain rule : why Ryu cannot go jab into short
August 2026 : The training mode : point all of it at the player- 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 HPare jab/strong/fierce,LK MK HKare short/forward/roundhouse.>is a chain link. SoLP > MP > HPis the standard ground series. +$B7,+$4D. Byte offsets into a fighter's object in work RAM.+$B7holds 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 kickPress 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) > +$B7Strictly greater. That one word is the entire mystery.
- 1per-character data$0E8000–$0E8048 · 72 B
- 2rank pointer table — one long per character$0E8048–$0E8080 · 56 Bindexed by charid×2, which is why ids are even
- 3rank blocks$0E8080–$0E81C0 · 320 B
- 4Wolverine's 48 bytes$0E81C0–$0E81F0 · 48 Bkicks ranked punches+1
- 5rank blocks$0E81F0–$0E82B0 · 192 B
- 6Ryu's 48 bytes$0E82B0–$0E82E0 · 48 Bpunches and kicks share ranks
- 7rank blocks$0E82E0–$0E8300 · 32 B
- Athe directory every character goes through$0E8048 · marker
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.
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."
Ryu's block ranks his punches and his kicks identically — 1 3 5 in both rows. Wolverine's kicks are ranked punches plus one — 1 3 5 against 2 4 6. That is the only difference, and it is the whole difference.
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:
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:
- 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
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 → | LP | LK | MP | MK | HP | HK |
|---|---|---|---|---|---|---|
| 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.
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.
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.
- 1program data$0E0000–$0E68AE · 26.2 KB
- 2HIT TEST pointer table$0E68AE–$0E68E0 · 50 B+ charid×2 → rows of $20 bytes. Damage, hitstop, hitstun, guard stun, knockdown, dizzy, and Mut.
- 3program data$0E68E0–$0E6FEE · 1.8 KB
- 4pushbox shape directory$0E6FEE–$0E7020 · 50 Bthe box viewer's one indirect read
- 5program data$0E7020–$0E8048 · 4.0 KB
- 6magic-series rank pointer table$0E8048–$0E8080 · 56 B+ charid×2 → the 48-byte block. Everything above is downstream of these 34 bytes.
- 7the rank blocks themselves$0E8080–$0F0000 · 31.9 KB
- AWolverine — kicks are punches+1$0E81C0 · marker
- BRyu — punches and kicks share ranks$0E82B0 · marker
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 −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:
- 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
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.
| route | source of the value | headless test | live on hardware |
|---|---|---|---|
| chain legality (rank rule) | verifiedlive rank block + the three gate bytes | verified | pending |
| move properties incl. Mut | verifiedHIT TEST row at $0E68AE + charid×2 | verified | pending |
| hitbox geometry | verifiedper-object table at +$6C | bridged | pending |
| frame data (startup/active/recovery) | bridgedmeasured from state transitions, not read from a table | verified | pending |
| blockstring gap | bridgedderived from the defender's stun flag | verified | pending |
| damage scaling readout | bridgedbase Dmg against life actually lost; the exact ROM walk is not pinned here | verified | pending |
| blocked-vs-hit discrimination | pendingstun with no life drop — inferred, no flag found | verified | pending |
| advancing-guard button pair | pendingnever confirmed; ships as a menu option | bridged | pending |
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 themvsccamera 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.