case study7 min read$33

MvC Had the Digits for Five Super Bars. One Instruction Said Three.

The cap is one byte. Four other tables also assume three. A statically verified five-level patch uses Capcom's existing art; a full-match play test is still pending.

cps2marvel-vs-capcomreverse-engineering68000hyper-meter
CPS-2 Reverse EngineeringPart 39 of 43
Browse all writing
On this page

The Hyper meter in arcade Marvel vs. Capcom stops at three levels. It stops there because of one instruction.

The cap is one byte

The meter lives on each fighter object: a level byte at +$274, and a 0..$90 fraction at +$272. The gain routine adds to the fraction, and every time it crosses $90 it promotes one level:

$01CD58  moveq   #$3,d1              ; <- the cap
$01CD5A  cmp.b   $274(a6),d1
$01CD5E  ble.w   $1cde6              ; already capped, no gain at all
...
$01CD74  add.w   $272(a6),d0         ; gain + stored fraction
$01CD78  cmpi.w  #$90,d0             ; one level costs $90
$01CD7C  ble.b   $1cd94
$01CD80  subi.w  #$90,d0
$01CD84  addq.b  #$1,$274(a6)        ; level++
$01CD88  cmp.b   $274(a6),d1
$01CD8C  bgt.b   $1cd94
$01CD8E  move.b  d1,$274(a6)         ; clamp
$01CD94  move.w  d0,$272(a6)

moveq #$3,d1 becomes moveq #$5,d1. In the encrypted member that is the word 84CD becoming 81C7. That is the entire cap.

It is also the entire trap, because four other places arrived at "three" on their own.

The four places that also knew about three

Three of the four have no room to grow. The voice table at $01CDBE is immediately followed by live code; the team-Hyper table at $016E2A is followed by an rts that is a real branch target; the indicator table at $006B9A has its index-4 slot occupied by a non-numeric state. Extending any of them in place would overwrite something that runs.

The team-Hyper table did not need to exist. Its four entries are $0000, $00C0, $0180, $0240 — level times $C0. The three instructions that read it are exactly as long as three instructions that multiply:

; before, 10 bytes                    ; after, 10 bytes
move.w  d0,d1                         move.w  d0,d1
add.w   d1,d1                         mulu.w  #$c0,d1
move.w  $16e2a(pc,d1.w),$50(a6)       move.w  d1,$50(a6)

No hook, no payload, and it extends to five levels by construction.

The voice table got a private copy with a clamped index, so levels 4 and 5 announce with the level-3 sound rather than reading whatever bytes follow the original table.

The HUD was the part that could have needed art

The level indicator is a sprite. The drawing routine reads the level byte, scales it by four, and pulls a tile code out of the table at $006B9A:

$006B4C  move.b  -$4d8c(a5),d5       ; P1 meter level
$006B52  tst.w   -$3fcc(a5)          ; special state?
$006B58  move.b  #$4,d5              ; ...then index 4
$006B6E  add.w   d5,d5
$006B70  add.w   d5,d5
$006B72  move.w  $6b9a(pc,d5.w),d2   ; tile code
$006B76  movem.w d0-d3,(a0)          ; x, y, tile, attribute

The table's tile codes are $6AB4, $6AB5, $6AB6, $6AB7 for levels 0 to 3. Consecutive. That is a promising shape, but the first attempt to confirm it decoded tile $6AB4 out of the graphics ROM and got a fragment of character artwork, not a numeral.

The sprite engine adds $10000. Decoding $16AB4 instead:

Eight decoded CPS-2 16x16 tiles in a row, reading 0 1 2 3 4 5 6 7 in the game's HUD numeral font
TILES $16AB4 THROUGH $16ABB // The level indicator's numeral strip, decoded straight out of the graphics ROM. Levels 4 and 5 already exist.

So the answer to "does a five-level meter need new artwork" is no, and it was answerable without running the game at all. $6AB8 draws 4 and $6AB9 draws 5, and a payload routine now computes $6AB4 + level instead of reading a table that had no room for them.

The gauge bar itself needs nothing. It is nine tiles wide with sixteen fill steps each — moveq #$8,d6 for the loop, cmpi.w #$10,d2 for the clamp, $90 total, which is exactly one level. Levels do not add bars; the bar refills and the numeral counts. Only the "the bar reads full" comparison had to move from 3 to 5.

The trap that cost the most time

Two of the tables above are read PC-relative, like move.w $6b9a(pc,d5.w),d2. One nearby table is read through an address register, like movea.l (a2)+,a1.

On CPS-2 that distinction decides which bytes you are looking at. The 68000 uses program space for PC-relative addressing and data space for everything else, and the C-board decrypts program space only. So:

Same ROM, two different views, a few hundred bytes apart

A table read PC-relative comes back decrypted. The identical bytes reached through (a2)+ come back raw. In the HUD and meter code both kinds appear within a few hundred bytes of each other. Read the wrong view and a perfectly ordinary table looks like noise — or, worse, noise looks like a table.

The tell was a table of long pointers at $006F1C that reads as E62A 48CB 3D5F 58A2 in the decrypted image and as 00006F40 00006F62 00006F62 00006F62 in the raw member. Clean pointers, in the raw view, for a table the code reaches with movea.l (a2)+,a1. Once that is understood, the tables that only make sense decrypted and the tables that only make sense raw stop contradicting each other.

Every edit

Where a five-level meter touches the program
24 · everything else — untouched by this featureABCD$000000$100000
  • 1
    HUD — gauge and level indicator
    $000000$007000 · 28 KB
    Full-bar threshold, special-state index, and the two indicator draw sites.
  • 2
    team-Hyper meter value
    $007000$017000 · 64 KB
  • 3
    meter gain, cap and level-up voice
    $017000$01D000 · 24 KB
  • 4
    everything else — untouched by this feature
    $01D000$100000 · 908 KB
  • A
    the cap
    $01CD58 · marker
  • B
    gauge full
    $006C4A · marker
  • C
    level glyph
    $006B6E · marker
  • D
    team value
    $016E08 · marker
sitewasnow
$01CD58moveq #$3,d1moveq #$5,d1
$006C4A, $006C90cmpi.b #$3,d4cmpi.b #$5,d4
$006B58, $006BD8move.b #$4,d5 (special state)move.b #$6,d5 — 4 is a real level now
$006B6E, $006BEElea + the stock booleanjmp to a payload routine that computes $6AB4 + level
$006B8C, $006C0Csecond table readmove.w d4,d2 — the routine left the frame tile there
$01CDA84-entry voice table readjmp to a clamped private copy
$016E084-entry value table readmulu.w #$C0,d1
$025B98$025BAA, $0D45D6$0D45E8grant-full writes 3writes 5

156 bytes of new payload in an existing $FF run. No member changes size.

How the edits were checked

There is no m68k assembler in the build environment, so every instruction was hand-encoded next to the mnemonic it is supposed to be:

Ins("0c400005", "cmpi.w #$5, d0"),
Ins("c0fc0060", "mulu.w #$60, d0"),
Ins("207c001861c0", "movea.l #$1861c0, a0"),

and a verifier disassembles each one with capstone and refuses the build on any mismatch. It has already earned its place twice: it caught move.w #$6BDA,d4 written where d2 was meant, and it caught a two-byte edit that had been positioned in the middle of a four-byte instruction. Both would have shipped.

Afterwards a second pass reads the built ROM back, decrypts each edited address, and compares the plaintext to what the author wrote. Sixty-one checks, zero failures.

What this does not claim

Static, and unbalanced on purpose

Every claim above comes from the ROM: decrypted listings, raw preimages, decoded tile bitmaps, byte-exact round-trips. None of it comes from a long play session. A five-level meter also changes the game's economy — no Hyper cost was rebalanced, and the level-3 Hypers still cost three. That is a design decision left open, not an oversight.

The meter change rides inside the combined build alongside the Super-to-Super work and the six-color palette system. It is not a separate download.

Written by Daniel Plas Rivera · 1,578 words · $33

ShareXLinkedIn