The actual bug — a 65816 lesson…
The MVN trap: some opcodes hardcode their banks
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.
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!
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:
ADC $8E has no CLC — it deliberately inherits the carry from the 24-bit source-address addition above it.
JSL doesn't disturb flags, so the carry chain survives into our routine unchanged.