case studyUpdated Aug 26, 2026136 min read$1B

I Wanted Cable Without MvC2. So I Put Him in MvC1.

I wanted Cable with six buttons. His art, four-shot rifle, and imported voice now run in MvC1; his native behavior is still unfinished. The road back from Dreamcast.

cps2mvc2naomidreamcast68000
CPS-2 Reverse EngineeringPart 17 of 43
Browse all writing
On this page

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.

Cable v30 in Marvel vs Capcom: the four-shot CPS-2 milestoneYouTube ↗

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 automated gates pass; the player's acceptance gate is still open

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.

The automated standing-HP gateMvC2 G23s16 shot phases 667–674, rendered and gated in MvC1CONFIRMED IN GAME
screen capture · loops
visual cycleflash → bloom → full yellow shot → thinning decay
rounds4 complete eight-phase subchains
contactsframes 28 · 72 · 104 · 136
health144 → 125 → 106 → 86 → 68
sound ring$165 posted four times
video896×672 · 60 fps · synchronized AAC audio
buildmvsc_cable_v30.zip
md5e7b9bff2a8b027028e7bee342df16cdf

The yellow projectile in the poster is the shot itself, not a beam substituted for it. This confirms visual-phase survival, four contacts and recoil inside the controlled fixture. It does not confirm the manual move's cadence or muzzle placement; both remain open defects.

/blog/clips/cable-standing-hp-four-round.mp4

One input, four damage events
  • 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 unequal point drops are the engine's live damage result, not four copied labels. The important release gate is four distinct health transitions, one during each round's active phase.

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.

Contact sheet from the v30 Cable idle gate showing his long idle animation advancing through the complete sequence in Marvel vs Capcom
The v30 idle gate: 76 distinct records facing right, then the same 76 facing left. This proves the expanded idle is not being cut back to the donor's shorter loop when Cable turns around. It does not prove any attack, special or super.

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.

A playable core is not the whole animation tree
  • 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

These bars answer different questions. A move can enter the right route and speak while other states in the same character tree still use donor-sized chains.

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

15 STOPS · 4 CORRECTED · 6 RETRACTIONS · 1 OPEN
  1. 0x128DFC   00 RYU
    0x128F50   17 CABLE
    0x128F5C   18 LAST BOSS 1
    no PLxx_WIN.BIN:  18  19  1A

    Read out of the retail disc's boot binary, 1ST_READ.BIN, 1,809,817 bytes. Anyone with the ISO can repeat it.

    01 / 15measuredbefore a single byte was patched

    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.

    Retracted here
    That 2_DP.BIN was the game executable. It's DreamPassport, the Dreamcast's web browser. It opens 09 00 09 00 - SH-4 NOP - 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.
    Read this section
  2. Six decoded Cable sprites: white hair, silver cybernetic arm, blue bodysuit with gold harness

    Offline render from PL17_DAT.BIN through the project decoder. Nothing is on a board yet.

    02 / 15measured4,031 blobs, zero failures

    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
  3. Six converted Cable sprite frames in a strip

    Converted art rendered offline. The build at this stop rendered these same chains gold on the board.

    03 / 15measured11 chains · 99 of 1,621 frames

    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

    Retracted 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.
    Read this section
  4. Four renders of the same Cable walk frame under four palette hypotheses; three garish, the fourth correct blue and gold

    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.

    04 / 15correctedfive guesses, one measurement, 3,390 pixels

    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_tile assembles the bitplanes backwards, so stored pen 0011 is hardware pen 1100. Four-bit reversal.

    Retracted here
    That the bug was anywhere in colour space, and that any of that week's palette builds had reached the screen at all. $1C9724 is 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.
    Read this section
  5. A black frame with a white humanoid silhouette isolated on the right, every pixel drawn using palette entry zero

    Live capture, palette entry 0 isolated. A whole character in one colour - not damage, not corruption.

    05 / 15measured11 → 38 → 101 chains

    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
  6. The MvC1 lifebar showing Cable's portrait and the name CABLE in the game's green letter art

    Live capture on the board. Both halves of the HUD: his own face, his own name, his own palette.

    06 / 15measuredthe HUD becomes his

    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 tileA/tileB are 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.

    Read this section
  7. Two Cables facing each other in Marvel vs Capcom, both correctly coloured

    Live capture. Both fighters are the port; nothing here is a mock-up.

    07 / 15measuredconversion is nearly done, writing hasn't started

    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 section
  8. x=354   anim $18176E
    x=390   anim $181786
    x=414   anim $181796
    12 px/frame, pointer advancing

    Live per-frame trace off the running board. Not a disassembly reading.

    08 / 15correctedconversion ends here

    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.

    Retracted here
    That MvC1's projectile spawn path was stubbed and a mover had to be written. Class $2C is 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. $0024C4 is 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.
    Read this section
  9. 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 $153

    Two live probes. The right column asserts P1's character id, which the first probe never did.

    09 / 15correctedtwo things un-shipped

    MP stops tagging, and my own notes turn out to be stale

    $17B is never postedmy source file versus the live sound queue

    v5 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 $153 restored - see stop 15.)*

    Retracted here
    That $019452 is 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.
    Read this section
  10. Live Marvel vs Capcom capture, Cable getting up off the floor

    Live capture. Wake-up slots 1-4 are seated and counted but no harness has reached them - seated is not the same as seen.

    10 / 15measured108 of 338 chains

    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 section
  11. 23B8AC   lea $23B980.l,a0
    one non-zero row: slot 26, id $0121
    the block I fixed three times: $0D5EF0
      hijacked at its second instruction

    Drained live out of the game's own sound command queue, same five actions before and after.

    11 / 15corrected26 clips, QSound banks $F0+

    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.

    Retracted here
    That the voice block I fixed across three passes was his. $0D5EF0 is 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.
    Read this section
  12. $E8048   one 48-byte block per fighter
    punches   1  3  5
    kicks     1  3  5     tie, refused
    kicks     2  4  6     15 links granted

    Rank 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.

    12 / 15measuredten bytes

    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 - +$08 isn'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.

    Read this section
  13. Live capture of Cable's hyper firing a beam across the screen

    Live capture. Full width, gun height, head cap bursting at the far end - right shape, wrong colours. Those get fixed at the next stop.

    13 / 15measuredthe moves start hurting

    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
  14. Two extreme zooms of the Hyper Viper Beam: gold and orange slabs on the left, a magenta core on the right

    Two live captures at extreme zoom, same frame, different builds.

    14 / 15measured123 of 338 chains

    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 $0024C4 is X-only. $002490 is two-axis. Four bytes.

    COVERAGE 27% OF THE ANIMATION TREE

    Read this section
  15. Three live Marvel vs Capcom captures of Cable's Psimitar

    Live capture. The blade is drawing in his body palette - a known open bug, and it is in this picture.

    15 / 15open1 August 2026 · 123 of 338 chains · 499 frames

    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
Sixteen stops. Ticks are evenly spaced by count, not by time. The line under the rail is the one number that runs the whole way: percent of Cable's animation tree drawing Cable's own art.Era labels are free text, not points on an axis, and none of them is a claim about a specific date.

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" x0

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

  1. LZSS compression: a flag byte, then either a literal or a back-reference token.
  2. 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.
  3. 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:

Six decoded Cable sprites from PL17_DAT.BIN: white hair, cybernetic silver left arm, blue bodysuit with gold X-harness and red X buckles, yellow boots
PL17, decoded straight off the disc: LZSS, then the PowerVR de-twiddle, then a vertical flip. That is Cable, by eye.

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."

Cable sprites reconstructed from the converted CPS-2 tile bin
Not the Dreamcast data: these are rendered from the converted CPS-2 128-byte tile bin, through the project's reference arcade decoder. The conversion is lossless. All 4,031 sprites reassemble pixel for pixel.

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:

The donor is 40% smaller than the character riding him
  • 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
Distinct tiles per character, walked out of the stock ROM's animation trees. One unnamed slot measures 14,542 and stays off this chart. A few sprite lists per character did not parse, so every count runs slightly low. The ranking is what I would defend, not the last digit.

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:

The chain-start rule

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 / HK

Eleven 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.

Six converted Cable sprite frames in a strip: turning, crouching, rising with the rifle, and punching
The frames those chains now ask for: converted Cable art, straight out of the CPS-2 tile bank.

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

Four renders of the same Cable walk frame under different palette hypotheses; the first three are garish yellow, gold and blue-gray, the fourth is correct blue and gold
The same frame, the same tiles, the same 16 colors, four different index mappings. Three of these shipped to a build and were looked at on a CRT before the fourth was found.

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:

Diagram: decode_tile pen index on top, CPS-2 hardware pen index below, with crossing lines showing a 4-bit reversal, and the resolved color swatches underneath
How to make the hardware tell you its own pen indices
1. Turn the palette into a ruler

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.

2. Get exact pixel positions, not a guess

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.

3. Build the confusion matrix

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.

4. Read the answer off the wall

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 + 0x0487C4

Every 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.

After
Before
‹›
Correct row, dead copyCorrect row, live copy
Same ROM, same palette values, same frame. The only difference is which of two nearly identical palette blobs received the write.

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.

A black frame with a white humanoid silhouette isolated on the right, showing every pixel drawn using palette entry zero
Every pixel on screen drawn with palette entry 0, isolated. It is a whole character. Not damage, not corruption. An entire fighter rendering in one color.

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.

The red pixels became a progress bar I could not argue with
  • 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

Each jump is a different subsystem solved. The remaining gap is where the specials live.

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:

idx 00/25 · 4t (67.1 ms)
The idle chain out of the current build, played at the durations in its own animation record. Loops like this one were immune to the bug: they re-enter their own start and never reach the terminal, which is why idle and walking looked perfect while the jumps were playing one frame. Drag the ribbon to step it — each cell is that frame's hold.Idle stance · 112×140 px · 25 frames · 100 ticks · 1.68 s @ 59.63 Hz · loops to frame 0cable-idle.png — group 0 slot 0, record $12C386, mvsc_cable_v13.zip (md5 af0ac9c786ac11d6231bed7796325ab9)

The measurement, same harness, old build as the control:

standing HP records enteredjump records entered
before11
after65

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.

Why this one matters

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.

Ten captured frames showing Cable walking, jumping, and landing with a dust cloud
Walk, jump, and landing, captured live, including the engine's own landing dust. The animation chains, sprite lists, and tile codes are all Cable's. Only the skeleton is Ryu's.

Here's the whole pipeline to this point, disc to screen:

Disc to screen, five mechanical steps
Drawing the diagram…
Every stage here is checkable against ground truth. None of it touches behavior. That is the part with no donor.

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 Marvel vs Capcom lifebar reading CABLE in the game's green name font
CABLE on the lifebar, built entirely from the game's own letter art.

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.

Four candidate 32x32 crops of Cable's head taken from his idle sprite
Choosing the crop. Cable's own idle frame, cropped four ways. The third keeps the head, shoulders, and both X-harness badges inside 32x32.

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 MvC1 lifebar showing Cable's portrait and the name CABLE
Both halves of the HUD, done: his own face, his own name, his own palette.
All 23 MvC1 lifebar portraits decoded and laid out in a grid
All 23 lifebar portraits, decoded straight from the table. Row 9 is Ryu, the slot Cable now occupies. Note rows 18 through 22 reusing earlier tiles: the palette-swap secret characters share their twin's face, the same sharing the tile census found.

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.

August 6 correction: the conversion job did happen

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.

Capcom left him a seat at every table

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.

Sixteen frames of Cable's Hyper Viper Beam: reaching back, hauling a multi-barrel cannon off his back, shouldering it, bracing, firing, then lowering and stowing it
Hyper Viper Beam, frame by frame, decoded from the Dreamcast disc. Labels are the engine's own: S is startup, H is the firing hold, R is recovery, with each frame's duration in ticks.

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.

A decoded MvC1 beam segment sprite
MvC1 already has beam machinery. The question was never whether the board can draw one.

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.

Frames of Cable's grenade toss decoded from the disc
The grenade: one child object, an arc, and a contact detonation. The cheap experiment that de-risks the expensive 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 advancing

The 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.

Inputv4 probev5 probe (P1 id asserted)
st.MP"LAUNCH" 116 pxno — normal punch again
st.HP+HK"LAUNCH" 116 pxno — tag path, not scored as launch
st.LPnono

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.

Live Marvel vs Capcom capture, Cable versus Cable, the right-hand Cable on the ground pushing himself up in his own blue and gold art
Group 18, live. He gets knocked down and gets up as himself. Two days ago this was a red silhouette.

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.

Live capture of Cable's hyper firing a beam that spans the width of the screen at gun height with a bright burst at the far end, drawn in slabs of red and blue instead of white energy
The Hyper Viper Beam, live. Full width, gun height, head cap bursting at the far end. Right shape, wrong colors.

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 sets

Then 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 strength

Strictly 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.

Chain links that connect, before and after ten bytes
  • 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

Fifteen granted links, with the negative controls still refusing: lp->lp and hp->mp remain illegal, exactly as they are for stock characters.

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.

And the probe that lied to me first

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

The first two days of behavior work, in animation chains
  • 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

Chains, not moves. A chain is one animation sequence in the tree. The newest groups are small in count and large in what they let him do.

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 nothing

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

inputbeforeafter
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.

What is genuinely not done

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:

GateStatusEvidence
Identify Cable on the discpassedPL17, self-labeled by Capcom; sprites recognizable by eye
Convert art to CPS-2 tilespassed14,095 tiles, all 4,031 sprites reassemble pixel-perfect
Decode MvC2 frame data (S5/S1)passedsprite lists and chains rebuilt as CPS-2 records
Render in MvC1passedlive capture, correct palette, 100% pen mapping
Animate on the donor chassispartialv24 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 artpassedROM-harvested letters, his own face on all four surfaces
Bullet travelspassed12 px/frame across five traced frames, animation advancing, no object leak
False MP launcher revertedpassed$019452 stock again; HP+HK scores TAG not LAUNCH; no MP→tag thunk
Launcher existspassedd/f+HP lifts 90 and 198 px on repeated trials; MvC2's own 3HP input
Box tables he ownspassedhitbox / box-shape / move-property row 9 relocated, 22 of 23 rows byte-identical
18 normal input routespassedall six standing, six crouching and six aerial routes reached live; full MvC2-native timing is not claimed
Normal collision paritypartialCable owns relocated box-shape, hitbox and move-property rows; every active shape/property pair still needs a fresh release audit
Wake-up artpassed for slot 0three live captures of him getting up as himself; slots 1 to 4 seated, never reached
Viper Beam drawspassedg22s14/s15 off Ryu's fireball onto MvC2's blue beam, on effect row 12
Hyper Viper Beam drawspassed53 to 84 sprites, 24 columns, x=48 to 425, every one on effect row 17
Beams aimpassedLP climbs 60 px, HP drops 58 px over 33 frames; 0 px before the fix
Beam soundpassed$163 on fire, $078 on release, 5 of 5 actions; control character posts neither
Psimitarpassedthree strengths latch $e6=$02, draw his blade, put P2 in hitstun
Psimitar invulnerabilityalready stockbox sets $45/$46 carry an attack box and zero hurt boxes
Select nameplate overlapnot a defectstock plates are 64 px wide drawn 48 px apart; Morrigan overlaps identically
Pre-fight walk-indoes not existclean-ROM control: no stock character walks in either
Encrypted-ROM mergepassedword granular, 3,371 B applied, 6 B skipped, no split ciphertext word
Real MvC2 gunshotowned on $324QCF.HP posts $324; stock $153 restored; WAV is a gunshot by ear
Every pose seatedpassedall 13 g19/g20 chains draw Cable; intro upgraded to the 35-record rifle set
Cable's voicepartialall 18 normals produce fresh imported QSound key-ons in v24; QCF+HP and Hyper Viper Beam produce no fresh Cable voice session
Write-set disciplinepassedeight stages, 269,199 B, total diff equals the union of the stages
Hyper beam damagepassed, alignment open20 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 Flipnot startedeach 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.

The same beam at the same zoom drawn as a magenta core with a bright pink ramp, on effect row 17
Extreme zoom on the Hyper Viper Beam drawn in gold and orange slabs, inheriting Cable's body palette
‹›
Row 0, his bodyRow 17, effect
Same tiles, same geometry, same object, two builds. Drag to wipe. The flat red field around the beam is a separate open bug and it is in both halves; it is the next thing on the list.

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.

idx 00/10 · 4t (67.1 ms)
The beam object itself, ten frames on effect row 17. Frames 8 and 9 draw nothing on purpose — their sprite list is tile code 0, because a second object carries the complementary two frames and what you see on screen is the union of the two. The chain loops back to frame 1, not frame 0.Hyper Viper Beam (beam object) · 224×80 px · 10 frames · 40 ticks · 0.67 s @ 59.63 Hz · loops to frame 1cable-hvb.png — group 24 slot 0, record $132B7E, mvsc_cable_v13.zip (md5 af0ac9c786ac11d6231bed7796325ab9)

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 all

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

Line chart of the Viper Beam's vertical flight over 33 frames. A green line climbs to 60 pixels above the spawn row, a pink line drops to 58 pixels below it, and a grey dashed line runs perfectly flat along zero.
Measured live, one sample per frame. The flat dashed line is the same probe before the four-byte change.

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.

Live Marvel vs Capcom capture with a large flat red block standing where the Psimitar should be
Chain runs, art unseated
Live Marvel vs Capcom capture of Cable swinging a long energy blade through Venom
Same chain, records repointed

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$e6recordrise
DP+LP$02$12FDCE8 px/frame
DP+MP$02$12FE9212 px/frame
DP+HP$02$12FF5616 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.

idx 00/12 · 1t (16.8 ms)
The heavy version, twelve frames. All three strengths share this art and differ only in the hold on the blade frame: 4, 8 and 12 ticks. That frame is the widest cell in the ribbon, which is what a 12-tick hold looks like. The blade is gold here rather than MvC2's white-and-green because every authored list in this build writes attribute $0000, so it reads Cable's body ramp. That is a known open bug, it is on the list at the end of this page, and it is in this strip.Psimitar (DP+HP) · 222×194 px · 12 frames · 49 ticks · 0.82 s @ 59.63 Hz · loops to frame 0cable-psimitar.png — group 22 slot 5, record $12FF56, mvsc_cable_v13.zip (md5 af0ac9c786ac11d6231bed7796325ab9)
The invulnerability was already paid for

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:

actionbeforeafter
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: 4eb900002490

The 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 PARENT

The 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 mixturepassed, 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.

Where $232490 actually points

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.

Cable, running inside Marvel vs Capcom on CPS-2 hardwareYouTube ↗

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.

A three by three grid of live Marvel vs Capcom captures showing Cable in nine different states: idle, walking, dashing, super jumping, standing heavy punch and kick, crouching heavy punch, blocking, and knocked down
Nine converted states, every pane a live capture, every lifebar reading CABLE.

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.

The Electrap, arcing37 consecutive frames, fc 1422 to 1458CONFIRMED IN GAME
screen capture · loops
x197 → 321
y407 → apex → 466
frames37 consecutive, none dropped or repeated
rate5963/100 fps, the board's own
buildmvsc_cable_gren4.zip
md5a17ca1918bf4c910d447704d5ad18370
harnessrecord/harness/gren4_gate.lua

The arc is the one part of this move that is not in any sprite list — it is runtime, so no strip out of the ROM can carry it, and this is the only figure on the page that has to be a screen capture. The x and y columns are the gate log's own per-frame rows for the frames in the clip.

/blog/clips/cable-electrap-arc.mp4

Two Cables facing each other in Marvel vs Capcom, both correctly colored in blue and gold with white hair and plasma rifles
Both fighters on the same chassis, rendering correctly. Earlier in this page, the one standing still was a red silhouette.

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.

What the search did not find, and said so

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:

Shaft thickness per frame, ours against MvC2
  • Frame 020pixels

    MvC2: 20

  • Frame 148pixels

    MvC2: 48 — the frame I had deleted

  • Frame 224pixels

    MvC2: 24

  • Frame 356pixels

    MvC2: 56 — also deleted

Measured live off object RAM. Before this, the same probe on the previous build saw only two values: 20 and 24.
The shaft, playingten records, one engine tick eachCONFIRMED IN GAME
idx 00/10 · 1t (16.8 ms)
shaft height20 px
hold1t · 16.8 ms
frame0 / 9
records10, drawing 8 distinct lists
loops toframe 6
tiles758 / 758 from the expansion
palette row17, $1D9144

Pause it anywhere and the still is wrong about the move: frame 7 is a 56 px tube, frame 8 is a 20 px cord, and neither of them is the beam. Because the chain returns to frame 6, what sustains on screen is the last four cells of this ribbon, repeating. Frames 8 and 9 reuse the sprite lists of frames 4 and 5, which is why ten records draw eight lists.

cable-hvb-thickness.png — id $12 group 24 slot 0, record $132B7E, mvsc_cable_v21.zip (md5 fc2f38930be73699caf58b61ccbe1622)

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.

The far half, in phasethe super's second objectCONFIRMED IN GAME
idx 00/10 · 1t (16.8 ms)
drawsnothing
hold1t · 16.8 ms
blank list$1339BE, one part, tile code 0
loops toframe 6
tiles220 / 220 from the expansion

The two leading blank records are still at the head of the chain, and because the chain returns to frame 6 they are entered once, when the super starts, and never come round again. Run this beside the strip above and the phase is the point: what a player sees is the union of two objects on the same cycle, which is why a seam here reads as a step in the middle of one beam.

cable-hvb-far.png — id $12 group 24 slot 2, record $132C22, mvsc_cable_v21.zip (md5 fc2f38930be73699caf58b61ccbe1622)

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.

Two ways the measurement nearly flattered itself

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.

The control that makes this one stick

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.

Electrap, out of the shipped chainid $12 group 22 slot 31, record $130E6ACONFIRMED IN GAME
idx 00/6 · 50t (838.5 ms)
sprite list$56A184
hold50t · 838.5 ms
since chain start0t · 0 ms
chain6 records, 102 ticks
tiles168 / 168 from the expansion
palette row12, $1D90A4

Watch the pointer column rather than the picture. The last four records alternate between two lists, so the detonation is a two-list flicker and not four drawings — and the cell only flashes when the pointer actually changes, which is why it sits still from frame 2 to frame 4. The ribbon is the other half of it: the two wide cells at the head are the canister lying on the ground.

cable-electrap.png — id $12 group 22 slot 31, record $130E6A, mvsc_cable_v21.zip (md5 fc2f38930be73699caf58b61ccbe1622)

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.

nativeverifiedbridgeddonorpending
routeroutesound slotfresh sampleremaining work
standing normalsverified6 of 6 routesverifiedslots 1 / 2 / 3verifiedverified
crouching normalsverified6 of 6 routesverifiedslots 1 / 2 / 3verifiedverified
aerial normalsverified6 of 6 routesverifiedslots 1 / 2 / 3verifiedverifiedj.MK and j.HK repaired in v24
Viper Beam voice callverifiedQCF+HP reaches the movependingpendingpendingengine effects play; Cable speech does not
Hyper Viper Beam voice callverifiedQCF+PP reaches the movependingpendingpendingring ids post, but no fresh imported sample keys
Result: 18 of 18 normals verified at the sample channel, not merely at the sound-command ring.

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.

Why this was hiding in plain sight

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 frames I could not add

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:

Cable's beam in Marvel vs Capcom, after the placement fixYouTube ↗
The same frame, base above and v23 belowq129, ground Hyper Viper BeamCONFIRMED IN GAME
Two stacked Marvel vs Capcom frames. Top: the muzzle burst sits at the far left screen edge behind Cable and a thin pale cord runs from the screen edge through his legs across the whole screen. Bottom: the burst is on the rifle barrel in front of him and a thick red and white tube leaves the gun toward Spider-Man, his torso clear.
burst centroid x~40 → 166 ± 5
gun muzzle x150–157
beam ink left of Cable40,634 px → 0, on every frame
beam ink through his body85,211 px → 0
band centre y155.0 → 106–108 (bore y = 107)
buildmvsc_cable_v23.zip, md5 d0f7c543e46ac2f0984e489b9ca76886
basemvsc_cable_vsfix.zip, md5 b5451ce747ed63a7c839c7f4ea84eb99
captured2026-08-02, both panels from the gated runs

Top panel is the base build and it is not a staging accident: that thin pale cord starting off the left edge of the screen is the Hyper Viper Beam, firing correctly, doing its twenty hits. Nothing about the mechanism was broken. The origin was simply behind the barrel, so every measurement I had ever taken of this move was taken on art drawn in the wrong place.

/blog/shots/cable-hvb-v23-before-after.png

Everything below was measured on that pair of builds, on the same stage, with the same input schedule.

basev23
burst centroid x~40166 ± 5 (gun muzzle 150–157)
beam ink left of Cable40,634 px0 on every frame
beam ink through his body85,211 px0
band centre y155.0106–108 (gun bore y = 107)
seam step at the joint29 px0 / 1 / 2 px (neutral / up / down)
shaft thickness7 values48 px on 26 of 26 frames
thickness changes55 per second0
red fractionswings 0–79%42%, constant
hits / damage20 / 4220 / 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.

v23, frame q060: the burst is at the gun and the shaft leaves at bore height toward a crouching Spider-Man
Base build, frame q060: the muzzle starburst is at the left screen edge and a thick red slab runs the full width of the screen well below the rifle
‹›
base — burst at the screen edge, shaft at y≈155v23 — burst at the gun, shaft at bore height
Same frame, q060, on both builds. The band centre moves from y=155 to y≈107, which is the height of the gun bore. Measured on mvsc_cable_v23.zip md5 d0f7c543e46ac2f0984e489b9ca76886 against mvsc_cable_vsfix.zip md5 b5451ce747ed63a7c839c7f4ea84eb99, 2026-08-02.

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.

P1 side, facing rightv23, q070CONFIRMED IN GAME
Cable on the left of the screen facing right, the muzzle burst on his rifle barrel and the beam shaft running to an impact flare on Spider-Man
sprite displacement(+139, −45), 146 / 146 sprites
burston the barrel
ink over his torso0 px
buildmvsc_cable_v23.zip, md5 d0f7c543e46ac2f0984e489b9ca76886
lanelane_v23a, 2026-08-02

/blog/shots/cable-hvb-v23-p1-barrel.png

P2 side, facing leftv23, q070CONFIRMED IN GAME
Cable on the right of the screen facing left, the burst on his barrel and the beam shaft running left, his torso clear
sprite displacement(−139, −45), 146 / 146 sprites
burston the barrel
ink over his torso0 px
buildmvsc_cable_v23.zip, md5 d0f7c543e46ac2f0984e489b9ca76886
lanelane_v23b, 2026-08-02

The same 302 bytes produce both of these. The sign flips because the engine's own mirror flips it, which is exactly the mechanism the section below is about.

/blog/shots/cable-hvb-v23-p2-mirrored.png

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:

memberbyteswhatCPU range
mvc.exp302beam placement blobs$549F5C$569B23
mvc.0818the seam fix in HVBSTEP$28C11A$28C131
mvc.05a6the strobe pin6 list-pointer longs
What these three rows are pinned to

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-sectionredwhat it reads as
20 px0%thin pale cord, no red at all
24 px16%thin cord
48 px41%RRRR MM p cc CCCCCC cc p MM RRRR — the tube MvC2 draws
56 px64–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.

Five labelled Marvel vs Capcom frames in a row: the Electrap canister on the ground at Cable's feet, a blue electric detonation filling the lower screen, an empty field, another empty field, and Cable throwing a second trap
Live, past the frame that was supposed to loop forever. TERM+60 and TERM+280 are both empty fields — if the last frame repeated, either of those panels would still be showing the explosion, and the fifth would be impossible. Panels are 1× native 384×224 framebuffer dumps with the labels burned in above the game area, so no game pixel here is scaled. Captured 2026-08-02 on mvsc_cable_v23.zip md5 d0f7c543e46ac2f0984e489b9ca76886, lane_v23e.

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.

The noise floor, measured before anything else

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.

nativeverifiedbridgeddonorpending
routepresentationstate + inputcollision + damageaudio
body / palette / HUDverifiedbridgedCable occupies Ryu's directory rowbridgedbridgednot an audio-bearing surface
ground + air normalsverifiedverified18 of 18 live routesbridgedfresh shape/property parity audit still requiredverified18 of 18 fresh imported key-ons
launcherverifiedverifieddown-forward + HPverified12 of 12, 195 pxpending
Viper / Psimitar / Electrapverifiedbridgedbridgedbridgedengine effects work; spoken calls incomplete
Hyper Viper Beamverifiedbridgedbridged20 hits / 43 damage; visible and hit regions need vertical alignmentpendingCable's spoken call does not key
Psy-Charge / Crackdown / AHVB / Time Flippendingpendingpendingpending
MvC2 source codependingpendingSH-4 code is not portable to 68000pendingpending
Measured on the v24 lineage. The Dreamcast data describes MvC2 source behavior; it is evidence for re-authoring, not code or tables that can be dropped into MvC1.

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.

Where this connects

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.

Editor's update — August 26, 2026: the Ryu-slot lineage is retired, and Cable is moving to a native id

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.

Editor's update — August 26, 2026: the native seat boots, the stock roster is untouched, and the commit crash is now root-caused to three code-entry tables

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.

Written by Daniel Plas Rivera · 29,861 words · $1B

ShareXLinkedIn