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.
Artifacts: A build pipeline that reads live values out of the ROM, generates the 68000 source for the diagnostic screen around them, assembles it and injects the result -- plus the assembled handler itself, occupying $0F1000-$0F1FFF in expanded ROM space.
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.
graph TD
subgraph "Original F2 Test Menu"
M1["1: DIP SW TEST"]
M2["2:INGS TEST"]
M3["3: COLOR BAR"]
M4["4: SOUND TEST"]
M5["5: CROSS HATCH"]
M6["6: C.P.S.2 ADJUST"]
M7["7: I/O CHECK"]
M8["8: MEMORY CHECK"]
M9["9: EXIT"]
end
subgraph "Modified Test Menu"
N1["1: DIP SW TEST"]
N2["2: INGS TEST"]
N3["3: COLOR BAR"]
N4["4: SOUND TEST"]
N5["5: CROSS HATCH"]
N6["6: C.P.S.2 ADJUST"]
N7["7: I/O CHECK"]
N8["8: MEMORY CHECK"]
N9["9: ULTRA SETTINGS"]
N10["10: EXIT"]
end
M9 -.->|"Shifted to 10"| N10
N9 -.->|"NEW!"| PS["ULTRA SETTINGS Handler"]
style N9 fill:#112d20,stroke:#419865,color:#eaf2ef,stroke-width:3px
style PS fill:#12303a,stroke:#397b83,color:#eaf2ef,stroke-width:3pxKey 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:
graph LR
subgraph "Jump Table"
J0["[0] DIP SW handler"]
J1["[1] INGS handler"]
J8["[8] ULTRA SETTINGS → $0F1000"]
J9["[9] EXIT handler"]
end
J8 -->|"JMP"| PS["Our Custom Code at $0F1000"]
style J8 fill:#112d20,stroke:#419865,color:#eaf2ef
style PS fill:#12303a,stroke:#397b83,color:#eaf2efThe 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, one translation per function, confirming their structure:
| Function | Address | Size | First Instruction |
|---|---|---|---|
load_red_venom | $022B2E | 66 bytes | move.w #$24, d0 |
load_orange_hulk | $05D044 | 60 bytes | move.w #$24, d0 |
load_gold_war_machine | $07EF90 | 12 bytes | move.w #$24, d0 |
load_shadow_lady | $0A26C8 | 20 bytes | move.b #$24, d0 |
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:
sequenceDiagram
participant HW as CPS-2 hardware
participant B1 as OBJ bank 1 at $700000
participant B2 as OBJ bank 2 at $708000
participant Us as Our clear routine
HW->>B1: reading this bank right now
Us->>B2: clear the bank we can see
Note over B2: looks clean in the debugger
HW->>HW: VBlank -- banks swap
HW->>B2: now reading the cleared bank
Note over B1: the bank we never touched<br/>still holds the last fight's sprites
B1-->>HW: brown character blobs return
Us->>B1: fix: write FF terminators to BOTH banks
Us->>B2: every one of 1024 entries, each bank| 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.
graph TD
subgraph "Before Fix (3 bugs combined)"
B1["Tile 0 = '11' graphics everywhere"]
B2["Only clearing 1 of 2 OBJ banks"]
B3["VBlank handler rewrites sprites each frame"]
B4["Result: garbled screen with artifacts"]
end
subgraph "After Fix"
A1["VRAM filled with tile $0020 (space)"]
A2["GFXRAM at $900000 cleared"]
A3["Both OBJ banks cleared with $FF terminators"]
A4["Interrupts masked -- no VBlank repopulation"]
A5["Result: clean diagnostic display"]
end
style B4 fill:#351b1b,stroke:#b56565,color:#f6dada
style A5 fill:#112d20,stroke:#419865,color:#eaf2efExit 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
flowchart TD
A["Enter ULTRA SETTINGS"] --> B["Clear VRAM + GFXRAM + both OBJ banks"]
B --> C["Draw all 22 diagnostic lines"]
C --> D["Mask interrupts (IPL=7)"]
D --> E["Final sprite clear (stays clean)"]
E --> F["Wait loop"]
F --> G["Read $800000 directly"]
G --> H{"Any button pressed?"}
H -->|No| F
H -->|Yes| I["Restore SR (unmask interrupts)"]
I --> J["JMP $0DD2B0 (test menu redraw)"]
style A fill:#12303a,stroke:#397b83,color:#eaf2ef
style J fill:#112d20,stroke:#419865,color:#eaf2efText 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:
flowchart TD
A["Build pipeline (one command)"] --> B["Load base ROMs from ZIP"]
B --> C["Expand GFX: 8x 4MB → 16MB"]
C --> D["Expand Audio: 2x 4MB → 8MB"]
D --> E["Apply menu byte patches"]
E --> F["Generate 68000 assembly"]
F --> G["Compile with vasm"]
G --> H["Word-swap binary output"]
H --> I["Inject at $0F1000 in ROM"]
I --> J["Save 148MB mvscud.zip"]
style A fill:#33290f,stroke:#b28c36,color:#f4e9c8
style J fill:#112d20,stroke:#419865,color:#eaf2efWhat 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
Postscript: Re-Verifying Against the Running Machine
Months later the handler had drifted. A build regression had replaced the interrupt-masked render loop with a "minimal" busy-wait, and three defects had crept in: the screen hung (no button would exit), the diagnostic text overlapped stale menu text, and background garbage tiles ("11 11", brown blocks, a ghost "H") bled through. Rather than eyeball it, I drove the whole thing headlessly under MAME and let the running machine settle the arguments — several of which contradicted the assumptions written above.
A headless reproduction harness. MAME's Lua interface (-autoboot_script) can assert input ports, read the 68000's state, tap memory, and dump the emulated framebuffer — no window required. The tricky part was entering the service menu: holding the "Service Mode" line does nothing (it is a toggle, needing a press→release edge), and the test menu only appears after ~1,200 frames of boot intro. The harness toggles the switch, detects the menu by its screen signature (~94% black with sparse text), navigates to item 9, selects it, and screenshots each state to PPM:
-- detect the menu, then drive P1 Down x8 -> Button 1
if bf() > 0.80 and bf() < 0.995 and stable then menuUp = n end
-- capture the emulated screen straight to disk
local px, w, h = screen:pixels() -- 32-bit ARGB, no display neededInstrumentation beats guessing. Frame-boundary PC sampling only ever caught the idle wait-loop ($000328) — the menu logic runs inside the VBlank IRQ. So I switched to execution and exception taps: a read-tap on the handler region to prove it ran, and a tap on the 68000 exception-vector table ($000008-$00002F) that reads the pushed stack frame to recover the faulting PC and address the instant any bus/address/illegal fault fires. That turned "it crashes somewhere" into an exact instruction.
What the running machine corrected. Several claims earlier in this post were reverse-engineered from the disassembly and MAME docs but not re-checked against the game actually running. Driving live inputs and tapping writes disproved three of them:
| Earlier assumption | What the emulated CPS-2 actually does |
|---|---|
P1 input at $800000 / $804010 | $804010 is the kicks port (reads $FFFF for LP/HP), so no button registered — the hang. The real P1 stick + LP/MP/HP is $804000 (active-low: Up=bit3, LP=bit4, HP=bit6), verified by pressing and watching bits drop. |
Scroll VRAM at $660000 | $660000 is 16 KB of "Extra Memory" and clears nothing. The real tilemaps are $900000-$92FFFF — confirmed by tapping writes during an attract match (scroll1 text = $908000, 53,972 sprite writes to $708000). |
Fill all VRAM with tile $0020; mask IRQs to hold the clear | Each scroll layer has its own blank tile — scroll1 = $0020, scroll2 = $0900, scroll3 = $0000 (read from the game's clean menu). Filling scroll2/3 with $0020 paints a brown textured tile. And IRQ-masking (ori #$0700,sr) is a supervisor instruction — the handler runs in user mode, so it privilege-faults; the fix is to run the game's own VSync routine (jsr $0004FC) each loop so the game maintains the scroll layers for us. |
The QSound trap. The interactive sound test never played, independent of the input bug: CPS-2's Z80 shared RAM is wired umask16($00FF) — only the odd 68000 byte reaches the Z80, and Z80 byte $C000+N lives at $618001 + 2N. The play routine had been writing sequential (even) addresses, which the hardware silently drops. Mirroring the working sound-test hook ($618001/$618003/$618009/$61801F) fixed it.
With $804000 input, per-layer blank tiles, the VSync loop, and the odd-byte QSound protocol, the screen entered cleanly, responded to Up/Down/LP/HP, played injected tracks, and exited to a clean menu. One family of artifacts survived — a row of "11 11" tiles along the bottom, tan blocks on the flanks, a faint "H" — which I initially blamed on CPS-2's protection-obfuscated CPS-A scroll registers. That theory was wrong, and the real answer is the best bug of the whole project. Keep reading.
Postscript II: The Artifacts Were Never Where We Looked
The stubborn leftovers survived everything: clearing every tilemap window, clearing both OBJ banks every frame, even an isolate-and-restore sweep that zeroed each 16KB window of Video RAM one at a time (saving and restoring content so a palette wipe couldn't mask later windows). The sweep did teach us the real display map — $908000 = text layer, $90C000 = a higher-priority layer whose blank tile $6820 is transparent (zero it and the whole screen floods with opaque tile-0 "1" glyphs), $914000 = the palette (never write it), $918000 = blank pairs $0020,$0015 — but the artifacts shrugged all of it off. Write-taps then showed something damning: during the ULTRA screen, nobody writes OBJ RAM but our own clear loop, and nobody writes the tilemaps but our own fill and the game's text renderer.
Which left exactly one suspect: our own render call. Disassembling the game's text renderer at $00156C settled it. Its stream format is not the fixed 28-byte records the patch had assumed for months. It is:
[X][Y][attr] chars... '/' [X][Y][attr] chars... '@''/' ($2F) starts a new positioned line. '@' ($40) — and only '@' — terminates the stream. Two consequences:
- Our template began with raw text, so the renderer ate the title's first three characters as a position header. "9.ULTRA SETTINGS" had never displayed — a bug previously misdiagnosed as "row 0x02 is overscan."
- Our template ended with
$00,$00instead of'@'. The renderer marched straight past the end of the template, interpreting copy-slack and junk as characters and headers — painting tile-0 "11" glyphs and garbage cells at junk-controlled positions. The "artifacts" were the renderer's own brush, every single frame, which is precisely why no amount of VRAM clearing could remove them.
flowchart TD
tpl["Our text template"] --> rend["Game's text renderer at $00156C"]
rend --> fmt["Real stream format:<br/>X, Y, attr, then chars,<br/>slash starts a new positioned line,<br/>at-sign and ONLY at-sign ends the stream"]
fmt --> c1["Template began with raw text<br/>-- renderer ate the title's<br/>first three characters as a header"]
c1 --> miss["9.ULTRA SETTINGS never displayed<br/>-- misdiagnosed for months<br/>as 'row 0x02 is overscan'"]
fmt --> c2["Template ended with 00 00,<br/>not an at-sign"]
c2 --> past["Renderer marched past the end,<br/>reading copy-slack as characters<br/>and headers"]
past --> art["Tile-0 '11' glyphs and garbage cells<br/>repainted at junk positions<br/>EVERY FRAME"]
art --> why["Which is why no amount of<br/>VRAM or OBJ clearing removed them:<br/>the brush was ours"]One four-byte stream header, one '@' terminator, and every artifact vanished in the same build — plus the title displayed for the first time. The same discovery explained the main menu's misaligned "SELECT/START" footer: each entry's metadata positions the next line (successor chaining), so splicing new items in with hardcoded metadata had displaced the stock instruction rows. Preserving the original entry's final 4 metadata bytes restored the factory alignment.
The rebuilt screen also gained what a diagnostic menu should have had all along: a live input row (INPUT: FFFF FFFF — raw port words for stick/punches and kicks, refreshed every 8 frames, so you can watch bits drop as you press), edge-detected input (press-and-hold no longer machine-guns the sound code), the real injected-track count (it had been reading a dead variable and showing 00 forever), and a build-date stamp so every screenshot self-documents which ROM produced it.
Bug Chronicle
| Bug | Symptom | Root Cause | Fix |
|---|---|---|---|
| Menu hangs (regression) | No button exits ULTRA SETTINGS | Input read from dead port $804010 (kicks, reads $FFFF) | Read $804000 (real P1 stick+punches) |
| Text/menu bleed (regression) | Diagnostic text overlaps "10: EXIT" | Handler stopped clearing the tilemap | Clear scroll1 $908000 before render |
| Brown background (regression) | Whole bg turns brown/gray | Cleared scroll2/3 with wrong blank tile $0020 | Per-layer blanks: $0020/$0900/$0000 |
| Silent sound test | LP plays nothing | QSound shared RAM written on even (dropped) bytes | Odd-byte addressing $618001+2N |
| 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 | |
| 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 |
| "11 11"/tan/"H" artifacts (final) | Survive ALL VRAM/OBJ clears | Renderer $00156C stream needs '@' terminator; template ended $00,$00 → renderer painted junk past stream end every frame | 4-byte stream header + '@' terminator |
| Title never displayed | "9.ULTRA SETTINGS" missing | Renderer eats first 3 stream bytes as [X][Y][attr] header (not overscan!) | Proper stream-start header |
| SELECT/START footer shifted | Instruction rows misaligned vs stock | Entry metadata positions the NEXT line; spliced items carried hardcoded metadata | Preserve stock entry's final 4 metadata bytes |
| Sound code auto-repeat | Held key spins the counter | Raw level-triggered input in a fast loop | Edge detection (prev-state mask at $FF800A) |
| AUDIO TRACKS: 00 | Real imported tracks not counted | Read a dead variable (num_audio_tracks) instead of the CPS-2 import list | Fall back to len(cps2_import_info) |
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.
- You run in user mode -- Masking interrupts (
ori #$0700,sr) privilege-faults because the test menu executes unprivileged. Don't fight the VBlank handler; use it —jsr $0004FCeach loop lets the game maintain the scroll layers for you (see the postscript). - Verify port addresses against the running machine -- The real P1 stick+punches port is
$804000(active-low), not$800000or the kicks port$804010. A single wrong I/O address is the difference between a working menu and a dead hang; press a button and watch the bits. - Every scroll layer has its own blank tile -- text layer
$908000=$0020,$0006, the high-priority layer$90C000=$6820,$0000(transparent — tile 0 there is an opaque "1" glyph),$910000=$0900,$0000,$918000=$0020,$0015. And$914000is the palette: never blanket-clear Video RAM. 5b. Know your renderer's stream format before feeding it --$00156Cwants[X][Y][attr] chars '/' ... '@'. A missing'@'terminator meant the renderer painted junk beyond the template every frame — artifacts no amount of VRAM clearing could remove, because the brush was ours. When artifacts survive every clear, stop asking "what didn't I clear?" and start asking "what keeps drawing?" (write-taps answer that in minutes). - 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)