This post covers the injection mechanics: the decrypt-patch-re-encrypt loop, the input-sequence state machine written as 68000 assembly, CPS-2 palette engineering, and the design decision that ended the whole approach.
It does not re-document the system being injected into. The three secret table formats, the hardcoded position checks, the 13-state select machine at $01DFA0 and the 116-byte interrupt hook that shipped are in Reverse-Engineering MvC1's Hidden-Character System. The ID 0x2E evidence, the memory map and the palette values are in Binary Forensics: Recovering an Unreleased Character. The six shipped input codes are catalogued in the input-code post.
Goal: Add Armored Spider-Man (character ID 0x2E) to MvC1 through direct ROM patching -- new palette, new input code, new load function -- no source code required.
Constraints: CPS-2 program code is hardware-encrypted (using Phoenix Edition to bypass). Character system spans 5+ subsystems with no unified API. Secret characters use 3 different table formats. Armor implementation varies (flag-based vs animation-based).
Approach: Reverse engineer existing secret character implementations. Copy the simplest pattern (Gold War Machine: 12 bytes). Design an RGB444 palette. Add entries to all 3 table types. Patch position check assembly in expanded ROM space.
Result: ROM expanded to 148MB (file level). All 4 existing secret load functions decompiled. Position check blocker bypassed via Autovector 2 interrupt hook -- 116 bytes of 68000 assembly at $0F1FA0 intercept VBlank to detect Wolverine (ID $08) + Down → write Spider-Man (ID $0E) to $FF3053. Safety: 60-frame char-change window (char select only) + input edge detection. ROM built and verified.
Proof / Validation: Assembled hook verified via Capstone disassembly: CMPI.B #$08, D0 → BTST #2, D1 → MOVE.B #$0E, $FF3053. Original handler at $054A preserved. ULTRA SETTINGS: all four secret load functions and both palettes report OK. Build: 145MB ROM set output.
Artifacts: A unified build pipeline that assembles and injects the VBlank hook into expanded ROM space, the emulator-side prototype of the same logic that validated it before a single ROM byte moved, and the decompiled C for each secret-character load function it patterns itself on.
The binary analysis and injection techniques below stand as documented. The plan for the $2E slot changed twice, and both replacements have now shipped. First, the Armored Spider-Man / Iron Spider line was retired (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 that force-remapped $2E to real Ryu ($12) at state-machine dispatch -- exactly the kind of dispatch-layer injection this post describes. Then the donor 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, hardened by a cumulative chain of live-gated fixes. Her byte-identical animation/frame tables did carry over as predicted; making the ported code run natively is the harder story. Full write-ups: the donor chassis (history) and the native port (current).
Injecting a new runtime behavior into a legacy binary is four separate problems wearing one coat: getting at the code, finding a place to put new code, making the new code reachable, and giving it resources (palettes, tiles, IDs) the original never allocated. This post walks each one for a concrete target -- adding character ID 0x2E to Marvel vs. Capcom 1 by ROM patching alone. No source, no emulator cheats.
Getting at the Code: CPS-2 Encryption
CPS-2 program ROMs are hardware-encrypted; the board (or emulator) decrypts on the fly using a per-game key. You cannot grep an encrypted ROM for an opcode pattern, which rules out the normal first move.
The full round trip, if you are targeting a standard board:
# 1. Extract the ROM set
unzip mvscj.zip -d mvscj_extracted/
# 2. Decrypt the program ROM (cps2crypt ships with MAME tools)
./cps2crypt -d mvscj_extracted/mvscj.03 mvscj.03.dec mvsc.key
# 3. Patch mvscj.03.dec
# 4. Re-encrypt
./cps2crypt -e mvscj.03.dec mvscj.03.mod mvsc.key
# 5. Swap it back in
cp mvscj.03.mod mvscj_extracted/mvscj.03In practice all of this work targeted the Phoenix Edition (mvscud), whose program ROM is already decrypted -- step 2 and step 4 collapse to a file copy. That choice matters later: the assumption that "one ROM has one plaintext" survived this entire phase of the project and was only broken much later, when the native port found that opcode fetches and data reads decrypt differently.
Only program code is encrypted. Palettes, character tables and graphics pointers are plaintext in every build, which is why palette patching worked before any of this was set up.
Finding Somewhere to Put Code
MvC1 uses 1MB of a 4MB program window, and the expansion work had already established free space at $0F1000+. That is where every injected routine in this project lives -- new code in unused address space, reached by redirecting something the game already calls, rather than by growing or moving existing code (which would break the hardcoded jump tables).
The addresses the patcher needs -- the character table at $065CCA (32 bytes/entry), the roster at $AC7A, the stage table at $BB04C, the player structures at $FF3000/$FF3100 with the character ID at +$53, and the full CPS-2 hardware map -- are documented in the binary-forensics post.
Making It Reachable: The Input Sequence Machine
The intended activation path was a directional code, matching the six the game already ships. As a state machine it is a linear chain with a reset edge on every wrong input or timeout: ten states for ten inputs, each advancing only on the expected direction.
Written as an injected routine, it is a buffer comparison guarded by a cursor-position check. Spider-Man's grid position is $0F:
; ============================================
; Armored Spider-Man Selection Code Handler
; Insert into character select input routine
; ============================================
ARMORED_CODE_CHECK:
; Check if cursor is on Spider-Man (position 0x0F)
CMP.B #$0F, D0 ; D0 = current cursor position
BNE .skip_armored ; Not on Spider-Man, skip
; Check input sequence buffer
LEA input_buffer, A0 ; A0 = input history buffer
LEA armored_code, A1 ; A1 = expected sequence
MOVEQ #9, D1 ; 10 inputs to check (0-9)
.check_loop:
MOVE.B (A0)+, D2 ; Get buffered input
CMP.B (A1)+, D2 ; Compare with expected
BNE .skip_armored ; Mismatch, skip
DBRA D1, .check_loop ; Continue checking
; Code matched! Select Armored Spider-Man
MOVE.B #$2E, D0 ; Set character ID to 0x2E
MOVE.B #$01, armored_flag ; Set flag for palette load
BRA .select_character
.skip_armored:
; Continue with normal selection
RTS
; Expected input sequence (Down=2, Left=4, Up=8, Right=6)
armored_code:
DC.B $02, $02 ; Down, Down
DC.B $04, $04 ; Left, Left
DC.B $08, $08 ; Up, Up
DC.B $06, $06 ; Right, Right
DC.B $02, $02 ; Down, DownA longer variant was drafted as well -- Down, Down, Left, Left, Up, Up, Right, Right, Down, Left, Up, Right -- on the theory that a 12-input code is harder to trigger by accident. It was never needed, because this routine never shipped: getting it called is where the approach died.
The Resource Problem: Palettes
A palette swap is the cheapest way to give a new ID its own identity, since it reuses the base character's entire sprite bank. CPS-2 palettes are RGB444 big-endian -- 0x0RGB, two bytes per color, 16 colors, 32 bytes per palette -- not the RGB555 that gets assumed. Spider-Man has four of them in mvc.06a (P button, K button, Web P, Web K).
The silver/gunmetal design, all 16 entries with exact RGB444 values, is tabulated in the binary-forensics post. The one implementation note worth repeating here: write all 16, not just the ones you changed. A palette swap is a whole-palette store, and unwritten slots keep whatever the base character had, which produces a half-recolored sprite that looks like a rendering bug rather than a palette bug.
Why None of This Was the Answer
Every piece above assumes the injected code gets executed. It does not. The routine that decides whether a secret box exists for the cursor's current position compares against a hardcoded list of positions -- 1, 6, 11, 12, 13 -- and Spider-Man (15) and Wolverine (5) are not in it. Adding table entries changes nothing, because the code never reaches the tables for those positions.
That left two options:
- Surgery -- patch the position check inside the 13-state character select machine at
$01DFA0, which is coupled to three table formats, cursor navigation and rendering state - Bypass -- run new code every frame from outside the state machine entirely
The project took option 2: hooking the CPS-2's Autovector 2 (VBlank) interrupt with 116 bytes at $0F1FA0, which reads the character ID at $FF3053 each frame, detects Wolverine plus a newly-pressed Down, writes Spider-Man's ID, and tail-jumps to the original handler at $054A. It carries a 60-frame safety window so it can only fire on the character select screen, and input edge detection so a held direction fires once. It was prototyped as a MAME Lua script before a single byte was assembled.
The assembly, the safety mechanism, the Lua prototype and the build pipeline are documented in full in the hidden-box post.
- 1stock program$000000–$01DFA0 · 119.9 KB
- 213-state character-select machine$01DFA0–$020000 · 8.1 KBOption 1 was to patch the position check in here. It is coupled to three table formats, cursor navigation and rendering state — so it was not touched.
- 3stock program$020000–$0F1FA0 · 839.9 KB
- 4the injected hook$0F1FA0–$0F2014 · 116 B116 bytes on the Autovector 2 (VBlank) interrupt. It reads the character id at $FF3053 every frame, detects Wolverine plus a newly-pressed Down, writes Spider-Man's id, and tail-jumps to the original handler at $054A.
- 5stock program$0F2014–$100000 · 56.0 KB
- Aoriginal VBlank handler$00054A · marker
The Generalizable Part
The insight worth carrying out of this post is not the hook. It is the trade the hook represents: when the thing you want to modify is tightly coupled, stop modifying it and find a periodic entry point you can own. A frame interrupt is a place where your code runs unconditionally, with the machine in a known state, coupled to nothing. Once that pattern was proven here, it recurred: the donor-chassis Dark Sakura build works by owning the state-machine dispatch layer rather than editing the state machine, which is the same trade at a different altitude.
Where This Left the Project
| Piece | Outcome |
|---|---|
| Decrypt / patch / re-encrypt loop | Working; Phoenix Edition made it a no-op in practice |
| Injected input-sequence routine | Written, never wired -- superseded by the hook |
| RGB444 armored palette | Designed, values ready, never injected |
| Position check patch | Abandoned in favor of the VBlank hook |
| VBlank interrupt hook | Built, assembled, Capstone-verified, in the ROM build |
| Armored Spider-Man as a character | Retired -- the $2E slot became Dark Sakura |
The techniques survived the target. Every one of them -- injection into free ROM space, dispatch-layer interception, whole-palette stamping, prototype-in-Lua-first -- is load-bearing in the native ports that eventually filled the slot.