Goal: Understand MSHvSF's Sega Saturn port well enough to answer two questions with numbers: is the Saturn disc a second source of truth for the arcade data the native-character ports depend on, and could MvC content go the other direction -- onto Saturn hardware?
Constraints: No source for either side. The only inputs are the arcade CPS-2 ROM (encrypted) and the retail Saturn disc plus its 4MB RAM cartridge layout.
Approach: A byte-level audit of the disc's data files against the arcade ROM -- frame tables, sound maps, palettes, the sprite codec -- looking for mechanical, reversible transforms rather than assuming a rewrite.
Result: The Saturn port is a source-level SH-2 recompile with mechanically-converted assets. Character data tables are byte-identical to arcade; sprites are one deterministic pen-map away; the colorspace is a single precomputed LUT. That makes the disc a plaintext mirror of data I otherwise dig out of an encrypted ROM. The reverse direction -- MvC onto Saturn -- is a costed 4-phase plan: phases 0-2 demo-feasible, phases 3-4 research.
Proof / Validation: A 1,604-byte frame-record table matching the arcade ROM 100%; 57,370 of 57,370 sprite records on the disc decode and re-encode exactly; a 128KB colorspace LUT read straight off the disc.
Update (2026-07-27): the animation tree is carried and the engine uses it -- proven from save states taken during a real match, where High Work RAM holds cursor pairs six bytes apart walking record chains that are reachable by indexing the carried array. Getting there took four wrong answers in two days (absent, fully carried, partial, inert), and every one of them was a static search whose empty result I reported as a fact about the data. All four are kept below, because the pattern is the useful part. Also new: the runtime layout is now mapped (modules at 0x00200000 + slot*0x10000), the SH-2 disassembler was rebuilt as recursive-descent after inline literal pools produced two false findings, and the arcade player-struct map was tested against Saturn objects -- two of six fields transfer, so it is not the crib I said it was.
Primary docs: SATURN_VS_CPS2 -- what the Saturn port actually is and MVC_TO_SATURN_PLAN -- the 4-phase import plan with honest effort ranges.
Why a Console Port Became Interesting
This started as a detour from a stuck problem. While the NYC stage port sat on a load hang, a side question got interesting: MSHvSF had a Sega Saturn port in 1998, famously faithful, running on the 4MB RAM cartridge. If that port's data formats were close to arcade, the Saturn version could be a second source of truth for the RE -- or even a second target.
The character-port work spends weeks locating tables inside an encrypted CPS-2 ROM. If a retail CD carried the same tables in the clear, that changes the economics of everything downstream. So I audited the disc against the arcade ROM, byte by byte. The full writeup is the SATURN_VS_CPS2 gist; here is what it turned out to be.
A Recompile, Not a Rewrite
The Saturn port is a source-level SH-2 recompile with mechanically-converted assets. Not an emulator, not a rewrite-from-scratch: Capcom compiled the game's logic for the Saturn's SH-2 CPUs and ran the arcade's data through deterministic converters. The evidence is unusually clean.
- Character data tables are byte-identical to arcade. Shuma-Gorath's file (
MC05) carries his 1,604-byte frame-record table and his 1,216-byte sound map byte-perfect against the CPS-2 ROM. The same tables I spent weeks locating in the arcade ROM for the native-character work exist on a consumer CD, unencrypted, in files named per character. - Sprites are a deterministic pen-map away from arcade tiles:
saturn_pixel = (arcade_pixel + 1) & 15, so arcade's transparent pen 15 wraps to Saturn's 0, all wrapped in a bit-packed codec keyed by 2-bit codes. Proof standard: decoding record #694 produces 30 tiles with 0 of 7,680 pixels differing from the arcade originals under the pen-map. PALET.TBLis a 128KB lookup table mapping every CPS-2 12-bit color to Saturn RGB555 -- the whole colorspace conversion is one precomputed LUT on the disc.- The roster index is
arcade_id >> 1-- the Saturn character list is literally the arcade's id space with the always-even ids compacted. Shuma's arcade id$0Abecomes Saturn slotPL05/MC05. - The one place byte-identity fully breaks: audio, re-encoded to 4-bit ADPCM with zero byte-level correspondence to the QSound data.
The decode pipeline, exactly
The sprite transform is small enough to be a diagram and mechanical enough to be reversible -- which is the entire reason the reverse-direction plan (below) is even costable.
flowchart LR
A["arcade tile pixel<br/>0..15"] --> B["saturn = (arcade + 1) & 15"]
B --> C{"was arcade pen 15?"}
C -->|"yes, transparent"| D["becomes 0<br/>Saturn transparent"]
C -->|"no, 0..14"| E["becomes 1..15<br/>opaque"]
D --> F["2-bit-code<br/>bit-packed codec"]
E --> F
F --> G["VDP1 sprite tile<br/>record #694: 0/7680 diff"]Boot Chain and the 4MB Cart
The disc boots through a fixed chain -- IP.BIN -> 00LOAD.BIN -> SYS.BIN -> MM.BIN -- and the whole thing refuses to run without the 4MB RAM cartridge: the cart check validates ID byte 0x5C at 0x24FFFFFF before proceeding. Once up, characters and stages stream from CD into the cart, and the SH-2 feeds decompressed tiles to the VDP1 sprite hardware per frame; sprites stay compressed in the cart and are decoded per-tile on the way to VDP1.
architecture-beta
group disc(disk)[MSHvSF CD]
service ip(disk)[IP BIN] in disc
service load(disk)[00LOAD BIN] in disc
service sys(disk)[SYS BIN] in disc
service mm(disk)[MM BIN] in disc
service charfiles(disk)[Per character files eg MC05] in disc
service palet(disk)[PALET TBL 128KB LUT] in disc
group cart(database)[4MB RAM cart mandatory]
service slots(database)[Five 668448 byte streaming slots] in cart
service idbyte(database)[ID byte 5C at 24FFFFFF] in cart
group saturn(server)[Saturn]
service sh2(server)[SH2 source level recompile] in saturn
service vdp1(server)[VDP1 sprite hardware] in saturn
ip:R --> L:load
load:R --> L:sys
sys:R --> L:mm
mm:R --> L:sh2
charfiles:R --> L:slots
palet:R --> L:sh2
slots:R --> L:vdp1
idbyte:T --> B:sh2
sh2:R --> L:vdp1The cart's streaming region is five equal slots of 668,448 bytes (0xA3320) starting at 0x02486BC0, slot k at base + k * 0xA3320. That is the memory choreography the shipped roster is tuned against -- and it is essentially full, which becomes the load-bearing constraint on any reverse-direction plan.
flowchart TB
subgraph CART["Saturn 4MB RAM cart — streaming region from 0x02486BC0"]
direction LR
S0["Slot 0<br/>base 0x02486BC0"]
S1["Slot 1<br/>+0xA3320"]
S2["Slot 2<br/>+0x146640"]
S3["Slot 3<br/>+0x1E9960"]
S4["Slot 4<br/>+0x28CC80"]
S0 --- S1 --- S2 --- S3 --- S4
end
NOTE["Five equal slots · 668,448 bytes (0xA3320) each · slot k at base + k x 0xA3320<br/>Region is essentially full — the load-bearing constraint on any reverse-direction port"]
CART --- NOTEFor the arcade project this audit was an immediate, practical win: the Saturn disc is a plaintext mirror of data I otherwise dig out of an encrypted ROM. Byte-identical frame tables on a CD mean a second, independent witness for every table the native-character ports depend on.
The original version of this post inferred from file sizes that PL packs larger than one slot must be split across slots -- PL05.PAC is 757,784 bytes against a 668,448-byte slot. Unpacking it properly shows the opposite, and it is much tidier: slot 0 is the sprite archive at exactly 0xA3320 = 668,448 bytes, one cart slot to the byte, and the leftover is a separate audio bank section that lives elsewhere. Nothing splits. Capcom sized the archive to the slot.
What the Port Actually Carries -- and What It Quietly Rebuilt
"Character data tables are byte-identical" is true, and it is also the kind of sentence that hides the interesting part. Which tables?
I re-derived it exhaustively -- longest-common-substring between each decompressed MC module and the arcade ROM -- and measured coverage against the five per-character tables the native-port work already has addresses for. MC05 (Shuma) is 57,076 bytes decompressed, of which 13,169 bytes (23.1%) are carried arcade bytes:
That looked like a clean split, and I wrote it up as one: the Saturn carries the "what to draw" data and re-authored the "when to draw it" data. Spritelists, frame-index tables, offset tables, move properties and sound maps cross over byte-for-byte; the two-level animation tree that sequences them appeared not to exist on the disc at all.
That conclusion was wrong, and the way it was wrong is worth more than the finding.
The correction: 58 is less than 64
The tree is carried. It was carried the whole time.
mc_carried.py reports byte-identical runs of 64 bytes or more. A character's L1 group-offset array -- the top level of the animation tree, pure internal offsets with no pointers in it -- is 58 bytes: 29 groups times two. It sat one row under the reporting threshold, scored zero, and I wrote a headline off the back of it.
Searching for that 58-byte array directly finds it in 22 of 23 modules, unique hit in every one. And once you know where the Saturn tree is, the transform falls out immediately:
Where the correspondence holds, it is exact and two-sided. For a chain of K records:
arcade span = 16*K + 4 saturn span = 6*K + 2Same K on both sides. Every offset it predicts, it predicts perfectly -- 203 of 203, no misses. But it only gets to predict at all in the 7% of groups whose sub-array counts match arcade. In the other 93%, the Saturn sub-array has a different number of entries and there is no correspondence to check.
So this is a partial result, and I want to be exact about which part is which. The L1 array carriage is not in doubt: a specific 58-byte string appearing exactly once in each of 22 modules is not chance. What happens below the array is genuinely unresolved.
Then I went looking for the code that reads it, and found nothing. Four independent searches:
That third row is the one that stings, because it is the same mistake a third time. I searched for the address as a stored value. SH-2 position-independent code doesn't store addresses -- it reaches data by PC-relative displacement, so the address is never written down anywhere. I had to resolve every mova/mov.l @(disp,PC) target in all 22 modules to ask the question properly. The answer was still zero.
I wrote that up as "carried, not consumed" and moved on.
And that was wrong too
The emulator settled it, and not the way I expected.
Two save states from real matches -- an F5 snapshot mid-round and the quit autosave, same four characters loaded -- let me diff High Work RAM for words that hold a pointer into the character's module in both states and changed between them. Exactly four came back:
0x060F3834 module+0x03972 -> module+0x0443c
0x060F3838 module+0x03978 -> module+0x04442 <- always +6 from the first
0x060F3C8C module+0x05F4C -> module+0x060CC
0x060F448C module+0x0606C -> module+0x065ACThe first two are a cursor pair, six bytes apart in both states -- current record and next -- and both land inside the tree region. What they point at:
85 09 dc 70 1b c6 | 85 09 dc 90 1b c0 | 85 09 dc b2 1b ba | 85 09 dc d0 1b b4Six-byte records: an 85 xx tag, a rising 16-bit field, a falling one. And the closer -- walking the L1 array and gating on that 0x85 tag reaches 653 record positions across 893 chains, and all four live cursor targets are among them.
So the L1 array is the index, the chains under it are six-byte animation records, and the engine walks them six bytes at a time. The tree is carried and used.
Why four searches missed a thing that was running the whole time
Every one of them looked for the wrong kind of evidence:
- The pointer searches failed because access goes through the array's relative offsets plus a base held in work RAM. No absolute address is ever stored, so there was never anything to find.
- The relocation test failed because the records hold 16-bit relative values -- there is nothing to relocate. I read "no relocated pointers here" as "nothing uses this," and that inference is simply invalid.
Which is the third variant of one mistake in this post: a negative result is only ever as strong as the search that produced it, and "I found no pointer to X" is not the same claim as "nothing uses X."
Phase 3's blocker is gone. Field semantics are still open -- +0 is a two-valued tag (8509/850A) constant across 88% of chains, +2 rises and +4 falls -- but "you cannot express animation timing on Saturn" is false.
flowchart LR
subgraph ARC["ARCADE — mshvsf ROM"]
direction TB
A1["L1 group-offset array<br/>29 words · 58 bytes"]
A2["sub-offset array<br/>per group"]
A3["chain of K records<br/>16 bytes each"]
A4["terminator<br/>4 bytes"]
A1 --> A2 --> A3 --> A4
end
subgraph SAT["SATURN — MC05 module"]
direction TB
S1["L1 group-offset array<br/>29 words · 58 bytes"]
S2["sub-offset array<br/>rescaled"]
S3["chain of K records<br/>6 bytes each"]
S4["terminator<br/>2 bytes"]
S1 --> S2 --> S3 --> S4
end
A1 -- "byte-identical · 22/22 · live cursors walk it" --> S1
A2 -. "counts match in only 7% of groups" .-> S2
A3 -. "where they match: same K, 203/203 exact" .-> S3
A4 -. "unverified below this point" .-> S4The one that mattered was A1 -> S1. Those 58 bytes are the whole reason the tree was findable at all, and the whole reason it went missing for a day.
The 6-byte record's fields are open, and my first three readings of them were all wrong. From one chain: +0 constant, +2 moving in step with the arcade's spritelist pointers, +4 decrementing by exactly six. Across 499 clean chains in four characters, +0 is constant in 11% and +4 decrements by six in 2%. A lucky chain, not a pattern.
Then a proper differential over 18,811 exactly-paired records: no Saturn field is a function of any arcade field -- w0, w1, the spritelist pointer, or the 8-byte tail. Best consistency 71%, which for a test where most keys appear once is noise. And the three "fields" share a value profile (9007, 8002, 8209 all appear at +0, +2 and +4), which is what you see when the framing is wrong, not when you have three fields. Those values also disassemble as plausible SH-2.
So the 6-byte framing may itself be an artifact of the size law rather than a real record layout.
Which does invert the conclusion, in the end. The flat claim -- the Saturn re-authored all its animation sequencing -- is false. The tree crossed over, it is indexed the same way, and the engine walks it.
Unchanged either way: hitboxes and palettes are genuinely re-authored by the Saturn, both 0% carried. (Whether they must be authored for an import is a separate question, and the answer changed later — the Dreamcast MvC build carries the arcade hitbox tables at 98–99% and box-shape at 100%, proving they transfer verbatim into an SH-family recompile. See the Dreamcast analysis.) And box-shape joins them — I had it recorded at 15.3% carried, but the run-matcher accepts any byte-identical run of 64+ bytes and that table is 56% zero bytes. Zeros match zeros in any file, so the test could not fail. Classifying each matched run by whether it carries information (8+ distinct bytes, under half zeros): box-shape drops from 31% matched to 0%, its "matches" being runs of 98–100% zeros, while the same test on move-property returns 35% genuine carriage. The control is what makes that conclusive rather than merely suspicious — the method can tell the difference, and box-shape has nothing. Three structures are re-authored, not two.
I'd also spent a while characterising a 1,816-byte per-character region as the Saturn's replacement sequencer. It isn't that either. Arcade tree sizes vary from 13,270 to 31,924 bytes across characters while that region is fixed at 1,801-1,833; the correlation is r = -0.040. Its own structure is real and still unexplained -- the per-character blocks partition 0x00-0xD5 exactly, 214 entries, aliases sharing their donor's slice -- but it is some other fixed-size table.
Twice in two days, a confident negative turned out to be a property of my search rather than of the data. The debug flag "wasn't referenced" because I only searched absolute addressing modes; the animation tree "wasn't carried" because my minimum run length was 64 and the array is 58.
A negative result is only ever as strong as the search that produced it. Both were reported as findings before I'd asked what the method could not see.
What the first pass thought the replacement was
Before the correction, this region looked like the Saturn's own sequencer, sitting in a precisely bounded gap between two carried tables:
MC05+0x8AA0 .. 0x90E4 carried spritelists (arcade 0x1701A0, 1,604 B)
MC05+0x90E4 .. 0x97FC SATURN-NATIVE 1,816 B <-- the replacement
MC05+0x97FC .. 0xA255 carried offset table (arcade 0x1C3CE8, 2,157 B)One byte per entry, 248 runs, and the alphabet is a contiguous per-character symbol block: Shuma uses 0x2B..0x33, the next character sampled uses 0x6A..0x74, with bases that look sequentially allocated across the roster. So the bytes index a global pool in which each character owns nine to eleven entries. Tempting to call the two characters' streams "the same shape" -- they aren't. After subtracting each character's base they agree on only 51.8% of bytes, and that is mostly just the dominant base value. It's per-character data, and the next probe is concrete: find the ~220-250-entry pool and the SH-2 that adds the per-character base.
Two smaller corrections fell out of the same pass. Hitboxes are re-authored too -- the original audit never tested that table, and the import plan hadn't budgeted for it. And the missing palette is now a re-verified negative rather than a possible artifact: CPS-2 encrypts opcode fetches only, so data tables are plaintext in the raw ROM and corrupted in an opcode-decrypted image. Re-running against the correct view still finds no palette. It really isn't there.
Getting Runtime Visibility
Every wrong answer above came from asking a static question. So the next investment was in being able to ask runtime ones — which on a console with no debugger means the emulator's save states, treated as a memory-dump format.
Mednafen's container is simpler than it looks once you stop searching it and start parsing it:
[u8 name_len][name ASCII][u32 size — LITTLE-endian][payload] repeatedThe size field being little-endian on a big-endian machine is the kind of detail that costs an hour. WorkRAML is 1 MB at state offset 0x06B4478, and it maps to Saturn 0x00200000 — which independently confirms the module base found earlier by content-searching for MC05's bytes. Parsing beats searching: it gives exact section bounds, and it exposes the SH-2 register file that a content search can never find.
With that, the memory layout resolved:
That last row is the one I'd previously misread. Zero relocations in the tree looks like "the tree is dead weight." It isn't — it means the tree is relative, which is exactly what you'd expect of an index the converter compacted from 16-byte to 6-byte records. The absence of relocations was evidence about the encoding, and I read it as evidence about the usage.
The disassembler had to be rebuilt
The first SH-2 disassembler decoded every 16-bit word independently. That is fine for a hexdump and actively misleading for RE, because SH-2 compilers put literal pools inline, between functions. Constants disassemble as plausible instructions. Two "findings" in this project were literal-pool words read as code, including a set of "cursor advance sites" that were just 16-bit numbers.
The replacement follows control flow instead — trace from entry points, model delay slots, stop at rts/jmp, queue branch targets, and mark every PC-relative load's target as data. That last rule is what actually identifies the pools. It also has to track register constants, because this engine dispatches through pointer tables constantly: MM.BIN's own entry point yields 16 bytes of code before jumping indirectly.
Even so, coverage is 8.9% of MM.BIN's 850KB after seeding all 157 pointer tables. That is an honest number, not a good one, and it is why the payload consumer is still open — a targeted opcode search only searches the 9% I can prove is code.
Does the arcade struct map transfer?
The native-port work has a detailed map of the CPS-2 player struct. If it carried over, every field would come free. I tested the arcade offsets against four objects across eight to ten save states:
| CPS-2 offset | arcade meaning | transfers to Saturn? |
|---|---|---|
+$34 | animation pointer | yes — it is the anim cursor |
+$52 | character id, word | yes — 12/12 valid ids |
+$7C | HP | no — flips between 0x80/0x82/0xF0/0x00 |
+$80 | attack strength | no — values up to 0x5922 |
+$B0 | action id | no — constant 0x060F, the high half of an HWRAM pointer |
+$274 | super stock | no — always zero |
Two of six. And the two that transfer are precisely the two the converter had to preserve — the anim cursor and the id that indexes the carried tables. Everything the hand-written SH-2 owns was relaid out.
I had earlier described the arcade struct map as "a candidate crib" for the Saturn. It isn't one. Use $34 and $52; derive the rest, because porting the arcade offsets wholesale would produce four fields of confident nonsense.
A test that could not fail
I had the 6-byte animation record written up as decoded. Field +2 was "bit 15 a flag, bits 0-14 a module offset," and the evidence was a good-looking number: masking bit 15 puts the target inside the module for 1,134 of 1,134 records, versus 58% unmasked.
0x7FFF is 32,767. Every MC module is 48-60 KB. A masked value is inside the module by arithmetic, for any input whatsoever — random noise scores 100.0% on the identical test. The number was a property of the mask, not of the data.
And the honest version of the test doesn't merely fail to support the hypothesis, it contradicts it. Unmasked, a real module offset should land in range nearly always; chance alone gives 74-92% depending on module size:
| module | records | observed in-range | chance baseline |
|---|---|---|---|
| MC05 | 359 | 36.5% | 87.1% |
| MC03 | 225 | 15.6% | 74.4% |
| MC0C | 158 | 96.8% | 76.4% |
| MC10 | 30 | 0.0% | 92.1% |
Three of four do worse than random. The values cluster high instead — MC10's entire range is 0xFFE8-0xFFFA, which read as signed is −24 to −6. Nothing else I tried holds either: 37-44% of the values are odd, so it cannot be a code pointer; a word-offset ×2 reading leaves the module; and treating it as signed self-relative is invariant along a chain only about a third of the time.
Field +4 survives, and the contrast is the whole point: its end-of-chain claim rests on bit 15 being set on the last record of 156 of 156 chains and on no other record. That test had somewhere to fail and didn't. The +2 test had nowhere to fail and didn't.
The refutation was wrong too — the instrument had never been controlled
I wrote +2 off as unknown. That was also too strong, and finding out why produced the actual answer.
Every arcade↔Saturn comparison in this project runs through one pairing: a chain satisfying 16K+4 ↔ 6K+2 has arcade record i paired with Saturn record i. That pairing had never been checked. It has two things it must satisfy, both established independently — every Saturn record should carry the 0x8? character tag, and +4 bit 15 should mark the last record and no other:
| pairing | chains | records | carry the tag | end-flag correct |
|---|---|---|---|---|
| raw size-law | 2,653 | 18,811 | 26.6% | 7.5% |
| validated | 139 | 908 | 100% | 100% |
About three quarters of those 18,811 pairs are not records. Which means the result I'd been treating as settled — "no Saturn field is a function of any arcade field" — was measured on mostly noise. It was never a finding.
Rerun on the 908 pairs that pass the control, scoring only keys that repeat (a key appearing once cannot conflict, which is exactly what made the old numbers look like noise), against a null that shuffles the Saturn values within each character:
So +2 is the Saturn's re-encoded spritelist reference — the analogue of the arcade record's pointer, squeezed into 15 bits. And the step sizes give up a law:
arcade_span = 2 × saturn_span − 2209 of 593 consecutive frame pairs (35.2%) against a 3.8% null — z = +42.6.
I got that law wrong once before writing it down properly, in a way worth showing. My first reading was "arcade 6+4P, Saturn 2+2P, same part count P" — a 6-byte header plus 4 bytes per part against a 2-byte header plus 2 bytes per part. It fitted the data. It was also two unknowns fitted to two delta values, which is not a fit at all.
Grid-searching every (header, part-size) combination returns eight parameterisations tied at exactly the same hit count. They aren't eight competing answers — they're one statement wearing eight costumes: every tie satisfies hA = 2·hS − 2 and pA = 2·pS, and that collapses to the parameter-free relation above. My published 6/2 pair was outside that class (6 ≠ 2·2−2) and scored 17.1%, understating the real effect by half.
The honest content is narrower than "I decoded the header": only the 2:1 size ratio with a 2-byte framing offset is established. Header and part sizes remain unknown.
And the obvious follow-up died. If the Saturn sometimes drops sprite parts, the residual should be dArc − 2·dSat + 2 = 4k for integer k. That conforms 82.4% of the time — against an 82.0% null. z = +0.6. The divisibility is pure marginal distribution, and without running the null it would have been another confident headline.
It isn't a partial law, it's three families
Writing the relation as dSat = dArc/2 + c and simply histogramming c reframes the whole thing. The 35.2% was never "a law that holds a third of the time":
c | share of frame pairs |
|---|---|
| +1 | 35.2% — this is the law above |
| −1 | 18.4% |
| +3 | 17.0% |
| everything else | 29.4%, nothing above 3.4% |
Three buckets cover 70.6%, with a top bucket of 209 against a null max-bucket of 28.9 ± 3.2. And c turns out to be largely a per-character constant — ten characters sit at +1, three at −1, two at +3, each 43–86% dominant.
Two things follow. The arcade sprite part is exactly twice the size of the Saturn part — that's what makes dArc/2 an integer relation at all. And since c is the difference between the two headers, c varying by character means the headers aren't in one fixed relation across the roster. For a mechanical converter that's odd, and it's the next thing to explain.
I'd been reading a mixture as a partial law, which is a specific way to be wrong: a rule that fires 35% of the time invites you to hunt for noise in the other 65%, when the real structure was three clean rules stacked on top of each other. Plotting the residual instead of scoring the hypothesis is what showed it.
The base was never missing
I spent a while hunting for the base that offset was measured from, because module + (+2 & 0x7FFF) lands in SH-2 code rather than sprite data. There is no base. Bit 15 is part of the offset, not a flag — and masking it is what broke everything.
The tell was sitting in my own notes. MC05's carried spritelist region is at MC05+0x8AA0 = 35,488. A 15-bit offset tops out at 32,767. The mask made it arithmetically impossible for the target to be the spritelist region — the same mask that had earlier manufactured the vacuous "100% in range."
Fit the unmasked 16-bit value against the arcade pointer, per character:
| character | pairs | slope | max residual |
|---|---|---|---|
| MC00 | 144 | 0.459 | 0.1 |
| MC0D | 60 | 0.521 | 11.0 |
| MC03 | 28 | 0.532 | 22.9 |
| 13 others | — | ~0 | 300–34,000 |
Slope ≈ 0.5 — exactly what arcade = 2 × saturn − 2 demands.
And the bytes settle it. A spritelist is a list of signed sprite placement offsets; code isn't. Scoring the fraction of little-endian int16 in [-512, 512] at each target:
| unmasked | masked | |
|---|---|---|
| the three affine characters | 69% | 11% |
| the other thirteen | 17% | 24% |
MC00 alone is 82% against 11%. Read it directly and it isn't subtle — module+0x9467:
fc 00 78 ff d8 00 70 ff b0 00 74 00 22 00 4b 00 22 00
252 -136 216 -144 176 116 34 75 34Little-endian signed coordinates, running contiguously until code resumes at +0x94b0. The masked target for that same record, +0x1467, disassembles as SH-2 — c3 3e is trapa #62. That phantom is what an earlier pass in this project chased as an "executable payload."
So the animation record's +2 points straight at the Saturn spritelist, as an ordinary 16-bit module offset — for some records. That qualifier is the next section, and it's the one that finally explains why this field resisted five readings.
The field never had one meaning
Everything above leans on the arcade↔Saturn pairing, and that pairing is the weakest thing in this whole investigation — 139 chains survive its control, and only 3 of 16 characters fit. So I dropped the arcade side entirely.
The Saturn half can be tested alone: take all 4,972 engine-validated records, look at what sits at +2, and compare against a random-offset null in the same module. No pairing, seven times the sample.
The result deflates my own claim. Pooled, +2 targets are 20% coordinate-like against a 19% random baseline — nothing. Only 4 of 16 characters clear z > 3. MC00 drops from 82% to 36%. "+2 is a spritelist offset" is simply not true of records in general; the arcade-paired subset was enriched in whatever is true.
Splitting by the record's own opcode — +0's low byte — resolves it completely. Baseline 18.7%:
| opcode | records | coordinate-like at +2 | z | |
|---|---|---|---|---|
0x02 | 498 | 35.7% | +15.3 | spritelist offset |
0x05 | 556 | 32.4% | +13.1 | spritelist offset |
0x08 | 584 | 24.4% | +5.5 | offset, weaker |
0x07 | 153 | 20.5% | +0.9 | not an offset |
0x09 | 1195 | 17.7% | −1.4 | not an offset — and the most common |
0x0a | 231 | 12.8% | −3.6 | not an offset |
+0 is a discriminant and +2 is a union member. The record is a tagged union, and I had been asking "what does +2 mean?" as though that question had one answer. Every partial result in this post — mine and the earlier passes — was an average over record types that don't share a field layout.
And "opcode" is still the wrong word for it. Opcodes essentially never mix inside a chain — 0% co-occurrence for types 02, 04, 05, 07; 4–5% for 08 and 09. This isn't an instruction stream. It's a type tag on the entire chain.
That could be an artifact — a "chain" that's really just a run of repeated bytes would trivially show one constant tag. It isn't: of 705 chains with two or more records, 704 (99.9%) have records that vary, every type 99–100%. The tag holds still while the payload moves, which is the signature of a typed array rather than a repeated run.
So the model is +0 = (0x80 | char_index) << 8 | CHAIN_TYPE, with the type fixed for a whole chain and selecting the record format. Different animation chains are stored in different formats — types 02/05/08 carry spritelist offsets, 04/07/09/0A carry something else.
One tempting explanation for the odd ones is already dead. Type 0x04 shows up in only three characters, and 96% of its values land past those modules' decompressed size but inside the 64KB load slot — which looks exactly like a decompressor stopping early. Checked against a live save state: MC08 and MC0D have entirely zero tails between image end and slot end (1,164 and 6,136 bytes, not one non-zero byte), so those images are complete. Whatever type 04 is pointing at, it isn't data my extractor dropped.
The supporting profile agrees. Opcode 0x04 (771 records) holds a value below its module size only 4% of the time, so it is certainly not a pointer. Opcode 0x0a is the only one with small values — 38% below 4096, 3% below 256 — the shape of a count or an index. And the opcode set itself was wrong in my notes: it's {02, 04, 05, 07, 08, 09, 0A}, seven arms, not the three I had recorded.
Honest status: +2 is a spritelist offset for 02/05/08 — about 1,600 records, z up to +15 — and something else for 04/07/09/0A, about 2,300 records. Four of seven arms are still unidentified.
Decoded → unknown → identified but unlocated → a plain module offset → a union member.
The first four all presumed the field had a single meaning. Under that premise every honest measurement had to come back partial, and each partial result invited a new theory about the missing part — which is how one 16-bit field generated four wrong answers and a lot of confident prose.
What broke it was giving up the crutch. The arcade pairing was doing the work in every test, and it fails its own control; the moment I tested the Saturn side alone, with seven times the data and no pairing, the flat claim collapsed and the real structure was one GROUP BY away.
Decoded → unknown → identified but unlocated → a plain module offset. All in a day.
Underneath all four is a single unexamined assumption: I masked bit 15 at the very start because a high bit in a 16-bit field looks like a flag, and then never tested the unmasked reading. That mask went on to generate the vacuous "100% in range" that made version one look proven, hide the spritelist region behind an arithmetic impossibility, and point version three at SH-2 code.
The lesson isn't about nulls this time. It's that the assumption I never wrote down as an assumption was the one that cost the most — it was load-bearing in four consecutive analyses and never appeared in any of them as a claim to check.
Three revisions of one 16-bit field in a day: decoded, unknown, identified but unlocated. Only the third had explicit nulls and a positive control.
The first lesson was to null the hypothesis. The one that actually cost the most here is bigger: null the instrument too. I tested many hypotheses through that pairing and never once asked whether the pairing itself produced records. It didn't, three quarters of the time — so every negative it ever returned was uninformative, including several I'd written up as findings.
Before reporting a positive, compute what random input scores on the identical test. If the answer is "about the same," the test measured nothing.
This is the fourth instance of one family in this project: a 64-byte minimum run length against a 58-byte array; a floor-division pairing that admitted mismatched records; a "decrements by 6" predicate that a bit-15 flip made unpassable; and now a mask smaller than the container it was masking into. Two were false negatives and two were false positives, which is the tell that the problem was never optimism — it was not asking what the method could see.
The Codec, Verified at Scale
The first pass proved the sprite codec on one record: 30 tiles, 0 of 7,680 pixels wrong. With the disc re-imaged I wrote the inverse -- an encoder -- and ran both directions over every sprite archive on the disc.
That last row used to be the one that cost money. A deliberately dumb encoder is fine for proving a pipeline, but at 5x Capcom's density it blows the slot budget the moment you inject anything larger than a test record — so it was the last real risk in the Phase 0-2 band.
It's now closed. An optimising rewrite gets to 1.04x Capcom — 1,368 of 1,368 records re-encode and decode back exactly across all 19 sprite packs, every pack landing between 1.03x and 1.05x. That's 4.81x smaller than the naive version and within 4% of what Capcom shipped.
The naive encoder spent a flat 14 bits per pixel and used none of the five cheaper things the format offers: a mask bit of 0 repeats a vertical pixel pair for free; c1 = 0 repeats a row for 2 bits; c1 can update only the nibble that actually changed; and both nibbles have spatial predictors costing 2 bits where a literal costs 6. Plus three mask modes that store 4, 4 or 2 bytes per tile instead of 8. The rewrite works by simulating the decoder while emitting, so every predictor is tested against the canvas state the decoder will really have.
One bug from that is worth repeating, because its signature is so clean. The first attempt produced correct sizes and zero correct decodes. The cause is the codec's nastiest detail: a raw byte does not flush the pair buffer. The decoder's bit reader holds one byte and hands out four 2-bit pairs from it, while raw byte reads take the next byte from the same stream — so pairs keep filling a byte already behind the write head. I flushed before each raw write, which is the intuitive thing and is wrong. Right size with wrong output is a useful tell: the decisions were sound and only the serialisation was broken.
And the MVS-LZ side is done too, at 1.99x against Capcom's 1.95x over 51/51 disc streams. Two rules of that format bite anyone reimplementing it: a match may overlap itself, since the decoder copies byte-by-byte, so disp=1, len=18 is a legal 18-byte run fill; and the 4KB window is zero-filled, so a match may legally reach back past position 0.
The Conversion Inventory
Enough is now measured to say, per asset, exactly what porting one MvC character to this engine would involve — and which rows are the problem.
| asset | transform | status |
|---|---|---|
| sprite pixels | planar → chunky, +1 pen map, cut into W×H records, encode | mechanical, proven both directions |
| record directory | 6-byte entries {tile_slot, flag, offset} | mechanical |
| spritelists | byte copy | mechanical — schema matches |
| frame-index / offset tables | byte copy | mechanical — schema matches |
| move-property table | byte copy, stride $20 | mechanical — carried at 99.9% |
| sound map | byte copy, re-point ids at the new ADPCM bank | mechanical — same 0x4C0 stride |
| palette | ? | blocked — 0% carried, absent from every disc file |
| hitboxes | re-author | blocked — 0% carried |
| box-shape | re-author | blocked — 0% carried; a recorded 15.3% turned out to be zero-padding |
| animation tree | L1 array carried as-is; 6-byte record chains under it | structurally mechanical — engine proven to walk them; field semantics open |
| character logic | hand-written SH-2 | not mechanical |
| audio | QSound capture → 4-bit ADPCM | variant undetermined |
The top half is a script. The bottom half is a project.
And one number keeps the top half honest. MC05 is 57,076 bytes decompressed, of which 13,169 — 23.1% — is carried arcade data. The other 77% is hand-written SH-2, per character. "Character data tables are byte-identical" is true and it is the smaller share of what a character actually is on this machine. Transplanting the tables gets you a correct-looking thing that cannot move.
So the split is cleaner than the original phase plan implied:
- Phases 0-2 need no further reverse-engineering — and as of now, no further tooling either. All three missing write-direction tools are built and verified to byte-identity against the disc: an MVS-LZ compressor (51/51 disc streams round-trip; 1.99x compression against Capcom's own 1.95x), a PAC repacker (79/79 packs rebuild byte-identical), and an ISO sector patcher (EDC and Reed-Solomon ECC P/Q reproduced on 361/361 real sectors; MM.BIN and PL05.PAC rebuilt 415/415 and 371/371 sectors exactly). The emulator harness with the 4MB cart already runs.
- Phase 3 lost its binding constraint. It used to hang on the animation format being unknown and re-authored. It is neither. What's left is the SH-2 authoring cost, the cart budget, and two structures that genuinely are re-authored — hitboxes and palettes. Expensive, but tractable, and none of it binary.
Going the Other Direction: MvC on a Saturn?
Once you know the asset pipeline is deterministic and the data tables are arcade-identical, the tempting question writes itself: could MvC1 content -- a character, eventually the game -- go to the Saturn port's engine? I costed it properly rather than vibing it; the full plan is the MVC_TO_SATURN_PLAN gist. It's a phased plan with honest effort ranges, and the ranges are wide because they should be.
- Phase 0 — tooling: extractors and repackers for the disc formats20hours, low estimate
DONE — all three tools built, each verified to byte-identity
- Phase 1 — swap a single asset inside Saturn MSHvSF10hours, low estimate
DONE — verified through a real disc image, not yet booted
- Phase 2 — one MvC sprite rendering on real hardware30hours, low estimate
30–60 hours. Everything to here is a demo.
- Phase 3 — character logic, hand-authored SH-2 against a partial engine150hours, low estimate
150–400 hours
- Phase 4 — the whole roster and effects600hours, low estimate
600–1,500 hours or more
The verdict I stand behind: Phases 0-2 are demo-feasible and low-risk -- the pen-map and codec are proven reversible, so building the toolchain, swapping an asset, and getting one MvC sprite rendering in Saturn MSHvSF is a bounded project with a great screenshot at the end. Phases 3-4 are research projects, and I label them that way deliberately. Three walls stand between a rendered sprite and a playable character:
The animation stream format is a different structure, not a missing decode.Retired. This wall was the load-bearing one and it is gone: the arcade tree is carried, indexed the same way, and walked by the engine at runtime. What replaces it as a risk is narrower — the 6-byte record's field semantics, and the 93% of groups whose sub-array counts don't correspond.- Character logic means hand-authored SH-2. The recompile is Capcom's source through Capcom's compiler; we don't have the source. New character behavior means writing it, per character, in SH-2 assembly against a partially-understood engine.
- The 4MB cart is saturated. Five slots of 668,448 bytes, already streaming-tight for the shipped roster. MvC's bigger cast and effects don't fit the existing memory choreography without redesigning it.
Phase 3 also had a gate I could retire cheaply: do mvsc's tables even use MSHvSF's schemas? If not, "import" was the wrong word and the cost multiplies. They do — and that has since been measured rather than assumed. Both games index per-character tables through arrays of longword pointers, tiled back-to-back one row per character (mshvsf 26 rows at stride $68, mvsc 23 at $5C). All five table types line up: box-shape is exactly 2,048 bytes in both, palette exactly 192 in both, and for move-property the row layout matches field-for-field — measuring each byte position's occupancy across 1,990 mshvsf rows and 1,475 mvsc rows gives r = +0.991 against a shuffled null of −0.005 ± 0.178, with the always-zero positions agreeing 8 of 9. Move-property, box-shape and palette are byte-copy transplants. Both games index a per-character directory of animation trees by char_id * 2 (MSHvSF at $13775A, MvC at $0E676C), both use the same two-level tree into 16-byte records with a spritelist pointer at +4, and both store sound maps as 608 entries on a 0x4C0 stride. And since the Saturn carries the tree after all, that match transfers directly: the same two-level structure, run through the same 16-to-6-byte compaction, is the conversion path for MvC animation.
So the recommendation in the plan is the one this whole series already lives by: CPS-2-native porting is the right road for gameplay work, and the Saturn is a superb data source and a fun bounded demo target -- not the venue for new characters. Knowing precisely why, with hour ranges attached, is worth more than the fantasy.
Open Items
Stated plainly, because the project rule is no false success claims:
- Phase 1 is now an artifact, not a plan — a modified sprite has been swapped into a real MODE1/2352 disc image and read back out: archive rebuilt with all 2,648 offsets shifted, container repacked, sectors rewritten with regenerated EDC/ECC (371/371 valid), ISO9660 size field updated, then re-extracted and re-decoded byte-exact. What is not proven is that it renders — nothing has been booted, and confirming pixels on screen needs a human to play into a match, since attract mode never loads character modules. Nothing MvC-related runs on a Saturn today; Phase 2 is the same pipeline carrying an MvC sprite instead of a modified MSHvSF one, which is now a content problem rather than an engineering one.
- The animation record is a tagged union, tagged per CHAIN:
+0's type byte is constant for a whole chain and selects what+2means. For opcodes02/05/08(~1,600 records) it is a 16-bit module offset to the Saturn spritelist, z up to +15. For04/07/09/0A(~2,300 records) it is something else — four of seven arms unidentified, including09, the most common. Five readings of this field before the union structure showed up; see below. - The 1,816-byte region is not the animation stream and never was. That identification is withdrawn (see the correlation above); its own purpose remains unexplained.
- 93% of sub-array counts don't correspond between arcade and Saturn. The L1 index matches exactly; one level down, mostly it doesn't, and I have no model for why.
- Palettes must be authored — and that is now a characterised negative rather than a failed search. Every earlier pass looked for raw arcade bytes, but the disc ships its own CPS-2→Saturn colour LUT, so a converted palette could never have matched. Re-run across all 23 characters' palettes in every form the converter is known to use — raw, LUT-converted at all 16 brightness levels, and rotated to match the
(p+1)&15pen map, both endiannesses — over all 414 disc files and all 23 decompressed modules: zero hits, with a positive control. The two transforms this port demonstrably uses are both ruled out, so there is no arcade-derived shortcut left to guess at. - Hitboxes are re-authored, and that is newly-discovered unbudgeted work for any character import.
- Audio has zero byte-identity with the arcade -- the Saturn's 4-bit ADPCM is a separate encoding, not a witness for the QSound data.
Everything above nearly didn't survive. The original audit ran out of a temporary session directory, which was later reaped -- the disc image, every extracted file, every derived JSON, gone; only the writeup and five tools remained. The re-imaged disc now lives in a persistent project path, and the extractor refuses to default anywhere else. If a result matters, the artifact that produced it has to live somewhere you'd back up.
Postscript: what about the Dreamcast version?
A reasonable follow-up, and one I was asked directly: MvC1 got a near-arcade-perfect Dreamcast port, so wouldn't starting from that be easier than starting from an encrypted CPS-2 ROM?
Measured from the GD-ROM: no, by a wide margin. A Dreamcast character averages 11.41 MB (range 9.80–13.09) — 1.45× the Saturn's entire memory including the 4MB cart, and 17× the art slot a character actually gets. It is already compressed (zlib -9 gains only 1.13x, at 7.01 bits/byte entropy), so none of that is slack. The Dreamcast executable alone is 2.35× the Saturn's main RAM.
And it is the wrong direction regardless of size: both builds descend from the same arcade original, so going via the Dreamcast means undoing a conversion that moved the data away from the Saturn's format, in order to arrive back at something arcade-shaped and then apply the arcade→Saturn pipeline anyway. The full workings are in One Dreamcast Character Does Not Fit In An Entire Saturn.
The byte-identical frame tables here are the same tables the native Dark Sakura / Shuma-Gorath ports locate the hard way inside an encrypted CPS-2 ROM -- the Saturn disc is the plaintext cross-check. And the stuck-problem that sent me here in the first place is the NYC two-tier stage port, whose load hang has since been fixed -- the remaining wall there is art, not code.