Goal: Inject a custom diagnostic screen into MvC1's F2 Test Menu to verify every claim from the project documentation at runtime.
Constraints: No source code for the test menu system. Must integrate with existing jump table architecture. 68000 assembly only (no C compiler for CPS-2). Must handle CPS-2 sprite list terminator requirements. Limited free program ROM space (~60KB at $0F1000).
Approach: Reverse engineered the F2 Test Menu's jump table. Extended it with a 9th entry ("ULTRA SETTINGS") pointing to custom 68000 handler in expanded ROM space. Handler implements hardware-polled input, dual OBJ bank clearing, VRAM tile rendering, and an interactive sound test.
Result: 22-line diagnostic screen verifying ROM sizes, character roster (16 normal, 5 secret), palette integrity (P/K at $0C8642/$0C86A2), all 4 secret load function addresses, and audio injection status. Sound test supports codes $0900-$0904 for injected MvC2 tracks.
Proof / Validation: Boot game → F2 → select "9: ULTRA SETTINGS". All verification values are computed at patch time and embedded as ASCII in ROM. Any ROM corruption changes the displayed values. Pipeline output is deterministic.
Repo / Artifacts: CHARACTER_SELECT/scripts/patch_rom_check_complete.py (build pipeline with assembly generation), assembled handler at $0F1000-$0F1FFF.
CPS-2 arcade boards have a built-in test menu (accessed via the F2 key / operator button). This menu provides hardware diagnostics, input testing, and color calibration. But what if you want to add your own diagnostic page? This post documents the complete process of injecting a "ULTRA SETTINGS" submenu into MvC1's test system -- displaying 24 lines of real-time ROM verification data with an interactive sound test, using pure 68000 assembly.
Engineering Outcomes:
- Added "9: ULTRA SETTINGS" as a new menu item in the F2 Test Menu
- Built a 24-line diagnostic screen verifying ROM expansion, character roster, palettes, secret functions, and audio injection
- Solved the CPS-2 sprite list terminator problem (the "11 11" artifact)
- Implemented robust input polling across hardware ports, VSync, and game state flags
- Entire system built via automated Python + vasm pipeline
The Problem
We expanded MvC1's ROMs to CPS-2 theoretical maximums (128MB GFX, 16MB Audio). But how do you prove the expansion is correct? How do you verify that character data, palette locations, and secret function entry points are intact?
The answer: build a verification screen directly into the game.
CPS-2 Test Menu Architecture
The F2 Test Menu is a state machine managed by the game's program ROM. It has a menu count, a cursor position, a jump table for dispatching to each menu item's handler, and text rendering via the CPS-2 tile system.
Key ROM Locations
| Component | CPU Address | ROM Offset | Description |
|---|---|---|---|
| Menu count | $0DFA01 | $05FA01 | Number of menu items (9 -> 10) |
| Cursor limit 1 | $0DFA03 | $05FA03 | Cursor upper bound check |
| Cursor limit 2 | $0DFA0B | $05FA0B | Cursor wrap-around check |
| Cursor limit 3 | $0DFA1B | $05FA1B | Additional cursor bound |
| Cursor increment | $0DD3AC | $05D3AC | Wrap value when cursor moves up past 0 |
| Cursor decrement | $0DD3C6 | $05D3C6 | Wrap value when cursor moves down past max |
| Main dispatch | $0DD46E | $05D46E | Jump to selected handler |
| Jump table base | $0DD476 | $05D476 | Table of handler addresses |
| Custom code block | $0F1000 | $071000 | Free ROM space for our handler |
The Patches
Step 1: Increase Menu Count
The original menu has 9 items (1-8 + EXIT). We change the count byte from 0x09 to 0x0A:
CPU $0DFA01: 09 → 0A (menu now has 10 items)Step 2: Fix Cursor Limits
Three locations need updating so the cursor can reach position 9:
CPU $0DFA03: 08 → 09 (cursor limit 1)
CPU $0DFA0B: 08 → 09 (cursor limit 2)
CPU $0DFA1B: 08 → 09 (cursor limit 3)Step 3: Fix Cursor Wrapping
The cursor wrap values use 2-byte immediate values (critical discovery -- using 4-byte values corrupted the adjacent BNE.S instruction):
CPU $0DD3AC: 09 → 0A (wrap from bottom to top)
CPU $0DD3C6: 09 → 0A (wrap from top to bottom)Early attempts used CMPI.B #$0A, ... (4-byte instruction) to patch the cursor wrap. But the original code was CMPI.B #$09, ... followed immediately by BNE.S. The 4-byte patch overwrote the branch instruction's offset byte, causing the cursor to never wrap. The fix was using a 2-byte immediate patch that preserves the BNE.S instruction.
Step 4: Extend the Jump Table
The original jump table at $0DD476 has entries for items 0-8. We redirect item 9 (previously EXIT) to our new handler, and item 10 becomes EXIT:
The ULTRA SETTINGS Handler
The handler is written in 68000 assembly, compiled by vasm, and injected at $0F1000. It displays 24 lines of diagnostic information including an interactive sound test.
Screen Layout
9.ULTRA SETTINGS
PRG TOTAL: 4096KB
PRG USED: 872KB
PRG FREE: 152KB
GFX TOTAL: 0128MB
GFX USED: 032MB
GFX FREE: 0096MB
SND TOTAL: 16MB
SND USED: 08MB
SND FREE: 08MB
CPS2 GFX: AT MAXIMUM
CPS-2 MAX: 0128MB
NORMAL IDS: 15
SECRET IDS: 06
STAGES: 12
SPIDEY 2E: OK
P PALETTE: OK
K PALETTE: OK
FN RED VEN: OK
FN ORG HLK: OK
FN GLD WM: OK
FN SHD LDY: OKHow Each Value is Computed
| Line | Method | Source |
|---|---|---|
| PRG TOTAL | Static | Full 68000 address space $000000-$3FFFFF = 4096KB |
| PRG USED | Dynamic | Scans ROM backwards from $0FFFFF to find last non-$FF byte |
| PRG FREE | Computed | TOTAL - USED |
| GFX TOTAL | Patch-time | Python reads mvc.13m size, embeds value |
| GFX USED | Static | Original MvC data = 32MB |
| GFX FREE | Patch-time | TOTAL - 32MB original |
| CPS-2 MAX | Static | 128MB (hardware limit) |
| NORMAL IDS | Dynamic | Counts entries in roster table at $AC7A |
| SECRET IDS | Dynamic | Counts entries in secret character table |
| STAGES | Dynamic | Counts entries in stage table at $BB04C |
| SPIDEY 2E | Dynamic | Scans roster for ID byte $2E |
| P PALETTE | Dynamic | Reads word at CPU $0C8642, checks non-zero |
| K PALETTE | Dynamic | Reads word at CPU $0C86A2, checks non-zero |
| FN RED VEN | Dynamic | Reads word at $022B2E, checks for valid code |
| FN ORG HLK | Dynamic | Reads word at $05D044, checks for valid code |
| FN GLD WM | Dynamic | Reads word at $07EF90, checks for valid code |
| FN SHD LDY | Dynamic | Reads word at $0A26C8, checks for valid code |
Function Verification Logic
The 4 secret character load functions were independently decompiled to C in our project repository, confirming their structure:
| Function | Address | Size | First Instruction | Decompiled |
|---|---|---|---|---|
load_red_venom | $022B2E | 66 bytes | move.w #$24, d0 | src/character/load_red_venom.c |
load_orange_hulk | $05D044 | 60 bytes | move.w #$24, d0 | src/character/load_orange_hulk.c |
load_gold_war_machine | $07EF90 | 12 bytes | move.w #$24, d0 | src/character/load_gold_war_machine.c |
load_shadow_lady | $0A26C8 | 20 bytes | move.b #$24, d0 | src/character/load_shadow_lady.c |
The ULTRA SETTINGS screen verifies these functions are intact by reading the first word at each entry point. If it's $0000 (NOP) or $FFFF (empty ROM), the function is considered missing:
verify_function:
move.w (A0),D0 ; Read first word at function address
cmpi.w #$0000,D0 ; Is it NOP?
beq.s .fail
cmpi.w #$FFFF,D0 ; Is it empty ROM?
beq.s .fail
; ... write "OK" ...
.fail:
; ... write "NO" ...The Display Artifact Saga
Building the ULTRA SETTINGS screen required solving three interrelated display bugs. Each fix revealed deeper understanding of CPS-2 hardware.
Bug 1: "11 11" Text Artifacts
When the diagnostic screen first displayed, garbled "11 11" text appeared across the screen background. The root cause had two layers:
Layer 1: Tile 0 in GFX ROM has "11" graphics. CPS-2 scroll layers use tile map entries (16-bit tile number + 16-bit attributes). Clearing VRAM to $00000000 sets every tile position to tile index 0. In MvC1's graphics ROM, tile 0 contains "11" debug graphics from Capcom's development -- NOT a blank tile.
Fix: Fill scroll VRAM with tile $0020 (space character) instead of $0000:
; Clear scroll VRAM ($660000-$66BFFF) with tile $0020 (space)
; NOT tile $0000! Tile 0 in MvC1 GFX ROM contains "11" graphics.
lea $660000,a0
move.l #$00200020,d1 ; tile $0020 for both words
move.w #$2FFF,d0
.cv: move.l d1,(a0)+
dbra d0,.cvLayer 2: CPS-2 extended GFXRAM at $900000. Besides the standard scroll VRAM at $660000, CPS-2 has 192KB of additional video memory at $900000-$92FFFF. The game's display system may source tile data from this region. Clearing it eliminates any residual graphics:
; Clear GFXRAM ($900000-$92FFFF, 192KB)
lea $900000,a0
move.w #$BFFF,d0 ; 48K longwords = 192KB
.cg: clr.l (a0)+
dbra d0,.cgBug 2: Persistent Sprite Artifacts (Brown Character Blobs)
Character sprites from the game's last state appeared on the diagnostic screen despite clearing sprite RAM. This required understanding CPS-2's double-buffered OBJ RAM:
| Bank | Address Range | Size | Purpose |
|---|---|---|---|
| Bank 1 | $700000-$701FFF | 8KB | 1024 sprite entries |
| Bank 2 | $708000-$709FFF | 8KB | 1024 sprite entries |
The hardware reads from one bank while the game writes to the other. At VBlank, they swap. Writing $FF000000 (Y-position terminator) to ALL entries in BOTH banks eliminates sprites regardless of which bank the hardware is currently reading:
clear_sprites:
move.l #$FF000000,d1 ; $FF in Y high byte = STOP RENDERING
; Clear OBJ Bank 1 ($700000)
lea $700000,a0
move.w #$03FF,d0 ; 1024 entries
.cb1: move.l d1,(a0)+ ; Y=$FF00 (terminator), tile=0
clr.l (a0)+ ; X=0, flags=0
dbra d0,.cb1
; Clear OBJ Bank 2 ($708000)
lea $708000,a0
move.w #$03FF,d0
.cb2: move.l d1,(a0)+
clr.l (a0)+
dbra d0,.cb2
rtsBug 3: Game's VBlank Handler Repopulates Sprites
Even after clearing both OBJ banks, calling JSR $0004FC (VSync) triggered the game's VBlank interrupt handler, which immediately wrote sprite data back to OBJ RAM. Every frame: we clear, VBlank writes sprites, hardware displays sprites.
Fix: Mask all CPU interrupts after rendering:
move.w sr,-(sp) ; Save status register
ori.w #$0700,sr ; Set interrupt priority level to 7 (mask ALL)
bsr.w clear_sprites ; Final clear -- stays clean nowWith interrupts masked, no VBlank handler runs, no sprite DMA occurs, and our OBJ RAM clear persists indefinitely. The hardware still renders from its current state -- it just displays our clean data.
Exit Mechanism
With interrupts masked, the game's normal input system (which relies on VBlank processing) doesn't work. Instead, we read the hardware input ports directly:
; Direct hardware I/O -- works regardless of interrupt state
; $800000 bits 0-7: P1 joystick + buttons (active LOW)
; All bits high ($FF) = no buttons pressed
.wp: bsr.w frame_delay
move.w ($800000).l,d0
andi.w #$00FF,d0
cmpi.w #$00FF,d0 ; All released?
bne.s .ex ; No → button pressed → exit
bra.s .wp
.ex: move.w (sp)+,sr ; Restore SR (unmask interrupts)
movem.l (sp)+,d0-d7/a0-a6
jmp $0DD2B0 ; Test menu draw entry pointKey design decisions:
- Direct port reads (
$800000) work with interrupts masked since they're memory-mapped I/O - Frame delay via NOP loop provides timing without needing VBlank
- Exit restores interrupts before jumping back to the test menu's draw loop at
$0DD2B0 - Debounce phase waits for all buttons released before accepting new presses
Text Rendering
CPS-2 doesn't have a traditional framebuffer for text. Instead, text is rendered by writing tile indices to a RAM buffer, which the hardware's scroll layers display.
Word-Swapped Text
A critical discovery: CPS-2 ROMs store bytes swapped within 16-bit words. When the CPU reads from ROM, the hardware un-swaps them. But when writing to RAM, no un-swap occurs. This means text data in ROM must be pre-swapped:
String "OK" in ASCII: 4F 4B
Stored in ROM (swapped): 4B 4F
CPU reads from ROM: 4F 4B (hardware un-swaps)
Written to RAM: 4F 4B (no swap, correct)Decimal Number Display
The diagnostic screen displays numbers in decimal (e.g., "1024KB"). The 68000 doesn't have a decimal division instruction, so we use DIVU (unsigned divide):
; Convert D0 to 4-digit decimal string at (A1)
divu #1000,d0 ; D0.w = thousands, D0 high = remainder
addi.b #$30,d0 ; Convert to ASCII
move.b d0,(a1)+ ; Store thousands digit
swap d0 ; Get remainder
andi.l #$FFFF,d0
divu #100,d0 ; Hundreds
; ... continue for tens and ones ...Build Pipeline
The entire system is built by a single Python script:
What the Script Does
- ROM Expansion -- All GFX chips to 16MB (128MB total), all audio chips to 8MB (16MB total)
- GOLD Marker -- Plants
GOLD\x01atmvc.13m:0x3FFFFBfor verification - Menu Patches -- 9 byte-level patches to extend the test menu
- Assembly Generation -- Produces ~3600 bytes of 68000 code with embedded text data
- Compilation -- Calls
vasmm68k_motto assemble the code - Injection -- Word-swaps the binary and writes it to ROM offset
$071000 - Packaging -- Creates a single
mvscud.zipwith all 20+ ROM files
Bug Chronicle
| Bug | Symptom | Root Cause | Fix |
|---|---|---|---|
| Title missing | Top line invisible | Row 0x02 in CPS-2 overscan | Moved to row 0x03 |
| "11 11" background | Garbled text everywhere | Tile 0 in GFX ROM = "11" | Fill VRAM with tile $0020 (space) |
| Brown sprite blobs | Character sprites persist | Only clearing 1 of 2 OBJ banks | Clear both 708000 |
| Sprites reappear | Cleared sprites come back | VBlank handler rewrites OBJ RAM | Mask interrupts (IPL=7) |
| GFXRAM residue | Faint artifacts | $900000 GFXRAM not cleared | Clear 192KB at 92FFFF |
| 4-byte cursor patch | Cursor never wraps | Overwrote BNE.S opcode | 2-byte immediate |
| Item 9 = EXIT | New item acts as exit | Jump table not extended | Added new entry |
| Can't exit (VSync) | Stuck on ULTRA SETTINGS | $0004FC needed for input | Direct HW port reads at $800000 |
| Wrong exit address | Crash/hang on exit | $0D4172 was wrong return | Exit to $0DD2B0 (menu draw loop) |
| "W CHECK" text | Garbled menu item | Text buffer overflow | Fixed string lengths |
Lessons Learned
- CPS-2 has TWO OBJ RAM banks at
$700000and$708000-- hardware reads one while game writes the other, swapping at VBlank. You must clear BOTH. - Tile 0 is NOT blank -- In MvC1, it contains "11" debug graphics. Always fill cleared VRAM with a known blank tile index.
- VBlank handler is the enemy -- Calling
JSR $0004FCtriggers sprite DMA that overwrites your clean OBJ RAM. Mask interrupts to prevent this. - Hardware ports work without interrupts --
$800000is memory-mapped I/O that responds to direct reads regardless of CPU interrupt state. - 2-byte vs 4-byte immediate sizes matter -- 68000 instruction encoding is precise; overwriting adjacent instructions causes silent corruption.
- Test menu cursor limits are stored in multiple locations -- Missing even one causes wrap bugs.
- Know your exit path -- JMP-based dispatchers require explicit return addresses. The test menu's draw entry at
$0DD2B0is the safe re-entry point.
References
- CPS-2 hardware documentation (MAME
cps2.cppdriver source) - CPS-2 sprite format analysis (MAME
cps_draw.cpp) - MvC1 test menu disassembly via Ghidra + MAME debugger
- 68000 Programmer's Reference Manual (Motorola)