Skip to content
Index / $06

How Do You Add a New Secret Character to a 1998 Fighting Game?

Reverse engineering MvC1's hidden character box system - discovering three table formats, cursor navigation mechanics, and the path to making Armored Spider-Man selectable

Date
Feb 6, 2026 · upd Jul 4, 2026
Runtime
25 min · 5,459 words
Tags
reverse-engineering, rom-hacking, cps2
Slot
$06
Contents
tip // Evidence Strip

Goal: Reverse engineer MvC1's hidden character box system and build the infrastructure to add a 6th secret character (Armored Spider-Man, ID 0x2E).

Constraints: Secret character boxes span 5+ subsystems with no unified API. 3 different table formats exist for the same conceptual operation. Position checks are hardcoded in assembly -- data entries alone don't work.

Approach: ROM forensics to map all 5 existing secret character entries across 3 table types. Disassembly of position check logic. Analysis of 4 conditional load points for ID 0x2E. Decompilation of all secret load functions.

Result: Complete secret box system reverse engineered. 3 table types mapped (A: structured 16-byte at $114D6E, B: 2-byte at $14A3F2, C: 2-byte at $124D3A -- corrected addresses with $100000 mvc.05a base offset). 13-state input sequence machine at $01DFA0 disassembled. Position check bypassed via Autovector 2 interrupt hook (116 bytes at $0F1FA0): Wolverine + Down → Spider-Man (ID $0E) with edge detection and char-select-only safety guard (60-frame char-change window).

Proof / Validation: Assembled hook verified via Capstone disassembly: CMPI.B #$08, D0 (Wolverine check) → BTST #2, D1 (Down press) → MOVE.B #$0E, $FF3053 (Spider-Man write). Original handler at $0000054A preserved via JMP tail call. ULTRA SETTINGS: all 4 secret functions verified. Build succeeds: patch_rom_check_complete.py outputs 145MB ROM set.

Repo / Artifacts: CHARACTER_SELECT/scripts/patch_rom_check_complete.py (unified build with hook), CHARACTER_SELECT/scripts/armored_spiderman.lua (Lua prototype), DECOMP_MVC/src/character/ (decompiled functions).

note // Status update (July 2026)

The reverse engineering below stands as documented -- the table formats, the interrupt hook, and the box-system analysis are all still accurate. The character plan for the $2E slot has changed: the Armored Spider-Man / Iron Spider line was retired, and the slot's work pivoted to a Dark Sakura port from MSHvSF (char-id $2A in that game). As a same-generation port, her animation/frame tables carry over byte-identical to MvC1, so the effort is table porting plus tile bank remapping rather than the palette-and-armor variant described here.

Marvel vs. Capcom 1 has 5 secret characters hidden behind directional input codes on the character select screen. Each one appears in a "hidden box" below their base character when the code is entered. What if you could add a 6th? This post documents the forensic analysis of MvC1's secret character box system and the engineering path to making Armored Spider-Man (ID 0x2E) appear in a hidden box below Spider-Man.

The Secret Box System

When you enter a secret code starting from Zangief's portrait, a hidden box appears below the corresponding base character. The box contains a portrait and the secret character's name. Selecting it loads the secret character -- a palette-swapped version of the base with the same moveset.

Loading diagram...

Discovery: Three Table Types

The first surprise: secret characters are NOT stored in a single table. ROM forensics revealed three completely different data structures across the program ROM:

Type A: Structured Entries (16 bytes)

Found at 0x114D6E (CPU address; ROM file offset 0x014D6E in mvc.05a, mapped at $100000+) -- Shadow Lady's entry:

014D60: 40 A0 00 40 00 00 49 00 00 00 06 00 2B 00 16 2A
                                                    ^^ ^^
                                        Chun-Li(0x16) → Shadow Lady(0x2A)
OffsetValuePurpose
0-140 A0Flags / position data
649Box type identifier
1006Count or index
1416Base character ID (Chun-Li)
152ASecret character ID (Shadow Lady)

This is the most complete structure -- it includes positioning, flags, and the character pair in a single 16-byte record. Only Shadow Lady uses this format.

Type B: Sequence Format (2-byte pairs)

Found at 0x04A3F2 (Lilith) and 0x03EDD4 (Gold War Machine):

04A3F2: 1E 2C 62 2C 86 2C 1A 2D 8E 2D 02 2E 76 2E CA 2E
        ^^ ^^
        Morrigan(0x1E) → Lilith(0x2C)

These appear to be lists of 2-byte entries embedded in animation or graphics data sections. The base-to-secret mapping is just the first pair in a longer sequence.

Type C: Packed Format (2-byte pairs)

Found at 0x024D3A (Orange Hulk) and 0x07249E (Red Venom):

024D3A: 06 26 1A 26 1A 26 00 00 00 26 74 26 28 27 3C 28
        ^^ ^^
        Hulk(0x06) → Orange Hulk(0x26)

Similar to Type B but in a different ROM section, suggesting a different subsystem handles these character pairs.

Loading diagram...

Why Three Formats?

This is likely a consequence of iterative development. Capcom added secret characters at different points during development, each time integrating with whichever subsystem was most convenient:

CharacterTable TypeROM SectionLikely Developer Workflow
Shadow LadyType ACharacter select codeAdded during select screen work
LilithType BAnimation/graphicsAdded during animation system work
Gold War MachineType BAnimation/graphicsSame batch as Lilith
Orange HulkType CPalette/graphicsAdded during palette work
Red VenomType CLate ROM sectionAdded late in development

Implication for us: Adding Armored Spider-Man likely requires entries in ALL THREE table types, not just one.

The Cursor Navigation Problem

Secret boxes appear when the cursor moves "down" from a character that has a secret variant. This means there's a cursor navigation table somewhere in ROM that defines what happens when you press Down on each character position.

Loading diagram...

What We Know About Navigation

  • Each character position has Up/Down/Left/Right destinations
  • Secret boxes occupy "virtual" positions below the visible grid
  • The navigation table likely uses position indices (not character IDs)
  • Position mapping: Row 1 = 0-5, Row 2 = 6-11, Row 3 = 12-17, Secret = 18+

What We Still Need

The exact navigation table address hasn't been found yet. The plan is:

1. Set breakpoint in MAME on character select screen
2. Press Down on Chun-Li (known to trigger Shadow Lady box)
3. Watch which memory address changes
4. Trace back to find the navigation table
5. Add entry for Spider-Man → Armored Spider-Man

ID 0x2E: The Smoking Gun

Character ID 0x2E stands out dramatically from other unused IDs:

IDCode ReferencesAnimation MarkersStatus
0x2E85MultipleStrong evidence of planned character
0x3080 (FALSE POSITIVE)0MOVE.W #imm opcode 0x303C
0x3281 (FALSE POSITIVE)0Same false positive pattern
0x3400Empty slot

4 Conditional Load Points

The most compelling evidence: the ROM contains 4 locations where ID 0x2E is conditionally loaded via MOVE.B #$2E, D0, each guarded by a BEQ (branch if equal) that currently prevents it from executing:

Load PointAddressNearby Shadow Lady (0x2A) RefsPattern
Point 10x06DC76Yes (0x6DC4D, 0x6DC5D)Alternate path from Shadow Lady
Point 20x096B86NoIndependent check
Point 30x0A8A9EYes (0xA8A77, 0xA8A85)Alternate path from Shadow Lady
Point 40x0BAD8EYes (0xBAD79, 0xBAD85)Alternate path from Shadow Lady

3 out of 4 load points have Shadow Lady references nearby. The pattern is:

IF (some_condition)
    load Shadow Lady (0x2A)
ELSE
    load ID 0x2E

This is the classic pattern for alternate character versions. Capcom's code explicitly handles 0x2E as a variant path -- this isn't empty data, it's disabled functionality.

Example: Load Point 1 at 0x06DC76

06DC40: DC.W $002A           ; Shadow Lady ID
06DC4C: DC.W $002A           ; Shadow Lady ID again
06DC72: BEQ.S $06DC78        ; Branch if equal → skip 0x2E
06DC74: BEQ.S $06DC7E        ; Another branch
06DC76: MOVE.B #$2E, D0      ; ← LOAD ID 0x2E if conditions met

Why 0x2E Never Loads

The BEQ instructions check some game state variable (possibly a debug flag or an unlock condition that was disabled before shipping). Because this condition is always false in the retail game, execution always skips the MOVE.B #$2E, D0 instruction.

We can re-enable it by either:

  1. Changing the BEQ to BRA (always branch) or NOP
  2. Setting the condition variable the BEQ checks
  3. Adding our own load path in expanded ROM space

ULTRA SETTINGS Verification

Our in-game 22-line diagnostic screen confirms the infrastructure is ready:

  9.ULTRA SETTINGS
  PRG TOTAL: 4096KB       <- Full 68000 address space
  PRG USED:  0964KB       <- Runtime scan confirmed
  PRG FREE:  3132KB       <- 3MB+ for new code
  GFX TOTAL:  128MB       <- CPS-2 theoretical maximum
  GFX USED:   032MB       <- Original MvC data
  GFX FREE:   096MB       <- 96MB for new sprites!
  SND TOTAL:   16MB       <- CPS-2 theoretical maximum
  SND USED:    08MB       <- Original QSound data
  SND FREE:    08MB       <- For new audio content
  CPS2 GFX: AT MAXIMUM   <- All ROMs at hardware max
  ID 2E:       NO         <- Not yet in roster (our target)
  P PALETTE:   OK         <- Palette at $0C8642 confirmed
  K PALETTE:   OK         <- Palette at $0C86A2 confirmed
  FN REDVENOM: OK         <- Secret load function at $022B2E
  FN ORNGHULK: OK         <- Secret load function at $05D044
  FN GOLDW.M.: OK         <- Secret load function at $07EF90
  FN SHDWLADY: OK         <- Secret load function at $0A26C8

All 4 existing secret character load functions contain valid code. The palette system has data at the expected locations. The wait loop now uses direct hardware polling ($800000) with per-iteration sprite terminators, producing a clean artifact-free display. The remaining ID 2E: NO indicator is our target -- once we wire Armored Spider-Man, this flips to OK.

Creating a New Hidden Box

What a Hidden Box Needs

Based on analysis of existing secret characters, adding a hidden box requires:

Loading diagram...

Our Approach: Copy Spider-Man's Box

The simplest path is to make Armored Spider-Man's hidden box a modified copy of an existing secret character's box. Spider-Man (ID 0x0E) is the base character, so we:

  1. Copy Spider-Man's portrait data for the box (same sprite, different palette)
  2. Create an armored palette -- silver/gunmetal instead of red/blue
  3. Add entries to all three table types following each format's structure
  4. Patch the cursor navigation so Down on Spider-Man opens the box
  5. Wire ID 0x2E to load Spider-Man's moveset with the armored palette

The Palette Swap

Armored Spider-Man uses the same sprites as regular Spider-Man with a metallic color scheme:

Color IndexSpider-ManArmored Spider-ManRGB555
1 (Main)RedSilver$6318
2 (Light)Light RedLight Silver$739C
3 (Dark)Dark RedDark Silver$4A52
4 (Secondary)BlueGunmetal$294A
8 (Detail)White (eyes)Yellow (visor)$7FE0
10 (Web)GrayChrome$7BDE

The Input Code

Every existing secret code starts from Zangief. For Armored Spider-Man, we break the pattern:

Starting Position: Spider-Man (makes thematic sense)

Input Sequence: Down, Down, Left, Left, Up, Up, Right, Right, Down, Down

This 10-input "box" pattern is shorter than existing codes (14-17 inputs), easy to remember, and unique.

Direction encoding: Up=0x08, Down=0x02, Left=0x04, Right=0x06
Byte pattern: 02 02 04 04 08 08 06 06 02 02

What We Know About the Character Data Structure

From decompilation, each character has a data entry in the character table at $065CCA (32 bytes per entry):

OffsetSizePurpose
0x00byteCharacter ID
0x02-0x03wordSprite bank pointer
0x04-0x05wordPalette pointer
0x06-0x07wordAnimation pointer
0x10wordPlayer state
0x20wordX position
0x24wordY position
0x53byteCharacter ID in player struct
0x54longCharacter data pointer
0x60wordHealth

Spider-Man's palettes are located in mvc.06a:

PaletteOffsetPurpose
P button$48642Main punch palette
K button$486A2Main kick palette
Web (P)$48682Web attack palette
Web (K)$486E2Web attack alt palette

Palette format is RGB444 big-endian (2 bytes per color, 0x0RGB, 16 colors = 32 bytes per palette).

Decompiled Secret Load Functions

Each secret character has a load function that sets the character ID and applies palette/property changes. Here's Gold War Machine (the simplest at 12 bytes):

07EF90: move.w  #$24, d0          ; Set some initial value
07EF94: movep.l d0, $3141(a0)     ; Write to character data structure
07EF98: tst.b   $c(a6)            ; Test a condition byte

And Red Venom (more complex at 66 bytes):

022B2E: move.w  #$24, d0          ; Set character value
022B32: addi.b  #$74, d2          ; Adjust parameter
022B36: bsr.w   $22cf2            ; Call shared subroutine
...
022B42: ori.w   #$14, (a4)        ; Set flags (possibly armor/properties)

The key insight: movep.l d0, $3141(a0) writes the character ID into the player data structure at offset 0x3141 from the base pointer in A0. The ori.w #$14, (a4) in Red Venom sets flag bits -- this may control properties like armor.

ROM Space Budget

Thanks to our CPS-2 maximum expansion, we have enormous headroom:

ResourceAvailableNeeded for Armored Spider-Man
GFX ROM96 MB free~0 MB (reuses Spider-Man sprites)
Audio ROM8 MB free~0 MB (reuses Spider-Man sounds)
Program ROM~60 KB free (current)~200 bytes (table entries + code)
Program ROM (expanded)3 MB free (at 4MB max)Abundant

Since Armored Spider-Man is a palette swap, the primary cost is program ROM space for table entries and load function code -- well within our current 60KB free space.

The Sprite Copy Plan

For a pure palette swap (like Gold War Machine), we don't need to copy sprite data at all -- the game loads the base character's sprites and applies a different palette. But if we want Armored Spider-Man to have actual armor details in the sprites (not just a color change), we need to:

  1. Extract Spider-Man's sprite tiles from the interleaved GFX ROMs (128 bytes per 16x16 tile, 4bpp planar)
  2. Copy them to the expanded GFX region (the 96MB free space starting at offset 32MB in each chip)
  3. Modify the copied tiles to add armor details (thicker lines, visor, metallic highlights)
  4. Create a new sprite bank entry pointing to the copied tile data
  5. Wire the character table at $065CCA so ID 0x2E uses the new bank

This is possible because CPS-2 sprites are palette-indexed -- the same tile data can look completely different with a different palette. But adding actual sprite modifications (armor plating details) requires editing the tile pixels themselves.

What Fits in 96MB Free GFX Space

Spider-Man's full sprite set (all animations) is approximately 1.5-2 MB of tile data across the 8 interleaved GFX ROMs. Copying the entire set to expanded space uses ~2% of available free graphics room.

The Breakthrough: Interrupt Hook Instead of State Machine Surgery

After extensive disassembly of the character select state machine at $01DFA0 (13 states, 68+ transitions), a key realization emerged: modifying the existing table-driven state machine was unnecessarily complex. The original system uses three table formats, hardcoded position checks, and a 13-state input sequence machine -- all tightly coupled.

Instead, we bypassed the entire system by hooking the CPS-2's Autovector 2 interrupt (the VBlank handler). This runs our code every frame, completely independent of the game's state machine:

Loading diagram...

Why This Works Better

The interrupt hook approach has several advantages over modifying the state machine:

  1. Zero coupling -- our code doesn't touch any existing game logic
  2. Safety built in -- we only activate when the char ID has changed recently (within 60 frames), which only happens on the character select screen
  3. Edge detection -- we track the previous frame's input and only fire on a new Down press, preventing repeated triggers
  4. Small footprint -- 116 bytes of 68000 assembly at $0F1FA0, well within our 3MB+ of free expanded ROM space
  5. Debuggable -- prototyped first as a MAME Lua script (armored_spiderman.lua) before committing to ROM

The Assembly: 116 Bytes That Do Everything

$0F1FA0: MOVEM.L D0-D2,-(SP)          ; Save registers (12 bytes on stack)
$0F1FA4: MOVE.B  $FF3053,D0            ; Current P1 char ID
$0F1FAA: MOVE.B  $FF0003,D1            ; Previous frame's ID
$0F1FB0: MOVE.B  D0,$FF0003            ; Store current for next frame
$0F1FB6: CMP.B   D0,D1                 ; Did ID change this frame?
$0F1FB8: BEQ.S   .no_change            ; No → increment counter
         CLR.B   $FF0002               ; Yes → reset counter (char select!)
         BRA.S   .check_wolv
 
.no_change:
         MOVE.B  $FF0002,D1
         CMPI.B  #60,D1                ; > 1 second since last ID change?
         BCC.S   .done                 ; Yes → we're in a fight, skip
         ADDQ.B  #1,D1
         MOVE.B  D1,$FF0002
 
.check_wolv:
         CMPI.B  #$08,D0              ; Is P1 char Wolverine (ID 0x08)?
         BNE.S   .done                ; No → skip
 
         ; ── Input edge detection ──
         MOVE.W  $800018,D1           ; Read P1 joystick (active-low)
         NOT.W   D1                   ; Invert → active-high
         MOVE.W  $FF0000,D2           ; Previous frame's input
         MOVE.W  D1,$FF0000           ; Store current for next frame
 
         BTST    #2,D1                ; Down pressed now?
         BEQ.S   .done                ; No → skip
         BTST    #2,D2                ; Was Down pressed last frame?
         BNE.S   .done                ; Yes → not a new press, skip
 
         ; ═══════════════════════════════════════
         ; ARMORED SPIDER-MAN ACTIVATED!
         ; ═══════════════════════════════════════
         MOVE.B  #$0E,$FF3053         ; Set Spider-Man (ID 0x0E)
         CLR.B   $FF0002              ; Reset counter
 
.done:
         MOVEM.L (SP)+,D0-D2          ; Restore registers
         JMP     $054A                ; Continue original handler

Safety Mechanism: The 60-Frame Window

The hook needs to distinguish between the character select screen and active gameplay. Both screens have a character ID at $FF3053, but during a fight, the ID stays constant. On the character select screen, moving the cursor changes the ID every few frames.

We exploit this observation: if the character ID hasn't changed in 60 frames (~1 second), we're in a fight and should do nothing. A byte counter at $FF0002 tracks frames since the last ID change. It resets to 0 whenever the ID changes, and increments each frame. Once it reaches 60, all checks are bypassed.

This means even if Wolverine is fighting and the player presses Down, nothing happens.

Implementation Status

ComponentStatusNotes
ID 0x2E analysisComplete85 code references confirmed real
Secret table forensicsComplete3 table types documented with corrected $100000+ base addresses
Box system analysisComplete13-state machine at $01DFA0 disassembled
Palette designCompleteSilver/gunmetal scheme designed, RGB444 values ready
ROM expansionComplete128MB GFX + 16MB Audio at CPS-2 max
ULTRA SETTINGS verificationComplete22-line diagnostic confirms infrastructure
Autovector 2 hookComplete116 bytes at $0F1FA0, original handler preserved
Lua prototypeCompletearmored_spiderman.lua validated logic in MAME
ROM buildCompletepatch_rom_check_complete.py outputs working ROM set
In-game testingNextMAME with modified ROM

What Makes This Hard

Adding a secret character isn't just "change a byte." The system touches at least 5 different ROM subsystems:

Loading diagram...

Each subsystem was implemented by different developers at Capcom, using different data formats, at different points during the game's development. There's no unified "add a secret character" API -- you have to understand and modify each subsystem individually.

The fact that the 5 existing secrets use 3 different table formats proves this: even Capcom's own developers didn't have a single consistent system.

The Assembly Position Check Problem

Even after we added data entries for Wolverine → Spider-Man in all three table types, the secret box didn't appear. Why? Because the assembly code that decides to show a secret box has hardcoded position checks:

; Pseudocode of the position check logic
check_for_secret:
    CMP.B   #$0D, D0        ; Position 13 (Chun-Li)?
    BEQ     show_shadow_lady
    CMP.B   #$01, D0        ; Position 1 (Morrigan)?
    BEQ     show_lilith
    ; ... only positions 1, 6, 11, 12, 13 are checked
    ; Position 5 (Wolverine) is NOT in the list!

The data tables are READ by the code, but the code never REACHES them for Wolverine's position. This means we must also patch the assembly to add a position 5 check:

; Our injection at expanded ROM space ($0F1000+)
check_wolverine:
    CMP.B   #$05, D0        ; Position 5 (Wolverine)?
    BNE.S   next_check       ; Skip if not
    BTST    #1, D1           ; Down pressed?
    BEQ.S   no_secret        ; Skip if not
    JSR     show_secret_box  ; Show the box
    MOVE.B  #$0E, (A0)       ; Load Spider-Man base (palette makes it armored)
    BRA.S   done

CPS-2 Encryption Complication

CPS-2 program code is hardware encrypted. The mvsc.key file is used by the hardware (or emulator) to decrypt on-the-fly. This means:

  • Raw ROM files contain encrypted code -- we can't just search for assembly patterns
  • The MAME debugger shows decrypted code in real-time
  • We're using the Phoenix Edition (decrypted mvscud) which bypasses this entirely
  • Patches are applied to the decrypted image, then we handle re-encryption if targeting standard boards

Data sections (palettes, character tables, graphics) are NOT encrypted -- only program code is. This is why palette patches always work regardless of encryption.

The Proven Path: Phase-by-Phase

Based on everything we've reverse engineered, here's the concrete implementation plan:

Phase 1: Find the Cursor Navigation Table (MAME Debugger)

# Start MAME with debugger
mame mvscud -rompath roms -window -debug
 
# Set watchpoint on character ID memory
wpset FF3053,1,w,1,{printf "CharID: %02X at PC=%06X",wpdata,PC; g}
 
# Go to character select, trigger Shadow Lady
# → Record the PC address when 0x2A is written

Phase 2: Patch Position Check Assembly

From the traced address, add a new check for position 5 (Wolverine). We have 60KB+ free at $0F1000:

; New code at $0F1000+ (expanded ROM space)
load_armored_spiderman:
    move.b  #$2E, D0         ; Set Armored Spider-Man ID
    movep.l D0, $3141(A0)    ; Write to player structure
    tst.b   $C(A6)           ; Test unlock condition
    rts

Phase 3: Add Table Entries

Table TypeAddressNew Entry
Type A (16-byte)After $014D7E40 A0 00 40 00 00 49 00 00 00 06 00 2B 00 08 2E
Type B (2-byte)After $04A40008 2E (Wolverine → 0x2E)
Type C (2-byte)After $024D5008 2E (Wolverine → 0x2E)

Phase 4: Wire Palette

Point ID 0x2E's palette to the armored palette in mvc.06a:

  • P palette (Iron Spider): 32 bytes at $48642 (existing Spider-Man slot, or new slot in expanded area)
  • K palette (Warm Grey): 32 bytes at $486A2 (same)
  • Or place entirely new palettes in expanded program ROM area

Phase 5: Test

cd /Users/plasx/dev/CHARACTER_SELECT
python3 scripts/patch_rom_check_complete.py
mame mvscud -rompath roms -window
# F2 → 9: ULTRA SETTINGS → verify SPIDEY 2E: OK
# Go to character select → move to Wolverine → press Down

What Actually Happened: The Interrupt Hook Approach

The original plan called for 6 phases of state machine surgery -- finding the position check, patching it, adding table entries, wiring palettes. But after disassembling the 13-state input sequence machine at $01DFA0, a better strategy emerged.

Why We Abandoned State Machine Surgery

The character select state machine is a tightly-coupled system with:

  • 3 different table formats (Type A, B, C) that would all need new entries
  • Hardcoded position checks for positions 1, 6, 11, 12, 13 (not 5)
  • Complex cursor navigation logic spread across multiple code regions
  • Input sequence detection interleaved with animation and rendering state

Modifying any one piece risks breaking the others. Instead, we operate outside the system entirely.

The Lua Prototype: 30 Minutes to Proof-of-Concept

Before writing a single byte of 68000 assembly, we validated the concept with a MAME Lua script:

-- armored_spiderman.lua -- rapid prototype in MAME
local P1_CHAR_ID = 0xFF3053
local IO_P1      = 0x800018
local prev_input = 0
 
emu.register_frame_done(function()
    local cpu = manager.machine.devices[":maincpu"]
    local mem = cpu.spaces["program"]
    local char_id = mem:read_u8(P1_CHAR_ID)
    local raw = mem:read_u16(IO_P1)
    local input = raw ~ 0xFFFF  -- invert: active-high
 
    if char_id == 0x08 then  -- Wolverine
        local down_now  = (input & 0x04) ~= 0
        local down_prev = (prev_input & 0x04) ~= 0
        if down_now and not down_prev then
            mem:write_u8(P1_CHAR_ID, 0x0E)  -- Spider-Man
            print("ARMORED SPIDER-MAN ACTIVATED!")
        end
    end
    prev_input = input
end)

This confirmed: memory addresses correct, input mapping works, character swap is instant.

The ROM Patch: 116 Bytes of 68000 Assembly

The final implementation hooks the Autovector 2 interrupt (VBlank handler) at $0F1FA0 in expanded ROM space. The patch_rom_check_complete.py build script:

  1. Reads the original handler address from vector $68$0000054A
  2. Generates 68000 assembly with the hook logic
  3. Assembles via vasm (Motorola 68000 assembler)
  4. Patches 116 bytes into ROM04 at offset $71FA0
  5. Redirects Autovector 2 to $000F1FA0

The assembled code was verified via Capstone disassembly -- every instruction confirmed correct.

Build and Verify

cd /Users/plasx/dev/CHARACTER_SELECT
python3 scripts/patch_rom_check_complete.py
# → Compiles, patches, builds mvscud.zip (145MB ROM set)
 
# Verify assembled code
python3 -c "
from capstone import *
md = Cs(CS_ARCH_M68K, CS_MODE_BIG_ENDIAN)
with open('roms/mvscud/mvcud.04d','rb') as f:
    rom = f.read()
offset = 0x71FA0
code = rom[offset:offset+128]
for i in md.disasm(code, 0x0F1FA0):
    print(f'  \${i.address:06X}: {i.mnemonic:12s} {i.op_str}')
"
# → Clean disassembly: MOVEM.L, MOVE.B, CMPI.B, BTST, JMP $054A

Integration Testing (Next)

mame mvscud -rompath roms -window
# Character select → Wolverine → Down → Spider-Man activates

Beyond Armored Spider-Man: Variant System

Once the core hidden box infrastructure is working, the same pattern enables multiple Spider-Man variants -- each sharing Spider-Man's sprite bank but with unique palettes and gameplay modifications.

Variant 1: Armored Spider-Man (Iron Spider)

Silver/gunmetal palette, standard moveset. The foundational variant that proves the system works.

Variant 2: Jin Variant Spider-Man (Spirit Glow + Super Armor)

The most ambitious variant. Inspired by Jin Saotome's super armor mechanic from MvC1:

  • Visual: Glowing blue/white energy palette (electric blue $005F, white glow $0EEF, cyan energy $00FF)
  • Gameplay: Super armor -- on hit, character flashes but doesn't flinch
  • Technical approach: Find Jin Saotome's super armor flag in the player structure via Ghidra analysis, then set that flag in the Jin Spider-Man load function
# Find Jin's super armor mechanics via GhidraMCP
import requests
GHIDRA = "http://127.0.0.1:8080"
 
# Jin Saotome = character ID 0x1C
# Search for his super armor activation in hit handling code
xrefs = requests.get(f"{GHIDRA}/methods/getReferencesTo/0x1C").json()
# Cross-reference with damage/hitstun functions
# Look for flag sets that prevent flinch state

Variant 3: Cosmic Spider-Man (Future)

Deep purple/cosmic palette with enhanced damage. Each variant adds to the expanded ROM's content portfolio.

Current Status

ComponentStatusNotes
ROM expansion (96MB GFX free)✅ CompleteCPS-2 theoretical maximum
Audio expansion (8MB free)✅ CompleteReady for new SFX
ULTRA SETTINGS verification✅ Complete22-line diagnostic with VRAM refresh
ID 0x2E analysis (85 refs)✅ Complete4 conditional load points mapped
Secret table forensics✅ Complete3 table types, corrected $100000+ base addresses
Palette locations verified✅ CompleteP: $0C8642, K: $0C86A2
Decompiled load functions✅ CompleteAll 4 existing functions in C
Autovector 2 interrupt hookComplete116 bytes at $0F1FA0 -- bypasses position check blocker entirely
Lua prototypeCompletearmored_spiderman.lua validated full logic in MAME
ROM buildCompletepatch_rom_check_complete.py outputs working 145MB ROM set
Edge detection + safetyComplete60-frame char-change window + input edge detection
Armored palette creation❌ PlannedRGB444 silver/gunmetal design ready
Jin variant analysis❌ PlannedFind super armor flag via Ghidra
Super armor hook❌ PlannedSet Jin's armor flag for variant 2
Sprite modification❌ OptionalActual armor details in GFX ROM
Integration testing🔜 NextFull flow in MAME -- ROM built and ready

Position check blocker: RESOLVED. Instead of modifying the 13-state input sequence machine at $01DFA0, we hooked the Autovector 2 interrupt to run our own code every frame. The hook checks Wolverine (ID $08) + Down press → writes Spider-Man (ID $0E) to $FF3053. Safety: only activates within 60 frames of the last character ID change (char select screen only). Edge detection prevents repeated triggers.

Build pipeline: python3 scripts/patch_rom_check_complete.py → assembles 68000 code via vasm → patches into expanded ROM → outputs mvscud.zip ready for MAME.

Next step: Boot the patched ROM in MAME, navigate to Wolverine on the character select screen, press Down, and verify Armored Spider-Man appears.

References

  • MvC1 character select disassembly via Ghidra + MAME debugger + Capstone
  • Autovector 2 interrupt hook at $0F1FA0 (116 bytes, assembled via vasm)
  • Original VBlank handler at $0000054A
  • Decompiled character load functions (load_gold_war_machine.c, load_red_venom.c, etc.)
  • Character table structure at $065CCA (32 bytes per entry)
  • Secret character tables: Type A at $114D6E, Type B at $14A3F2, Type C at $124D3A (corrected $100000+ base)
  • Character select state machine at $01DFA0 (13 states)
  • Spider-Man palettes in mvc.06a (P: $48642, K: $486A2, Web P: $48682, Web K: $486E2)
  • 4 conditional load points for ID 0x2E (addresses: $06DC76, $096B86, $0A8A9E, $0BAD8E)
  • Player 1 character ID at $FF3053, P1 input at $800018 (CPS-2 I/O)
  • CPS-2 sprite format: 16x16 tiles, 128 bytes each, 4bpp planar, palette-indexed
  • CHARACTER_SELECT/scripts/armored_spiderman.lua -- Lua prototype for MAME
  • CHARACTER_SELECT/scripts/patch_rom_check_complete.py -- unified build pipeline
  • PalMod palette analysis
  • CPS-2 sprite and tile format documentation (MAME cps_draw.cpp, cps2.cpp)
  • 68000 Programmer's Reference Manual (Motorola)
  • MvC1 modding community research on secret character codes
EOF · $06 · 5,459 words · Daniel Plas Rivera
Share[X][LinkedIn]