Goal: Find program/data space for a native character import. One character needs ~63 KB resident; the game had 41,608 bytes free, fragmented, largest run 5,076.
Constraint: Marvel vs. Capcom already occupies 4.00 MB of the 4.00 MB CPS-2 program budget. There is no flat expansion available inside that window.
Approach: Enumerate every mapped region in the emulator core rather than trusting the platform summary. The 68000 has a 24-bit bus; the ROM window is only the first quarter of it.
Result: $410000-$5FFFFF is completely unmapped. Flat-mapped 1 MB at $500000 — no banking — and verified it live by reading signatures back through the 68000 at both ends of the window.
Proof / Validation: Signatures at $500000 and $5FFFF8 both read back (byte-swapped, as every CPS-2 data ROM is). Free space: 41,608 fragmented bytes → 1,048,576 contiguous.
The wall
I have expanded CPS-2 graphics to 64MB addressable and audio to 16MB. Program ROM I had written off, for a reason that looked solid: Marvel vs. Capcom uses all of it.
$000000 mvce.03a 512 KB $200000 mvc.07 512 KB
$080000 mvce.04a 512 KB $280000 mvc.08 512 KB
$100000 mvc.05a 512 KB $300000 mvc.09 512 KB
$180000 mvc.06a 512 KB $380000 mvc.10 512 KB
4.00 MB of 4.00 MBEight chips, half a megabyte each, $000000 through $3FFFFF. Nothing spare.
This matters because of what a native character port needs. Importing a fighter from another game means her data has to be resident: animation tree, the code that runs her specials, her hitbox tables. Measured, for one character:
- Animation tree + records26,338bytes
- Special-execution code28,672bytes
- Character code block2,628bytes
- Box shapes / box-set6,144bytes
- TOTAL needed63,782bytes
- Free in the entire 4MB41,608bytes
fragmented — the largest single run was 5,076 bytes
So the port did the only thing it could: instead of importing her data, it redirects. Individual per-character lookups get patched to borrow data that already exists in the ROM. It works — it is why the character runs at all — but it has a defining weakness. Any lookup nobody thought to redirect silently reads another fighter's values, and you discover those by playing. Backwards dash is wrong. The pre-fight walk glitches. Hitboxes are the wrong size. Each one looks like a separate bug and is actually the same bug wearing different clothes.
You cannot finish that list. You can only shorten it.
Reading the map instead of the summary
The platform documentation says CPS-2 supports 4MB of program ROM, and that is true — it is the size of the ROM window. But the 68000 has a 24-bit address bus. Sixteen megabytes. The ROM window is the first quarter of it, and I had never asked what was in the other twelve.
So I stopped reading summaries and enumerated every SekMapMemory call in the emulator core:
$618000-$619FFF QSound shared RAM
$660000-$663FFF NVRAM
$708000-$70FFFF Object RAM
$900000-$92FFFF Video RAM
$F00000-$F1FFFF (misc)
$FF0000-$FFFFFF Work RAMPlus a handful of registers at $400000 and the CPS-B block at $800000.
Look at the gap. $410000 through $5FFFFF is not mapped to anything. Nearly two megabytes of address space that no CPS-2 game ever used, sitting directly above the ROM window, reachable by the same instructions with no bank switching, no window, no paging.
- 1program ROM — full$00000000–$003FFFFF · 4.00 MB8 chips, 4.00 of 4.00 MB
- 2NEW: expansion window$00500000–$005FFFFF · 1024.0 KBflat-mapped, no banking
- 3QSound / NVRAM$00618000–$00663FFF · 304.0 KB
- 4Object RAM$00708000–$0070FFFF · 32.0 KB
- 5Video RAM$00900000–$0092FFFF · 192.0 KB
- 6Work RAM$00FF0000–$00FFFFFF · 64.0 KB
- Aunmapped from here$00410000 · marker
Four small changes
No banking logic, no mapper, no window register. The whole thing is a flat map:
cps.h — declare the buffer and a ROM type for it. The base and length constants have to live here, not in the .cpp, because they are used near the top of the allocation routine.
cps_mem.cpp — map it for both reads and instruction fetches:
SekMapMemory(CpsRomExp, 0x500000, 0x5FFFFF, MAP_READ);
SekMapMemory(CpsRomExp, 0x500000, 0x5FFFFF, MAP_FETCH);Mapping MAP_FETCH matters: code placed there executes unencrypted, exactly as the existing $100000+ data ROMs already do. Injected routines behave identically to the ones already living in the ROM's gaps — there is just vastly more room.
cps.cpp — a loader case for the new ROM type.
d_cps2.cpp — one line adding the chip to the romset, marked BRF_OPT so every existing romset still loads unchanged when the file is absent.
The bug that made it look broken
First build: the chip loaded (Loading mvc.exp... (OK)) and the window read back as zeros instead of my $FF fill. The mapping looked wrong.
It was an ordering mistake, and an easy one to make:
Cps2Init(): CpsGetROMs(false) → CpsInit() → CpsGetROMs(true) → CpsRunInit()
^ roms load here ^ memory is
allocated hereThe allocation happens inside CpsRunInit, which runs after the load pass. My buffer was carved from that arena, so the pointer was still NULL when the loader needed it, and the guard I had written (if (bLoad && CpsRomExp)) quietly skipped the load. Giving the buffer its own allocation in the loader fixed it.
Worth noting because the symptom — chip loads fine, memory reads zeros — points at the mapping, which was correct the whole time.
Proving it, not assuming it
The core's debug print does not reach stdout in this build, so its silence proved nothing. Instead I wrote a distinctive signature at each end of the window and read them back through the 68000 from a Lua harness:
$500000 = 9AE7005CADDEEFBE wrote E79A5C00DEADBEEF
$5FFFF8 = FFC011EE33225544 wrote C0FFEE1122334455Both present. Byte-swapped, which is not a defect — it is the same ^1 interleave every CPS-2 data ROM uses, and the same idiom the build tools already write with.
Reading both ends matters: the base signature alone would prove the region is mapped, but not that the whole megabyte is addressable. Two signatures prove the extent.
What it changes
- Before — total free41,608bytes
scattered across the ROM's gaps
- Before — largest single run5,076bytes
the number that actually constrained anything
- After — one contiguous window1,048,576bytes
One character needs 63,782 bytes. The new window holds that sixteen times over, contiguously.
That is the difference between a port that borrows and a port that imports. The redirect architecture was never a design choice — it was the only thing that fit. With a contiguous megabyte, the animation tree, the special-execution code, the code block and the box tables can all be resident as her data, and the defect list stops being something you discover by playing.
The honest caveats
This is offline-only. Like the 64MB graphics expansion before it, a stock CPS-2 board has no such chip and would not see this region. It is an emulator-core capability, not a hardware discovery, and it costs standard netplay compatibility.
Nothing is imported yet. The space exists and is proven addressable. Writing the importer against it is the next piece of work, and this post claims nothing about it.
1MB is a choice, not a ceiling. $410000-$5FFFFF is closer to 2MB. I mapped one megabyte because it is comfortably more than needed and leaves the boundary well clear of the register block at $400000. Widening it later is a constant.
The constraint this removes is the one described in the hardware-limits post, whose program-ROM section I had to correct: it claimed MvC used 1MB of a 4MB budget, having counted only the two chips named like program ROM and missed that mvc.05a through mvc.10 are program-space chips too. The character work this unblocks is the Dark Sakura native port.