I grew up on Marvel vs. Capcom 1, and on the whole Versus line before it. Six buttons: light, medium and heavy on both punch and kick. Combos were something you learned, and the vocabulary lived in your hands.
Then MvC2 was announced and I was hyped — look at the size of that cast — and then I got on the stick. The six buttons were gone: four attacks, two assist buttons. Combos got easier to start and easier to spam, and a lot of what made the older games feel like a craft went with them. I loved how MvC2 looked. I never loved playing it.
Which left me somewhere strange: the character I most wanted to play lives in a game I don't want to play. Cable is a fantastic design — the rifle, the grim face, the AHVB loop that defines him competitively — and he is stuck on the wrong side of a control scheme I bounced off.
So the goal was never "port a character" as an abstract exercise. It is simpler and more selfish than that: get Cable into the game I actually want to play him in, with six buttons, MvC1's engine, and the pacing I grew up with. Then let everyone who feels the same way have him there too.
One thing before the road starts, because it is the reason all of this was hard. Nobody had ever moved a character from the Dreamcast and NAOMI generation back into a CPS-2 game. Not a sprite. A character. There is no 68000 Cable to copy — not in a leak, not in a prototype, not anywhere — so his data could be translated and his behavior had to be written from nothing.
He is standing in the game at the bottom of this page. The road there is the interesting part.
This v30 recording is a progression marker, not the finish line. It captures the build in which the automated fixture could display all eight rifle-shot phases four times and re-arm four contacts. Later hands-on testing exposed the shortened repeat window, muzzle alignment, beam, grenade and audio defects measured in the August 7 audit below. Keeping the video here makes that sequence visible: first make the mechanism exist, then make it behave like Cable.
August 7 release audit: from four flashes to a source-length firing state
The latest research build is mvsc_cable_v33.zip, md5
e8bc68d130d0a776ccb1a56a2d3335c9. v30 remains embedded above because it is the visual
milestone where the full rifle round first survived. v32 fixes the timing structure that video
could not prove, and v33 repairs one missing super-call route. None of those narrow passes turns
Cable's whole MvC2 moveset into a finished port.
The fixture proves that four eight-phase visual chains can complete, four collision events can
re-arm, four $165 commands can reach the sound ring, and a late press can still request the next
round during the full 99-tick source window. It does not prove that the muzzle is perfectly seated
on every frame or that the animation feels identical under a human hand. Animation playback
across the rest of the character still appears too fast; source-versus-CPS-2 timing parity is
being measured family by family rather than declared correct.
- Before standing HP144opponent health remaining
fixture settled
- Round 1 · frame 28125opponent health remaining
first independent contact
- Round 2 · frame 72106opponent health remaining
hit latch re-armed
- Round 3 · frame 10486opponent health remaining
third contact
- Round 4 · frame 13668opponent health remaining
fourth contact
The bug was not that the source art was missing. All eight MvC2 shot lists were already present.
The first implementation spawned them as one-tick child effects, and that CPS-2 path reduced the
sequence to tiny flashes. v30 moves the same eight source phases into fighter-owned, two-tick
records, combines them with Cable's four recoil poses from G07s02, and gives every phase at
least one raster in which it can be seen. That is why the huge yellow bloom and the long shot
finally appear instead of a blinking tip.
The collision fix had to be equally specific. A single attack latch remembered the first contact and suppressed the next three. The firing event now rearms that latch before each active phase, so all four rounds can hit without ending the attack state. Cable also recoils on every round; the target takes normal knockback. The visual chain, state lifetime, collision and sound command are now four measurements of the same event rather than four unrelated green checks.

What v32 and v33 cross — and what they still do not
v32 restores the source firing state's 99-tick repeat opportunity instead of squeezing each
decision into an eleven-check CPS-2 window. Late-window input, four complete visible rounds, four
contacts and four sound posts now agree. v33 then raises Cable's private voice slot once on the
first live Hyper Viper Beam record; the sound ring resolves it to the imported $161 sample.
Cable is still not a complete port. The muzzle needs player-facing visual acceptance. Ground Viper Beam still floats and travels across the stage like its donor Hadouken. The automated air route now spawns and produces three distinct vertical paths — LP climbs 69 pixels, MP stays level, and HP descends 48 — but that arithmetic pass does not settle the reported manual-entry and visual presentation problems.
The v33 Electrap visual re-gate walks the small dark canister list through flight and rest, then
the source L0/L1 blue electrical oval through the detonation. That corrects the report that the
current lineage still draws a hand or has no blue circle. The explosion remains silent, and a
fresh combined contact-plus-art gate is still required before the opponent-hit presentation is
called source-accurate. Hyper Viper Beam's spoken call now
reaches the live ring, but the MvC2 behavior the player actually feels — button-mash growth,
vertical steering, matching beam and hit regions, full recovery and complete audio — is still work
in progress. The pre-existing $13C engine post also remains later in the super sequence. Time
Flip is not present. AHVB, Psy-Charge and Crackdown remain unfinished. More broadly, Cable's move
and voice SFX map is incomplete, and the character's playback rate is still being compared against
MvC2 because several animations look too fast.
That boundary matters more than the build number: v30 proves the visual mechanism, v32 restores the source-length decision window, and v33 proves one imported super call. They do not close presentation, system-wide timing or the rest of the move spreadsheet.
August 6 release audit: what v24 actually proves
This article grew alongside the ROM, which means some older claims below record what I believed
before the next gate disproved it. This is the current ledger for mvsc_cable_v24.zip, md5
fcd0f8df20ec651dab2383580e4b6e5f. v24 changes exactly two bytes from v23: the sound-slot
byte on the active frame of aerial medium kick and aerial heavy kick. No graphics, timing,
physics, damage, hitbox, command or 68000 code changed in that step.
- Normal routes reached100% of the audited surface
18 of 18 in the live input schedule
- Normal routes with imported voice100% of the audited surface
18 of 18 fresh QSound key-ons
- Distinct chains fully on Cable art48.22% of the audited surface
163 of 338
- All animation records on Cable art39.98% of the audited surface
742 of 1,856
That distinction is the release gate. A live route proves the input dispatcher reached the move. A Cable sprite-list pointer proves what was drawn. An attack-box/property match proves contact. A QSound channel key-on proves audio actually played. None of those facts can substitute for the other three.
The road, start to finish
0x128DFC 00 RYU 0x128F50 17 CABLE 0x128F5C 18 LAST BOSS 1 no PLxx_WIN.BIN: 18 19 1ARead out of the retail disc's boot binary, 1ST_READ.BIN, 1,809,817 bytes. Anyone with the ISO can repeat it.
Capcom wrote the answer down and shipped it
1 in 32,500odds the three missing win poses are the three last bosses by chance
59 NUL-terminated strings that label their own hex index. A self-labelling table only proves itself, so the control was blind: exactly three indices lack a win-pose file, and all three are the last bosses. Bosses don't have win poses.
Read this sectionRetracted here
That2_DP.BINwas the game executable. It's DreamPassport, the Dreamcast's web browser. It opens09 00 09 00- SH-4NOP- and so does every SH-4 binary. A signature every member of a class shares cannot pick one member out of the class. That cost most of a day.
Offline render from PL17_DAT.BIN through the project decoder. Nothing is on a board yet.
All 4,031 sprites come out clean
4,031 / 4,031decoded, exact stream consumption
LZSS, then the PowerVR de-twiddle - the hardware swizzles pixels into a Morton curve, so pixels adjacent on screen are not adjacent in memory - then a vertical flip. Get one of the three wrong and you get structured static. Get all three right and you get a person.
Read this section
Converted art rendered offline. The build at this stop rendered these same chains gold on the board.
He walks, in completely the wrong colours
6%of the animation tree drawing Cable
MvC1 gives every character exactly 23 rows of animation directory with zero slack, so there is no free slot - Cable rides Ryu. Chain starts are a lookup and can be redirected; chain interiors are reached by pointer arithmetic that bypasses any redirect.
COVERAGE 6% OF THE ANIMATION TREE
Read this sectionRetracted here
That MvC2's decoded pixels are byte-identical to CPS-2 tiles for shared characters. The identity signal came from a recipe that mis-interleaved columns 8-15 of each tile; its best match was 6.5% in a scrambled band. Collision noise, read as signal. The re-run fed the search known-present arcade fragments as a positive control, scored 3,491/3,491, then found 0 of 60 against MvC2. Reprocessed derivative, not a copy.
Four index mappings of one walk frame. Three of these shipped to a build and were looked at on a CRT before the fourth was found.
One measurement beats five guesses
100% / 3,390 pxstored pen to hardware pen, one consistent map
The colours were never wrong. Five encodings, every one a guess about colour space, while the bug lived in index space:
decode_tileassembles the bitplanes backwards, so stored pen0011is hardware pen1100. Four-bit reversal.Read this sectionRetracted here
That the bug was anywhere in colour space, and that any of that week's palette builds had reached the screen at all.$1C9724is an inert copy that never reaches the hardware; the live blob is$1C87C4. Every palette write before that one was a no-op, which is why the cyan never changed.
Live capture, palette entry 0 isolated. A whole character in one colour - not damage, not corruption.
The red silhouette was a progress bar
89%drop in measured red pixels from repointing five jump records
The palette is his globally, but only some chains point at his art, so every unconverted chain plays Ryu's sprites through Cable's colours and Ryu's dominant pen resolves to
$0F22. Not a bug - a metric. Caveat that looks like a bug and isn't:dc[15]is$FF22, a real red, the X-buckles on his harness, 7,636 pixels.COVERAGE 17% OF THE ANIMATION TREE
Read this section
Live capture on the board. Both halves of the HUD: his own face, his own name, his own palette.
His name, cut out of other characters' names
768 / 768 pxthe B in CABLE, lifted off GAMBIT's nameplate
The lifebar name is tiles, not text, and
Read this sectiontileA/tileBare two copies differing only in fill pen - which is why a name dims when you tag. Control ran in the safe direction first: render RYU from the game's own font, 384 of 384 px. C and L came off CHUN-LI. SPIDER-MAN refused at 84% because that name is kerned, so nothing was taken from it.
Live capture. Both fighters are the port; nothing here is a mock-up.
He is in the game, and QCF+P throws a hadouken
23 rows, zero slackthe same shape in three separate subsystems
He walks, dashes, super jumps, blocks, takes hits. Some art had to be drawn, not decoded - the select portrait and win shot never existed for Cable in MvC1's style. And every tile reads one 16-colour palette with one entry transparent, so any tile gets 15 colours, hard stop. Quarter-circle-forward still threw a hadouken.
Read this sectionx=354 anim $18176E x=390 anim $181786 x=414 anim $181796 12 px/frame, pointer advancingLive per-frame trace off the running board. Not a disassembly reading.
Writing begins, and a launcher ships that is not one
12 px/framethe bullet, after eight longwords at $093390
Conversion fails loudly - the sprite is red, the tile is garbage, you can see it. Behaviour fails quietly: the move comes out, nothing looks broken, and it is wrong in a way you have to go measure. Nine to fifteen days collapsed into a table edit.
Read this sectionRetracted here
That MvC1's projectile spawn path was stubbed and a mover had to be written. Class$2Cis an attached beam body that was never supposed to fly anywhere; watching it not fly and concluding the projectile was broken is the single dumbest hour of this project.$0024C4is a universal position integrator that had been running on Cable's bullet the whole time, at Ryu's fireball speed - which for a rifle round reads as not moving.input v4 probe v5 probe (P1 id asserted) st.MP LAUNCH 116 px no, a normal punch my notes: gun fires $17B live queue: gun posts $153Two live probes. The right column asserts P1's character id, which the first probe never did.
MP stops tagging, and my own notes turn out to be stale
$17Bis never postedmy source file versus the live sound queuev5 restores the stock eight bytes and blanks the thunk. I was one build away from seating a real MvC2 gunshot, verifying every byte, and hearing nothing - because the id my weeks-old notes asserted is never triggered. Files don't tell you when they go stale. The machine does, if you ask it. *(The gunshot eventually ships on
$324, with stock$153restored - see stop 15.)*Read this sectionRetracted here
That$019452is the launcher. It loads the HP+HK mask and calls a checker, and it is Variable Change - the tag-out path. The 42-byte thunk made medium punch, and every super containing MP, tag the partner in. Worse, the gate that blessed it scored LAUNCH when P2's Y rose, and after a tag the incoming partner's entry attack lifts the dummy. The positive control was a misidentified tag.
Live capture. Wake-up slots 1-4 are seated and counted but no harness has reached them - seated is not the same as seen.
Boxes he owns, and eighteen normal routes drawing Cable art
22 of 23box-table rows asserted byte-identical after moving row 9
Group 18's five slots hold 6, 7, 11, 15 and 11 records against sequences of 8, 6, 6 and 6 - which only conflicts if you pair slot to slot. Read what the thirteen records point at and there were never five sequences, there were three, shared between slots. Four bytes inside each of thirteen records.
COVERAGE 19% OF THE ANIMATION TREE
Read this section23B8AC lea $23B980.l,a0 one non-zero row: slot 26, id $0121 the block I fixed three times: $0D5EF0 hijacked at its second instructionDrained live out of the game's own sound command queue, same five actions before and after.
Three green gates on a table he never reads
1 non-zero entryin the voice table he actually reads
A 22-byte write once the ring named the right table. Two bounds I'd rather state than have you find: every id his voice posts is past 818, the end of the sample directory, so the builder now refuses any id above 818; and two dash grunts are still Ryu's, because four candidate rows exist and none is proven his.
Read this sectionRetracted here
That the voice block I fixed across three passes was his.$0D5EF0is hijacked at its second instruction by a thunk an earlier build left behind, and Cable's voices come from$23B980- one non-zero entry, the engine's shared medium-attack impact. Three static gates passed while describing a table the character does not read. A green check on one table is not a statement about the subsystem. Only the live differential is.$E8048 one 48-byte block per fighter punches 1 3 5 kicks 1 3 5 tie, refused kicks 2 4 6 15 links grantedRank table read out of the ROM; the 15 links measured live, with lp-to-lp and hp-to-mp still refusing exactly as they do for stock characters.
Six numbers were why he played like Ryu
0 → 15chain links granted live, after ten bytes
The complaint I couldn't answer with a screenshot: he still plays like Ryu. A chain is legal only if the candidate's rank is strictly greater, and Ryu's kicks tie his punches, so every same-strength link died on a comparison. The gate is a combo counter: 2 HIT, then 3 HIT. And the probe lied first -
Read this section+$08isn't X on this build, so every starter whiffed and my control couldn't succeed. A negative result from a control that cannot succeed is not a negative result.
Live capture. Full width, gun height, head cap bursting at the far end - right shape, wrong colours. Those get fixed at the next stop.
Electrap arcs, and the hyper starts hurting
258 pxElectrap detonation launch, with hitstun
Instead of patching the code that places beam segments - behind another id gate, on classes other characters share - I patched the drawing. A CPS-2 sprite list is already a list of parts, each with its own tile and offsets, so nine segments go into the list and the existing code draws the one object it knows. Twenty hits, chip on block, nothing to the partner behind him. Zero instructions changed.
Read this section
Two live captures at extreme zoom, same frame, different builds.
The beams turn magenta with no palette byte written
0 bytespalette bytes written - it was a re-ink onto effect row 17
A spawned child inherits its spawner's palette row, and Cable's body is row 0, so his beam drew itself in his skin and jacket colours. Authoring a row is the trap - rows here are shared. I dumped live PALRAM against different lineups and found rows 12 and 17 identical regardless, then re-inked onto pens they already hold. And aim: a day of writing y-velocity fields the mover never reads, because
$0024C4is X-only.$002490is two-axis. Four bytes.COVERAGE 27% OF THE ANIMATION TREE
Read this section
Live capture. The blade is drawing in his body palette - a known open bug, and it is in this picture.
Today: he stands in the game, holding a Psimitar
4 movesPsy-Charge, Crackdown, AHVB and Time Flip: unwritten
All eighteen normal routes enter and draw Cable art, and the verified links chain. That does not mean every route has native MvC2 record counts or timing; the v24 census below makes that limit explicit. A Viper Beam aims on the ground and in the air. A Psimitar uses startup invulnerability that had been sitting in Capcom's own shoryuken data since 1998. The launcher settled at 12/12 and 195 px; AHVB remains unwritten.
Read this section
- WALKSnot yet true
- HIS COLOURSnot yet true
- NAME + FACEnot yet true
- HIS VOICEnot yet true
- LAUNCHERnot yet true
- CHAINSnot yet true
- HYPER HITSnot yet true
- VIPER BEAMnot yet true
The problem, in one sentence
MvC2 runs on Sega NAOMI: an SH-4, a different engine, different formats. MvC1 is CPS-2, a 68000.
My earlier imports, Dark Sakura and Shuma-Gorath, came out of Marvel Super Heroes vs. Street Fighter, which is also CPS-2. Same CPU, same engine family, same data formats. Porting them meant finding the routine in the donor ROM, relocating it, and fixing what broke. Painful, but mechanical.
Cable has no such donor. There has never been a 68000 implementation of Cable. Not in a leak, not in a prototype, not anywhere. So here's the sentence that frames everything below: his data can be translated. His behavior has to be written.
Pulling him off the disc
Take him off the Dreamcast disc rather than the arcade board, and the reason is storage. A CPS-2 board is a sealed box: encrypted program, art fused into mask ROMs, everything addressed by a machine that expects it all present at once. A Dreamcast has 16 MB of RAM and a disc drive, so the game can't hold everything at once. It has to stream. And a game that streams has to keep its data in discrete, addressable, self-describing chunks, because it needs to find them at runtime. The format does half my job for me.
My first move, though (and I'll own this one) was spending a chunk of a day reverse engineering
the wrong program entirely. I opened the disc, found a 3.6 MB file called 2_DP.BIN starting
with 09 00 09 00 09 00, which is SH-4 NOP, and called it the game executable.
It's not. It's DreamPassport. The Dreamcast's web browser.
2_DP.BIN: "DreamPassport" x2 "Mozilla" x2
"CAPCOM" x0 "MARVEL" x0 "NAOMI" x0 "CABLE" x0Every SH-4 binary starts with those NOPs. I'd pattern-matched on a signature that identifies
the architecture, not the program. The actual game is 1ST_READ.BIN, 1,809,817 bytes, the
standard Dreamcast boot binary, which I should have looked for by name in the first place.
Worth writing down because it's a specific failure mode: a signature every member of a class
shares cannot pick one member out of the class.
The right file paid out immediately. At 0x128DFC there's a run of 59 NUL-terminated strings
that label their own hex index:
0x128DFC "00 RYU"
0x128E04 "01 ZANGIEF"
0x128E10 "02 GUILE"
0x128E1C "03 MORRIGAN"
0x128E28 "04 ANAKARIS"
0x128E34 "05 STRIDER HIRYU"
...
0x128F50 "17 CABLE" <- the target
0x128F5C "18 LAST BOSS 1"
0x128F6C "19 LAST BOSS 2"
0x128F7C "1A LAST BOSS 3"
...
0x129120 "3A KOBUN"Fifty-nine entries spanning 0x00..0x3A, which is exactly the 59 PLxx file indices on the
disc. Capcom wrote the answer down and shipped it.
Now, a self-labeling table proves the table is consistent, not that it describes the files.
So here's a check with a real chance of coming out wrong. Only 56 of the 59 indices have a
PLxx_WIN.BIN, a win-pose file. If the index space is genuinely shared between the debug
roster and the disc, the three missing ones should be characters that have no win pose.
Enumerated blind:
indices with no win pose: 18, 19, 1A
18 -> "18 LAST BOSS 1"
19 -> "19 LAST BOSS 2"
1A -> "1A LAST BOSS 3"Bosses don't have win poses. Under a random index-to-name mapping, landing exactly on those three is 1 in C(59,3), about a one-in-32,500 shot. The table is real.
So Cable is PL17_DAT.BIN: 1,139,360 bytes, his complete character archive in one file.
Inside, a section table:
PL17_DAT.BIN
S0 @0x000040 964,736 B sprite bitmaps (4,031 blobs)
S1 @0x0EB8C0 65,856 B sprite lists (681 lists, 7,718 parts)
S2 @0x0FBA00 1,824 B palettes (57 x 16 x ARGB4444)
S3 @0x0FC120 65,536 B collision (64 pages x 1,024 B)
S5 @0x10C120 24,288 B animation / frames
S8 @0x112F40 3,584 B move properties (52 of 128 slots used)One detail here did a lot of quiet work: S3, S7 and S8 are identically sized in every one
of the 59 characters. The whole roster shares one fixed action-id namespace, which means I
could decode the same section for a character whose moveset I know cold and read an unknown
character's rows against that frame. Ryu became the measuring stick for Cable.
Getting actual pixels out meant undoing three things stacked on top of each other:
- LZSS compression: a flag byte, then either a literal or a back-reference token.
- PowerVR twiddling: the Dreamcast swizzles texture pixels into a Morton-order curve so its hardware can fetch 2D neighborhoods efficiently. Pixels adjacent on screen are not adjacent in memory. This is the dangerous step, because a wrong decode looks like plausible static rather than obvious garbage.
- A vertical flip on top of that.
Get any one of the three wrong and you get noise that's just structured enough to make you think you're close. Get all three right and you get a person:

All 4,031 of his sprite blobs decode with zero failures and exact stream consumption. The decompressor lands precisely on each blob's end, 4,031 times. Honestly, that number is better proof the format is understood than any single picture is.
The part where I retract something
Early on I published a claim I liked a lot: that MvC2's decoded pixels were byte-identical to CPS-2 tiles for shared characters, meaning you could lift arcade tiles straight off the disc. It's false, and the way it fell apart is the most instructive thing on this page.
The "identity" signal came from an arcade pixel recipe that mis-interleaved columns 8 through 15 of each tile. Its best match was 6.5% in a scrambled-texture band. That's collision noise, and I read it as signal. The re-run built the comparison properly: the exact same search code was first fed known-present arcade fragments as a positive control and scored 3491/3491 = 100%, proof it would find matches if they existed. Against MvC2 it found 0 of 60 tiles, 0 of 400 row fragments, and 1 or 2 of 8,000 8-pixel windows. That's the shuffled-null floor. MvC2's Dreamcast art is a reprocessed derivative of the arcade art (extra shading bands, consistent with a rescale, anti-alias, and requantize pass in the NAOMI conversion), not a copy.
So tile-lifting was dead, and what survived was better grounded. The decoder was proven: all 1,533 Ryu blobs and all 4,031 Cable blobs decode with exact stream consumption, and Ryu renders as recognizable Ryu. Conversion means re-encoding those pixels into fresh CPS-2 tiles, which can be validated end to end because Ryu exists in both games. That's the difference between "we think we can convert this" and "we can check our converter against ground truth."

So how big is he, once he's arcade-shaped? I walked all 23 character animation trees in the stock ROM, followed every chain to its sprite lists, expanded each list's parts through the tile-addressing rule, and counted the distinct tiles each character actually uses. Then I put Cable next to them:
- Hulk, the largest in the game16,708tiles
shares his entire tile set with a palette-swap twin in a hidden slot
- CABLE, converted from MvC214,095tiles
third largest overall, out of a cast of 23 plus him
- Captain America13,332tiles
- Ryu, the skeleton he is riding8,291tiles
- Roll, the smallest4,936tiles
Sit with that Ryu row for a second, because it's my favorite fact in the whole project. The character lending Cable a skeleton is a bit more than half his size, and it still works.
And none of it matters for space, because of a piece of prior work: a custom FBNeo core that
expands the graphics region from 32 MB to 64 MB, built earlier for unrelated reasons. Two
things fell out of seating him there. The good one: the sprite-bank window turned out to span
all 65,536 word values, so Cable's tiles at codes $C400..$FB0E (far above anything the
original game ever used) pass through to the hardware unmasked, with zero core changes. I'd
budgeted time for a mapper fight that never happened. The less good one: the shipped graphics
chips carry roughly 6 MB of legacy art that no manifest tracked, and a naive rebuild would have
silently destroyed it. It's documented and preserved by overlay now. Check what's already there
before you write. I nearly didn't.
The honest cost of the expanded core: it's a custom core, so builds that use it won't play against stock Fightcade clients. That's a real trade, and it's why the character work and the core work are kept separable.
Borrowing a skeleton
Seated tiles are inert. Something has to ask for them, frame by frame, at the right screen offsets. That something is the animation tree, and MvC1 gives every character exactly 23 rows of animation directory, packed with zero slack. There is no free slot to build a new character in. (Hold onto that "23 rows, zero slack" shape. It's going to show up everywhere.)
So instead of authoring a chassis, I borrowed one: Cable rides Ryu. Ryu is the ideal donor for a diagnostic. Bone stock, standard palette blob, standard tree, standard state machine, and the character-select cursor already starts on him, so reaching him needs no code at all.
The mechanism is a chain-start hijack, and it rests on one rule the earlier ports taught me the hard way:
Animation chain starts are installed by a lookup and can be redirected. Chain interiors cannot. Interiors are reached by pointer arithmetic that bypasses any redirect you install.
So you rewrite the start record in place: keep its side fields, set its control word to
$8000 | duration (bit 15 means the next record is a long pointer at +16), point its
sprite-list field at Cable's frame 0, and write the continuation address into the following
slot, after proving nothing else references that slot.
Ryu tree $12C030
(0,2) walk forward -> Cable $5F0100
(0,3) walk back -> Cable $5F0C1C
(1,0) jump -> Cable $5F1D78
(1,1) jump forward -> Cable ...
(1,2) jump back
(8,0) (8,1) (8,2) -> standing LP / MP / HP
(9,0) (9,1) (9,2) -> standing LK / MK / HKEleven chains to start with. Each one is a start record rewritten in place plus a run of parked
16-byte records at $5F6000 in the expansion file, with loop chains returning to their own
start and one-shot attacks returning to Ryu's original terminal so the engine still recovers to
neutral.

And it worked immediately. The records advanced, the sprite lists advanced, the tile codes reached the hardware. He walked.
He was also completely the wrong color.
Five wrong answers about a palette

Here's what made this genuinely hard: the colors were never wrong. I had Cable's exact
16-entry palette, lifted from PL17_DAT.BIN at 0x0FBA00, and an offline render using that
palette produced a perfect Cable. Blue suit, gold harness, white hair. I had a picture of the
right answer sitting on disk the whole time.
The identical palette, written into the ROM, rendered gold. Then white. Then cyan. Five encodings (direct, complement, RGB-order swapped, brightness-adjusted, per-entry remapped), and every one of them was wrong on the board while the offline render stayed right.
You see the trap? Every one of those five attempts was a guess about color space. The bug lived in index space, and no amount of trying different colors was ever going to find it.
The move that broke it open was to stop asking "what color should this be" and start asking "which palette entry is the hardware actually reading for this pixel?" That's directly measurable, and it needs no color theory at all:

Overwrite palette RAM so that hardware pen p renders as pure green at intensity p (red 0, blue 15, green p). Now every pixel on screen reports its own hardware pen index in its green channel. No color theory involved; it's a measuring instrument.
Read the sprite's 26 entries straight out of OBJ RAM at $708000: eight bytes each, [x, y, code, attr], coordinates masked to 10 bits. That gives the precise screen rectangle of every single tile, so no image alignment search is needed anywhere.
For each tile, compare the pen my decoder stored against the pen the hardware reported, pixel by pixel. Placement locks at dx = -64, dy = -14 with an overlap score (IoU) of 0.932.
Every stored pen maps to exactly one hardware pen, at 100% consistency across 3,390 pixels: [0,8,4,12,2,10,6,14,1,9,5,13,3,11,7,15]. That's not a color bug. That's 4-bit reversal. Five guesses got nowhere; one measurement settled it.
cps2_gfx_codec.decode_tile assembles the four bitplanes in the opposite order from the
hardware. Stored pen 0011 is hardware pen 1100. Index the palette by the hardware pen,
rom[h] = CABLE_ROW[bitrev4(h)], and he's blue.
Except I built that fix into the ROM, and Cable came out cyan.
The corrected row was right. It was going to the wrong place. Reading palette RAM live showed
his colors present but nibble-reversed, in stored-pen order, which meant the rows on screen
were coming from somewhere other than the blob I'd spent the week patching. Searching the ROM
for the byte pattern actually sitting in palette RAM found it in mvc.06a, six rows at stride
$20:
patched all week : $1C9724 <- inert copy, never reaches the screen
actually live : $1C87C4 <- mvc.06a + 0x0487C4Every palette build before that one had been a no-op. The cyan I kept staring at wasn't "my colors, wrong." It was the previous attempt, still sitting there, unchanged, because nothing I wrote had ever arrived.
One CPS-2 quirk worth keeping if you ever touch this hardware: sprites treat pen 15 as transparent, not pen 0 like most formats. Get that backwards and your character becomes a silhouette, and it's exactly the kind of assumption that survives five debugging rounds because it never announces itself.
The red silhouette that turned into a progress bar
Standing still, Cable was a solid red silhouette. Walking, he was Cable. That symptom turned into the most useful diagnostic in the project.

The cause falls straight out of how the port works. The palette is Cable's globally, but
only some animation chains had been repointed at Cable's art. Every chain not yet converted
still plays Ryu's sprites, through Cable's colors. Ryu's dominant pen resolves to $0F22, a
hard red, so uncovered animations render as red silhouettes.
That's not a bug to fix in the palette. That's a progress bar. And it handed me the metric I should have had from the start: walk every chain in the animation tree and count which records point at Cable's sprite lists versus Ryu's.
- First working build, 11 chains6% of animation frames drawing Cable
99 frames out of 1,621
- Idle, blocks, crouch and air normals, 38 chains12% of animation frames drawing Cable
- Dashes, hit reactions, super jump, 101 chains17% of animation frames drawing Cable
315 Cable frames
- Still someone else83% of animation frames drawing Cable
throws and every special — wake-ups and win/intro poses are seated now
Two findings came straight out of that metric. Exactly five records inside the jump chains still carried Ryu sprite lists. Ryu's jump chains are longer than Cable's parked ten-frame jumps, so the tail ran off the end of Cable's frames into Ryu's landing art, producing a red flash mid-jump. Repointing those five at Cable's fall frame dropped measured red pixels by 89% in a live capture.
And one caveat that looks like a bug and isn't: some red on Cable is correct. Palette entry
dc[15] is $FF22, a genuine bright red. That's the X-buckles on his harness: 7,636 pixels,
0.21% of his art. Small red details are him. Flat red regions are unfinished work.
The bug that passed every static check
Seating the next thirty chains turned up a defect in the first eleven, and if you only take one debugging lesson away from this page, take this one.
The animation record's control word has a top bit. I'd read it as purely structural ("the next record is a long pointer at +16") and set it on every chain start I hijacked. It isn't structural. The state machine reads that bit as "this move is over."
On a chain start, a one-shot move therefore ends after frame 0. Loops are immune. They re-enter their own start and never reach the terminal, which is exactly why walking and idle looked perfect and hid this for days. The three jumps and six standing normals had been playing a single frame since the day I built them, and I hadn't noticed.
Here is the loop that hid it, running at the board's own speed:
The measurement, same harness, old build as the control:
| standing HP records entered | jump records entered | |
|---|---|---|
| before | 1 | 1 |
| after | 6 | 5 |
The fix was to stop being clever: restore the stock record verbatim and overwrite only the four bytes holding the sprite-list pointer. Durations, the top bit's placement, frame ids, box indices, and topology all stay byte-identical, so the engine sees exactly the timing it was written for.
The first attempt passed every static verification I had: valid lists, terminating chains, no overlaps, no orphaned records. It was still wrong. It took a control run against the unmodified ROM to expose it, by comparing how many records the engine actually walked.
A static check cannot see a state machine's opinion. If your verification never runs the thing, it's checking your model of the machine, not the machine.
And there's a second sting. My own coverage metric had been counting those parked records as Cable frames, because my walker followed the pointer the engine was ignoring. The number was inflated by exactly the frames that were broken. The tool and the bug shared an assumption.

Here's the whole pipeline to this point, disc to screen:
flowchart TB
disc["PL17_DAT.BIN, the Dreamcast disc"] -->|"LZSS, de-twiddle, flip"| sprites["4,031 sprites decoded"]
sprites -->|"re-encode, nothing lost"| tiles["14,095 CPS-2 tiles"]
tiles -->|"seated at $C400 and up"| gfx["Expanded graphics ROM"]
gfx -->|"Ryu's hijacked chains ask for them"| screen["Cable, on screen, in his own palette"]Building the CABLE nameplate
The lifebar name is not text. It's a strip of tiles drawn from a record table indexed by
character id: the record address is recordbase plus id times 4, with block A at $006D9C and a
mirrored block B at $006E5C. Ryu is id $12, so his records sit at $006DE4 and $006EA4.
Each record is eight bytes (tileA, attr, x_add, tileB). The width lives in a nibble of
attr (nx = ((attr & $0F00) >> 8) + 1 tiles), and block B places itself with an x_add of
negative nx * 16.
And here's a detail I love. tileA and tileB are two complete copies of the name, pixel
identical except for the fill pen: light green for the character on point, dark green for the
one tagged out. That brightness change when you tag? Not a palette effect. The renderer
literally picks a different run of tiles.
So the job was: build CABLE out of the game's own letter art, then write new records pointing
at it. Before cutting anything, I ran a control in the safe direction. Render RYU from the
game's name font at $16e00 and slide it across the tile atlas. It matched at 384 of 384
pixels, which proved two things at once: the font data was genuine, and the pen mapping was
outline pen 7 with fill pen 11.
Then the harvest, letter by letter:
- A and B came off GAMBIT's nameplate. The B landed with a 768 of 768 pixel match, an exact hit.
- C and L came off CHUN-LI, also located at 100%.
- E came straight from the font's own glyph.
One name refused to cooperate. SPIDER-MAN topped out at 84%, because that name is kerned in the atlas instead of sitting on a fixed 8-pixel pitch. So the rule became simple: only cut letters from words that match exactly, and take anything else from the font.
Assembled, seated, and written into fresh records, both tile runs, both blocks:

The portrait, and the one-pixel rule
The little face beside the lifebar is the encryption story from the palette war, back for
another round. The portrait comes from a 23-entry table at $006AA6, one word per roster row.
Read it raw and you get this:
$BC7A $E6B3 $B31F $5478 $E14B $6428 $A9C2 $0B90 ...Noise. No structure at all. Now read the same bytes through the decrypted view:
$6CEE $6EC2 $6EC4 $6EC6 $6EC8 $6ECA $6ECC $6ECE $6EE0 $6EE2 ...A clean table stepping by two. Most data on this board is read raw, but the portrait table is reached by a pc-relative read from inside the encrypted region, so it resolves against the decrypted image. Same address, same bytes, two completely different meanings depending on which lens you hold up.
The step of two says each portrait is two tiles wide. The art lives one bank up, at
tile + $10000, in 2x2 blocks. And the palette question answered itself in a way I didn't
expect: aligning Ryu's portrait pens against a live screenshot recovers Cable's own
palette, bit-reversed exactly as the pen permutation predicts. The HUD portrait draws with
the character's palette row. Which explains the garbled face perfectly (Ryu's pens were
resolving through Cable's colors), and means Cable's own face needs zero palette work.
Authored in his own stored-pen space, it comes out right by construction.

The first crop looked right and was subtly wrong: his face sat flush against every edge of its 32x32 cell, and every stock portrait is visibly inset. Rather than eyeball it, I measured the ink extents of all 22 stock portraits:
r 1 $6EC2: rows 9..30 cols 1..30
r 4 $6EC8: rows 1..30 cols 1..30
r 9 $6EE2: rows 3..30 cols 1..30 <- the slot Cable takes
r16 $6EAE: rows 5..30 cols 1..28 <- Mega Man
...
bottom-row histogram: [(30, 22)]Every single one of the 22 ends at row 30. Not one reaches row 31. Every one starts at column 1, never column 0. A histogram with a single bucket isn't a style preference. It's a rule. His crop was rebuilt to land in rows 7..30, columns 1..31, and asserted at build time so it can't regress.
Writing it back is the view trap in reverse: the tile number goes into the table through the decrypted image and is then re-encrypted, with a round-trip assertion that the re-encrypted ROM decrypts byte for byte back to what was written. Row 9 (Ryu's slot) points at the new block. The other 22 rows are asserted unchanged.


The art that was never drawn
Everything up to here came out of Cable's own pixels. The lifebar face, the name, the sprites: all of it was already on the disc, waiting to be decoded.
Some of what this game wants does not exist for Cable anywhere, on any disc. The half-body portrait on character select, the versus splash before a fight, the winner shot on the results screen — Capcom drew those by hand for each fighter in 1998, and nobody ever drew them for Cable in MvC1's style. You cannot decode what was never made. Those surfaces are being rebuilt from official Capcom and Marvel artwork.
The constraint they have to fit is worth writing down, because it is the part people get wrong. The board draws everything in 16 by 16 tiles, every tile reads from exactly one 16-color palette, and one of those 16 is always transparency. So any single 16 by 16 chunk of a drawing gets 15 colors, hard stop. Bigger art earns more colors only by giving different regions different palettes:
I got that budget wrong the first time, and the correction is instructive. I read the hyper portrait as a single 16-color palette straight off the ROM's palette map. Counting the colors in Mega Man's stock hyper portrait gives 28 — impossible under one palette. Those slots use the same multi-palette trick the select art does, and the bigger characters carry around 75 usable colors. Reading a table told me one thing; counting the actual shipped artwork told me the truth.
One surface note worth passing on, because it cost me a re-crop: the versus splash only ever shows the top 112 rows of a 224-row portrait. The bottom half is seated correctly and is never on screen. That is the surface being what it is, not the seating being wrong, and you find it by looking rather than by reading a table.
Getting the game to let him make noise
He was silent because our own build was throwing his sound events away. Five per-character
sound selectors carry a guard that returns early for any fighter id above $2C. Cable is $2E.
His events never reached a table at all.
And here's the thing. That guard is correct. All eight of MvC1's stock per-character sound
tables hold zero at his id, and the per-frame voice directory is, once again, exactly 23 rows
with zero slack. Index it at $2E and you're reading the first word of the next directory:
fourteen nonzero junk words out of still-encrypted program bytes. Delete the guard without
adding a table and Cable doesn't start talking. He starts making noise.
Same 23-row ceiling, third different subsystem. This game was built for exactly 23 characters and it means it.
Two layers sit behind the guard. He has no trigger code: 221 of the game's 256 sound calls are
a literal "load this sound id, call the player" pair sitting inside a move handler, and Cable
has no move handlers, so he simply doesn't possess 89% of the game's sound triggers. And his
voice doesn't exist here yet: his 26 MvC2 voice clips are Dreamcast streaming audio that still
has to be converted to QSound format. At least the capacity argument is dead on this core.
Stock QSound caps at 8 MB and shipped about 92% full, but the expanded core loads two extra
sample chips (mvc.h11m and mvc.h12m) for 16 MB of QSound space, roughly half of it
free. His full voice set fits. Only the conversion work is real.
Two details made this cheaper than it looked. His music always worked, because the BGM selector was never guarded, so he inherits a shared theme. And four of his animation records still carried Dark Sakura's voice indices, inherited from the build he was composed against, firing the dispatch on every hit and getting swallowed by the guard. That proved the dispatch side was alive end to end before I wrote a single new table.
So the first sound fix was exactly the cheap one: one branch in those five thunks plus a 128-byte table of borrowed stock sound ids. About a day, zero new audio data, and "he is mute" became "he grunts." Land a hit and you hear impact. Jump in, and you hear him land, with his own weight-class thud, not Ryu's. His actual voice is the remaining conversion job.
The paragraph above is the historical state of the build, not the current one. The imported
QSound bank is present now, and a channel-level gate proves all eighteen normals start a fresh
Cable sample whose bytes match the finished ROM. Two of those routes — aerial medium kick and
aerial heavy kick — were silent until v24 because their sound slots had been attached to obsolete
donor chains instead of the live g21s4 and g21s5 attack records. Spoken special and super
calls are still open.
Every one of those stock per-character sound tables is followed by exactly one spare word before the next routine begins, and that word sits at index 23, which is precisely the character id this port uses. No stock fighter can ever reach it. It has been sitting there empty since 1998, in table after table, waiting for a 24th character nobody ever added.
Hyper Viper Beam, since you were going to ask
Cable's identity is one move, so here is exactly where that move stands.

Every pixel of this move is in the cartridge right now. It needs 1,033 distinct CPS-2 tiles (671 for Cable's body across six chains, 364 for the beam objects), and all 1,033 are seated in the expanded graphics region, byte-identical to the decoded source. The check ran with a negative control one tile-code off, which matched 0 of 150. That's 132 KB, 7.3% of Cable's allocation. If you could press the button today, the art would be waiting.
The construction is genuinely beautiful, and it's worth seeing how a 1998 board draws a beam at all:
- The beam is not part of Cable. It's four independent objects: a shaft, a rounded head cap, a muzzle flash, and a beam-origin overlay with separate ground and air variants.
- The shaft is one 32-pixel-wide sprite list, repeated. A screen-crossing energy wall is that single list drawn again every 32 pixels. No stretching, no scaling, no special wide sprite anywhere.
- The shaft frames cycle heights 32, 48, 32, 64, which is the entire reason the beam pulses when you watch it. The head cap's heights match the shaft frame for frame, so the nose pulses in sync.
- The firing hold is two records that point at themselves, one tick each, alternating forever. The data loops; the engine decides when you've stopped mashing. That's how a mashable hyper is represented.
- The frame the beam appears on is marked in data, not code: a per-frame event id that occurs exactly six times in the whole of Cable, on precisely his four beam moves.
He actually carries two complete beam sets: blue for the ordinary Viper Beam, pink for the hyper.
So what stops it? Not the art. And (I have to correct myself in public here) not the engine
either. I'd been saying MvC1's projectile spawn path was stubbed, and I'd said it as if
measured. Wrong. The address I'd recorded as "the spawn helper" isn't a subroutine at all. Read
as data, it's a clean 17-entry pointer table for tag-in and partner entrances. And the "stub"
it supposedly called sits inside a 25 KB run of $FF filler that this project has been
carving its own thunks out of for months. The stub was ours. MvC1 renders multi-segment beams
today, three of them: War Machine's, and two of Ryu's.

What actually stops it is smaller and more specific: roughly 130 hardcoded
parent-character-id checks scattered through the engine's shared child-object library. A
spawned child that doesn't recognize its parent's id destroys itself on its second frame.
Ryu's projectile literally compares against his own id in four places. Cable is $2E, so he
can't borrow anyone's projectile class as-is. It has to be cloned and every gate retargeted.
Then his hitboxes need a table he owns, which lands right back on that 23-row directory with
zero slack.
Honest cost: 9 to 15 engineer days, none of it art. About a third is the id-gate work, which is paid once and then amortized across every projectile he'll ever have.
Which is why the right first move isn't the beam. It's the grenade: one child object instead of eight, 346 tiles instead of 1,033, and it shares both hard costs. If the spawn channel is broken, you want to find that out on a four-day move, not a fifteen-day one.

Status, since I started down that road: the projectile class now spawns and survives its parent-id check, and then sits inert. Position and animation pointer never advance. Nine stock callers do setup we haven't reverse-engineered yet. One unknown, three moves downstream of it: the gun, the grenade, and all eight beam segments.
Hold onto that paragraph. Every sentence in it is wrong, including the estimate above it, and finding out how wrong is the first thing in the last chapter of this page.
Two questions I get constantly
Both have better answers than I expected, because I finally went and measured them instead of guessing.
Why not just mod MvC2 back to six buttons?
Because it secretly already is one. The medium attacks were never removed from MvC2. They were removed from the button decoder, and that decoder is about twenty instructions long.
The data says it first. Every character on the disc carries 128 move-property slots, and the first eighteen are six stances times three strengths: standing punch, standing kick, crouching punch, crouching kick, air punch, air kick, each in light, medium, and heavy. Fifty-seven of the fifty-nine characters have all eighteen populated. Ryu's standing punches read damage 4 / 7 / 13, hitstun 10 / 20 / 24, strength class 4 / 8 / 12. Three clean ascending ladders. The only exceptions are Last Boss 3, who isn't a normal fighter, and Chun-Li, missing exactly one slot: heavy air kick. And because a ladder you go looking for is a ladder you'll find, two null controls: shuffle the eighteen slots into random order and the ladder test passes 0 times out of 1,180; run the identical test against the specials region instead and it passes 0 out of 59.
Then the test that should have killed the whole idea. Cable is MvC2-original. He never existed on CPS-2, so if the mediums were leftovers inherited from the arcade games, a brand-new character couldn't have any. He has all three. His medium punch is a distinct 6-frame arcing hook with a slash trail. So do Ruby Heart and Amingo. Capcom authored medium attacks for characters that had no six-button version to inherit them from, and then didn't wire them to a button.
The code agrees from the other direction. MvC2's input word is a standard Capcom three-plus-three panel (the assist buttons are sitting in the medium buttons' seats), and the decoder masks those two bits off and runs four hardcoded comparisons: light punch or light kick sets strength 0, heavy punch or heavy kick sets strength 2. No path produces 1. But the field it writes has one writer and three readers, and all three are three-valued. One maps strength 0/1/2 onto consecutive resource ids 15/16/17, which are exactly the light, medium, and heavy air-kick slots in the move-property block, the same numbering arrived at independently. Another contains a branch taken only when strength equals 1, jumping into a function-pointer dispatch.
That's real code, in the shipped retail game, that can never run.
What I haven't done: patched the mask and booted it. Two things stay unmeasured. Whether the animation selector resolves slot 1 correctly for all six stances, and whether the medium sequences are reachable from the state machine at all. Both are settled by patching two mask bits and pressing start, and until someone does, "the content is all there" is the claim, not "it works." (The easy-chain complaint has a cleaner answer, by the way: cancel rules are not compiled into the engine. They're small per-move tables where one byte value means "this cancel does not exist." Tightening MvC2's chains would be a data edit, not a code project.)
Could MvC1 do three-character teams?
Technically yes, practically no, and the wall is not where you'd think.
It isn't memory. A fighter object is exactly 1,024 bytes and there are exactly four of them: 6.25% of the machine's 64 KB of work RAM. Two more need 2,048 bytes, and there's a single contiguous 16,256-byte window that reads zero in every live memory dump. That's eight times the headroom required, with two more windows of similar size beside it. It isn't graphics either: roughly 170,000 free tile slots in the expanded banks, call it thirteen more characters' worth of art.
The wall is that "two characters per side" is not a number anywhere in this game. There's no team array, no team count, no loop over a team. The routine that answers "which of my characters is on point right now" is six instructions: load the address of slot A, load the address of slot B, test whether A is benched, and if it is, swap the two registers. That single swap is the entire team-management system. With two characters, "my partner" and "the other one" are the same idea, so the game never has to make a choice. With three, that swap becomes a search through a list, and the list doesn't exist.
The pointer meaning "my partner" is written in exactly two places in the whole megabyte of code, both at match start, one of them a hardcoded literal address. The four character slots are written as literal addresses at roughly 270 sites, and seven separate four-wide global arrays are unrolled the same way. There's one genuine hardware ceiling underneath. The sprite palette page has 32 rows, twelve already committed to the four fighters at fixed addresses, and no unclaimed row turned up. That one's at least workable: you could re-upload rows on tag.
The unrolled machinery can't be worked around. Only rewritten. So the honest answer is that 3-on-3 isn't a patch to MvC1. It's a different program that happens to share MvC1's art.
Where converting ends and writing begins
Everything up to here was conversion. Everything after it is behavior, which is a different job with a different failure mode. Conversion fails loudly: the sprite is red, the tile is garbage, you can see it. Behavior fails quietly. The move comes out, nothing looks broken, and it is just wrong in a way you have to go measure.
Five of them follow. Four started with me being confidently wrong about something, and the pattern in how I was wrong is more useful than any of the individual fixes.
The bullet: I spent a week solving the wrong problem
Go back and read the status I wrote above. "The projectile class now spawns and survives its
parent-id check, and then sits inert. Position and animation pointer never advance." I'd
pinned that on class $2C, and the plan was to write a mover and hook it up. Nine stock
callers doing setup I hadn't reverse engineered. Nine to fifteen engineer days.
Then I stopped reading disassembly and put a tracer on the live objects instead, and the whole
thing fell apart in about ten minutes. Class $2C is not the projectile. Neither is class
$00. Both of those are attached multi-segment beam bodies. They hang off the fighter. They
were never supposed to fly anywhere, and me watching them not fly and concluding the projectile
was broken is the single dumbest hour of this project.
The thing that actually travels is a static class $004 object, and what you see on screen is
a separate class $34 child it pulls out of a pool. There was never a missing mover. $24C4
is a universal position integrator, it does x += v and v += accel for everything in the
game, and it was running on Cable's bullet the entire time. The bullet was moving. It was
moving at Ryu's fireball speed, which for a rifle round reads as not moving at all.
So nine to fifteen days of writing a mover collapsed into editing eight longwords in a table:
There's a booby trap next door worth flagging. My first attempt patched $096C4E and changed
absolutely nothing on screen. That address is a different character's table sitting right
beside Cable's. It accepts your write, it looks correct, and it does nothing. If you're doing
this yourself: the address is $093390, and confirm it by watching a number change on screen,
not by reading it.
Live trace of the fixed bullet, one line per frame, x position on the left:
x=354 anim=$18176E
x=378 anim=$18177E
x=390 anim=$181786
x=402 anim=$18178E
x=414 anim=$181796 12 px/frame, animation pointer advancingThe launcher that wasn't: forty-two bytes of tag-out
I shipped a "launcher" and it made Cable worse than stock. Here is the correction.
$019452 loads the HP+HK mask and calls a checker. I read that as the launcher. It is
Variable Change: the tag-out path. The 42-byte id-gated thunk at mvc.exp +0x008200 that
rewrote Cable's MP into that site made medium punch (and supers that include MP) tag the
partner in. The discipline about cmpi.w #$12 was real. The site was wrong.
The gate that blessed it was wrong too. The probe scored LAUNCH when P2's Y rose. After a tag, the incoming partner's entry attack lifts the dummy, and that Y-pop looked exactly like a launch. The positive control was a misidentified tag.
| Input | v4 probe | v5 probe (P1 id asserted) |
|---|---|---|
| st.MP | "LAUNCH" 116 px | no — normal punch again |
| st.HP+HK | "LAUNCH" 116 px | no — tag path, not scored as launch |
| st.LP | no | no |
v5 restores the stock eight bytes at $019452 and blanks the thunk. MP is a punch. The
real MvC1 launcher for Cable is still unauthored; owning it means finding a site that is
actually a launcher, or writing the property-row path, with the identity assert in the
harness from day one.
Wake-ups: the blocker was real and it was the wrong shape
Knock Cable down in the July 29 build and he flashes red on the way up, because group 18, the knockdown and wake-up chains, was unseated. This had been on the CANNOT list for a while with a real reason attached: the record counts don't line up. Group 18's five slots hold 6, 7, 11, 15, and 11 records. The MvC2 sequences that should fill them are 8, 6, 6, and 6 frames. Four sequences, five slots, none of the counts matching. Earlier builders looked at that and skipped the group, which was the right call with the information they had.
The information was the problem. Those counts only conflict if you pair slot to slot. I went and read what the thirteen distinct records actually point at, and the picture changes completely: slots 1 through 4 aren't four different animations. They share one 4-frame roll cycle and one 3-frame stand-up tail between them. Only slot 0 is its own thing, a 6-frame get-up.
So there were never five sequences to find partners for. There were three. And MvC2 has all three sitting right there: its 8-frame kip-up resamples onto the get-up, its last four frames resample onto the stand-up tail, and its 6-frame roll resamples onto the roll cycle. Every tile was already in the ROM from the art work months ago. The whole fix is rewriting four bytes inside each of thirteen records, the pointer field, nothing else.

One honest note, because it matters and it's the kind of thing that's easy to quietly skip. All thirteen records are rewritten and the census counts all fifty frames. But the harness only ever reached slot 0, even mashing directions through the knockdown to try to trigger the recovery rolls. So slot 0 is measured and slots 1 through 4 are seated but unwatched. Those are two different strength claims and I'm not going to merge them into one number.
The Hyper Viper Beam: don't patch the code, patch the drawing
This is Cable's signature. In MvC2 it's a beam that spans the screen, 32-pixel segments repeating outward from the muzzle with a head that bursts at the far end.
Level 1 already worked before I touched anything, which surprised me. The super gets accepted, the meter drains exactly one level, Cable's body animation plays, five child objects spawn and all five retire cleanly with no leak. The engine was fine. It just drew garbage, because those children were still pointing at Ryu's fireball art.
Two things I had wrong going in.
The first was which records to fix. My notes said the live super installs group 24 slots 0 and 4. It doesn't. I traced it and it's slots 0 and 2. I would have patched slot 4, verified the bytes, confirmed the build, and gotten an identical screen with no idea why.
The second was the harder one and it's the interesting part. The plan was to find the tables that place each beam segment and patch them so segments lay out in 32-pixel steps along X. That means editing placement code on classes that other characters share, behind another id gate, after reverse engineering fields I hadn't found yet. Real work, and risky work.
Then it clicked that I was patching the wrong layer. A CPS-2 sprite list is already a list of parts, each with its own tile and its own x and y offset. One list can hold as many parts as I want, anywhere I want them. I don't need the code to place nine segments in a row. I can just write nine segments in a row into the list and let the existing code draw the one object it already knows how to draw.
So that's what it does. Slot 0 carries the muzzle flash plus the near half of the shaft, slot 2 carries the far half plus the head cap, both stepping 32 pixels outward, and MvC2's 4-frame thickness pulse is preserved by walking its shaft and head lists frame for frame. MvC2's beam geometry, zero instructions changed, and therefore zero possible impact on any other character.

And look at it. The shape is right and the colors are absolutely not. Every beam part sits on MvC2's palette row 4, and this build deliberately doesn't reseat palettes, so the segments render through whatever palette the super object is already holding. You get slabs of red and blue where you want white-hot energy.
I could fix that. I'm not going to yet, and the reason is a scar. An earlier palette edit on this project bled into a row that other things shared, and I spent real time hunting it. A wrong-colored beam that hurts nobody beats a right-colored beam that quietly recolors a stock character. It goes on the list, honestly labeled, and it gets done when I can prove the row is Cable's alone.
The beam also does no damage. It draws, it doesn't hit. That's level 3 and it isn't started.
The gunshot: the ROM said one thing, the machine said another
Cable's gun made a fireball whoosh, because that's what Ryu's fireball plays. I wanted the actual sound off the MvC2 disc.
That means cracking Sega's DTPK audio banks, and two details in that format ate most of the time. The sample's start address is 23 bits, not 24. Bit 23 is a flag. Read it as an address and one sample lands eight megabytes past where it belongs, which I only caught because the decoder refuses to accept samples that overlap each other. And there's no field telling you the sample width, so whether a clip is 4-bit ADPCM or 8-bit or 16-bit PCM has to be worked out from the ratio between its loop point and its byte count.
Seating it on the CPS-2 side was the easy half. The QSound sample ROM has a 66,126-byte gap after the last stock sample, and the sample directory is 818 entries of eight bytes each. Pick a clip, resample to 24 kHz 8-bit signed, drop it in an empty bank, point a directory entry at it.
Which entry is where I nearly shipped a lie. My own source file, written weeks ago, says the
gun fires sound id $17B. So I aimed at $17B. Then, before building, I ran a probe that
drains the game's real sound command queue while driving every one of Cable's actions, just to
confirm.
The gun posts $153. It has never once posted $17B.
I was one build away from decoding a Dreamcast audio format, seating a real MvC2 gunshot in arcade ROM, verifying every byte of it, and hearing absolutely nothing, because the id I'd attached it to is never triggered. Not a bug I would have found by reading more carefully. The file was just stale, and files don't tell you when they go stale. The machine does, if you ask it.
At this point in the build, his voice was still not in, and I could finally say why instead of
hand-waving. Every id Cable's voice posted was numerically past 818, the end of the sample
directory. They aren't
sample indices at all. The sound chip's driver dispatches them some other way, and no amount of
editing that directory will ever reach them. My first draft tried to seat a pain grunt on
$521 and would have written almost eight kilobytes past the end of the table into unrelated
driver data. The builder now refuses any id above 818, so that particular mistake can't happen
quietly again.
Merging four builds without trusting myself
Four separate scripts patched this ROM. Wake-ups, beam, sound, and everything from before. Each one was built and tested on its own. The obvious risk is two of them writing the same byte and the last one silently winning.
"They touch different files" is not proof, so the merge script doesn't say it. It computes the exact set of byte offsets each track changes, asserts the three sets are pairwise disjoint, and then asserts the total difference between the old tip and the new one equals their union exactly. If a single byte changed that no track claims, the build fails and nothing ships.
v3 -> v4 12,564 bytes changed
wakeup n hvb = 0 wakeup n sfx = 0 hvb n sfx = 0
total diff == union of the three claim setsThen the gate battery, eight probes in eight isolated emulator lanes, each with its own copy of the emulator binary so killing one can never touch another, and the ROM hash checked before and after every single run.
The one I'd point at is a control I almost didn't run. The win screen has a red block over it for a few frames during the globe zoom, and my first reaction was that I'd broken something. So I ran the identical capture against the old build and diffed every frame. Same red block, same frame. It's a pre-existing hiccup where the portrait's tiles arrive before its palette does, and it's been there the whole time. Fifty-one of sixty captured frames are pixel identical between the two builds, and the nine that differ all start exactly where the beam gets drawn.
If I hadn't run the control I'd have spent a day fixing someone else's bug, or worse, reverted good work to make a symptom go away.
Six numbers were why he played like Ryu
The complaint that mattered most was the one I could not answer with a screenshot: he still plays like Ryu. Not looks. Plays. The art was his, the sounds were his, and pressing buttons still felt wrong.
It turned out to be six numbers.
MvC1 decides whether one attack can cancel into another with a rank table at $E8048: one
48-byte block per fighter, indexed by stance — standing, crouching, air, superjump — plus six
if the move is a kick. A chain is legal only if three things hold at once: you are still inside
the current move, the current move connected, and the candidate's rank is strictly greater
than the one you are in.
Wolverine's ranks are 1, 2, 3, 4, 5, 6. A clean zigzag: punches and kicks interleave, so
light punch flows into light kick flows into medium punch, the magic series everybody's hands
already know.
Cable inherited Ryu's:
punches 1 3 5
kicks 1 3 5 <-- kicks TIE with punches at the same strengthStrictly greater fails on a tie. So lp -> lk was refused. mp -> mk was refused. Every
same-strength link in the series died on a comparison, and what survived was a single lane:
light, medium, heavy, and nothing else. That is exactly what "plays like Ryu" feels like from
the stick, and it was one table row inherited along with the slot.
The fix is ten bytes: set his kick rows to 02 / 04 / 06.
- Stock (Ryu's ranks 1,1,3,3,5,5)0links granted in the live probe
every same-strength punch-kick link refused
- After (1,3,5 / 2,4,6)15links granted in the live probe
lp->lk, mp->mk, and the full zigzag, contact-gated
The gate is a combo counter on screen: 2 HIT, then 3 HIT on the ground, and 3 HIT into
4 HIT in an aerial rave off a d/f+HP launcher.
My first chain probe reported no ground chains for anyone — including Wolverine, my control.
That should have been impossible, and it was: +$08 is not the X position on this build. It
reads 0000, so my "walk into range" step never moved, every starter whiffed at round-start
distance, and since contact gates every chain in this engine, nothing could chain. For
anybody. Ever.
A negative result from a control that cannot succeed is not a negative result. Real X is
+$0C. That one wrong offset cost a day and nearly buried a ten-byte fix under "MvC1 just
doesn't have ground chains."
Where the coverage sat at that checkpoint
- First working build, 11 chains6% of animation frames drawing Cable
99 frames out of 1,621
- Dashes, hit reactions, super jump, 101 chains17% of animation frames drawing Cable
the July 29 build, where this post originally ended
- Wake-ups and beam, 108 chains19% of animation frames drawing Cable
402 Cable frames; group 18 went 0 to 50, group 24 went 0 to 24
- The specials pass, 123 chains27% of animation frames drawing Cable
499 Cable frames; Psimitar, throw poses, Electrap art
Two percentage points doesn't look like much and I want to be straight about why it's more than it reads. Group 18 is five slots and group 24 is a handful of records. But group 18 is every time he hits the floor, which in a real match is constantly, and group 24 is his signature hyper. The chains you notice aren't distributed evenly across the tree.
The specials pass added fifteen more and took him past a quarter of the tree — 123 of 338 chains, 499 frames. The remaining 215 are the long tail: alternate hit reactions, throw variants, and the animations for moves that don't exist yet.
The voice bug that passed three green gates
This one is worth telling because it is the same mistake as the launcher, wearing a different costume.
I pulled 26 voice clips off PL17_VOI, resampled them to 24 kHz, and seated them in QSound
banks $F0 and up. That space is real: this core loads 16 MB of sample ROM and the Z80's
key-on routine writes the full 8-bit bank byte, so the upper 8 MB is addressable, and I checked
that not one of the 818 descriptors reaches it before overwriting anything. Then I cleared every
Ryu voice id out of Cable's voice block and raised the per-frame sound slot on all eighteen
attack frames, because the engine skips the voice hook entirely when that byte is zero.
Every gate passed. The block had zero Ryu ids. The slots were set. And the game still sounded exactly like Ryu with the wrong sound effects, which is precisely what I was told.
The ring is what caught it. $0D5EF0, the voice routine, is hijacked at its second instruction
by a thunk left behind by an earlier build:
23B890 cmpi.w #$12,$52(a6) ; Cable?
23B898 jmp $23C222 ; everyone else, stock path
23B8AC lea $23B980.l,a0 ; Cable, his own table, over in the arena
23B8B6 beq.b out ; a zero entry plays nothingCable never reads the block I spent three passes fixing. His voices come from $23B980, and
that table had exactly one non-zero entry: slot 26, pointing at $0121 — the engine's
shared medium-attack impact. One stock CPS-2 sound was his entire voice. Filling that table is a
22-byte write, and at this stage every attack posted a Cable id next to the impact. The v24 audit
later tightened that claim from a ring post to actual sample playback:
| input | before | after |
|---|---|---|
| st.LP | $120 | $108 + $120 |
| st.MP | $121 | $10E + $121 |
| st.HP | $122 | $11C + $122 |
Three static gates described a table the character does not read. The lesson is the one the launcher already taught and I clearly had not learned: a green check on one table is not a statement about the subsystem. Only the live differential is.
Psy-Charge, Crackdown, AHVB and Time Flip. Psimitar and Electrap now exist; these four do not. Each is new 68000 behavior, not a reskin. The foundation they need is in place (box and property tables he owns, ~914 KB of free program space, the spawn channel mapped), which is why they are next rather than hypothetical.
A red rectangle overlaps the hyper. The beam is the right color and the right shape, and something else is drawing a flat red slab on top of it. Two candidates are measured — a chain with four records still pointing at stock Ryu sprite lists, and the beam-head child object — and neither is attributed yet. I am not patching a candidate.
Two dash grunts are still Ryu. $027A and $027B resolve through a shared arena sound map
whose per-character rows are not decoded. Four candidate sites, none proven to be his, so none
patched.
The select screen still composites wrong. The side art draws as one oversized figure instead of the two-object illustration every stock character uses, and the confirm pose draws six sprites of the wrong art where stock draws fifteen. Both are located exactly. Neither is fixed, and I shipped no build claiming otherwise.
The Psimitar blade takes his body palette. Same class of bug the beams had, and the same fix should apply — find an existing effect row, re-ink onto its pens. It just hasn't been done yet.
The launcher was never flaky. My harness was. d/f+HP measured clean early on, then a later
probe scored it flaky, and I wrote in an earlier version of this page that I would not claim it
until I knew which run had lied. The re-measurement settles it: 12 of 12 launches at three
different spacings, 195 pixels of lift every single time, zero variance, plus a super-jump
cancel at exactly 12 frames on 3 of 3. Stock Ryu on an unmodified ROM, run through the same
battery in the same slot, produces a byte-identical summary.
The flaky reading came from three faults in the probe, and the third is the one that stings.
It never coined player two, so the dummy was CPU-controlled — walking, jumping and blocking,
drifting up to 239 pixels between trials, so roughly half the "no" cells were the starter
whiffing rather than the launcher failing. It had no contact gate, so a whiff and a
connected-but-non-launching hit scored identically. And its negative control fired and I talked
myself out of it: standing light punch scored a 59-pixel lift against a 24-pixel threshold, and
I wrote that off as "did not repeat." Once st.LP launches, the metric is measuring the dummy,
and nothing downstream of it means anything.
Recovery rolls are unwatched. Wake-up slots 1 through 4 are seated and counted, but no harness has managed to trigger them live yet. Seated is not the same as seen.
The gates, honestly, measured on the corrected tip lineage (mvsc_cable_v5 / v6 /
mvsc_cable_intro / mvsc_cable_poses), each in an isolated lane with the ROM hash checked
before and after:
| Gate | Status | Evidence |
|---|---|---|
| Identify Cable on the disc | passed | PL17, self-labeled by Capcom; sprites recognizable by eye |
| Convert art to CPS-2 tiles | passed | 14,095 tiles, all 4,031 sprites reassemble pixel-perfect |
Decode MvC2 frame data (S5/S1) | passed | sprite lists and chains rebuilt as CPS-2 records |
| Render in MvC1 | passed | live capture, correct palette, 100% pen mapping |
| Animate on the donor chassis | partial | v24 census: 169 of 338 chains contain Cable art, 163 are fully Cable; 742 of 1,856 records (39.98%) use Cable art |
| Name, portrait, win and VS art | passed | ROM-harvested letters, his own face on all four surfaces |
| Bullet travels | passed | 12 px/frame across five traced frames, animation advancing, no object leak |
| False MP launcher reverted | passed | $019452 stock again; HP+HK scores TAG not LAUNCH; no MP→tag thunk |
| Launcher exists | passed | d/f+HP lifts 90 and 198 px on repeated trials; MvC2's own 3HP input |
| Box tables he owns | passed | hitbox / box-shape / move-property row 9 relocated, 22 of 23 rows byte-identical |
| 18 normal input routes | passed | all six standing, six crouching and six aerial routes reached live; full MvC2-native timing is not claimed |
| Normal collision parity | partial | Cable owns relocated box-shape, hitbox and move-property rows; every active shape/property pair still needs a fresh release audit |
| Wake-up art | passed for slot 0 | three live captures of him getting up as himself; slots 1 to 4 seated, never reached |
| Viper Beam draws | passed | g22s14/s15 off Ryu's fireball onto MvC2's blue beam, on effect row 12 |
| Hyper Viper Beam draws | passed | 53 to 84 sprites, 24 columns, x=48 to 425, every one on effect row 17 |
| Beams aim | passed | LP climbs 60 px, HP drops 58 px over 33 frames; 0 px before the fix |
| Beam sound | passed | $163 on fire, $078 on release, 5 of 5 actions; control character posts neither |
| Psimitar | passed | three strengths latch $e6=$02, draw his blade, put P2 in hitstun |
| Psimitar invulnerability | already stock | box sets $45/$46 carry an attack box and zero hurt boxes |
| Select nameplate overlap | not a defect | stock plates are 64 px wide drawn 48 px apart; Morrigan overlaps identically |
| Pre-fight walk-in | does not exist | clean-ROM control: no stock character walks in either |
| Encrypted-ROM merge | passed | word granular, 3,371 B applied, 6 B skipped, no split ciphertext word |
| Real MvC2 gunshot | owned on $324 | QCF.HP posts $324; stock $153 restored; WAV is a gunshot by ear |
| Every pose seated | passed | all 13 g19/g20 chains draw Cable; intro upgraded to the 35-record rifle set |
| Cable's voice | partial | all 18 normals produce fresh imported QSound key-ons in v24; QCF+HP and Hyper Viper Beam produce no fresh Cable voice session |
| Write-set discipline | passed | eight stages, 269,199 B, total diff equals the union of the stages |
| Hyper beam damage | passed, alignment open | 20 hits for 43 damage in the v23 regression gate; visible beam and hit region remain vertically misaligned against crouchers |
| Psy-Charge, Crackdown, AHVB and Time Flip | not started | each still needs new 68000 behavior; source behavior is evidence, not portable code |
The specials pass, or: five bugs that were never where the bug was
Everything above got him standing, moving, and chaining. This last stretch is the one that makes him look like Cable instead of a blue Ryu with a rifle — and every single fix in it landed somewhere other than where the symptom pointed.
The beam turned magenta and I never wrote a single palette byte
MvC2's Viper Beam is a magenta plasma bolt. Mine came out in slabs of gold and red, which is because a spawned child in this engine inherits the palette row of the object that spawned it. Cable's body is row 0. So his beam was politely drawing itself in his skin and jacket colors.
The obvious fix is to author a palette row for beams. I didn't, and I want to explain why,
because it's the same trap that has bitten this project three times. Palette rows in MvC1 are
shared. Box shapes $3A and $3B are used by an entire animation group. Reseat one and you
find out weeks later that somebody else's move turned green. Every table in this game has a
sharing pattern, and the pattern is never documented.
So instead of writing a row, I went looking for one. During the hyper I dumped live PALRAM and diffed it against the same frame with a different team on screen:
r12 FFFF FFFD FFF8 FFE5 FFC4 FFA4 FF82 FF65 FDFF FAFF F6EF F4BF F48F F46F F12F F000
r17 (magenta ramp, identical across every lineup tested)Rows 12 and 17 are lineup-independent. They're fed from $1D90A4 / $1D9124 / $1D9144
through a pointer table at $0D86C8 — they're the engine's own effect rows, and they load the
same way no matter who is fighting. Row 12 is a blue ramp. Row 17 is a magenta one. They are, by
luck that I will absolutely take, very close to MvC2's own beam sets.
So the fix isn't a palette write at all. It's a re-ink: requantize the beam art onto the pens
those rows already contain, then set the sprite attribute's low five bits to the row index
($000C = row 12, $0011 = row 17 — it's a decimal index sitting in a hex field, which cost me
an hour by itself). Zero palette bytes written. Nothing else in the game can notice.
Live, the hyper now draws a band of 53 to 84 sprites across 24 distinct x columns from x=48 to
x=425, and the probe reports r17 for every one of them. Not most. All of them.
Four bytes of aim, after a day of the wrong theory
In MvC2 the Viper Beam aims: light climbs, heavy dives. I spent a day on the theory that this
lives in the frame data — a y-velocity field on the projectile's records — and wrote a builder to
set it. The probe said the beam flew perfectly flat. Every frame, every strength, dy=+0.
Which is a much better result than a wrong number, because a value that changes nothing means
the value isn't the mechanism. The child's per-frame mover is $0024C4, and reading it properly
instead of skimming it:
0024C4 ; adds $14(a6) to $0C(a6) <- x velocity into x
; ...and never touches $10(a6) <- y is not in this routine at allIt is an X-only mover. No y-velocity field could ever have worked, because nothing was going
to read it. There is already a two-axis mover sitting at $002490. The fix is retargeting one
jsr operand — four bytes:
Psimitar: the animation was already in there
DP+P. Cable's energy blade. Before this pass it latched correctly and drew a flat red
rectangle, which by now is this project's universal signal for the chain is running and the
art is not seated.


Two separate captures, side by side rather than under a wipe divider — deliberately. The camera sits in a different place in each and the score reads 1000 against 2500, so they are two runs, not one run with one variable changed. A slider between them would imply a controlled comparison I did not actually make. What is the same in both is the chain, the latch and the three-strength dispatch; what changed is which sprite lists the records point at.
The latch was never the problem. All three strengths already dispatch cleanly, with distinct records and properly scaled rising velocity:
| input | $e6 | record | rise |
|---|---|---|---|
| DP+LP | $02 | $12FDCE | 8 px/frame |
| DP+MP | $02 | $12FE92 | 12 px/frame |
| DP+HP | $02 | $12FF56 | 16 px/frame |
And it hits — the opponent's state byte goes to $36, hitstun, on frames 2 to 3 depending on
strength. That's Ryu's shoryuken skeleton doing exactly what a shoryuken skeleton does, and it is
a genuinely good fit for a rising blade.
I had written a whole task for adding Psimitar's invulnerable startup. Then I re-read the property row and found I'd had the field map backwards in my own notes.
A property row is 16 bytes. I had recorded word 0 as a hurt box and words 2 through 4 as attack
boxes. It is the other way around: word 0 is the attack box, words 2, 3 and 4 are the three
hurt boxes. Which means when I looked up the box sets this chain actually uses — $45 and
$46 — and read them correctly, they have an attack box and zero hurt boxes.
The startup is already invulnerable. Capcom's own shoryuken data did it in 1998. The task wasn't hard, it was already done, and I only found that out by fixing a mistake in my own documentation.
The beam finally makes noise
The Viper Beam fired in complete silence except for the gunshot. Here is the ring before, and the ring after, on the same five actions:
| action | before | after |
|---|---|---|
| QCF.LP | $324 | $163 + $324 + $078 |
| QCF.MP | $324 | $163 + $324 + $078 |
| QCF.HP | $324 | $163 + $324 + $078 |
| air QCF.HP | $129 $324 | $163 + $324 + $078 |
| QCF.PP (hyper) | $13C $150 $274 $275 | + $163 + $078 |
$163 is the sustained beam tone, posted on the firing frame. $078 is the release. Five out of
five actions, including the air version and the hyper.
And the control, which is the part that makes this a measurement instead of a hope: the same
probe run on a different character posts $120 and $18F/$2E6 on the same inputs and
never once posts $163 or $078. The new ids are Cable's, not a global side effect I've
sprayed across the game.
Two things I was going to fix that were not broken
This is my favorite category, because the win is not shipping a patch.
The select-screen nameplate overlap. His name plate visibly runs into the next character's. Obvious bug. Except: stock plates are 64 pixels wide and drawn 48 pixels apart, so they overlap by design — and Morrigan and Captain Commando overlap by exactly the same eight pixels in an unmodified ROM. There was nothing to fix. If I had "fixed" it I'd have broken the select screen for all 22 other fighters.
The missing pre-fight walk-in. Cable doesn't walk in before the round. I traced the chain, I found the group, and then I ran the same capture on a clean ROM with a stock character — and nobody walks in. MvC1 doesn't have a pre-fight walk-in. It has an intro pose. I'd been asked for a feature the engine has never had, and the only thing that could tell me that was the control run.
Both of these cost about two hours and produced zero bytes of patch. That's the correct outcome, and the only reason I know it is the control.
The grenade bug had my fingerprints on it
The Electrap art glitched. I went hunting for a tile-conversion error, and found the child
object's tile bank at $0936D2 pointing somewhere wrong — because my own v10 merge had
overwritten it. Not a conversion bug, not an engine quirk. Me, three builds ago, merging two
write-sets that touched adjacent bytes without checking.
Which leads directly to the last thing.
Merging two builds when the ROM is encrypted
Two lines of work ran in parallel off the same base: one doing beam palette, tiling and aim, the other doing Psimitar and beam sound. Both produced a working ROM. Neither could be shipped without the other.
A normal merge is a byte diff. This is not a normal merge. CPS-2 program ROM is encrypted as a per-word bijection: a 16-bit word is the atomic unit, and half of one build's word spliced onto half of another's decrypts to garbage — not a subtly wrong value, an invalid opcode.
The diff found five contested bytes in mvce.04a, and they were exactly the aim fix: one build
had 631D where the other had DF89 in the mover's jsr operand.
The first merge attempt failed loudly, and I'm grateful it did:
AssertionError: class-4 subtype 2 step 1 (air beam) at $0934D0: 4eb900002490The sound builder checks its own preconditions before writing, and refused, because the other
build had already retargeted that jsr. A plain byte-level merge would have taken half of one
change and half of the other and produced a ROM that boots, plays, and crashes when you press
punch in the air.
So the merge resolves at word granularity, with the aim fix given precedence:
The lesson I'd hand to anyone doing this: a builder that asserts its own preconditions is worth more than a builder that works. That assertion caught a merge conflict that would otherwise have shipped as an intermittent crash, and it caught it in under a second, before anything was written.
And then that same assertion let a crash through anyway, which is the next section.
The merge assertion I was proud of was not enough
The build above shipped, and a week later Cable's air Viper Beam started resetting the board.
I want to walk this one carefully, because it is the most instructive bug in the project and it is entirely mine.
The two builds I merged disagreed about one instruction — the per-frame mover the air projectile calls:
stock 4EB9 0000 24C4 jsr $0024C4 x-axis only
build A 4EB9 0000 2490 jsr $002490 stock two-axis mover
build B 4EB9 0023 B930 jsr $23B930 own two-axis, gated on id $12
------------------------------------------------------------------
the merge 4EB9 0023 2490 jsr $232490 <- NEITHER PARENTThe high word came from B. The low word came from A. And the assertion I wrote up two sections ago — no ciphertext word left as a mixture — passed, correctly, because no individual word was a mixture. Both words were intact. Each came wholly from one parent.
A jsr abs.l operand is two words. I had asserted word-level integrity on a machine where the
thing that needs to stay intact is an operand, and operands span words. The unit I checked was
not the unit that matters.
Nowhere near code. $00232490 lands inside mvc.07's win-quote text table, in the middle of a
Portuguese victory line:
Esta foto com certeza sera manchete do jornal!
So the 68000 fetched a sentence, decoded it as instructions, executed it, and took an illegal
instruction at $2324D2. Capcom's own exception handler then recorded the fault and wiped the
evidence, which is why it presented as a clean reset rather than a crash.
To see it at all I had to freeze that handler — patch $0E5952 to bra.s * — so the dump at
$FF2CB6 survived long enough to read.
The fix is three bytes. The interesting part is what the search found: a word-granular
neighbour-tag scan of the whole megabyte turned up exactly one such site, so there was no
second splice hiding. It also turned up a different latent crash in the routine the fix now
calls — a thunk whose not-Cable branch landed one instruction past its own rts, in $FFFF
filler, which is a LINE-F exception. That is this project's very first failure mode, verbatim,
sitting live in a shipped build and waiting for a character who wasn't Cable to throw an air
projectile.
The corrected lesson, which is narrower and more useful than the one I wrote before: assert on the unit the hardware cares about, not the unit your file format happens to use. The cipher works in words, so I checked words. The processor works in operands, and nobody was checking those.
Two bug reports that were my own leftovers
Around the same time I was told the character-select side art was broken and the ground hyper fired nothing at all. Both were reproducible on the operator's machine and neither reproduced on mine, which normally means somebody is holding it wrong.
Somebody was, and it was me.
FBNeo auto-loads and re-saves a save state at
~/Library/Application Support/fbneo/config/games/mvsc.fs on every run — and that file is shared
by every automated test lane and every human play session on the machine. My harness runs had been
writing it. So a play session launched afterwards booted into my test harness's end state: one
build's video RAM, palettes and object RAM sitting underneath a different build's code.
That is a genuinely nasty failure mode, because stale VRAM does not look like a stale save. It looks exactly like a rendering bug in the ROM you think you are testing. I spent a round of diagnosis on defects that were my own residue.
Under a forced clean boot (-no-autostate, private config tree per lane), neither report survives:
- The select panel is clean. Its 74 object entries use 162 distinct tiles and not one tile is used twice — so the tile-aliasing defect that caused the original blocks is genuinely gone. The montage matches its authoring source to a mean error of 5.26/255 across 19,663 opaque pixels, which is quantisation noise, not clipping. Measured against a stock character captured in the same run, Cable's standing figure is smaller than Zangief's, so "the figure crowds the panel" isn't a Cable defect either.
- The ground hyper fires every time. Three casts, three beams, 268 to 283 sprites at peak, with the opponent's lifebar visibly draining.
No fix was invented for either, which was the right outcome and took real discipline to reach — the tempting move is always to patch something and declare victory.
He is standing in the game
That was the road. Here is where it ends up.
Not a mock-up, not a sprite sheet, not a render. A 1998 arcade board drawing a character it was never built to know about, through its own renderer, at 59.63 Hz.
Nobody had moved a character from the Dreamcast and NAOMI generation back into a CPS-2 game before.

The next two paragraphs collect milestones from several isolated historical builds. They are not a claim that every feature is present and re-verified together in v30; the August 7 audit above is the current release boundary.
Across those research builds, he walks in on his own two feet, in his own colors. The
lifebar says CABLE, built from the game's own letter art, with the B cut straight off
GAMBIT's nameplate. The portrait beside it is his face, inset by one pixel, because all 22 stock
portraits are. He walks, dashes, super jumps, blocks high and low and in the air. He throws all
eighteen normal routes with Cable art — and now they chain, because six numbers in a rank
table finally say they can. That route gate does not mean all eighteen have MvC2-native record
counts or timing. He launches with d/f+HP, which is MvC2's own launcher input. When he lands
you hear his weight. When he gets hit he reacts in his own art, and when he wins he poses as
himself.
Quarter-circle-forward and punch fires a Viper Beam, not a hadouken, in blue plasma, with a gunshot he owns and a beam tone underneath it — and it aims: light climbs sixty pixels, heavy dives fifty-eight. In the air it fires too. Dragon-punch and punch swings a Psimitar, his energy blade, on all three strengths, with startup invulnerability that turned out to have been sitting in the donor's data since 1998. Quarter-circle-back and kick throws an Electrap that arcs, lands, sits as a canister, and detonates — and the explosion hits for real, 258 pixels of launch with hitstun. Two quarter-circle punches spend a meter and fire a Hyper Viper Beam in magenta across the whole screen: twenty hits, chip on block, and nothing at all to the man standing behind him. Those isolated gates remain useful evidence, but the grenade's final art, collision and audio combination and the hyper's mash growth, full steering and complete audio remain open on the current line.

Reading MvC2's beam out of its own SH-4
The beam still didn't look right. Mine was a thin magenta cord; MvC2's is a fat ribbon that visibly breathes. So instead of guessing at it a fourth time, I went and read their code.
1ST_READ.BIN on the Dreamcast disc is 1.8 MB of SH-4 — 2,345 rts, ten thousand PC-relative
loads, the classic NOP sled at the entry point. Capcom's actual beam logic, sitting right there.
(It needed a purpose-written disassembler: the off-the-shelf SH backend decodes no floating point,
and MvC2 keeps every object position as an IEEE float, so the entire coordinate story is invisible
without it.)
The thickness pulse is not an algorithm. It is a four-frame animation.
Four lists, one frame each, looping: 60 ÷ 4 = the 15 Hz breathing I had been trying to reverse engineer. There is nothing to port. It is an animation.
And I had deleted the two fat frames — 15 and 17 — in an earlier pass, because they were 40 to 70% flat crimson and I read them as translucent glow layers that CPS-2 could not draw. They are not glow. They are the thick frames of the cycle. Removing them is precisely why my beam is a constant-width cord: I had kept 20 and 24 and thrown away 48 and 56.
Two more corrections fell out of the same read:
There is no translucency flag, and there never was. I had built a whole porting problem around
sprite flag $0040 being a blend bit that CPS-2 has no answer for. It isn't a blend bit. The flag
word is {bit 15 = H-flip, bit 14 = V-flip, bits 6–4 = palette row} — all 7,718 of Cable's sprite
entries take only 19 distinct values — so $0040 >> 4 is palette row 4, which is the beam
palette I had already identified by other means. My claim contradicted a fact in my own notes.
The dithering scheme I had scoped was solving a problem that does not exist.
The list count was short by eight. It's 26 lists, not 18 — there's a second flare-and-burst family running alongside the first, which is likely a good share of the missing ink.
It also turned up a completely decoded beam driver — coroutines, state tables, keyframe mover — and then proved it wasn't Cable's and refused to use it. The test: parse the animation group/slot tables of all 58 character files, then check whether every group and slot a code window touches is actually populated for that character. The module calls animation group 27 slot 10; Cable has exactly one slot in group 27.
The test isn't vacuous — it uniquely pins about fifteen other code regions to single characters. It just doesn't pin this one to Cable. So the report names the closest unfinished candidate and stops, which is worth more than a confident wrong attribution. This page has already published two of those.
Putting the four frames back
Restoring lists 15 and 17 took the shaft from a constant cord to MvC2's cycle exactly:
- Frame 020pixels
MvC2: 20
- Frame 148pixels
MvC2: 48 — the frame I had deleted
- Frame 224pixels
MvC2: 24
- Frame 356pixels
MvC2: 56 — also deleted
Ink on screen went from 47.2% of MvC2's to 93.1% — and live, against the previous build on the same stage with the same schedule, 1.99× the pixels. The remaining 6.9% is one deliberate crop, asserted at build time rather than estimated.
Two more things fell out of doing it properly.
There was a visible joint in the middle of the beam, and nobody had noticed. The far half of the shaft ran two ticks behind the near half, because its chain carried two leading blank records — so the beam had a 4–8 pixel thickness step partway along its length, every time. Caught two independent ways: a column-extent profile off live object RAM, and a pointer log showing lag 2 on 104 of 104 frames. Rotating the far half's art by two frames closes it; the seam check now reads zeros across the whole cycle.
And the dither came out. An earlier build had stippled twelve lists with a one-pixel
checkerboard to fake the translucency I thought CPS-2 couldn't do. Since $0040 turned out to be a
palette row rather than a blend bit, all twelve are opaque again — the way Capcom drew them.
Worth recording, because both would have produced a number I'd have published.
The first pass computed "percentage of MvC2's beam" using a denominator built from our own cropped art. It read 100.0%, and it could not have read anything else. It was tautological. It's now printed only as a labelled identity check and never as a score.
The second: the existing measurement window was calibrated when the muzzle was a 61×54 star. The new muzzle is a 191×128 burst that still sits inside that window, so the window was reporting the burst's height as the shaft's thickness — overstating the result by roughly 60%, in our favour. The replacement takes the median per-column extent, which the burst can't move.
A measurement that can only return good news isn't a measurement.
And one more correction to my own brief: I had said eight sprite lists were missing and were probably much of the shortfall. They aren't missing — four of them are byte-identical to four already seated, every RGBA byte, and the other four are the same flash on a different body pose. They're two pose variants of one muzzle event, not two layers. Seating them would have duplicated the burst on top of itself.
The round where I stopped counting and started looking
Four times running, a gate passed and then the person playing the game found it broken. The pattern was identical every time, and it took four rounds to see it: the gate measured the mechanism and the defect was in the appearance.
So this round started by rendering our art and MvC2's art side by side at matched scale for every move, and reading both pictures before touching anything. It found more in one pass than the previous three combined — and it refuted two of my own theories on the way.
The grenade was throwing his arm, literally
All eight frames of the grenade's flight held the same sprite list, so there was no tumble — one image, dragged through the whole arc. And that image was a 48×48 pair of flat wedges drawn on palette row 0, Cable's own body palette: his blue costume and his skin tone. It read as an arm because it was made of arm colours.
MvC2's real Electrap is three objects we had never seated: a pale-blue capsule, a lightning-bolt and electric-ball pair while it arms, and a 144×96 blue electric oval with a white spiky core for the detonation. What we had instead were 8×8 specks — some as small as two opaque pixels — and a generic yellow starburst.
Seating the right art is easy to claim, so the gate classified every captured frame by shape against the four newly-seated lists plus four deliberate distractors — the old yellow blob, a lightning bolt, a hand, a starburst.
Distractors won 0 of 9. Best matches: 0.993 on the electric ball, 0.959 on the detonation, 0.672 on the capsule against 0.404 for its runner-up. And the child's motion trace is identical, 167 of 167 rows, between the old build and the new one — so every column is the same instant of the same move and only the pixels changed.
Two-thirds of the beam was switching off, four frames at a time
The reach complaint had an exact cause. The far half of the beam walks its own chain of animation
records, and that chain's terminal record loops back to a record whose sprite list is
$1339BE — the engine's own blank list — held for four frames.
So roughly 1.7 times a second, the outer two-thirds of the beam simply did not draw. Measured off object RAM, the reach collapses from ~450 px to 252, 252, 251, 250, then snaps back. MvC2 cannot do this: all four of its shaft frames are 32 px wide, so tiling them gives the same length every frame. Their beam changes thickness and nothing else.
Four bytes.
The chop was a rate, not a shape — and fixing it fully breaks the steering
I had a theory that MvC2 varied thickness along the beam as a travelling wave, and that our whole-beam swap was the flicker. The audit measured their per-column heights and refuted it: every 32-pixel segment is a perfectly uniform band, 20/48/24/56, and the caps are phase-locked to the shaft. MvC2 genuinely swaps the entire beam's thickness in one-frame steps.
The difference was purely the rate. Every one of our twenty records carried dur=4 — a 7.5 Hz
strobe the eye resolves as flicker — where MvC2 runs dur=1, about 30 Hz, which fuses into a
shimmer.
The honest part: dur=1 was built first, and it silently kills the beam's steering. Held-up
and held-down converge to 160.30 and 161.79 pixels — flat — where dur=2 keeps them at 148.10 and
170.91. So the shipped value is 2: twice MvC2's rate, half of ours. Better, not fixed, and the
remaining half needs a code change to the animation-advance ordering rather than another data edit.
A launcher is one byte of hit property
Third attempt at making medium punch launch, and the first two failed because both started from a patch instead of from the mechanism. This one measured what a launcher is.
When a hit lands, the victim looks up a property row using a code the attacker supplies in
field $80. Cable's working launcher, d/f+HP, carries $80 = $08. Standing MP carries $01.
Everything else that differs between those two moves — animation chain, move ids, record bytes —
is irrelevant.
A launcher is a hit that hands the victim property code $08 — and poking that one field at
the moment of contact, with zero ROM bytes changed, made medium punch launch. That is the
mechanism, proved before anything was built.
The fix could not use it. Writing $08 into standing MP's animation record was built and gated
and it failed: the standing-normal state handler never consumes the record byte that would latch
into $80, so a record simply cannot set the property code for a normal. (Which is also why the
very first attempt — retargeting the animation tree — was doomed: it changed which animation MP
plays, never which code it hands over.)
So the shipped fix goes at the other end. It leaves $80 alone — measured on the shipped build,
standing MP still carries $01 — and instead rewrites row $01 itself, inside Cable's own
exclusive property table, to carry the launcher's values. Thirteen bytes of data, no code.
The blast radius is bounded and measured: standing LP still never launches (0 of 3), standing HP
never launches (0 of 3), crouching MP is untouched (0 of 3), and d/f+HP still launches at 195 px.
The negative controls aren't decoration here — an earlier attempt shipped a partner tag by mistake
and nobody caught it for weeks.
The sound ring said “voice”; the QSound channel said “prove it”
This section used to correct the page by claiming Cable's live voice table was all zero. That
correction was itself wrong: it read a stale stage of the build. In the v23 lineage, the table at
$23B980 has 22 populated four-byte rows, and the expanded QSound banks really do contain the
converted Cable samples.
The sound ring still was not enough evidence. A ring event only proves that the 68000 posted an id. It does not prove that the QSound driver resolved that id, opened the intended descriptor and started reading sample bytes. The release gate now watches the channel session and compares the first bytes it reads with the bytes inside the finished ROM.
On v23, that gate found 16 of 18 normal attacks starting fresh Cable samples. Aerial medium
kick and aerial heavy kick were silent. Their clips were fine; their sound-slot bytes had been
wired to the old g13s1/g13s2 donor routes while the playable character actually enters
g21s4/g21s5. v24 changes the active record in each live chain — $12E8EA+$0A and
$12E93E+$0A — and nothing else.
v24 audio gate
Imported audio works for normals; move-specific calls are still a separate job
A ring post is not counted as playback. Every verified cell below opened a fresh QSound session whose bytes matched the shipped ROM.
| route | route | sound slot | fresh sample | remaining work |
|---|---|---|---|---|
| standing normals | verified6 of 6 routes | verifiedslots 1 / 2 / 3 | verified | verified |
| crouching normals | verified6 of 6 routes | verifiedslots 1 / 2 / 3 | verified | verified |
| aerial normals | verified6 of 6 routes | verifiedslots 1 / 2 / 3 | verified | verifiedj.MK and j.HK repaired in v24 |
| Viper Beam voice call | verifiedQCF+HP reaches the move | pending | pending | pendingengine effects play; Cable speech does not |
| Hyper Viper Beam voice call | verifiedQCF+PP reaches the move | pending | pending | pendingring ids post, but no fresh imported sample keys |
This is why the current claim is narrower and stronger: Cable is not silent, and all eighteen normals have imported voice audio. He does not yet have a verified spoken Viper Beam or Hyper Viper Beam call.
He was vibrating, and it was three bugs stacked on each other
The report was "he vibrates like he's missing frames during the super." He was missing frames — during the hyper's sustain he showed three unique frames out of MvC2's fourteen, the worst ratio anywhere in the port. But the count was the least interesting of three defects sitting on top of each other, and the middle one is the reason it read as a vibration specifically.
One: we were toggling the wrong two frames. MvC2's sustain alternates between two poses whose difference is almost entirely horizontal — the ink centroid moves 2.9 px across and 0.4 px down. It reads as a rifle recoil. Ours alternated between a different pair, moving 0.9 px across and 3.6 px down, with the sprite's top edge jumping 5 px against MvC2's 1. That is a vertical bob.
Two: five times too slow. MvC2 holds each of those poses for a single tick — a 2-tick alternation, fast enough to fuse into a shimmer. Ours held each for five, a 10-tick period. A vertical bob at 6 Hz is not a shimmer. It is a vibration, exactly as described.
Three: the recovery pose was trapped inside the loop. The frame where he lowers the gun sits in the sustain cycle, so once every 48 ticks his body jumped about 16 px sideways and lost 29% of its ink, then snapped back.
A census of all 338 chains explains how it survived: at this point, only three chains in the port had ever been grown. Every other one was an in-place pointer rewrite that kept the donor's record count and the donor's durations, byte for byte. Cable has been playing his animations on Ryu's timing this whole time — 160 chains of it.
The technique that fixes it came from a sibling project doing the same thing with a Street Fighter III character: park a full-length record sequence in expansion ROM, repoint the chain's start at it, and distribute the donor's total duration across every displayed frame so the move still takes exactly as long as the engine expects.
And then the part I did not expect. Growing the chain resolved the steering conflict that had blocked the beam's rate for two builds.
The beam had a standoff: MvC2's thickness rate needs one tick per frame, and setting one tick per frame silently flattened the player's ability to steer it — held-up and held-down converged to within 1.5 px of each other. I had shipped a compromise and written down that the real fix needed a code change.
It didn't. Both symptoms were the same disease — too few records advancing too fast — and giving the chain its real frames fixed them together:
Damage came out identical — 22 hits, same total, the per-hit vector differing by a single adjacent swap, which is precisely what moving from two ticks to one does to a beam that lands one tick earlier.
The idle has the largest gap in the whole port — 26 unique frames missing — and it is deferred, not done. The missing frames are a psi-spark flourish whose parts sit on palette rows this port drops by design. It is blocked on effect-palette work, not on effort, and no amount of chain growth will produce it.
The beam was 139 pixels behind the gun
I spent weeks on this beam's colour, its thickness, its rim, its breathing rate. Every one of those was a real defect, and every one of those fixes held. None of them was the thing anybody was actually looking at.
The whole beam assembly was anchored 139 pixels behind Cable's own gun. The muzzle starburst sat on his back, at the left edge of the screen. The shaft was drawn from screen x=0, ran forward through his own sprite, and left on the right. On the thick frames it covered his body entirely. I had been grading the paint on a beam that was in the wrong place.
Here it is after the fix, in a real match — the normal Viper Beam first, then the hyper at 19, 23 and 28 hits:
Everything below was measured on that pair of builds, on the same stage, with the same input schedule.
| base | v23 | |
|---|---|---|
| burst centroid x | ~40 | 166 ± 5 (gun muzzle 150–157) |
| beam ink left of Cable | 40,634 px | 0 on every frame |
| beam ink through his body | 85,211 px | 0 |
| band centre y | 155.0 | 106–108 (gun bore y = 107) |
| seam step at the joint | 29 px | 0 / 1 / 2 px (neutral / up / down) |
| shaft thickness | 7 values | 48 px on 26 of 26 frames |
| thickness changes | 55 per second | 0 |
| red fraction | swings 0–79% | 42%, constant |
| hits / damage | 20 / 42 | 20 / 43 |
The fix is 302 bytes of data and no code. The 128 sprite lists in the parked bend array at
$549E9C each carry a mode-2 position blob; the builder subtracts 139 from local x and 45 from
local y in every one of them. Tile codes, part counts, per-part deltas and list lengths are
untouched — the art is the art, it was just being hung from the wrong nail.
One data change had to be correct on both facings, which is the part I expected to go wrong and didn't: 146 of 146 sprites move by exactly (+139, −45) with Cable facing right, and by exactly (−139, −45) with him facing left.
The whole build is 326 changed bytes across three of the ROM's thirty-six members. It builds twice to the same hash, which is the only reason I am willing to print byte counts at all:
| member | bytes | what | CPU range |
|---|---|---|---|
mvc.exp | 302 | beam placement blobs | $549F5C–$569B23 |
mvc.08 | 18 | the seam fix in HVBSTEP | $28C11A–$28C131 |
mvc.05a | 6 | the strobe pin | 6 list-pointer longs |
Every number in this section was measured on mvsc_cable_v23.zip, md5
d0f7c543e46ac2f0984e489b9ca76886, on 2026-08-02, built by scripts/build_cable_v23.py from
mvsc_cable_vsfix.zip, md5 b5451ce747ed63a7c839c7f4ea84eb99. Built twice, same md5 both
times.
This page has previously printed a byte-level diff for a patch that had been reverted three builds later, so the rule now is that a table stating ROM bytes carries the build, the hash and the date it was measured on, and none of these rows is a claim about any later build.
The mirroring theory was mine, and the arithmetic was a coincidence
I went into this convinced I knew the mechanism. The burst was landing at x≈40, and mirroring
150 about Cable's sprite centre of 94 gives 38 — which matched the measurement almost exactly.
So this smelled like leftover screen = C − local mirroring, the same convention that had made
an earlier build draw the beam behind him.
It is not that. Fitting the live object dump against the ROM's own sprite list gives
obj_x = 119 − local_x on 10 of 10 parts. The mirror is exact, and it is load-bearing:
negative local x already draws forward. There was nothing wrong with it.
Which means the fix I was about to make would have been a second mirroring — precisely the bug my own notes warn about, and I would have arrived at it because two numbers landed within two pixels of each other. The origin was 139 px behind the barrel. The arithmetic agreeing with the wrong story was luck, and a theory that survives on one coincidence is not evidence for anything.
The strobe, and the part of this I liked
The sustained beam was cycling seven different cross-sections at 55 changes per second. Read one frame at a time, four of them dominate:
| cross-section | red | what it reads as |
|---|---|---|
| 20 px | 0% | thin pale cord, no red at all |
| 24 px | 16% | thin cord |
| 48 px | 41% | RRRR MM p cc CCCCCC cc p MM RRRR — the tube MvC2 draws |
| 56 px | 64–78%, core 0% | a red slab |
So the correct MvC2 cross-section was already being drawn — on one frame in four. The art was right the entire time. The sequencing picked a different list three times out of four, and what reached the screen was the average of a right answer and three wrong ones.
The fix is six bytes: pin the near child to list f05 and the far child to f15 in the two
dur=1 self-loops. Thickness went from seven values and 55 changes a second to 48 px on 26 of 26
frames and zero changes, and the red fraction stopped swinging between 0 and 79% and settled at a
constant 42%.
And one thing that buys, which I would rather write down than have somebody find. MvC2 does not hold a single cross-section either — it cycles 20/48/24/56 at one tick each, and at that rate the eye fuses it into a shimmer. Ours was cycling seven of them at roughly twice the rate, which does not fuse; it flickers. Pinning to the 48 px tube buys a stable beam that is the right shape, at the cost of MvC2's breathing. Theirs is the right shape moving. Closing that last gap is work on the sequencer, not another pin, and until it is done this is a deliberate divergence rather than a match.
The seam: the art was authored continuous all along
Steer the beam and it broke into two disconnected pieces, with a 29 pixel step at the joint between the near and far halves. The obvious suspect was the art, so that got checked first, out of the ROM rather than off the screen: decoding all 16 frames across all 7 bend slots, the two halves are authored continuous at every steering level, never more than 3 px apart. Capcom's data was fine.
The fault is in HVBSTEP at $28C100. The steer accumulator $57(a2) lives on the shared
parent, and it was being advanced only by the object with $26(a6) == 0 — the near half. But the
engine steps the far half first. So on every frame, the far half read the accumulator before
the near half had updated it, and drew the previous frame's steering level. The two halves were
never in disagreement about geometry; they were one frame apart in time.
Giving the accumulator to whichever half runs first is 18 bytes, and takes the step to 0 px neutral, 1 px held up, 2 px held down.
An earlier round of this post says a seam check "now reads zeros across the whole cycle," and it did. That check was run on a beam nobody was steering, and a steer-driven defect cannot appear in it. The reading was true and the coverage was not.
The Electrap defect that a picture refused to confirm
A static read of the grenade's chain found the terminal record at $130EBA carrying ctrl
$8003, with its +16 long pointing at itself ($00130EBA). That is a self-loop on the
last frame of the detonation, which reads as "the boom repeats forever and the trap never frees."
It was written down as a defect, with a fix ready.
Live measurement refused it. The trap frees at TERM+2, frozen for two frames, and can be
thrown again sixteen frames later. The launch reaction is unchanged at dY=258. A positive control
built during v18 — pointing $130ECA at $00130E6A — showed why: the free is driven by the
terminal record's duration expiring, not by the +16 pointer at all, so a self-referential
+16 is not a trap.
The branch that was going to fix it came back byte-identical to base and was skipped. The no-op was the result.

The white frames, settled by a character I never touched
21% of captured beam frames came back as full white — mean RGB of exactly 255 — clustering at two per three-hit burst. That is the kind of number that gets a fix written for it.
Instead: fire stock Ryu's hyper on a stock, unmodified mvsc set (e4e4b86f…). Nine
white frames, same two-per-burst cadence. Identical behaviour on an untouched ROM with an
untouched character. It is MvC1's own multi-hit flash and it has nothing to do with Cable.
Item closed, nothing changed. That is twice in this section alone that the right fix was no fix, and there are two more further up this page. All four were found the same way: run the same capture on stock, or on the base build, before writing anything.
Earlier sessions on this move had seen phase noise of 55,293 px between runs of the same build,
which would swallow most of the deltas above. So the first thing this round measured was the
harness against itself: two runs of the same build produced 96 of 96 byte-identical captures.
Zero pixels. The old noise did not reproduce under lane_priv's private HOME, which is where
it came from.
Then the blast radius: CHAR=$00 on v23 versus base gives 40 of 40 byte-identical screenshots
and a 41,003-line object-RAM and state log that diffs to zero lines. A data change to Cable's
beam is invisible to a stock character, which is what it should be and not what it always is.
Regressions all carried their own controls: air QCF+P — the move that used to crash — 3 of 3 clean; air QCF+PP started and ended 3 supers; medium punch launched 3 of 3 with standing LP at 0 of 3 as the negative; the hyper did 20 hits for 43 damage while the null control fired from behind Cable stayed silent (0 hits, health 144 → 144, meter still consumed); a full round to a KO; the Electrap freed and was rethrown.
What v23 did not fix
Six items, and none of them is small print.
The startup ramp still strobes. About four frames, q55 to q59, before the pin takes over. You can see the old cycling if you are looking for it.
The muzzle star overlaps his gun arm — 37,590 px across 26 frames. All of it is star over arm; none of it is beam through torso, which is the thing that was actually wrong. Cosmetic, but it is there.
The shaft still runs off the right edge of the screen with no cap and no retract. MvC2's ends where it should.
The curvature is still far short of spec. The band centre goes 106.5 neutral, 105.5 held up, 111 held down. The steering is real and it is nothing like the ~90° arc MvC2 gets.
The art now sits at bore height and the hitbox does not. This one is a genuine regression in one respect: the beam used to be drawn low, at y≈155, which happened to sit closer to where the move actually hits. Damage is provably unaffected — 20 hits, 43 damage, every run — but for a crouching opponent the picture and the hit no longer coincide vertically, and that is worse than base. It needs the hitbox moved, not the art moved back.
The seam is gated in the wrong space. At normal fighting range the near/far joint is off-screen, so the 0/1/2 px result is measured in object sprite coordinates, not in pixels at the joint column. The mechanism is proven; the on-screen appearance of the joint at long range is not.
What is still wrong, in his own words
I would rather you hear this from me than find it yourself.
The red rectangle is gone, and the way it was found is the part worth keeping. Rather than patch either of my two suspects, a probe correlated every red pixel to an owning object by tile code — decoding each object's sprite list out of the ROM and looking up every sprite the hardware drew. It named two object families nobody had suspected, both still pointing at stock Ryu lists. The suspect I would have bet on was cleared by a negative control: blanking it changed the red pixel count by exactly zero, 4128 to 4128. Palette-row-1 sprites during the super went from 1049 to 10, and those ten are one sprite that is already on screen before the super starts.
The select confirm pose is fixed, and I like the root cause: it drew a flat red 64x112 block because the graphics words that pose reads were all zero, so every pixel resolved to pen 0 — which in the committed-player palette is (255, 34, 34). He also wears a new 90s wordmark now.
The select side art is still wrong, and I called it fixed twice. The tile-aliasing defect — Capcom reusing identical tiles inside the donor's illustration while my builder seated each sprite independently, last write winning — is genuinely gone; the panel's 74 objects now use 162 tiles with none repeated. But an agent that actually read the capture rather than counting tiles found the real problem: the panel draws two overlapping Cable illustrations at different scales, a large bust with a smaller standing figure over it. A stock character on the same frame is one illustration at one scale. That is what correct looks like, and this isn't it.
The Psimitar blade is on its own palette now — an exact match, as it turns out: hardware pen n equals MvC2's own pen n+9 for the whole flare ramp, weighted error 0.0 across all 11,050 flare pixels. Still zero palette bytes written.
The beam is still about half of MvC2's. It fires, it damages, it's the right colour and it
aims — but it's a cord where theirs is a ribbon, and the muzzle burst is a fraction of the size.
I now know exactly why (see the SH-4 section above) and the fix is seating art I already have,
not inventing anything. Written on the v18-era build. As of mvsc_cable_v23.zip
(d0f7c543e46ac2f0984e489b9ca76886, 2026-08-02) the shaft holds a constant 48 px tube in the
right place; what is left of this item is the missing end cap and MvC2's thickness cycle, both
itemized in the v23 section above.
The Viper Beam special is still a blue line, and the colour is not the reason. MvC2's own regular Viper Beam is blue — I read the palette row out of their sprite lists after asserting confidently that the blue was my invention. It isn't. The real defect is the shape: ours flashes a 192-pixel bar for two frames and then becomes a travelling blob, where MvC2 sustains a tiled shaft with a rounded cap and a spiky white muzzle star. Square-cut at both ends, missing both end pieces.
His normal-attack voice works; his move calls did not in v24. That build proves fresh imported
QSound samples on all eighteen normals, including the two aerial kicks repaired there. Viper Beam
still lacks a verified spoken call. v33 supersedes the Hyper Viper Beam half of this note: its
first live body record now selects Cable's private slot 25, which posts imported clip $161 once.
The rest of the super's audio and behavior remain under audit.
Most of the tree is still not a native Cable reconstruction. The v24 census finds 169 of 338 distinct chains containing any Cable art and 163 fully Cable; across individual records the ratio is 742 Cable to 1,114 donor, or 39.98%. That is a graphics-ownership measurement, not a timing measurement: many converted routes still inherit Ryu's record count or duration budget. The hyper and walks received explicit growth work; the rest must be proven family by family.
Psy-Charge, Crackdown, AHVB and Time Flip do not exist. Each is new 68000 behavior. AHVB is the single most important move in MvC2 Cable's kit, which is a slightly awkward thing to admit this far down the page.
The launcher is settled, and the doubt was mine. d/f+HP launches 12 out of 12 at three
spacings, 195 px every time, matching stock Ryu byte for byte. The "flaky" reading was a probe
whose dummy was CPU-controlled, so half its failures were whiffs — and whose negative control
fired and got rationalised away.
Two dash grunts are still Ryu's, because four candidate table rows exist and none is proven to be his. I would rather leave a wrong sound in than patch a row that belongs to someone else.
What this actually took
The honest shape of the thing, now that it is far enough along to see:
The art was nearly free. 4,031 sprites decoded with zero failures, re-encoded into 14,095 CPS-2 tiles losslessly, seated in graphics ROM I had already expanded for another project. Cable would be the third-largest character in the game and 1.7x the size of the skeleton he rides.
The behavior is the whole project. MvC2 ran on an SH-4. MvC1 runs a 68000. There is no code to move — not in a leak, not in a prototype, not anywhere. Every move he makes that Cable makes had to be written against a 1998 engine with, famously, zero spare rows in any table.
The bugs were never where I looked first. A palette that survived five builds because the defect was in index space, not color space. A launcher that "worked" and was a partner tagging in. Three green gates on a table the character does not read. A chain probe whose control could not succeed. A beam that wouldn't aim because the mover it called doesn't have a y axis. A grenade glitch I'd caused myself, three builds earlier, in a merge. Every one of them was caught by the same thing: running it and measuring, instead of reasoning about the bytes.
And four times, the right fix was no fix. The select nameplate overlaps because stock nameplates overlap. Cable doesn't walk in before the round because nobody walks in before the round. The hyper flashes white because MvC1's multi-hit flash goes white on stock Ryu too. The Electrap's self-referential last record frees anyway, because the engine ends that record on its duration and never reads the pointer. All four look exactly like defects and all four had a patch ready. Three of them died against a capture on an unmodified ROM, which is the only thing standing between me and shipping a regression to all 23 characters. The Electrap died against a positive control on an earlier build of my own, because a stock ROM has no Electrap to ask.
And it started with a screenshot somebody else posted in 2022, of Cable's sprite standing in Gambit's slot with the lifebar still reading GAMBIT. I did not find the trailhead. He did.
August 6 systems audit: the donor lesson changed the Cable punch list
The reciprocal Zero/Mega Man experiments exposed a distinction this build now treats as a release gate: a longer animation chain does not lengthen the gameplay state that owns it. A dispatcher can return to neutral while valid imported records still sit unread in expanded ROM. Cable's long MvC2 moves therefore need three proofs, not one: every body record is reachable, every active box selects a move-property row with the same attack-shape id, and the 68000 state survives through recovery.
August 6 audit
Cable's playable core is real; native coverage is still incomplete
Latest evidence separated by subsystem. ‘Verified’ means that exact layer has its own gate.
| route | presentation | state + input | collision + damage | audio |
|---|---|---|---|---|
| body / palette / HUD | verified | bridgedCable occupies Ryu's directory row | bridged | bridgednot an audio-bearing surface |
| ground + air normals | verified | verified18 of 18 live routes | bridgedfresh shape/property parity audit still required | verified18 of 18 fresh imported key-ons |
| launcher | verified | verifieddown-forward + HP | verified12 of 12, 195 px | pending |
| Viper / Psimitar / Electrap | verified | bridged | bridged | bridgedengine effects work; spoken calls incomplete |
| Hyper Viper Beam | verified | bridged | bridged20 hits / 43 damage; visible and hit regions need vertical alignment | pendingCable's spoken call does not key |
| Psy-Charge / Crackdown / AHVB / Time Flip | pending | pending | pending | pending |
| MvC2 source code | pending | pendingSH-4 code is not portable to 68000 | pending | pending |
The next Cable build should fail closed if an attack shape has no property row at the identical index, if a long route returns to idle early, or if a control fixture cannot take damage. That is a more important improvement than adding another screenshot: it attacks the class of bug that made Zero's light normals look complete while exposing zero active frames.
The GFX headroom this leans on is the 64 MB core expansion, and the program window is the $500000 mapping. The transplant tooling that does not apply here is what built the Dark Sakura native port.
The build this page measures rode Ryu's directory row, and that seat is exactly why the remaining defect list refused to shrink. The sound row under Cable is Ryu's 32-slot block with a single imported call (the Hyper Viper Beam voice at slot 25), unpatched routes fall through to Ryu's handlers, and the beam, gun and grenade lifecycles were bent over projectile code that was never shaped like MvC2's. The v33 lineage is now a donor-quarantined research build: mined for its art conversion, voice exports and diffs, never inherited byte-wise.
The redo lives in a dedicated repo on the character-neutral expanded base — virgin upper
GFX banks, an all-FF expansion window, a donor QSound bank — and Cable claims the engine
family's last free native id, $32, the same alias-row mechanism
the Dark Sakura native port proved for $2E. Ryu returns
to the roster untouched, which converts "no Ryu under Cable" from a patch backlog into a
property of the seat itself.
Measured this pass on the pinned base: the three absolute pointer tables carry physical
$32 slots ready for in-place claims; the four PC-relative word tables take the proven
relocate-and-thunk extension, whose replacement tables already span the new id; the five
per-character directories alias eight bytes into their neighbour on a $32 fetch — the
silent-corruption mode the native ports fix with a 26th row — and the extension arenas plus
the whole 1 MiB expansion window verify virgin. The source side is pinned too: Cable's
Dreamcast container censuses at 4,031 sprites, 681 assembly lists and 57 palettes, and his
28 animation groups are adopted as the chain skeleton for re-authoring.
The acceptance denominator is frozen at 52 lanes before any byte moves: the four mechanics
this page leaves unfinished — an MvC2-correct Viper Beam and Hyper Viper Beam, gun rounds
that fire their real shot counts with muzzle flash and per-bullet impact explosions, a
grenade with source arc and detonation, and Time Flip — plus negative lanes asserting that
Ryu's rows stay byte-identical and that no action under $32 ever posts a Ryu sound id.
The behaviour gate philosophy applies unchanged: receipts
before claims, and static evidence closes nothing.
The first $32 build exists and ran on real hardware emulation. It changes exactly
331 bytes against the neutral base — twelve in the encrypted program for two jump
patches, seven for the absolute pointer-table slots, and three hundred twelve for the
relocated word tables and their thunks — and nothing else. $2E and $30 stay
byte-for-byte reserved for the two native characters that already own them, every stock
lookup table is identical to the base, and the boot checksum rule was re-derived from
the running machine and verified rather than assumed.
The result splits cleanly. Plain boot passes: the machine reaches attract and
character-select, runs at full speed, and the twenty-three stock characters select and
play with no regression — a patched-ROM checksum warning is the emulator noticing the
edit, not a fault. Committing $32 does not yet survive: the cursor commits the new
id and then, seventeen frames later, the program counter lands in the boot RAM self-test
and the machine reboots. That is progress, not failure — the relocate-and-thunk work
held through commit; something read after it still faults.
Reading the base directly names the suspects. Three per-character code-entry pointer
tables — $18DF0, $185C8, $18712 — are clean id×4 tables that end at the last stock
id $2C; their $2E/$30/$32 slots are past-end garbage ($66000008, $D02E004D,
$302E0052). Committing a character jumps through one of them, and a garbage pointer is a
wild jump. The earlier build extended the asset-translate tables but not these. Unlike
those, the three are read PC-relative — they carry zero absolute references in the
decrypted image — so they cannot be patched by address search alone; the exact read site
has to be observed at runtime before the same relocate-and-thunk extension can be pointed
at them. A read-tap probe that forces $32 and watches those slots is written and waiting
for one windowed run. The fix is identified; only its precise attachment point is pending
the measurement. Static analysis names the table; the machine will name the instruction.








