Skip to content
Index / $0A

How Do You Add a Custom Diagnostic Screen to Arcade Hardware?

Engineering a new menu item and 24-line verification screen into CPS-2 arcade firmware using 68000 assembly, jump table extension, and sprite system mastery

Date
Feb 6, 2026
Runtime
14 min · 2,982 words
Tags
systems-engineering, assembly, reverse-engineering
Slot
$0A
Contents
tip // Evidence Strip

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.

Loading diagram...

Key ROM Locations

ComponentCPU AddressROM OffsetDescription
Menu count$0DFA01$05FA01Number of menu items (9 -> 10)
Cursor limit 1$0DFA03$05FA03Cursor upper bound check
Cursor limit 2$0DFA0B$05FA0BCursor wrap-around check
Cursor limit 3$0DFA1B$05FA1BAdditional cursor bound
Cursor increment$0DD3AC$05D3ACWrap value when cursor moves up past 0
Cursor decrement$0DD3C6$05D3C6Wrap value when cursor moves down past max
Main dispatch$0DD46E$05D46EJump to selected handler
Jump table base$0DD476$05D476Table of handler addresses
Custom code block$0F1000$071000Free 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)
warning // The 2-Byte vs 4-Byte Bug

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:

Loading diagram...

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: OK

How Each Value is Computed

LineMethodSource
PRG TOTALStaticFull 68000 address space $000000-$3FFFFF = 4096KB
PRG USEDDynamicScans ROM backwards from $0FFFFF to find last non-$FF byte
PRG FREEComputedTOTAL - USED
GFX TOTALPatch-timePython reads mvc.13m size, embeds value
GFX USEDStaticOriginal MvC data = 32MB
GFX FREEPatch-timeTOTAL - 32MB original
CPS-2 MAXStatic128MB (hardware limit)
NORMAL IDSDynamicCounts entries in roster table at $AC7A
SECRET IDSDynamicCounts entries in secret character table
STAGESDynamicCounts entries in stage table at $BB04C
SPIDEY 2EDynamicScans roster for ID byte $2E
P PALETTEDynamicReads word at CPU $0C8642, checks non-zero
K PALETTEDynamicReads word at CPU $0C86A2, checks non-zero
FN RED VENDynamicReads word at $022B2E, checks for valid code
FN ORG HLKDynamicReads word at $05D044, checks for valid code
FN GLD WMDynamicReads word at $07EF90, checks for valid code
FN SHD LDYDynamicReads 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:

FunctionAddressSizeFirst InstructionDecompiled
load_red_venom$022B2E66 bytesmove.w #$24, d0src/character/load_red_venom.c
load_orange_hulk$05D04460 bytesmove.w #$24, d0src/character/load_orange_hulk.c
load_gold_war_machine$07EF9012 bytesmove.w #$24, d0src/character/load_gold_war_machine.c
load_shadow_lady$0A26C820 bytesmove.b #$24, d0src/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,.cv

Layer 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,.cg

Bug 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:

BankAddress RangeSizePurpose
Bank 1$700000-$701FFF8KB1024 sprite entries
Bank 2$708000-$709FFF8KB1024 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
    rts

Bug 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 now

With 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.

Loading diagram...

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 point

Key design decisions:

  1. Direct port reads ($800000) work with interrupts masked since they're memory-mapped I/O
  2. Frame delay via NOP loop provides timing without needing VBlank
  3. Exit restores interrupts before jumping back to the test menu's draw loop at $0DD2B0
  4. Debounce phase waits for all buttons released before accepting new presses
Loading diagram...

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:

Loading diagram...

What the Script Does

  1. ROM Expansion -- All GFX chips to 16MB (128MB total), all audio chips to 8MB (16MB total)
  2. GOLD Marker -- Plants GOLD\x01 at mvc.13m:0x3FFFFB for verification
  3. Menu Patches -- 9 byte-level patches to extend the test menu
  4. Assembly Generation -- Produces ~3600 bytes of 68000 code with embedded text data
  5. Compilation -- Calls vasmm68k_mot to assemble the code
  6. Injection -- Word-swaps the binary and writes it to ROM offset $071000
  7. Packaging -- Creates a single mvscud.zip with all 20+ ROM files

Bug Chronicle

BugSymptomRoot CauseFix
Title missingTop line invisibleRow 0x02 in CPS-2 overscanMoved to row 0x03
"11 11" backgroundGarbled text everywhereTile 0 in GFX ROM = "11"Fill VRAM with tile $0020 (space)
Brown sprite blobsCharacter sprites persistOnly clearing 1 of 2 OBJ banksClear both 700000and700000 and 708000
Sprites reappearCleared sprites come backVBlank handler rewrites OBJ RAMMask interrupts (IPL=7)
GFXRAM residueFaint artifacts$900000 GFXRAM not clearedClear 192KB at 900000900000-92FFFF
4-byte cursor patchCursor never wrapsOverwrote BNE.S opcode2-byte immediate
Item 9 = EXITNew item acts as exitJump table not extendedAdded new entry
Can't exit (VSync)Stuck on ULTRA SETTINGS$0004FC needed for inputDirect HW port reads at $800000
Wrong exit addressCrash/hang on exit$0D4172 was wrong returnExit to $0DD2B0 (menu draw loop)
"W CHECK" textGarbled menu itemText buffer overflowFixed string lengths

Lessons Learned

  1. CPS-2 has TWO OBJ RAM banks at $700000 and $708000 -- hardware reads one while game writes the other, swapping at VBlank. You must clear BOTH.
  2. Tile 0 is NOT blank -- In MvC1, it contains "11" debug graphics. Always fill cleared VRAM with a known blank tile index.
  3. VBlank handler is the enemy -- Calling JSR $0004FC triggers sprite DMA that overwrites your clean OBJ RAM. Mask interrupts to prevent this.
  4. Hardware ports work without interrupts -- $800000 is memory-mapped I/O that responds to direct reads regardless of CPU interrupt state.
  5. 2-byte vs 4-byte immediate sizes matter -- 68000 instruction encoding is precise; overwriting adjacent instructions causes silent corruption.
  6. Test menu cursor limits are stored in multiple locations -- Missing even one causes wrap bugs.
  7. Know your exit path -- JMP-based dispatchers require explicit return addresses. The test menu's draw entry at $0DD2B0 is the safe re-entry point.

References

  • CPS-2 hardware documentation (MAME cps2.cpp driver source)
  • CPS-2 sprite format analysis (MAME cps_draw.cpp)
  • MvC1 test menu disassembly via Ghidra + MAME debugger
  • 68000 Programmer's Reference Manual (Motorola)
EOF · $0A · 2,982 words · Daniel Plas Rivera
Share[X][LinkedIn]