The actual bug — a 65816 lesson…

The MVN trap: some opcodes hardcode their banks

Symptom

Menu KO tiles showed the right offsets but sourced from bank $D2 (the vanilla walking-sprite bank) instead of $E8. Tester's tile-viewer data:

| Hero | Job | Expected block | Observed source |

|------|-----|----------------|-----------------|

| Faris (charID 3) | 0 | 3 → $0240 | $12 0240 |

| Lenna (charID 1) | 1 | 6 → $0480 | $12 0480 |

| Bartz (charID 0) | 10 | 50 → $2580 | $12 2580 |

Three-for-three matches on the offset with a constant wrong bank. This table is a beautiful example of how precise test data collapses a bug hunt: the offset math is proven correct, so only the bank plumbing is suspect.

Root cause

The menu tile copier C2:D304 moves tiles with MVN, the 65816 block-move instruction — and MVN is peculiar: both the source and destination banks are encoded in the instruction itself, as operand bytes. You cannot parameterize an MVN's bank through a register. So Square's programmers did the pragmatic 1992 thing — they enumerated the three banks character graphics lived in:

C2/D319  LDA $E2 / ADC $8E / AND #$00FF   ; resolve bank byte
C2/D320  CMP #$00D4 / BEQ → MVN $D4,$7E
C2/D325  CMP #$00D3 / BEQ → MVN $D3,$7E
C2/D32A  LDA #$001F / MVN $D2,$7E         ; DEFAULT — the fall-through

Our bank $E8 matched neither compare and fell into the $D2 default.

Vanilla bank + our offset = exactly the observed tiles!

Fix

The 7 bytes of bank-resolve code at C2:D319 become JSL $E85300 / BRA $D340 / NOP, and the freespace routine (50 bytes at E8:5300) re-implements the dispatch with a fourth case:

CMP #$00E8 / BEQ → MVN $E8,$7E     ; the Wounded Wardrobe case!

Two preservation details that make this a drop-in replacement: