The received wisdom in the FGC is that Street Fighter III could never have run on CPS2. Capcom built CPS3 for it, the sprites are enormous, the animation counts are absurd, end of discussion.
I want to know whether that is true, or whether it is just something everyone repeats.
So I am testing it the only way that settles anything. Start from a complete 3rd Strike character, put a measured slice of him on real CPS2 sprite rendering inside Marvel vs. Capcom, and measure what breaks first. Not a mockup, and not a claim that the current ROM already contains every Ken frame or rule. The experiment has to earn that description one system at a time.

That clip is the first build where Ken is unambiguously a CPS2 character rather than a mockup: picked from the select screen, moving under player control, drawn by the same sprite path that draws everyone else on the board.
It also shows the problem this post ends on. Watch the normals. They snap through a few frames and stop, because at that point Ken's frames were being seated into the donor's slot count rather than his own. That is not a rendering fault or an art fault, and the section on the donor's animation table puts a number on exactly how short it falls.
The question, stated so it can be answered
"Could CPS2 have run 3rd Strike" is too vague to test. Broken into things that have numbers:
flowchart TD
Q["Could CPS2 run 3rd Strike?"] --> A["Does the ART fit in addressable ROM?"]
Q --> B["Can the SPRITE PATH reach it?"]
Q --> C["Can the ANIMATION FORMAT hold the frame counts?"]
A --> A1["measured: 37.5 MB for 20 characters"]
B --> B1["tested: sprites do NOT reach it yet"]
C --> C1["donor chains far too short, must be grown"]
A1 --> R["art is not the limit"]
B1 --> S1["driver gap: no mapper call in Cps2ObjDraw"]
C1 --> S2["MEASURED: donor is 179 records short.<br/>Layout, not format. Entry mechanism solved"]Three questions. Two now have measured answers.
Answer one, the art fits
I extracted 133 Ken poses from live CPS3 char RAM and counted unique tiles after deduplication.
That last number is the one that matters. Thirty seven megabytes of sprite tiles for the whole cast. For a 1999 arcade board that is enormous, but it is a quantity of ROM, not a property of the silicon.
Answer two, the sprite path reaches further than anyone says
Here is where I was wrong for an entire post, and the mistake is the useful part.
The custom FBNeo core this project uses already extends CPS2 graphics to 64 MB. When I seated Ken's tiles up there the character rendered as garbage, so I went looking for why, and found this sitting in the driver:
// SPRITES cannot be extended (raw 18-bit code | (y&0x6000)<<3,
// no mapper in Cps2ObjDraw, hard 32MB cap).I believed it, backed off, and reseated everything into the donor's cramped stock bank.
That comment is out of date. The sprite path does have a promotion, a few files away:
if (Cps2Turbo || Cps2ExtScroll) {
if (yw & 0x1000) yw |= 0x8000;
n |= (yw & 0xe000) << 3; // Y bit12 becomes code bit18
}And the driver's own startup log confirms it is live in this build:
CpsInit: nCpsGfxLen=0x4000000 Cps2ExtScroll=1A comment is not a measurement. I treated a stale code comment as evidence and let it override an experiment I had already run. When the character rendered as garbage the correct move was to instrument the sprite path and find out why, not to read the nearest available explanation and accept it.
Thirty seven and a half megabytes of art against a sixty four megabyte ceiling. So I built a ROM
that seats Ken above the 32 MB line, flipped the donor's bank word from $0006 to $000C, and
ran it.
He was invisible on the select screen and garbled in the match.
An earlier revision of this post said the art fits inside addressing this core already has. That was wrong, and the experiment above is what proved it. The promotion exists in the sprite code, but nothing triggers it: the game never sets Y bit12 for these sprites, and flipping the bank word does not make it. I had found a mechanism and assumed it was wired up.
My first explanation for this was that sprites never call the graphics bank mapper the way background layers do. That is true, and it is also not the reason. I checked it before building on it, and it does not survive.
Sprites do not need the mapper. They index graphics memory directly:
nCpstTile <<= 7; // tile index becomes a byte address
nCpstTile &= nCpsGfxMask; // and the mask is (1<<26)-1 at 64 MBNothing there caps a sprite at 32 MB. A sprite whose code is 0x40000 lands at exactly 32 MB and
would draw correctly. The mask is already wide enough.
So the real gate is narrower and more interesting. A CPS2 sprite cannot express a code that large in the first place. The object entry gives 16 bits of code word, and the only extra bits come from the Y coordinate word:
| Y bit | use |
|---|---|
| 0 to 9 | screen coordinate, masked to 0x3FF |
| 10, 11 | unused |
| 12 | promoted to code bit 18 when the extension is on |
| 13, 14 | code bits 16 and 17 |
| 15 | end of sprite list marker, not available |
Two bits of banking natively, giving 18 bits and exactly 32 MB. Reaching further needs bit 18, which can only come from Y bit 12, which the game has to set when it writes the object entry. It does not. Flipping a graphics bank word changes which tiles the game asks for, not which extra coordinate bits it writes.
Once you see it this way the theoretical limit is countable. Y bits 10 and 11 are unused. So are
X bits 10, 11 and 12, since X masks to 0x3FF and only bits 13 to 15 carry the priority level.
Against a roster that needs 37.5 MB, nineteen bits is already enough and twenty is comfortable.
So I instrumented the emulator's sprite draw directly, counting every code it renders. Not a screenshot, a census.
codes<32MB=18000 codes>=32MB=0 y_bit12_set=0 max_code=33733 (25.7 MB)Zero sprites above 32 MB. Zero with Y bit 12 ever set, across eighteen thousand draws, with the bank word flipped and the tiles physically sitting up there. The game simply never emits the bit, and no ROM side bank change makes it.
This measurement failed three times before it worked, and the last cause was mine: I had pasted
the diagnostic into Cps1ObjDraw instead of Cps2ObjDraw. Same anchor text, first match wins,
and CPS1 never runs for this game. I spent a while suspecting the emulator of something exotic
when I had instrumented a function that is never called.
The number I did not expect
Look at max_code again. The highest sprite code Marvel vs. Capcom ever renders is 0x33733.
Marvel vs. Capcom leaves 6.28 MB of its own sprite space empty. At 1.87 MB for a complete 3rd Strike character, that is room for three full CPS3 characters inside a stock CPS2 address space, with no expansion, no driver change and no extra bits.
That reframes the question. The interesting claim is no longer that CPS2 could have run 3rd Strike given enormous ROM. It is that a shipped CPS2 game was already carrying enough unused sprite address space for several 3rd Strike characters, and the ceiling only becomes real at full roster scale.
The build I inherited was making things up
This didn't start from zero. It started from a working directory full of scaffolding, a 245-row animation map, four 91 MB ROM zips, and a document titled "Ken piece → pixel gate — Status: PASSED."
The character in those ROMs was garbled and scrambled in game.
So the first job wasn't building. It was working out which claims in front of me were load-bearing. Four were false, and they compound in a way worth showing, because every one of them produces an artifact that looks like progress.
One: the sprite format was invented. The gate document described a clean-sounding layout — a
12-byte header, then W eight-byte tile descriptors, then W eight-byte place records, with a
tag word that was "always $0200." That format does not exist. It has no counterpart in the
CPS-3 hardware. It had been pattern-matched out of ROM bytes by staring until structure
appeared, which is a thing bytes will always eventually do for you.
Two: the evidence didn't support the conclusion. The gate claimed PASSED on a mean normalized cross-correlation of 0.563 across 16 of 27 tiles, with no control. The contact strip offered as proof looks like this:

That staircase is what you get when tile contents are roughly right and tile placement is entirely wrong.
Three: the capture was never mid-match. The harness that produced the source data drove the
game to the character select screen, then entered its "fight" phase on a frame counter and
dumped memory blindly. Every sprite entry in that capture is a tile=0x1 stub. There was no
character art in it at all. It was a dump of a menu.
Four — and this is the one that reached the ROM. The builder that seated Ken's tiles didn't read any of that. It read a PNG:

A screenshot. With the background in it. Quantized to 15 colors, chopped on a raster grid, and written into graphics ROM as Ken's sprite tiles. That is the complete explanation for the garbled character. It was never Ken's data. It was a photograph of Ken, sliced up.
Each failure produced something that looks like forward motion — a format spec, a passing gate, a capture file, a built ROM. None were tested against anything that could have said no. A similarity score with no control is the tell: 0.563 out of 1.0 sounds like most of the way there, right up until you ask what a random answer scores.
What I decided would count as proof
I picked the standard before writing anything, because picking it afterward is how you end up with 0.563.
The emulator already renders these sprites correctly. So the bar isn't "does my output look like Ken." The bar is exact RGB equality with MAME's own frame, over thousands of pixels, with a deliberately-wrong alignment scored alongside. Plausible-but-wrong decodes don't survive that.
Before any of that, the art had to come out of CPS3 correctly
Everything above rests on the extraction being right, and the version of this project I inherited had it badly wrong in a way that is worth showing, because every mistake in it looks like progress.
The inherited build shipped a document titled "Ken piece to pixel gate, Status PASSED". It claimed
a sprite format with a 12 byte header and a tag word that was always $0200. That format does not
exist anywhere in CPS3 hardware. It had been pattern matched out of ROM bytes by staring until
structure appeared, which is a thing bytes will always eventually do for you.
The evidence filed under PASSED was a mean normalised cross correlation of 0.563, with no control, and this contact strip:

Worse, none of it reached the ROM anyway. The builder that seated Ken's tiles read from a PNG:

So I threw it out and read the hardware instead. The real format is in MAME's driver: a four
dword object list pointing at three dword sub entries, with sizes indexed through
tilestable = {8,1,2,4}. The single most useful line in it is that when xsize == 0 the entry is
not a sprite at all, it is a command to draw a background tilemap. Skip those and you get every
character on screen with no background, for free.
Four bugs, three of them invisible
Tiles are 8bpp at 256 bytes, not 6bpp. Colour RAM is 0x40000 bytes, not 0x20000. The scroll
registers are write only, so a capture reads back zeros and you have to recover offsets from the
picture instead.
And then the one that took longest. MAME holds char RAM as 32 bit words but decodes it through a byte pointer, so on a little endian host the bytes inside every word are reversed relative to what a Lua read hands you:
Look at the left one. Blonde hair, white gi, black belt, brown gloves. It is obviously Ken. If you were eyeballing your way to success you would call that a win and build everything downstream on a decoder that gets every pixel wrong.
The gate

Exact equality against a control two orders of magnitude lower. That is a gate. A similarity score with no control, like the 0.563 above, is not.
Getting it onto CPS2 at all
Conversion is where the format tax lands. CPS3 gives 8bpp art. CPS2 sprites get fifteen colours plus transparency, and sprites treat pen 15 as transparent rather than pen 0, which will turn your character into a silhouette if you assume otherwise.
Two palette bugs are worth carrying if you ever do this.
The first deletes a colour channel silently. The packing is three shifts, and the values arrive
from the quantiser as numpy uint8, where (r >> 4) << 8 overflows to zero. Every red nibble was
being discarded by a function with no error and a perfectly reasonable looking implementation.
The second is subtler and cost longer. CPS2 sprite hardware addresses the palette row with the 4 bit reversed pen index:
0 8 4 12 2 10 6 14 1 9 5 13 3 11 7 15That permutation is an involution and 15 maps to itself, so transparency looked perfect the whole time while every visible colour was scrambled.
Quantisation loss, an overlapping sprite, or a palette write that never landed all fit that symptom. Flattening Ken's entire palette row to one impossible colour and counting pixels distinguishes all three in a single run, because it converts a question about colour into a question about pixel count. In game: 4,551 magenta. Offline render: 4,551 ink. Exact match, so nothing was overlapping him and the row was live, which leaves only the pen mapping.
The palette bug that deletes red
The first build came out of the verifier looking like this — correct geometry, correct transparency, every tile in the right place, and completely the wrong colours:
The packing function is three shifts:
return ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4)The colours arrive from the quantizer as a numpy uint8 array. So r >> 4 is a uint8,
and uint8 << 8 doesn't widen — it overflows to zero. Every red nibble was being silently
discarded, in a function with no error, no warning, and a perfectly reasonable-looking
implementation. Ken came out green because his palette had R=0 in all fifteen entries.
I found this before booting anything, because the builder renders the sprite back out of the zip it just wrote — decoding the seated tiles, walking the parked list, and reading the palette from ROM. That render is the gate. If it doesn't show Ken, the emulator is not going to either, and debugging it in an emulator would have meant a five-minute round trip per attempt instead of five seconds.
The second palette bug: right transparency, wrong everything
With red restored, the offline verifier showed a perfect Ken. In game he came out like this — legs and belt roughly right, torso a mess:
It would have been easy to blame the 15-colour budget, or to guess that a second sprite was drawing on top of him. Instead: flatten Ken's entire palette row to one impossible colour, rebuild, and count.
Every pixel of that silhouette is drawn by my tiles through my palette row. Nothing is overlapping him, and the row is reaching the hardware. So the geometry was right, the palette was live, and the colours were still wrong — which leaves exactly one thing: the mapping from pen index to palette slot.
CPS-2 sprite hardware addresses the row with the 4-bit-reversed pen index relative to the codec's decode domain:
0 8 4 12 2 10 6 14 1 9 5 13 3 11 7 15That permutation is an involution, and 15 maps to itself — which is why transparency looked
perfect the whole time while every visible colour was scrambled. Writing each colour to
PEN_PERM[d] instead of d fixed it in one pass.
Three plausible stories fit the symptom — quantization loss, an overlapping sprite, a dead palette copy. A flat diagnostic palette distinguishes all three in one run, because it turns a question about colour into a question about pixel count, which is not a matter of opinion.
The bank redirect I had to give up on
Gambit's tiles come from graphics bank 3. The project's allocation ledger had a plan for Ken: point Gambit's bank word at bank 6, out in the expanded graphics ROM, and seat Ken there with room to grow.
I tried it. To avoid blanking every non-idle animation, I first copied the whole of Gambit's stock bank 3 into bank 6, then overwrote only the tiles Ken's idle needed. Careful, and wrong:

So I dropped it. Ken's tiles now go into Gambit's own bank 3, near the top, and the bank word is never touched. That removes an unknown instead of building on top of it. Working out the real expansion bank mapping is its own job, and it belongs to the part of this project where Ken needs more than 32 tiles — not to the part where he needs to stand up.
Answer three, this is what actually stops you
Not memory. Not addressing. The animation format.
CPS2 characters are driven by chains of 16 byte records. The engine walks a chain by advancing 16 bytes at a time until it reaches a record whose control word has bit 15 set, then jumps to the longword that follows. One record, one displayed frame.
Gambit, the donor slot Ken rides, has an entire animation tree of 21,630 bytes. That is 1,351 record slots for everything he does. Standing, walking, every attack, every reaction, every win pose.
Ken in 3rd Strike has far more animation than that per action. His idle alone is smoother than anything in Marvel vs. Capcom, because CPS3 was built to afford it.
flowchart LR
subgraph Donor["Gambit idle chain, 24 records"]
R1["rec 0"] --> R2["rec 1"] --> R3["rec 2"] --> Rn["rec 23"]
end
subgraph Ken3S["Ken idle in 3rd Strike"]
K1["many more frames"]
end
K1 -.->|"cycled to fit"| R1
K1 -.->|"cycled to fit"| R2
Rn -->|"never reached"| G["draws Gambit"]The first measured build seated 89 records. Everywhere it did not reach, the donor showed through. That is why the select screen animates as Gambit, why win poses are his, why air attacks are his, and why a ground normal flashes one Ken frame then reverts.
None of that is a separate bug. It is one fact reported nine ways. The donor's chains are too short, so they have to be grown rather than filled.
What ran at the first measured checkpoint
Twelve chains, 89 records, 1,630 unique tiles, one shared palette row.

The lifebar still reads GAMBIT, deliberately. MvC1 draws lifebar names as tile graphics rather than strings, and an earlier version of this project scrambled the game by writing guessed pointers into that table.
Answer four, the engine can be taught a move it never had
Art fitting is one claim. A parry is a different one, because Marvel vs. Capcom has no state for it. There is no animation to swap, no chain to repoint. Pressing toward an incoming attack in this engine does nothing at all. If Ken is going to parry, the machine has to learn a rule it was never written with.
A parry is really one sentence of logic: at the instant damage is about to be applied, if the defender just tapped toward the attacker, the damage becomes zero. So the whole feature lives or dies on finding the exact instruction where damage is applied.
Finding it took three wrong answers first, and the wrong answers are the interesting part.
The obvious move is to find the health field, watch what writes to it, and read off the address.
An existing harness in this project already claimed to locate health automatically: it snapshots
the player struct, lands a hit, and reports whichever value dropped. It has been reporting player
struct plus $7C for a long time.
$7C is not health. Across two hundred and eighty nine measured frames of a character being
beaten, it went up, from 128 to 130. Every damage number that harness has ever printed read
the wrong word, which means results resting on it measured nothing at all.
The second candidate, $E2, was worse, because it looked convincing. It starts at 128 and a
before and after comparison shows it falling to 96, a clean drop of 32. It is a trap. The field
toggles between 128 and 96 continuously. Sample it at two moments and you can manufacture any
answer you like.
Both failures share one root: a difference between two instants is not a decrease. Health has a property neither of those fields has. During a beating it can only ever fall. So the test is not magnitude, it is monotonicity. Sample every word in the struct every single frame, and throw away any offset that ever rises, no matter how good its before and after looks.
One field survived that filter across the whole four hundred byte struct:
P2+$270 144 -> 0 never rose144 is $90, and it sits directly beside the meter field that was already known. That is health.
The earlier searches missed it for a dull reason worth admitting: they scanned $30 to $FE, and
$270 is nowhere near that window.
With the right address, one write hook gave up the whole routine:
$01B740 4A6E 0270 tst.w $270(a6) ; defender health
$01B744 6758 beq.s ...
$01B746 6100 01F2 bsr.w ...
$01B74A 6100 1714 bsr.w ...
$01B74E 9B6E 0270 sub.w d5,$270(a6) ; damage applied here
$01B752 6E4A bgt.s ... ; did they survive
$01B754 0828 0005 0016 btst #5,$16(a0)
$01B75C 426E 0270 clr.w $270(a6) ; knocked outa6 is the defender, d5 is the damage. One shared routine drives both players, so a single hook
covers everyone.
The hook has to fit in four bytes, because that is the width of the instruction being replaced and
anything wider would shift every instruction after it. bsr.w is exactly four bytes. It reaches
plus or minus thirty two kilobytes, and there is exactly one run of filler in that range, forty
eight bytes of zeros, which is enough for the entire check rather than just a jump.
cmpi.b #$04,$15(a6) did the defender tap toward the attacker
bne.s .normal
moveq #0,d5 parried, this hit does nothing
.normal:
sub.w d5,$270(a6) the instruction we displaced
rtsSixteen bytes. The ordering matters more than it looks: sub.w runs last so that the bgt.s
immediately after the hook still reads its flags. On a 68000 neither bsr nor rts touches the
condition codes, so the knockout path downstream never notices anything happened.
The trigger needed no second hook either. The player struct carries a direction latch at $15
that reads $04 on a forward tap and holds about two frames past release. That short hold is the
parry window, handed over by the engine for free.
Two byte writes. That is the entire feature, which also means the offline installer never has to reason about any of it.
It does not run yet, and the reason is worth recording. CPS2 program code is encrypted, so code patches here are made against a decrypted image and shipped as a rebuilt set with the encryption key neutralised. That technique was developed against MAME. In the FBNeo core this project uses, a dead key does not mean skip decryption, it means decrypt with garbage, and the game never boots. An unpatched control built exactly the same way failed identically, which is how the patch was cleared of blame. The parry needs either a MAME target or a properly encrypted patch, and that is a delivery problem rather than a design one.
The chain addresses were guesses, and most of them were wrong
For weeks the build seated Ken onto animation chains at addresses I had worked out by tracing the game by hand. Walking backward played kicks. Jumping played an uppercut. Blocking vibrated between two poses at sixty frames a second. I assumed the art was mismatched and went hunting for better ways to match sprites to poses, twice, and both attempts made the game worse.
The art was never the problem. The addresses were.
An animation chain is a run of records, and the engine keeps a cursor at player struct +$34
pointing at whichever record is playing right now. So there is no need to decode the animation
tree, infer chain boundaries, or match shapes. Script an action, watch the cursor, and the set of
values it visits is the chain.
action what the engine actually plays what I had seated
walk_fwd $1199CA..$119AFA (20 records) $11A4C2
walk_back $119B42..$119C72 (20 records) $1199DA <- inside WALK_FWD
jump_up $119CBA..$119F66 (16 records) $119D9A <- the LAST record
jump_fwd $119DAE..$11A46A (22 records) $119F42 <- inside JUMP_BACK
jump_back $119E92..$119FBA (18 records) $119E6E <- inside JUMP_FWD
idle $119846..$1199B6 (24 records) $119846 correct
crouch $11A402 (1 record) $11A402 correctWalking backward was seated inside the walk forward chain. The two jumps were swapped into each other. Two of fifteen guesses were right.
That table explains every symptom at once, and it explains them better than any theory about art did. It also produced a fact no amount of static analysis had given me: blocking has no chain of its own. It reuses walk backward's records. That is why blocking vibrated, and why nothing I did to the art ever fixed it.
Two details cost a run each and are worth writing down. The probe must force the character, because the navigation picks whoever it lands on and another character's chains live several hundred kilobytes away, so the first run mapped precisely zero records. And frames belonging to idle have to be stripped from every other action, because an action captured from a standing start carries idle's opening frames with it, which would seat a jump with idle art at its start.
Seated records went from eighty five guessed to a hundred and ninety three measured.
The lesson is not subtle and I had already been taught it twice this project, once by a health field and once by a parry trigger. When a program can be asked directly, asking beats reasoning.
The donor has nowhere near enough slots, which is the whole thesis
With the right chain addresses the animations finally played the right actions, and immediately exposed the real problem underneath. They were still short. Attacks snapped through a few frames and stopped.
That is not a bug. It is the thing this project exists to measure.
action donor slots Ken frames shortfall
hk 7 32 +25
jump_fwd 22 51 +29
jump_back 18 51 +33
hp 11 25 +14
lp 3 14 +11
mk 5 19 +14
crouch 1 11 +10
---
total +179Seven slots for a roundhouse that has thirty two frames. Twenty two for a jump that has fifty one. Gambit's roundhouse plays at twenty two percent of Ken's. One record for a crouch that Ken draws in eleven.
This is what "CPS3 was built for it" actually means when you put a number on it. Not that the hardware cannot draw the frames, and not that the ROM cannot hold them. The donor's animation table was authored for a character with a fraction of the frames, and every slot is a fixed sixteen byte record in a fixed run.
The fix is the one the format allows. A record's control word has a terminal bit, and the engine jumps to the longword that follows a terminal record. So record zero is marked terminal and pointed at a chain authored in the program expansion window holding the rest of the frames, which loops back to the start. Only record zero and the first longword of record one are touched, and record one belongs to the same chain, so nothing outside it moves.
Twelve chains grown. Roundhouse from seven frames to thirty two. Jump forward from twenty two to fifty one.
The capture at the top of this post predates that change, which is why the normals in it are clipped. A second capture against the grown chains will go here, so the two sit side by side: the same character, the same hardware, the same sprite path, with the only difference being whether the animation table has room for his frames.
That is the answer to the third question this post opened with. The animation format can hold CPS3 frame counts, because chains are a linked structure rather than a fixed table. What it cannot do is hold them in the slots a 1998 character was given, and nothing about that is a silicon limit.
Three wrong fixes for one problem, and the thing that finally cracked it
I told the person testing this that the animation length problem was fixed. Twice. It was not fixed either time, and he found out the same way both times: by playing it and watching Ken's roundhouse stop after seven frames.
This section is that whole sequence, because the failures narrow the search in a way the success never could have.
Attempt one. A record's control word has a high bit, and the engine jumps to the longword after a record that has it set. So mark record zero of a chain terminal, point it at a longer chain authored in spare ROM, done. I built it, saw the bytes land, and said it worked.
It did nothing. The engine kept walking record to record as if nothing had changed. Worse, writing that jump target overwrote the first longword of record one, which is a real record in the same chain.
Attempt two. Read the ROM properly this time. The high bit is on the last record of every chain, and the longword after it points back at the chain start:
hk terminal $11A7EE ctrl=8004 +16 -> $11A78E
idle terminal $1199B6 ctrl=8003 +16 -> $119846It is a loop pointer. So divert the loop into an extension that ends by looping back to the real start. Clean, only touches the tail, nothing else moves. I built that too.
Also nothing. And this failure was informative: an attack never reaches its loop pointer. The move ends by state transition before the chain closes. Only genuinely looping animations, idle and the walks, ever follow it.
The test I should have run first. Instead of theorising about how chains end, ask what the engine does when a chain does not end. Clear the terminal bit on the roundhouse's last record and watch:
before hk plays $11A78E $11A79E $11A7AE $11A7BE $11A7CE $11A7DE $11A7EE
after hk plays ... $11A7EE $11A7FEIt walked straight into $11A7FE, a record it had never played in its life. The engine advances
sixteen bytes at a time and stops only at a terminal record. Length is not a format limit at all.
It is a layout problem: the chain has no room after it because the next chain starts immediately.
Which turns the whole thing into one question. To give a chain room, move it. To move it, you have to know how the engine decides where a chain begins.
The entry address, which is not where anyone would look
I had already failed at this twice. There is no pointer table: scanning the entire four megabyte program for anything pointing at a chain start returns exactly one hit per chain, and it is that chain's own loop pointer. And the animation tree does not encode it either. Testing measured chain starts against it as word offsets from its base matched one of sixteen, which is what chance looks like.
So stop hunting for a table and watch the code instead. The cursor at player struct +$34 holds
whichever record is playing. Hook writes to it, and when the value lands on a chain start we
already measured, dump the program counter and every register.
CHAIN walk_fwd = $1199CA written by ppc=$001DFC
d0=0000039E ... a0=001199CA
CHAIN hk = $11A78E written by ppc=$001DFC
d0=000010F0 ... a0=0011A78EThe address arrives in a0. And d0 is carrying a small number that is obviously an offset. Take
it away and see what is left:
walk_fwd $1199CA - $39E = $11962C
walk_back $119B42 - $516 = $11962C same base
crouch $11A402 - $D9C = $119666 different base
hp $11A642 - $FAC = $119696
hk $11A78E - $10F0 = $11969EThose bases are not arbitrary. Subtract the tree's own address, $1195EE, and they are $3E,
$78, $A8, $B0 — the first, fourth, ninth and tenth entries in the tree table.
chain address = $1195EE + tree_word[i] + d0The tree was never a list of chains. It is a list of group bases, and each action carries its own delta within a group. That is exactly why searching it for chain addresses found nothing: I was looking for one number in a place that holds half of one.
And it is the unlock. Changing a single word in that table relocates an entire group of chains into the program expansion window, where contiguous space is not scarce. The engine will walk as far as you let it, because it walks blindly and stops only where you tell it to.
One constraint survives, and it is worth stating rather than discovering later: within a group the
deltas are fixed by other code, so a relocated group must place each chain at its original delta,
and the gap to the next delta caps that chain's length. Between the fierce punch at $FAC and the
roundhouse at $10F0 there are $144 bytes, which is twenty records. Enough for the roundhouse's
thirty two frames? No. Enough to stop guessing? Yes.
Known wrong at that checkpoint, stated plainly
I would rather list these than have you find them.
| symptom | cause |
|---|---|
| Select screen and win pose are Gambit | same 635 unrepointed records |
| FIXED: the donor's art faces left, so the engine's flip turned Ken away | |
| Idle looks glitchy and short | 24 donor records holding a much longer CPS3 idle |
| Ground normals flash Ken then revert | only 85 of 720 records repointed |
| Air and jump attacks all Gambit | same 635 unrepointed records |
| Shoryuken shows Ken knocked down | frames labelled by the input I sent, not by what Ken did |
Facing: solved, after two confident wrong answers
Ken faced away from his opponent for weeks. The fix took one image, and I did not take that image until I had burned two theories on reasoning instead.
Theory one: the mirror flag. He faces the wrong way, so flip the art. He faced wrong with mirroring on and wrong with it off, which rules mirroring out as the variable entirely. That should have ended it. Instead I moved to another theory.
Theory two: the anchor. Sprite list positions are origin relative, and every stock donor chain
straddles its origin: idle at x0 = -16, crouch -80, a fierce punch -105. My frames were
anchored to the corner of their own bounding box, so mirroring about the origin would throw the
sprite bodily across itself. It fit the evidence, it explained the symptom, and it was wrong. The
extractor renders in a fixed thousand by thousand wrap space because the CPS3 scroll registers are
write only, so all three hundred and thirty four poses report the identical position. Applying that
anchor adds the same constant to every frame. It shifts him and changes nothing about facing.
The answer was available the whole time: render the donor's own art and look at it.

Gambit's unflipped art faces left. The engine mirrors it to face right for player one. Ken's extraction faces right, because he was player one on the left in 3rd Strike, so the engine's flip turned him away from his opponent. Storing him mirrored, matching the donor's convention, fixes it.
There is a reason this took so long, and it is not only stubbornness. An earlier attempt to render
Gambit produced scattered garbage, which is why I stopped trusting that approach and went back to
reasoning. That render was wrong for a specific, findable reason: each list entry carries a part
size. Attr bits 8 to 11 and 12 to 15 give a block of bx + 1 by by + 1 tiles, not a single
tile. Decode that and the sprite assembles correctly. Ignore it and you get confetti that looks
like proof the method does not work.
Confirmed in game.
The last row in the table deserves calling out too. In the extraction sweep I saved every frame under the name of the button I was pressing. When a motion failed or Ken got hit, the file still went into the shoryuken pile. I seated one of those and got a sprite lying on its back. The chain trace had the opponent neutralised and reached zero contamination. The frame extraction never did, and I carried its labels forward without rechecking them.
The remaining work at that checkpoint, finally measured
For a long time I described the gap as "about fifteen chains seated out of maybe two hundred and forty five". That was an estimate, and it was wrong in both directions. Here is the measurement.
A record can draw Gambit if and only if its sprite list pointer aims at one of Gambit's lists. Scanning his whole animation region for exactly that condition finds every such record, with no dependence on understanding the animation tree:
records pointing at Gambit art: 720
records covered by the traced actions: 85
records that still draw GAMBIT: 635Eleven point eight percent. That one number is the entire remaining bug list. The crouch that will not hold, the missing normals, the absent air attacks, the win pose, the select screen, and Gambit appearing discoloured in the middle of everything are not six problems. They are 635 records that were never repointed, and the discolouration is simply Gambit's art being drawn through Ken's palette.
Two attempts to decode the animation tree at $1195EE failed, and both failures are informative.
It is not a table of word offsets from its own base: only two of fifteen known entry points matched,
which is what chance looks like across four hundred slots. And the engine enters chains at
arbitrary offsets, so ten of the fifteen known entry points sit mid run, which means clean
structural chain boundaries do not exist to be found.
That is why the next step does not need the tree at all. The 720 records are enumerable directly, and repointing all of them makes it impossible for Gambit to be drawn regardless of what the tree does. Full coverage is not the same as full accuracy, and that distinction matters: an untraced record would show the wrong Ken frame for whatever the engine is doing, rather than the right one. It is a much weaker promise than "the animation is correct", and worth keeping separate.
What comes next, in dependency order
- Make the game emit Y bit 12 when it writes object entries for the imported character, so sprite codes can carry bit 18 and reach past 32 MB. The emulator side already accepts it.
- Re-extract every Ken frame with the opponent neutralised, so labels mean what they say and the frame count is measured rather than estimated.
- Author new longer chains in the 1 MB program window, one record per Ken frame, and repoint the donor's chain starts at them. This is how a CPS2 character gets CPS3 frame counts.
- Seat everything in the expansion rather than the donor's shared bank.
Step 3 is the thesis in one sentence. If a CPS2 character can be given CPS3 frame counts and still render correctly, the format was never the barrier either.
Where that first checkpoint stood
Numbers rather than adjectives. This chart records the pre-expansion checkpoint; the dated audits below carry the project forward without rewriting the failures that led there.
flowchart LR
E["Extract from CPS3<br/>133 poses"]:::done --> C["Convert to CPS2 4bpp<br/>1,630 tiles"]:::done
C --> S["Seat into donor<br/>89 of 1,351 records"]:::wip
S --> G["Grow chains to<br/>CPS3 frame counts"]:::todo
G --> A["Address above 32 MB<br/>needs Y bit 12"]:::todo
A --> N["Identity, sound,<br/>select screen"]:::todo
classDef done fill:#112d20,stroke:#419865,color:#eaf2ef
classDef wip fill:#33290f,stroke:#b28c36,color:#f4e9c8
classDef todo fill:#0d181b,stroke:#3b5150,color:#b8c7c3| milestone | measured | state |
|---|---|---|
| Sprite decode matches MAME | 98.35 percent exact RGB, 1.81 percent control | done |
| Ken poses extracted | 133 across 17 moves | partial, sweep labels unreliable for specials |
| Converted to CPS2 tiles | 1,630 unique, 0.20 MB | done for what is extracted |
| Records seated in the donor | 89 of 1,351, 6.6 percent | in progress |
| Chains covered | 12 | in progress |
| Colour accuracy after conversion | 74 to 88 percent exact | done, limited by one shared palette row |
| Sprites addressing past 32 MB | measured: 0 of 18,000 draws, Y bit 12 never set | blocked, cause proven |
| Unused sprite space in stock mvsc | 6.28 MB, about 3 full characters | measured |
| Chains grown to CPS3 frame counts | none | not started |
| Lifebar name, select art, win poses | none | not started |
| Sound | none | not started |
Against the thesis specifically:
| question | answer |
|---|---|
| Does the art fit in addressable ROM? | yes, 37.5 MB for the roster against a 64 MB reachable ceiling |
| Can a sprite code reach that far? | no, measured zero across 18,000 draws. But 6.28 MB is free below the line |
| Can the animation format hold CPS3 frame counts? | unknown, this is the next real work |
Six point six percent of one character, in one donor slot, in one game. That is the honest position. What has been settled is that the two limits everyone assumes are fatal, ROM size and sprite addressing, are both quantities rather than walls.
The recipe, if you want to do this yourself
This generalizes to any CPS-3 character, and most of it to any system MAME supports. The order matters more than the specifics.
1. Find the hardware's format, not the data's apparent format. Read the emulator driver.
draw_sprites in the platform's .cpp is the actual specification, and it's free. Don't
pattern-match structure out of ROM bytes — bytes will always eventually look structured.
2. Decide what would prove you wrong, before you start. Exact equality against the emulator's own output over a large pixel count, always with a deliberately-wrong control alongside. If your metric can't distinguish your answer from a wrong answer, it isn't a metric.
3. Capture from a real gameplay moment, and make the harness verify that itself. Don't trust a frame counter. Mine dumps only when the live sprite list is genuinely rich — ≥150 tiles across ≥12 distinct tile numbers. A harness that can't tell a menu from a match will hand you a menu.
4. Expect the byte-order trap. Any time an emulator stores memory at one width and reads it at another, a host-endianness mismatch is waiting. It produces recognizable, entirely wrong output. Test both orders early; it costs one line.
5. Skip the tilemap draw commands. On CPS-3 that's xsize == 0. Most sprite hardware has an
equivalent. This is what gives you clean, background-free art with no image processing at all.
6. Identify your character by palette content, not by index or palette bank. Take the exact colour set from one confirmed frame and score candidates against it. Exclude static overlays first — the HUD legitimately shares your character's colours.
7. Capture sparse. Only what each frame references: object list, sub-entry ranges, referenced tiles. 276 frames fit in 9 MB. Full char RAM dumps would have been over two gigabytes.
There's a full decompilation of the PS2 Anniversary build of 3rd Strike, and it's genuinely
useful — it gives Capcom's own names for the character data model: ten typed animation tables
(nmca normal, dmca damage, atca attack, saca super arts, …), six hitbox tables, and a
125-opcode animation VM.
It contributed nothing to the work above, and it's worth saying why. It has no pixel data — the character tables are filled from data files at runtime. And its structures don't transfer to the arcade binary: I tried three ways (a byte-signature search, an animation-table scan, and a function-pointer-table fingerprint) and all three came back empty, or came back with degenerate matches that collapsed the moment I required the entries to be distinct. The decomp is authoritative for shapes and names, never for bytes or addresses. It'll matter in part two, when there are live traces to label.
Why this matters beyond one character
If the art fits, and the sprite path reaches it, and the animation format can be grown, then the honest answer to the original question changes. Not "CPS2 could never have run 3rd Strike" but "CPS2 could have run 3rd Strike given enough ROM, and CPS3 existed for reasons that were partly about cost and cartridge economics rather than pure capability."
I do not get to claim that yet. One of three answers is measured and favourable. The second came back negative on the first honest test, with a known cause and a known fix. The third has not started.
That is a worse position than this post originally claimed, and a much better one to actually build from.
August 5 audit: the sample is substantial, not complete Ken
The later Zero experiment supplied the missing conceptual split. Expanding an animation table can make a record addressable without making its owning state live long enough to execute it. Ken's prototype must therefore be described by what was measured: 276 sampled source frames, 133 poses, and 17 move families—not a complete CPS‑3 character.
August audit
Ken's sprite translation passes; full animation, box provenance, and gameplay logic remain open
Current evidence after reclassifying donor and source ownership.
| route | source capture | CPS-2 rendering | animation + state | combat logic |
|---|---|---|---|---|
| 276-frame sample | verified | verified | bridged | pending |
| 133 poses / 17 moves | verified | verified | bridgednot the complete character census | pending |
| expanded donor records | verified | verified | bridged639 of 720 repointed; 81 remain effect/other | pending |
| live pointer census | verified | verified | pending193 traced records do not prove every route | pending |
| collision geometry | bridged | bridged | pendinguntraced poses use nearest-box guesses | pending |
| parry / SF3 rules | pending | pending | pending | pendingseparate re-authored feature, not imported Ken logic |
The current expanded pass contains 334 authored poses and 4,834 tiles. Those are useful capacity results. They do not erase the jump mismatch—51 source frames against two donor records—or prove that the untraced collision guesses, physics, attack properties, cancel windows, and reactions are Ken's. The next milestone is a source-proven move census with per-route state lifetime and contact tests, not a larger montage.
August 7 inspection: v5 can store the routes; live dispatch still refuses them
The latest reproducible artifact is mvsc_ken_extended_v5.zip, md5
0eeac2e0bab1308b0ea1d276c484ebec. Its emitted manifest contains 35 authored visual
routes, 1,205 record instances, and individual chains as long as 92 records. The new
inventory includes the previously omitted crouching LP/MP/HK, six aerial normals, forward/back
dash and both dash-recovery schedules.
The important word is visual. The builder itself explicitly excludes a claim that Gambit's handlers have become Ken's SF3 hitboxes, velocities, projectile objects or supers. Hands-on testing then supplied the negative controls the manifest cannot: the select highlight still performs Gambit's animation; dash still exposes Gambit; Hadouken plays a Ken wind-up but launches Gambit's card. The newly authored crouch, air and dash routes do not become live merely because they exist in the manifest. The mapped normals do not yet prove Ken's source startup, active, recovery, attack boxes, damage, stun or cancel windows.
That difference is now measured. An early v5 dash hook trapped Ken indefinitely in animation-state
groups $14/$16. I removed the unsafe hook instead of accepting a moving screenshot. The clean
dash gate now reports PARTIAL 0/2: zero private records sampled, zero animation writes and
three donor fallbacks for each direction. Ken no longer wedges there, but he also does not have an
accepted SF3 dash.
The Tatsu result is deliberately narrow. The earlier v4 path adds a private QCB recognizer and a movement thunk; the isolated gate measures horizontal travel, lift, landing error and zeroed velocity at neutral in both orientations. It does not yet certify source collision, attack timing, pushback, invulnerability, damage, sound or cancel behavior. v5 retains those authored schedules while changing the unsafe dash path. Shoryuken and Hadouken likewise have imported visual schedules, not complete SF3 combat implementations.
v5 acceptance audit
What belongs to Ken, what is still wearing Gambit's rules
A route is only complete when presentation, state, collision and audio agree.
| route | source frames | target schedule | combat behavior | identity + sound |
|---|---|---|---|---|
| idle / walks / crouch / jumps | bridged | verified | bridgedtarget physics; full SF3 cadence not accepted | pending |
| six standing normals | bridged | verified | pendingsource boxes, damage, stun and cancels not proven | pending |
| crouching normals | bridged | bridgedsix visual schedules authored; live matrix not accepted | pending | pending |
| aerial normals | bridged | bridgedsix visual schedules authored; live dispatch not accepted | pending | pending |
| forward / back dash | bridged | pending0/2 routes; donor fallback after unsafe hook removal | pending | pending |
| Hadouken | bridged | verified | pendingstill spawns Gambit's card | pending |
| Shoryuken | bridged | verified | pending | pending |
| Tatsu | bridged | verified | bridgedtravel proven; source attack data not proven | pending |
| supers / taunt / select / wins | pending | pending | pending | pending |
The existing Ken images on this page document earlier milestones. They do not prove v5. The next authentic capture set must come from the hashed v5 ROM and show, at minimum: the Gambit select fallback; both dashes; all standing, crouching and aerial normal inputs; the card appearing after Ken's Hadouken wind-up; LK and HK Tatsu traveling in both facings; and an overlay pass for hurt and attack boxes. Until those frames exist, this section carries no replacement “finale” image.






