case studyUpdated Aug 31, 202630 min read$29

MvC Doesn't Count Past 99. I Made It Show 999 Without Growing the ROM.

The stock counter really saturates; it isn't hiding extra hits. A separate display counter reaches 999 while the gameplay counter and ROM size stay unchanged.

All versions · Every public patch

Jump to evidence
cps2marvel-vs-capcomreverse-engineering68000assembly
CPS-2 Reverse EngineeringPart 31 of 43
Browse all writing
On this page
The 999-HIT display is also in the combined DHC release

This page remains the standalone v1 release history and full reverse-engineering tutorial. The current DHC download includes the same stock-size 0–999 display alongside Super-to-Super, Special-to-Super, six colors, Magnetic Shockwave, Gold Mega Optic, and native-Duo isolation. In both editions, fighter+$120 remains capped at 99; 100–999 lives only in the display shadow.

Marvel vs. Capcom stops its combo counter at 99 HIT. This patch takes it to 999 — on real CPS-2 hardware, in the original 1998 arcade ROM, without expanding it by a single byte and without touching the counter the game uses for damage.

Marvel vs. Capcom running on CPS-2 with a hardware-rendered 999 HIT counter on the Player 1 side
FINAL HARDWARE HUD // P1 renders 999 HIT using Capcom's digit tiles and one preallocated sprite record—no Lua-drawn text and no ROM expansion

Why anyone wanted this

Players got better. That is the whole origin of this patch.

Combos in Marvel vs. Capcom kept getting longer, and eventually enough people were pushing past ninety-nine hits that the counter's behaviour became a real question in the community: what happens after 99 HIT? The display just stops. The hits keep landing, the opponent keeps taking damage, and the number on screen sits there. The natural assumption — mine too, going in — was that the game must still be counting somewhere and simply not drawing it.

It isn't. That turned out to be the first real finding of this whole exercise, and it is why the patch is a patch and not a two-byte display tweak: the counter genuinely stops at ninety-nine. The byte saturates, on purpose, in a single instruction, which is where the disassembly below starts. There is no hidden hundreds digit waiting to be revealed. If you want the number to keep going, something has to count for it.

The reason it matters is that a combo counter is a scoreboard. Once it reads past ninety-nine you can actually answer the questions people were asking:

  • How long was that, really — a hundred and four, or two hundred and forty?
  • Did the loop I just found beat the one in that clip?
  • Is this route worth the meter, or does the damage flatten out?
  • Can we set a record and have the screen agree with us?

Marvel vs. Capcom 2 has this. Its counter rolls into three digits, and hundred-hit combos are common enough there to be an achievement. MvC1 never got it — the sequel solved a problem the first game shipped with, and arcade MvC1 players have been living with the ninety-nine ceiling ever since. This patch brings that piece of the MvC2 experience back to the game it should have been in first, on the original CPS-2 hardware, without expanding the ROM by a byte.

Start where the game increments the count

The clean decrypted 68000 code at CPU $01D18E is the important starting point:

; clean MVC, decrypted CPU view
$01D18E  move.b  $0120(a0),d0
$01D192  addq.b  #1,d0
$01D194  cmpi.b  #$63,d0       ; decimal 99
$01D198  bls.s   $01D19E
$01D19A  move.b  #$63,d0       ; clamp every later contact

A0 is the attacking fighter. A tempting change is to replace $63 with $E7 or $FF, but that still cannot represent 999, and it changes a gameplay value consumed outside the HUD. The safe boundary is to leave the byte domain alone.

The patch replaces the 16-byte block with:

4E F9 00 3F 10 00  4E 71 4E 71 4E 71 4E 71 4E 71

That is JMP $003F1000 followed by five 68000 NOPs. The payload maintains a word shadow, clamps that word to 999, then returns min(shadow,99) in D0 before rejoining at $01D19E.

move.w  (a1),d0
addq.w  #1,d0
cmpi.w  #999,d0
bls.s   store
move.w  #999,d0
store:
move.w  d0,(a1)
cmpi.w  #99,d0
bls.s   rejoin
moveq   #99,d0
rejoin:
jmp     $001D19E

This is the central design decision: 999 is presentation state, not a change to MVC's native damage/combo state.

Where the extra digit comes from

The stock HUD already reserves 16 consecutive eight-byte sprite records for the combo label:

record 0  tens digit
record 1  ones digit
record 2  H
record 3  I
record 4  T
records 5..15  cleared by stock code

At $0BC684, Capcom runs an 11-record clear loop:

moveq   #10,d0
clear:
clr.l   (a0)+
clr.l   (a0)+
dbra    d0,clear
rts

The patch reproduces that clear, then uses record 5 by moving the existing five visible records up one slot. It fills record 0 with the hundreds digit from Capcom's own digit-tile table at $0BC690 (data view: 6AC0 6AC2 … 6AE2, one word per glyph). No new object pool, graphics tile, palette, or ROM allocation is required.

For P1, the old 99 HIT group begins at the left boundary. The new hundreds digit stays at the old tens X coordinate and the five stock records shift right by $20 — the digit pitch Capcom's own layout uses. For P2, the group is right-anchored, so the hundreds digit extends left by $20 while the five stock records keep their original X positions.

Marvel vs. Capcom running with a mirrored 999 HIT counter on the Player 2 side
MIRRORED LAYOUT // P2 adds the hundreds digit to the left, retaining the stock right-side anchor

The two renderer hooks

The first renderer hook replaces 22 decrypted bytes at $0BC5BA and reads the side-specific word directly:

; payload entry $3F1048
lea     $FF7F20,a1
tst.b   2(a4)             ; fighter side
beq.s   side_ready
lea     $FF7F22,a1
side_ready:
moveq   #0,d0
move.w  (a1),d0
cmpi.w  #999,d0
bls.s   convert
move.w  #999,d0
convert:
jsr     $00001962         ; Capcom binary -> packed BCD
jmp     $000BC5D0         ; stock tens/ones + HIT path

The second hook replaces the 12-byte clear/return at $0BC684, shifts the five records when the word is at least 100, divides by 100, writes the hundreds tile — and then repairs one thing Capcom's code was never designed to handle. That repair is the next section, because the first build shipped without it.

The stock HUD object's +$60 field remains a byte. That detail matters: an earlier prototype widened it to a word and appeared to work for one frame, but MVC later reused +$61 and silently turned the stored value into $03E7. The final renderer never relies on that adjacent byte.

100 rendered as "1 0": the leading-zero trap

The first full battery reported PASS at 99, 100, 101, and 999 — the counters were mechanically right in RAM, and 999 looked perfect on screen. Then the boundary frames came back looking like this:

The first build rendering 100 HIT as a 1, a blank gap, and a 0
THE BUG // first build, value 100: hundreds and ones render, the tens position is a hole. 101 rendered as a convincing-looking '1 1'

999 hid the defect; 100 and 101 exposed it. The cause is in Capcom's stock two-digit writer at $0BC5D0, which the patch reuses for the low two digits:

; stock tens-digit path, decrypted CPU view
$0BC5E8  andi.w  #$f,d0        ; BCD tens nibble
$0BC5EC  beq.b   $0BC5FE       ; zero? -> blank the record
$0BC5EE  add.w   d0,d0
$0BC5F0  move.w  (a3,d0.w),d0  ; digit tile
$0BC5F4  move.w  d0,$4(a0)
$0BC5F8  move.w  d4,$6(a0)     ; attr $111F
$0BC5FC  bra.b   $0BC608
$0BC5FE  move.w  #$6800,$4(a0) ; blank tile
$0BC604  clr.w   $6(a0)        ; attr 0

For a two-digit maximum this blanking is correct — it is why the game shows 5 HIT instead of 05 HIT. Beside a hundreds digit it is wrong: 100 becomes 1_0 and 101 becomes 1_1, with a 32-pixel hole where the significant zero should be. The BCD input the patch feeds the stock writer is shadow % 100, so every x00–x09 value trips the same branch.

The fix rides the registers the payload already has. After divu.w #100 the quotient sits in the low word and the remainder in the high word, and Capcom's code always writes the tens record's X/Y — only the tile and attribute get blanked. So the payload forces the glyph back:

; after writing the hundreds tile from (a3,d0.w)
swap    d0                ; d0.w = divu remainder 0..99
cmpi.w  #10,d0
bcc.s   done              ; tens 1..9: stock drew the real digit
move.w  (a3),$c(a0)       ; digit-table entry 0 -> tens record tile
move.w  #$111f,$e(a0)     ; stock visible attribute
done:

Eighteen bytes. The payload grew from 254 to 272, still inside the same stock $FF run in mvc.10. Values 100–109, 200–209, and every other x0y family now render all three digits at the stock pitch, and values with a live tens digit — 110, 999 — are untouched because the stock writer already drew them.

Marvel vs. Capcom displaying a correct 100 HIT after the fix
FIXED // 100: hundreds, forced tens zero, and ones, all at Capcom's 32-pixel digit pitch
Marvel vs. Capcom displaying 200 HIT
200 // the remainder-zero family: forced tens zero plus Capcom's unconditional ones digit

One worry remained: does forcing anything leak into the two-digit domain? It cannot — the whole hundreds path is gated on shadow >= 100 — but "cannot" is a claim, so it went into the battery instead: 11 HIT and 99 HIT were captured on the patched ROM and on the unpatched parent in the same aligned 51-frame window. Fifty of fifty-one frames are bit-identical across the entire counter region. The one divergent frame is the patch making its own point: a trailing hyper contact landed inside the window, the unpatched byte stayed saturated at 99, and the patched build rendered the honest 100 — a value difference, not a rendering one. The spacing you see between two narrow 1 glyphs at 11 HIT is Capcom's own 32-pixel cell layout, unchanged.

Side-by-side counter crops of 11 HIT on the unpatched parent and on the patched build
STOCK PARITY // 11 HIT, unpatched parent (top) vs patched build (bottom): the below-100 renderer is byte-for-byte stock

Where done: actually goes: two hooks, two moments

Someone working through this patch in a hex editor sent back the best kind of question: in the tens-zero fix above, where does the done: label bring you to in the stock tens-digit path?

Nowhere — and that is the answer. Those two blocks of code never touch each other. They belong to two different hooks that fire at two different moments of the same HUD build, and putting them side by side (which is how I presented them) makes them look like one routine with a branch. Here is the actual order of events in a single frame:

  1. Hook one fires at $0BC5BA. The payload reads the 16-bit display shadow, clamps it to 999, calls Capcom's binary→BCD helper at $001962, and jumps into the stock writer at $0BC5D0.
  2. Stock code runs, start to finish. It writes the tens record, the ones record, then X/Y, then the three HIT letters. The blank-on-zero branch lives in here: at $0BC5EC a zero tens nibble jumps to $0BC5FE, which writes blank tile $6800 and clears the attribute. Both the real-digit path and the blank path then converge at $0BC608 and carry on to the ones digit. Nothing of mine is executing during any of this.
  3. Hook two fires at $0BC684 — the 11-record clear loop at the very end of the routine. The payload reproduces that clear, and only then does the three-digit work: shift the records, write the hundreds digit, repair the tens record. done: is the end of that block.

So done: falls through to exactly two instructions and leaves:

; the payload's second hook — the tail in question, at its real addresses
$3F10DC  divu.w  #100,d0            ; d0 = remainder:quotient
$3F10E0  andi.w  #$F,d0
$3F10E4  add.w   d0,d0
$3F10E6  lea     $0BC690,a3         ; Capcom's own digit-tile table
$3F10EC  move.w  (a3,d0.w),$4(a0)   ; hundreds tile -> record 0
$3F10F2  move.w  #$111F,$6(a0)
$3F10F8  swap    d0                 ; d0.w = remainder, 0..99
$3F10FA  cmpi.w  #10,d0
$3F10FE  bcc.s   $3F110A            ; tens 1..9: stock already drew the real digit
$3F1100  move.w  (a3),$C(a0)        ; force the '0' glyph into the tens record
$3F1104  move.w  #$111F,$E(a0)
$3F110A  movem.l (a7)+,d0-d1/a0-a3  ; <-- this is `done:`
$3F110E  rts                        ; back to whatever called $0BC684

done: is a register restore and an rts. It does not re-enter stock code, because the stock code it appears to be patching already finished running two steps earlier.

Why the repair writes $C/$E when stock blanked $4/$6

The second half of the same question, and the part that actually took me a while to get right when I wrote it. Stock blanks the tens digit at tile $4(a0), attribute $6(a0) — that is sprite record 0. My repair writes tile $C(a0), attribute $E(a0) — record 1. Those are not two different digits. They are the same record, at two different addresses, because the payload moved it in between:

Diagram of the sprite record array: in stock the records are tens, ones, H, I, T and eleven cleared; after the patch's shift they are hundreds, tens, ones, H, I, T, with the tens record moving from tile offset $4 to tile offset $C
THE SHIFT // records are eight bytes each, so moving the whole visible group up one slot moves the tens record's tile from $4(a0) to $C(a0). The repair writes the record where it now lives

Each record is eight bytes (X, Y, tile, attribute). The payload slides the five stock records up one slot with ten move.l -(a1),-(a2) copies, which frees record 0 for the hundreds digit and puts everything else eight bytes higher. The blanked tens record lands at $C/$E, and that is where it gets its glyph back.

Exact CPU hooks and ROM members

These are the only decrypted program hooks:

roledecrypted CPUclean plaintextpatched plaintext
increment + shadow$01D18E1028012052000c0000636304103c00634ef9003f10004e714e714e714e714e71
16-bit BCD input$0BC5BA7000102e00604eb9000019620c4000996f04303c00994ef9003f10484e714e714e714e714e714e714e714e71
hundreds record$0BC684700a4298429851c8fffa4e754ef9003f10764e714e714e71

The encrypted on-disk edits are correspondingly small:

membermember offsetbytespurpose
mvce.03a$01D18E16encrypted increment hook
mvce.04a$03C5BA22encrypted BCD/render hook
mvce.04a$03C68412encrypted hundreds hook
mvc.10$071000272word-swapped payload at CPU $3F1000

Those member offsets are where a hex editor actually lands — but the bytes sitting there are encrypted, so they look nothing like the 68000 above. This is the same three hooks in the raw file view, which is what you will be staring at if you patch by hand:

byte patch $01D18E · mvce.03a16 bytes · 16 changed
before8F0AFA24CA32343503F5BCEBAA305F6F
after8D4159C3502493DB2E97E86A82F55CA7
increment + display shadow. Decrypted, these same 16 bytes are the JMP $3F1000 and its five NOPs.
byte patch $03C5BA · mvce.04a22 bytes · 21 changed
beforeE3733FB75F8E8137AF3856160FD08ED5C1A82EDDFD9B
after2E73AB522F7402DD4D507B6CEFB3EAD66EEF4B04F285
16-bit BCD render hook — the entry that feeds the shadow to Capcom's digit writer.
byte patch $03C684 · mvce.04a12 bytes · 12 changed
beforeA8260994D1A9132B0540EA9A
after3F859A224AFE6C17EE2FF9C6
hundreds-record hook. The 272-byte payload itself is plain, unencrypted bytes in mvc.10 at $071000.
Where the patch touches the 4 MB program map
12 · mvc.05a … mvc.09 — high program and data3ABC$000000$400000
  • 1
    encrypted program — mvce.03a + mvce.04a
    $000000$100000 · 1 MB
    All three code hooks live here. CPS-2 decrypts this on opcode fetch only, which is why the same addresses read as sense in a debugger and as noise in a hex editor.
  • 2
    mvc.05a … mvc.09 — high program and data
    $100000$380000 · 2.50 MB
    Untouched by this patch.
  • 3
    mvc.10 — stock code and data
    $380000$3F1000 · 452 KB
  • 4
    the 272-byte payload
    $3F1000$3F1110 · 272 B
    An existing run of $FF inside an already-allocated 512 KiB member. This is the whole of 'no ROM expansion'.
  • 5
    still-free $FF tail
    $3F1110$400000 · 59.7 KB
    About 60 KB of the run is still spare after the payload.
  • A
    increment hook
    $01D18E · marker
  • B
    BCD render hook
    $0BC5BA · marker
  • C
    hundreds hook
    $0BC684 · marker
Three hooks in the encrypted low program, one payload in the free tail of mvc.10. The other 18 ZIP members come out byte-identical to the dat-official parent.
Hilbert-curve map of Marvel vs. Capcom's four-megabyte CPS-2 program space at one byte per pixel, with $FF filler bytes drawn in gold and the payload arena at the tail of mvc.10 outlined in white
THE WHOLE ROM, ONE BYTE PER PIXEL // an order-11 Hilbert curve, so addresses that are neighbours in the ROM stay neighbours on screen and each 512 KiB member shows its own texture. Gold is $FF: 315,156 bytes of it, 7.51% of the program space, 39% of that inside mvc.10 alone

That map is the same claim as the diagram above it, drawn from the actual bytes instead of from my description of them. The outlined block in the top right is the entire budget this patch had to work with — 61,440 bytes of stock $FF at the tail of mvc.10 — and the patch spends 272 of them. The salmon noise filling the top-left quadrant is the encrypted low program, which is the next section's problem.

The payload symbols are $3F1000, $3F1048, and $3F1076; its assembled SHA-256 is d692d0c1c3156da204b8ad0eb65baae89df5eac104b3875304c4f364a4a7a35d.

Only mvc.10, mvce.03a, and mvce.04a differ in the output; their patched member SHA-256s — the values Fightcade peers should compare — are:

mvc.10    7cc249a037732587efc33639364473ef805bafe3d0c199c09fb75be8a6c83184
mvce.03a  88093767524214a39e3357ea9c6e7c004f98b5fbcc921b6d138a2e6254806541
mvce.04a  745f7d060a9ab6296f14899f783b76b518f6d5382e196022d2dedecca425c2ba

Every other member passes through untouched. That is what "no ROM expansion" means here: the code uses an existing $FF run in the already allocated 512 KiB mvc.10, not a larger EPROM image and not an extra ZIP member.

Why MAME and Ghidra seem to disagree

MAME's debugger disassembly window shows the 68000's decrypted opcode-fetch view. If you break at $01D18E in MAME, you see the meaningful move.b/addq/cmpi sequence above. Opening raw mvce.03a in a hex editor shows encrypted CPS-2 bytes instead.

For Ghidra, I use this workflow:

  1. Preserve the raw archive and record every member SHA-256.
  2. Word-unswap mvce.03a and mvce.04a, concatenate them, and run the CPS-2 key schedule for the Euro parent key.
  3. Import the resulting 1 MiB plaintext as Motorola 68000, big-endian, 32-bit address space at image base $000000.
  4. Create executable memory for that low program and label $01D18E, $0BC5BA, $0BC684, $0BC690, and $001962.
  5. Map the word-unswapped mvc.10 high-program member at CPU $380000. Member offset $071000 therefore appears at CPU $3F1000.
  6. Use MAME breakpoints on the same CPU addresses to verify register roles and rejoin behavior.

One CPS-2 subtlety the tens-zero fix depends on: the encryption applies to opcode fetches, so data tables read through move.w (a3,d0.w) come from the plain member bytes. The digit-tile table at $0BC690 disassembles as garbage in the decrypted code view and reads as clean consecutive glyph words (6AC0 6AC2 …) in the data view. Both views are the same ROM.

Two Hilbert-curve maps of the same megabyte of Marvel vs. Capcom's program ROM side by side: the raw on-chip bytes on the left, the CPS-2-decrypted opcode view on the right, with the digit-tile table marked in both panels
SAME MEGABYTE, TWO VIEWS // CPU $000000-$0FFFFF as raw on-chip bytes (left) and after CPS-2 decryption (right), one byte per pixel. The marked ring in each panel is the digit-tile table at $0BC690: clean glyph words on the left, garbage on the right, same ROM

There is a measurement in that pair that I got backwards on the first look, and it is worth stating as a number rather than an impression. The raw view looks more structured — big flat regions, visible block seams — and the decrypted view looks like fine noise, so the obvious read is that decryption scrambles things. Measured across that megabyte it is the other way round: raw is 7.580 bits per byte of entropy, decrypted is 6.800. Decryption lowers entropy, which is exactly what revealing structure looks like. The decrypted panel appears noisier only because 68000 opcodes cluster into a narrow band of byte values, and a narrow band painted at one byte per pixel reads to the eye as dense speckle. Both things are true at once, and only one of them is visible.

Do not paste decrypted JMP bytes into the encrypted .03a/.04a files

The builder patches the decrypted CPU image, re-encrypts it with the pinned Euro-parent keys, and then verifies decrypt-after-encrypt equality. The CMD installer carries the already audited encrypted disk bytes. CPU-view and member-view offsets are related, but their byte values are not interchangeable.

The 99 cap was never what protected damage scaling

One more thing worth settling, because it is the first objection anyone raises when you widen a counter: does any of this touch damage? A claim that came in alongside the question above was that MVC's damage scaling maxes out at 18 hits, not 99. It does — and the ROM says so in one instruction. There are two gameplay consumers of the native counter at fighter+$120, and each one clamps the value before it uses it:

; consumer A — $01CA68
$01CA68  move.b  $120(a4),d0
$01CA6C  cmpi.b  #$12,d0        ; 18 decimal
$01CA70  bcs.b   $01CA74
$01CA72  moveq   #$12,d0        ; saturate at 18, then index a nested table
 
; consumer B — $01CC74
$01CC74  move.b  $120(a6),d1
$01CC78  cmpi.b  #$A,d1         ; 10 decimal
$01CC7C  ble.b   $01CC80
$01CC7E  moveq   #$A,d1         ; saturate at 10
$01CC80  add.w   d1,d1
$01CC82  movea.l #$0E73DA,a1    ; the scaling table
$01CC88  move.w  (a1,d1.w),d1
$01CC8C  mulu.w  d1,d0          ; damage * multiplier
$01CC8E  lsr.w   #5,d0          ; / 32

Consumer B's table is readable, and it is the familiar shape of a scaling curve. Reading it needs the same trick as the digit-tile table earlier in this post — it is data, so it comes from the raw member bytes, not the decrypted view:

Damage multiplier by combo count — the table at $0E73DA, in thirty-secondths
  • count 032of 32

    full damage

  • count 132of 32
  • count 230of 32
  • count 328of 32
  • count 416of 32

    half

  • count 58of 32
  • count 64of 32
  • count 72of 32
  • count 81of 32

    the floor: 1/32, and the code enforces a minimum of 1 damage anyway

  • count 91of 32
  • count 10 and up1of 32

    the index is clamped here, so every later hit in the combo reads this same entry

By hit eight the multiplier is already on its floor, and by hit ten the index stops moving at all. The other consumer stops caring at eighteen. Either way, the whole 19–99 range of that byte is invisible to damage scaling — and the display shadow's 100–999 range is further outside it still. So the design decision this post opens with, keeping the native byte saturated at 99, is not merely conservative; it is provably outside the domain any scaling code can observe.

But 'scaling maxes at 18' is not 'the byte is free above 18'

Worth separating, because it would be an easy and expensive mistake. The scaling consumers saturate early, but they are not the only readers: fighter+$120 has twenty-five references in the program. The HUD reads it ($0BC2C2, $0BC434, $0BC44E), chain logic compares it against small constants ($01AEAE, $01D242, $07CF84), and other code writes it outright ($049566 sets #$F, $06BAEC adds #$D). Widening that byte would still be a gameplay change with a long tail of consequences. Leaving it capped and putting the 0–999 count in its own display-only word remains the right call — the scaling clamps just prove the blast radius was never as wide as the 99 suggested.

The storage locations that failed

This part took longer than drawing the digit. Several locations looked unused in a short trace and were wrong:

  • fighter+$928 is outside the safe fighter-local contract and corrupted adjacent RAM.
  • fighter+$35F/$360 was overwritten with $03E7 on later engine paths.
  • mirror bytes fighter+$920/$921 were also live engine state.
  • HUD +$60/+61 could not become a word because MVC reuses +$61.
  • the first proposed globals $FF7B00..$FF7B03 visibly worked, but a clean-parent write hook proved 13 overlapping stock writes, so that candidate was revoked before packaging.

The final pair $FF7F20/$FF7F22 is in a bank MVC initializes during boot. The first-hit code resets its word again, so boot contents never become a count. The write census was re-run for this release against the true dat-official parent, with a MAME write tap on all four bytes and the attacker seated on P1 and then on P2: stock writes them only while the match phase is still $0000, and zero times while the fight is live at $0800.

This is a useful reverse-engineering lesson: an address is not spare because it begins at zero or survives one move. Storage ownership needs an actual write census across the lifecycle in which the feature depends on it.

Boundary evidence, not just a 999 screenshot

The live wrapper does not draw text. It boots a real two-player match, seats Captain America against Ryu through the game's own challenger flow, and lets Captain's natural multi-hit hyper do the counting. The harness stages requested-1 only after the first natural combo commit, and requires the next natural hit to advance the payload to the requested value. Screenshots are the CPS-2 sprite HUD as the emulator rendered it — gui.gdscreenshot under FBNeo, video.snapshot under MAME. A cut-down version of that harness is in the walkthrough below.

clip · loops · audio available
LIVE GAMEPLAY // Wolverine + War Machine ride a Proton Cannon straight through the 99->100 layout change to a natural 132 HIT on the patched build
Marvel vs. Capcom displaying stock-layout 99 HIT on Player 1
99 // below 100 the extra record stays disabled and the layout remains stock
Marvel vs. Capcom displaying 101 HIT on Player 1
101 // the value that exposed the leading-zero trap, now rendering all three digits
Marvel vs. Capcom displaying a mirrored 101 HIT on the Player 2 side
P2 101 // the mirrored layout gets the same forced tens zero, anchored from the right

The accepted final battery, all on the sealed release archive:

siderequested HUDnative fighter byteresult
P11111visible 11 HIT, bit-identical to unpatched parent
P19999visible 99 HIT, bit-identical to unpatched parent
P110099visible 100 HIT, forced tens zero
P110199visible 101 HIT
P111099visible 110 HIT, live tens untouched
P120099visible 200 HIT, remainder-zero family
P199999visible 999 HIT, saturated
P210199mirrored visible 101 HIT
P299999mirrored visible 999 HIT

The offline model also walks 1,200 consecutive increments: display state reaches 999 and stays there, while the returned native value reaches 99 and stays there. Encrypted build verification decrypts all three output hooks, reads back the high-ROM payload, checks the exact changed-member set, and rejects any member-size drift.

Build it yourself, step by step

The installer at the end is the only file I hand out. Everything that used to sit beside it as a download — the payload source, the builder, the harness — is below as a procedure instead, because a procedure outlives a dead link and because the standing complaint about this scene is that nobody shows their work. Bring your own dump; nothing here contains ROM data. Tools: Python 3, m68k ELF binutils, and MAME or FBNeo.

1. Start from a dat-official parent

MD5 56106b6e6aad66e7146e11f00f4e60cc, 21 members, mvsc.key among them. Every offset below is relative to that exact archive. If yours differs, the addresses still exist but the instructions at them may not, and a hook written over the wrong instruction gives you a ROM that boots fine and dies at the first combo.

2. Get a decrypted CPU image, and prove it before touching anything

Word-unswap mvce.03a and mvce.04a, concatenate them at CPU $000000 and $080000, and run the CPS-2 decryption across $000000-$0FFFFF. MAME's cps2crypt.cpp is the canonical implementation — Andreas Naive's analysis of the cipher, Nicola Salmoria's code — and the per-game key is the 20-byte mvsc.key member already sitting inside your own archive.

Check the result before going further, because everything after this assumes it:

sha256 of the correct decrypted 1 MiB
  c3e8fdff391d8666f231b6bde175b110b99e2e2da531eeeaea3189e38ec8910a
 
reset vector at $000000
  00FF245C  initial SP      000000E2  initial PC

If that hash matches, you are looking at the same program I was. Three landmarks confirm you can also read it:

CPU addressdecrypted bytesdisassembles to
$01D18E1028 0120 5200 0C00 0063 6304 103C 0063move.b $120(a0),d0 · addq.b #1,d0 · cmpi.b #$63,d0 · bls.b · move.b #$63,d0
$0BC5BA7000 102E 0060 4EB9 0000 1962 0C40 0099 6F04 303C 0099moveq #0,d0 · move.b $60(a6),d0 · jsr $1962 · cmpi.w #$99,d0 · ble.b · move.w #$99,d0
$0BC684700A 4298 4298 51C8 FFFA 4E75moveq #10,d0 · clr.l (a0)+ ×2 · dbra d0 · rts

That first row is the entire 99 cap, in the open, in eight words. #$63 is 99.

3. Assemble the payload

This is all of it: 272 bytes of 68000 that keep a 16-bit display shadow, feed it to Capcom's own BCD digit writer, and repair the tens zero the stock renderer blanks.

        .cpu 68000
        .text
        .even
        .set SHADOW_P1,0xff7f20
        .set SHADOW_P2,0xff7f22
 
        .globl hit999_increment
hit999_increment:
        /* Hooked from $01D18E.  A0 is the attacker; preserve D1. */
        move.l  %d1,-(%a7)
        lea     SHADOW_P1,%a1
        tst.b   0x2(%a0)
        beq.s   .Linc_side_ready
        lea     SHADOW_P2,%a1
.Linc_side_ready:
        moveq   #0,%d1
        move.b  0x120(%a0),%d1
        tst.b   %d1
        bne.s   .Linc_sync
        clr.w   (%a1)                    /* first hit of a new chain */
.Linc_sync:
        moveq   #0,%d0
        move.w  (%a1),%d0
        cmp.w   %d1,%d0
        bcc.s   .Linc_have_base
        move.w  %d1,%d0                 /* absorb native forced-count paths */
.Linc_have_base:
        addq.w  #1,%d0
        cmpi.w  #999,%d0
        bls.s   .Linc_store
        move.w  #999,%d0
.Linc_store:
        move.w  %d0,(%a1)
        cmpi.w  #99,%d0
        bls.s   .Linc_rejoin
        moveq   #99,%d0                 /* native byte remains capped at 99 */
.Linc_rejoin:
        move.l  (%a7)+,%d1
        jmp     0x001d19e
 
        .even
        .globl hit999_render_begin
hit999_render_begin:
        /* Hooked from $0BC5BA.  Keep HUD +$60 as Capcom's stock byte; +$61
         * is reused later, so the renderer reads the side shadow directly. */
        move.l  %a1,-(%a7)
        lea     SHADOW_P1,%a1
        tst.b   0x2(%a4)
        beq.s   .Lrender_side_ready
        lea     SHADOW_P2,%a1
.Lrender_side_ready:
        moveq   #0,%d0
        move.w  (%a1),%d0
        move.l  (%a7)+,%a1
        cmpi.w  #999,%d0
        bls.s   .Lrender_convert
        move.w  #999,%d0
.Lrender_convert:
        jsr     0x00001962              /* native binary -> packed BCD */
        jmp     0x000bc5d0              /* stock tens/ones + HIT renderer */
 
        .even
        .globl hit999_render_finish
hit999_render_finish:
        /* Hooked from $0BC684.  Reproduce the stock 11-record clear first. */
        moveq   #10,%d0
.Lclear_tail:
        clr.l   (%a0)+
        clr.l   (%a0)+
        dbra    %d0,.Lclear_tail
        movem.l %d0-%d1/%a0-%a3,-(%a7) /* preserve stock post-clear register state */
 
        lea     SHADOW_P1,%a1
        tst.b   0x2(%a4)
        beq.s   .Lfinish_side_ready
        lea     SHADOW_P2,%a1
.Lfinish_side_ready:
        move.w  (%a1),%d0
        cmpi.w  #100,%d0
        bcs.s   .Lrender_done            /* <=99 remains byte-for-byte stock */
 
        /* Move the five stock records (two digits + H/I/T) up one slot. */
        movea.l 0x34(%a6),%a0
        lea     0x28(%a0),%a1
        lea     0x30(%a0),%a2
        moveq   #9,%d1
.Lshift_records:
        move.l  -(%a1),-(%a2)
        dbra    %d1,.Lshift_records
 
        /* P1's stock "99 HIT" starts at the left edge.  Keep the hundreds
         * digit at the old tens coordinate and move the five stock records
         * right by $20; placing the new digit left would render off-screen. */
        move.w  0x8(%a0),%d1
        move.w  %d1,(%a0)
        move.w  0xa(%a0),0x2(%a0)
        tst.b   0x2(%a4)
        bne.s   .Lright_side_layout
        lea     0x8(%a0),%a1
        moveq   #4,%d1
.Lshift_right_x:
        addi.w  #0x20,(%a1)
        lea     0x8(%a1),%a1
        dbra    %d1,.Lshift_right_x
        bra.s   .Lwrite_hundreds
.Lright_side_layout:
        /* The mirrored/right-side stock group is already right-anchored; add
         * its hundreds digit to the left of the former tens coordinate. */
        subi.w  #0x20,(%a0)
 
.Lwrite_hundreds:
        divu.w  #100,%d0                 /* low word quotient, high word remainder */
        andi.w  #0x000f,%d0
        add.w   %d0,%d0
        lea     0x000bc690,%a3           /* native decimal digit tile table */
        move.w  (%a3,%d0.w),0x4(%a0)
        move.w  #0x111f,0x6(%a0)
 
        /* Stock blanked the tens record (tile $6800, attr 0) when the BCD tens
         * nibble was zero.  With a hundreds digit present that middle zero is
         * significant: force digit-table entry 0 back into the shifted tens
         * record.  Stock already wrote its X/Y unconditionally. */
        swap    %d0                      /* d0.w = divu remainder 0..99 */
        cmpi.w  #10,%d0
        bcc.s   .Lrender_done            /* tens 1..9: stock drew the real digit */
        move.w  (%a3),0xc(%a0)           /* '0' glyph -> tens record tile */
        move.w  #0x111f,0xe(%a0)         /* stock visible attribute */
.Lrender_done:
        movem.l (%a7)+,%d0-%d1/%a0-%a3
        rts
m68k-elf-as -m68000 -o hit999.o mvsc_999_hit_counter.s
m68k-elf-ld -Ttext=0x3F1000 -e hit999_increment -o hit999.elf hit999.o
m68k-elf-objcopy -O binary -j .text hit999.elf hit999.bin
 
wc -c hit999.bin        # 272
sha256sum hit999.bin    # d692d0c1c3156da204b8ad0eb65baae89df5eac104b3875304c4f364a4a7a35d

The three entry points land at $3F1000, $3F1048 and $3F1076. Step 5's hooks jump to exactly those addresses, so if your build shifts them, the hooks move with them.

4. Write the payload into mvc.10, word-swapped

CPU $3F1000 is member offset $071000 in mvc.10, and that member is stored word-swapped relative to the CPU's view. The assembled bytes go in with each byte pair reversed:

assembled, CPU order    2f01 43f9 00ff 7f20 4a28 0002 6706 43f9 …
on disk inside mvc.10   012f f943 ff00 207f 284a 0200 0667 f943 …

Getting that backwards is the most common way to end up with a ROM that boots, plays, and dies the instant something connects. mvc.10 is not encrypted, so there is nothing else to do to it — the 272 bytes overwrite $FF, and the member size does not change.

5. Patch the three hooks in the decrypted image

Each hook is a jmp to a payload entry point, padded with nop out to the exact length of the stock code it replaces, so nothing downstream shifts by a byte:

CPU addressdecrypted beforedecrypted afterlen
$01D18E1028012052000c0000636304103c00634ef9003f1000 + 4e71 ×516
$0BC5BA7000102e00604eb9000019620c4000996f04303c00994ef9003f1048 + 4e71 ×822
$0BC684700a4298429851c8fffa4e754ef9003f1076 + 4e71 ×312

With all three applied, the decrypted megabyte should hash to:

92f6ba9638891ba01eda735d49c5e424da17fc691aba2c0f0fff538ee25d3072

6. Re-encrypt, repack, check the receipts

Re-encrypt the patched plaintext with the same key and range, then decrypt your own output again and compare it against the plaintext you just built. That round-trip is not ceremony; it is the cheapest way to catch a key or range mistake before it becomes a crash you spend an evening on. Word-swap the result back into mvce.03a and mvce.04a, and repack all 21 members.

Three members change and eighteen must come out byte-identical. The three should hash to the values listed earlier in this post — mvc.10 7cc249a0…, mvce.03a 88093767…, mvce.04a 745f7d06…. If a member you never touched has moved, something in your repack is rewriting data it should have copied.

7. Watch the two counters disagree

The claim worth testing is not that 999 appears — it is that the display counter and the gameplay counter are now separate, with the old one still capped. Drop this beside your ROM and run mame mvsc -autoboot_script watch999.lua:

-- watch999.lua — P1 display shadow against the native gameplay byte
local mem  = manager.machine.devices[':maincpu'].spaces['program']
local last = -1
emu.register_frame_done(function()
  local shadow = mem:read_u16(0xFF7F20)        -- display count, 0..999
  local native = mem:read_u8(0xFF3000 + 0x120) -- gameplay count, still 0..99
  if shadow ~= last then
    last = shadow
    print(string.format('display=%3d  native=%3d', shadow, native))
  end
end)

Build a long combo and the two columns track each other up to 99, then part company: display keeps climbing, native sits at 99 for the rest of the chain. That divergence is the patch. Every screenshot in this post is a consequence of it, and the damage-scaling clamps above are why the second column staying put costs you nothing.

The result is intentionally narrow: a better display, no new gameplay counter domain, no graphics import, no ROM expansion, and enough byte-level detail for another 68000 researcher to rebuild it without me.

Get the standalone v1

Put the CMD beside a clean mvsc.zip, open PowerShell there, and run:

& ".\MVSC-999-HIT-COUNTER-v1.cmd" ".\mvsc.zip"

It writes mvsc_999_hit_counter_v1.zip and a JSON receipt. The original archive is never modified. The installer embeds only hashes and patch bytes; it contains and downloads no ROM data. Your archive's internal member order does not matter — verification is by member name, size, and SHA-256. If it refuses your parent, that is the feature: it will not patch an archive whose members it cannot account for.

Written by Daniel Plas Rivera · 6,536 words · $29

ShareXLinkedIn