This is the method post for MvC1's character select screen: how you locate an entity system in a compiled binary with no symbols, what the raw scan actually returned, and which of its answers turned out to be wrong.
The findings themselves are documented in depth elsewhere and are summarized rather than repeated here. The three secret table formats, the hardcoded position checks and the interrupt hook that eventually bypassed them are in Reverse-Engineering MvC1's Hidden-Character System; the ID 0x2E evidence and the decompiled load functions are in Binary Forensics: Recovering an Unreleased Character. Read this one for the technique and the scan output.
Goal: Map MvC1's character select entity system -- cursor navigation, character ID assignment, secret character table formats -- entirely from the compiled binary.
Constraints: No source code or debug symbols. Hardware-encrypted program code (mitigated via Phoenix Edition). Multiple data formats for the same conceptual operation.
Approach: MAME debugger memory watches and breakpoints during character select. Ghidra static analysis for cross-references. Byte-pair pattern matching for base-to-secret character mappings across the whole program ROM.
Result: Character data table at $065CCA (32 bytes/entry). Player structure mapped: char ID at +$53, data ptr at +$54, health at +$60. Unlock flags at $FF803C-$FF804C. 3 distinct secret table types located. 18 grid positions mapped. Hardcoded position checks found for positions 1, 6, 11, 12, 13 only -- position 5 missing, which is the blocker.
Proof / Validation: ULTRA SETTINGS screen verifies NORMAL IDS: 16, SECRET IDS: 05, palettes OK, all 4 secret load functions present. Cursor position variables confirmed via MAME memory watch at $FF2000/$FF2002.
Artifacts: A structured character-ID map covering both the normal and secret rosters, plus two machine-readable C headers generated from it -- one for the memory map (table bases, player-struct offsets, unlock-flag block), one for the character-ID enum -- so build tools and prose read the same numbers.
The table forensics and memory maps below stand as documented. The plan for the $2E slot has since moved twice. First, the Armored Spider-Man / Iron Spider line was retired (it was an addition attempt -- no stock character was ever removed) and the slot pivoted to a Dark Sakura port from MSHvSF (char-id $2A in that game), shipped July 11 on a donor-chassis architecture that force-remapped $2E to real Ryu ($12) at state-machine dispatch. Then the donor chassis itself was superseded: as of late July, both secret slots run as native ports -- Dark Sakura ($2E) and Shuma-Gorath ($30), their real MSHvSF code blocks relocated into the ROM, fighting on their own animation trees with no Ryu remap, with a cumulative chain of live-gated fixes shipped since and their names on both the select screen and the lifebar. The entity-system knowledge mapped here is exactly what both builds lean on. Full write-ups: the donor chassis (history) and the native port (current).
When you only have a compiled binary, how do you understand its UI system? You do not start by reading code. You start by finding data that has a shape you can predict -- and in a fighting game with palette-swapped secret characters, there is an obvious one: somewhere in the ROM, a base character ID must sit next to its secret ID.
The Scan: Look for the Pair, Not the Code
Every shipped secret is a variant of a known character, and both IDs are known. So the search is a two-byte needle: 16 2A for Chun-Li to Shadow Lady, 1E 2C for Morrigan to Lilith, and so on. Scanning mvc.05a for all five pairs returned this:
| Secret Character | Base → Secret | ROM Location | Occurrences |
|---|---|---|---|
| Shadow Lady | 0x16 → 0x2A | 0x014D6E | 1 |
| Lilith | 0x1E → 0x2C | 0x04A3F2 | 1 |
| Orange Hulk | 0x06 → 0x26 | 0x024D3A | 1 |
| Gold War Machine | 0x02 → 0x28 | 0x03EDD4 | 2 |
| Red Venom | 0x0C → 0x24 | 0x07249E | 1 |
| Roll | Special | N/A | Unlocked by code, but a unique character, not a swap |
Five hits, five wildly separated addresses, in three different ROM sections. That spread is the finding. A single unified secret-character table would have put them within a few hundred bytes of each other.
Three Formats, Not One
The bytes around each hit have three different shapes: a structured 16-byte record at 0x014D6E (Shadow Lady, the only one of its kind), and two different 2-byte-pair list formats -- one in the animation/graphics region (0x04A3F2 Lilith, 0x03EDD4 Gold War Machine), one in the palette/graphics region and a late ROM section (0x024D3A Orange Hulk, 0x07249E Red Venom).
| Secret | ROM Location | Table Type | Section |
|---|---|---|---|
| Shadow Lady | 0x014D6E | Type A -- structured, 16 bytes | Character select |
| Lilith | 0x04A3F2 | Type B -- 2-byte pairs | Animation/graphics |
| Gold War Machine | 0x03EDD4 | Type B -- 2-byte pairs | Animation/graphics |
| Orange Hulk | 0x024D3A | Type C -- 2-byte pairs | Palette/graphics |
| Red Venom | 0x07249E | Type C -- 2-byte pairs | Late section |
The field-by-field breakdown of the Type A record, the hex dumps for all three, and what each format implies for adding a sixth secret are in the hidden-box post.
Hypothesis: Why Only One Type A Entry?
Only Shadow Lady has a Type A record, and this never got a confirmed answer. The candidates: Shadow Lady was added last and got the newest format; the other secrets predate it and use a legacy path; or -- most likely, given the section spread -- each secret was simply integrated with whatever subsystem the developer working that week already had open. The operational consequence was the same either way, and it was expensive: a new secret probably needs entries in all three tables, not one.
What the Method Yielded
Memory watches during character select, plus decompilation of what reads those addresses, produced the runtime map the rest of the project runs on:
$065CCA Character data table (32 bytes per entry)
$FF3000/$FF3100 P1 / P2 player structure
+$53 byte Character ID ($FF3053 / $FF3153)
+$54 long Character data pointer
+$60 word Health
+$10 word Player state +$20/+$24 X/Y position
$FF803C-$FF804C Unlock flags (base $FF8000): Red Venom, Orange Hulk,
Gold War Machine, Shadow Lady, Lilith at +$3C/$40/$44/$48/$4C
$FF2000/$FF2002 P1 / P2 cursor grid positionThe four secret load functions those flags gate ($022B2E, $05D044, $07EF90, $0A26C8) are decompiled and compared in the binary-forensics post.
- 1earlier fields$000000–$000053 · 83 BPosition, velocity, timers and the rest of the per-frame state.
- 2+$53 character id$000053–$000054 · 1 BOne byte. This is the field that decides which character the engine believes it is animating, hit-testing and scoring.
- 3+$54 data pointer$000054–$000058 · 4 BPoints into the character data table at $065CCA, 32 bytes per entry.
- 4more state$000058–$000060 · 8 B
- 5+$60 health$000060–$000062 · 2 B
- 6remaining fields$000062–$000070 · 14 B
One detail from this map is worth carrying: cursor grid indices are not the on-screen reading order. Wolverine is position 5, Morrigan 1, Hulk 6, Venom 11, War Machine 12, Chun-Li 13, Spider-Man 15. The full index map is in the hidden-box post, and it is what makes the next finding legible.
The Blocker the Method Found
The assembly that decides whether to show a secret box does not consult a table of eligible positions. It compares against a hardcoded list: positions 1, 6, 11, 12, 13 -- exactly the five base characters with a shipped secret. Position 5, Wolverine, is not in it.
That is why data-only patching fails. You can add a correct entry in every table format and the box still never appears, because the code never reaches the table for a position it was not compiled to check. Confirming that experimentally -- add the rows, observe nothing, then find out why -- was the most useful negative result in this phase of the project. The patch that finally worked went around the check entirely, via a VBlank interrupt hook, and is documented here.
Two Corrections This Post Earned
False positives: IDs 0x30 and 0x32
During the scan, IDs 0x30 and 0x32 looked promising with 80+ ROM references each. They are the 68000 MOVE.W #imm,D0 instruction, which encodes as 0x303C and pattern-matches an 0x30 search. Their animation marker count is zero. The lesson generalizes past this ROM: a byte-frequency hit in a code region needs a second, structurally independent signal before it counts as evidence. The full filtering method is in the binary-forensics post.
This section originally read that those IDs were "empty slots available for new characters." Later work on the actual character ports disproved the implication. An ID having no data behind it does not mean the engine has a place for it: every per-character table in the engine has exactly 23 entries, ids 0x00-0x2C, packed with zero slack. 0x2E and 0x30 fall off the end of all of them -- 0x2E only resolves at all by borrowing Spider-Man's 0x0E entry. Capcom left no provisioned slots to fill. Using these IDs means handing the engine values it was never built to hold, and every crash that followed traced back to exactly that.
Encryption is narrower than it looks
Only program code is encrypted in CPS-2 ROMs. Data sections -- character tables, palettes, graphics pointers -- are plaintext. This is why palette patches always worked from day one while code patches needed the decrypted Phoenix Edition (mvscud). It is also a partial answer: the native-port work much later found that the opcode and data views of the same bytes decrypt differently, which broke assumptions built here.
References
- PalMod: shows Lilith/Shadow Lady have separate palette entries
- ROM Analysis: multiple table types found across mvc.05a
- MAME Debugger: instruction tracing and memory watches
- Ghidra: static analysis via GhidraMCP plugin
- Community: secret character codes documented by the MvC1 modding community