case studyUpdated Aug 1, 202617 min read$22

Could CPS2 Have Run 3rd Strike? Proving It With One Character

Everyone assumes CPS2 could never have run Street Fighter III. I am testing that by putting a complete 3rd Strike Ken into Marvel vs. Capcom on real CPS2 sprite rendering, and measuring what actually stops you. So far the answer is not the hardware. It is addressing, and addressing is a driver limit.

cps2cps3street-fighter-iiithird-strikeken
CPS-2 Reverse EngineeringPart 23 of 23
Browse all writing
On this page

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. Take a complete 3rd Strike character, put him on real CPS2 sprite rendering inside Marvel vs. Capcom, and measure what breaks first. Not a mockup. Not a mod that swaps a few frames. Every frame Ken has, drawn by the CPS2 sprite path.

Animated loop of Street Fighter III Ken inside Marvel vs. Capcom, standing in his idle, walking backward and forward, crouching, jumping and throwing punches and kicks, all in his white gi
3rd Strike Ken, running on the CPS2 sprite path inside Marvel vs. Capcom. Idle, both walks, crouch, jump and all six normals. Every frame here was extracted from CPS3 char RAM and converted to CPS2 tiles.

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:

What actually has to be true
Loading diagram...
Three independent limits. Only one of them turned out to bite.

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=1
The lesson, which cost me the wrong architecture

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

Correcting myself, in public, one section later

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 MB

Nothing 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 bituse
0 to 9screen coordinate, masked to 0x3FF
10, 11unused
12promoted to code bit 18 when the extension is on
13, 14code bits 16 and 17
15end 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.

Which makes the ceiling a question about spare bits

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. The work is not finding address space. It is making the game emit the bit, which means patching the code that writes object entries rather than patching the emulator.

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:

A horizontal strip of sixteen nearly identical tiles, each showing scattered orange and yellow blocks arranged in a diagonal staircase on black
Tiles marching diagonally into the corner. The document admitted the placement formula was incomplete three lines under the word PASSED.

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

A MAME screenshot crop of Ken standing in a stage with the background visible behind him, overlaid with a magenta 16 by 16 grid
A screenshot with the stage baked in, quantised to 15 colours and sliced on a raster grid. That is what was being written into graphics ROM as Ken's sprite tiles.

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:

After
Before
‹›
Same bytes, host orderSame bytes, swapped per word
One 32 bit reversal. The left is what garbled looks like when everything else is already correct, which is exactly why it is dangerous.

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

Three panels: a rendered sprite, MAME's screenshot of the same character, and an overlay where almost the entire figure is solid green
Mine, MAME, overlay. Green is an exact RGB match. The magenta at the top is the lifebar drawing over him.

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 15

That permutation is an involution and 15 maps to itself, so transparency looked perfect the whole time while every visible colour was scrambled.

After
Before
‹›
Straight pen index4 bit reversed pen index
Same tiles, same fifteen colours, same list. The only change is which palette slot each colour is written to.
The test that separated three explanations at once

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.

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.

Why the current build still shows Gambit everywhere
Loading diagram...
Seating a frame into a donor record replaces that one frame. Records never reached keep drawing the donor.

My current build seats 89 records. Everywhere it does not reach, the donor shows 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 is running right now

Twelve chains, 89 records, 1,630 unique tiles, one shared palette row.

Six Ken sprites in a row on a dark background: standing idle, walking forward, walking backward, crouching, jumping straight up curled, and jumping forward, all in a white gi
Rendered out of the written ROM rather than from source art. Movement states seated on chains identified by live tracing.
Ken idle, live in Marvel vs. CapcomGambit's slot, id $0ACONFIRMED IN GAME
Marvel vs. Capcom gameplay screenshot with the lifebar reading GAMBIT, showing Street Fighter III Ken standing in his idle stance in a white gi
fighter id$0A
animation pointer$119846
sprite list$507E00
tiles seated1,630
palette1 shared row, 15 colours
colour accuracy74 to 88 percent exact

The harness only takes a shot when the live animation pointer equals the chain the build wrote, and asserts the fighter id every frame. A frame that is not Ken cannot be captured.

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.

Known wrong, stated plainly

I would rather list these than have you find them.

symptomcause
Select screen animates as Gambitselect and win pose chains not seated
Attack frames face the wrong waymirror flag wrong, extracted art faces opposite
Idle looks glitchy and short24 donor records holding a much longer CPS3 idle
Ground normals flash Ken then revertonly the first records of each chain seated
Air and jump attacks all Gambitthose chains not traced or seated
Shoryuken shows Ken knocked downframes labelled by the input I sent, not by what Ken did

That last one deserves calling out. 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.

What comes next, in dependency order

  1. 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.
  2. Re-extract every Ken frame with the opponent neutralised, so labels mean what they say and the frame count is measured rather than estimated.
  3. 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.
  4. 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 this actually stands

Numbers rather than adjectives. Updated every time the build changes.

Road to one complete character
Loading diagram...
Green is measured and done. Amber is in progress. Grey has not started.
milestonemeasuredstate
Sprite decode matches MAME98.35 percent exact RGB, 1.81 percent controldone
Ken poses extracted133 across 17 movespartial, sweep labels unreliable for specials
Converted to CPS2 tiles1,630 unique, 0.20 MBdone for what is extracted
Records seated in the donor89 of 1,351, 6.6 percentin progress
Chains covered12in progress
Colour accuracy after conversion74 to 88 percent exactdone, limited by one shared palette row
Sprites addressing past 32 MBnot reached, cause identifiedblocked on Y bit 12
Chains grown to CPS3 frame countsnonenot started
Lifebar name, select art, win posesnonenot started
Soundnonenot started

Against the thesis specifically:

questionanswer
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?not yet, needs one spare coordinate bit the game does not set
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.

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.

Written by Daniel Plas Rivera · 3,677 words · $22

ShareXLinkedIn