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.

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 contactA0 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 71That 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 $001D19EThis 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 codeAt $0BC684, Capcom runs an 11-record clear loop:
moveq #10,d0
clear:
clr.l (a0)+
clr.l (a0)+
dbra d0,clear
rtsThe 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.

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 pathThe 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:

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 0For 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.


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.

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:
- 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. - Stock code runs, start to finish. It writes the tens record, the ones record, then X/Y, then
the three
HITletters. The blank-on-zero branch lives in here: at$0BC5ECa zero tens nibble jumps to$0BC5FE, which writes blank tile$6800and clears the attribute. Both the real-digit path and the blank path then converge at$0BC608and carry on to the ones digit. Nothing of mine is executing during any of this. - 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 $0BC684done: 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:

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:
| role | decrypted CPU | clean plaintext | patched plaintext |
|---|---|---|---|
| increment + shadow | $01D18E | 1028012052000c0000636304103c0063 | 4ef9003f10004e714e714e714e714e71 |
| 16-bit BCD input | $0BC5BA | 7000102e00604eb9000019620c4000996f04303c0099 | 4ef9003f10484e714e714e714e714e714e714e714e71 |
| hundreds record | $0BC684 | 700a4298429851c8fffa4e75 | 4ef9003f10764e714e714e71 |
The encrypted on-disk edits are correspondingly small:
| member | member offset | bytes | purpose |
|---|---|---|---|
mvce.03a | $01D18E | 16 | encrypted increment hook |
mvce.04a | $03C5BA | 22 | encrypted BCD/render hook |
mvce.04a | $03C684 | 12 | encrypted hundreds hook |
mvc.10 | $071000 | 272 | word-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:
- 1encrypted program — mvce.03a + mvce.04a$000000–$100000 · 1 MBAll 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.
- 2mvc.05a … mvc.09 — high program and data$100000–$380000 · 2.50 MBUntouched by this patch.
- 3mvc.10 — stock code and data$380000–$3F1000 · 452 KB
- 4the 272-byte payload$3F1000–$3F1110 · 272 BAn existing run of $FF inside an already-allocated 512 KiB member. This is the whole of 'no ROM expansion'.
- 5still-free $FF tail$3F1110–$400000 · 59.7 KBAbout 60 KB of the run is still spare after the payload.
- Aincrement hook$01D18E · marker
- BBCD render hook$0BC5BA · marker
- Chundreds hook$0BC684 · marker

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 745f7d060a9ab6296f14899f783b76b518f6d5382e196022d2dedecca425c2baEvery 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:
- Preserve the raw archive and record every member SHA-256.
- Word-unswap
mvce.03aandmvce.04a, concatenate them, and run the CPS-2 key schedule for the Euro parent key. - Import the resulting 1 MiB plaintext as Motorola 68000, big-endian, 32-bit address space at
image base
$000000. - Create executable memory for that low program and label
$01D18E,$0BC5BA,$0BC684,$0BC690, and$001962. - Map the word-unswapped
mvc.10high-program member at CPU$380000. Member offset$071000therefore appears at CPU$3F1000. - 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.

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.
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 ; / 32Consumer 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:
- 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.
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+$928is outside the safe fighter-local contract and corrupted adjacent RAM.fighter+$35F/$360was overwritten with$03E7on later engine paths.- mirror bytes
fighter+$920/$921were also live engine state. - HUD
+$60/+61could not become a word because MVC reuses+$61. - the first proposed globals
$FF7B00..$FF7B03visibly 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.



The accepted final battery, all on the sealed release archive:
| side | requested HUD | native fighter byte | result |
|---|---|---|---|
| P1 | 11 | 11 | visible 11 HIT, bit-identical to unpatched parent |
| P1 | 99 | 99 | visible 99 HIT, bit-identical to unpatched parent |
| P1 | 100 | 99 | visible 100 HIT, forced tens zero |
| P1 | 101 | 99 | visible 101 HIT |
| P1 | 110 | 99 | visible 110 HIT, live tens untouched |
| P1 | 200 | 99 | visible 200 HIT, remainder-zero family |
| P1 | 999 | 99 | visible 999 HIT, saturated |
| P2 | 101 | 99 | mirrored visible 101 HIT |
| P2 | 999 | 99 | mirrored 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 PCIf that hash matches, you are looking at the same program I was. Three landmarks confirm you can also read it:
| CPU address | decrypted bytes | disassembles to |
|---|---|---|
$01D18E | 1028 0120 5200 0C00 0063 6304 103C 0063 | move.b $120(a0),d0 · addq.b #1,d0 · cmpi.b #$63,d0 · bls.b · move.b #$63,d0 |
$0BC5BA | 7000 102E 0060 4EB9 0000 1962 0C40 0099 6F04 303C 0099 | moveq #0,d0 · move.b $60(a6),d0 · jsr $1962 · cmpi.w #$99,d0 · ble.b · move.w #$99,d0 |
$0BC684 | 700A 4298 4298 51C8 FFFA 4E75 | moveq #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
rtsm68k-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 # d692d0c1c3156da204b8ad0eb65baae89df5eac104b3875304c4f364a4a7a35dThe 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 address | decrypted before | decrypted after | len |
|---|---|---|---|
$01D18E | 1028012052000c0000636304103c0063 | 4ef9003f1000 + 4e71 ×5 | 16 |
$0BC5BA | 7000102e00604eb9000019620c4000996f04303c0099 | 4ef9003f1048 + 4e71 ×8 | 22 |
$0BC684 | 700a4298429851c8fffa4e75 | 4ef9003f1076 + 4e71 ×3 | 12 |
With all three applied, the decrypted megabyte should hash to:
92f6ba9638891ba01eda735d49c5e424da17fc691aba2c0f0fff538ee25d30726. 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.