reference22 min read$09

I Blamed MvC's Graphics. The Bug Was One Missing '@'.

A diagnostic menu painted garbage no amount of memory clearing could fix. The text renderer was still drawing because nobody had told it to stop.

systems-engineeringassemblyreverse-engineeringcps268000
CPS-2 Reverse EngineeringPart 7 of 43
Browse all writing
On this page
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.

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.

How it works
Drawing the 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)
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:

How it works
Drawing the 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, one translation per function, confirming their structure:

FunctionAddressSizeFirst Instruction
load_red_venom$022B2E66 bytesmove.w #$24, d0
load_orange_hulk$05D04460 bytesmove.w #$24, d0
load_gold_war_machine$07EF9012 bytesmove.w #$24, d0
load_shadow_lady$0A26C820 bytesmove.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,.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:

How it works
Drawing the diagram…
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.

How it works
Drawing the 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
How it works
Drawing the 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:

How it works
Drawing the 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

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 needed

Instrumentation 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 assumptionWhat 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 clearEach 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:

  1. 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."
  2. Our template ended with $00,$00 instead 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.
How it works
Drawing the diagram…

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

BugSymptomRoot CauseFix
Menu hangs (regression)No button exits ULTRA SETTINGSInput 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 tilemapClear scroll1 $908000 before render
Brown background (regression)Whole bg turns brown/grayCleared scroll2/3 with wrong blank tile $0020Per-layer blanks: $0020/$0900/$0000
Silent sound testLP plays nothingQSound shared RAM written on even (dropped) bytesOdd-byte addressing $618001+2N
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 WRONG — $914000 in that range is the palette; blanket-clearing blacks the screen. Clear per-layer windows with per-layer blank tiles
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
"11 11"/tan/"H" artifacts (final)Survive ALL VRAM/OBJ clearsRenderer $00156C stream needs '@' terminator; template ended $00,$00 → renderer painted junk past stream end every frame4-byte stream header + '@' terminator
Title never displayed"9.ULTRA SETTINGS" missingRenderer eats first 3 stream bytes as [X][Y][attr] header (not overscan!)Proper stream-start header
SELECT/START footer shiftedInstruction rows misaligned vs stockEntry metadata positions the NEXT line; spliced items carried hardcoded metadataPreserve stock entry's final 4 metadata bytes
Sound code auto-repeatHeld key spins the counterRaw level-triggered input in a fast loopEdge detection (prev-state mask at $FF800A)
AUDIO TRACKS: 00Real imported tracks not countedRead a dead variable (num_audio_tracks) instead of the CPS-2 import listFall back to len(cps2_import_info)

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. 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 $0004FC each loop lets the game maintain the scroll layers for you (see the postscript).
  4. Verify port addresses against the running machine -- The real P1 stick+punches port is $804000 (active-low), not $800000 or 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.
  5. 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 $914000 is the palette: never blanket-clear Video RAM. 5b. Know your renderer's stream format before feeding it -- $00156C wants [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).
  6. 2-byte vs 4-byte immediate sizes matter -- 68000 instruction encoding is precise; overwriting adjacent instructions causes silent corruption.
  7. Test menu cursor limits are stored in multiple locations -- Missing even one causes wrap bugs.
  8. 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)

Written by Daniel Plas Rivera · 4,743 words · $09

ShareXLinkedIn