Kajar notes of ChronoRET, rebuilding Chrono Trigger one matched routine at a time

14 August 2026: opening bank C1, from the math helpers to the battle windows

Bank $C1, the battle engine, is open. In one day it went from an empty file to 4,085 matched bytes: math and number formatting, the battle status bar and all its callees, the item and tech list renderers, and the per-frame window upkeep. 33 functions over sessions 34 to 40, plus a root-cause fix in the disassembler.

Opening a new bank

The build runs asar in patch mode: it starts from the original ROM and overwrites only the ranges the source covers. Opening a bank means:

  1. creating asm/bankC1/bankC1.asm
  2. adding it to asm/main.asm
  3. checking that make diff still reports 4,194,304 bytes identical before writing any matched code

The third step is not optional. If the new file emits or changes bytes outside its range by accident, the diff shows it at once. The build was clean before any code went in.

CPU state on entry. In bank $C0 the boot sequence leaves native mode, M=1 (8-bit A), X=0 (16-bit X/Y), DP=$2100, DB=$00 and the stack at $06FF. Bank $C1 is entered from the field engine with JSL $C10000 (Battle_Main), which JMPs into a bootstrap chain. The math routines at $C1:0089 are leaves called from deep inside that system, and their code assumes:

  • M=1 on entry. Each uses REP #$20 when it needs 16-bit A (to read a product) and SEP #$20 after.
  • X=0 (16-bit X/Y). The shift stubs never touch X; the multiply/divide routines use it once (an LDX in Battle_MulAccum to read the result register into a 16-bit index).
  • DP=0. Direct-page accesses ($AD, $AE, $AF, $A5, $B1 and so on) reach WRAM $7E:00xx.

DB varies. Battle_Mul8 writes the multiply registers at $004202/$004203 with STA.l (4-byte long form), which works whatever DB is. Battle_MulAccum saves DB with PHB, sets DB=0 (TDC / PHA / PLB) and uses the shorter STA.w. Battle_Divide goes back to the long form. That reflects what each routine can assume about its callers, not carelessness.

The hardware multiply and divide

The SNES CPU has math helpers at fixed addresses in the $4200 space.

Multiply ($4202–$4216):

  • write the 8-bit multiplicand to $4202 (WRMPYA)
  • write the 8-bit multiplier to $4203 (WRMPYB); this starts the multiply
  • wait at least 8 master cycles (2 NOPs are enough)
  • read the 16-bit result from $4216 (RDMPYL/H)

Divide ($4204–$4214):

  • write the 16-bit dividend to $4204 (WRDIVL) and $4205 (WRDIVH)
  • write the 8-bit divisor to $4206 (WRDIVB); this starts the divide
  • wait at least 16 master cycles; the game uses 6 NOPs, which is conservative and safe
  • read the 16-bit quotient from $4214 (RDDIVL/H) and the 16-bit remainder from $4216 (RDMPYL/H)

The remainder and the product share $4216. Which one is there depends on the last operation, which is why RDMPYL appears in both kinds of routine.

The math routines

Battle_Mul8 ($C1:0089–$00A6, 30 bytes; scouting had said 29). Reads $AD and $AE, mirrors them to $77/$78 (temporary storage, probably for the caller), writes both to the multiply registers in long form, reads the product with REP #$20 around the read, stores it at $AF, restores M=1, returns. The two NOPs after WRMPYB are the timing guard.

Battle_MulAccum ($C1:00A7–$00D6, 48 bytes). Does two multiplies and adds both into a 32-bit total: set DB=0 (so STA.w $4202 works), multiply $A5 × $A7, then $A8 × $A7, and add the products into $A9/$AA (16-bit low, 16-bit high). REP #$21 before the final ADC clears both the M flag (16-bit A) and carry in one instruction, so no separate CLC. PHP/PLP around the second multiply keep the processor flags safe across the register writes.

Battle_Divide ($C1:00D7–$010C, 54 bytes). Writes $B1 to WRDIVL, $B2 to WRDIVH and $B3 to WRDIVB, waits six NOPs (nearly twice what the hardware needs, a cautious choice for emulators and edge-case timing), reads the quotient from $4214 and the remainder from $4216, stores them at $B5 and $B7. An INC/STZ pair on $A029 brackets the work, like a "divide unit busy" re-entrancy guard. Whether anything reads it is a question for later; it is matched as is.

Shift helpers ($C1:010D–$011E, 18 bytes). Two fall-through chains:

  • Left: Battle_ShiftLeft7 at $010D has four ASL A, falls into Battle_ShiftLeft4 (one more ASL at $0111), which falls into Battle_ShiftLeft3 (three ASL + RTS at $0112–$0115). From ShiftLeft7 that is eight shifts, from ShiftLeft4 five, from ShiftLeft3 three. The reference names count each entry point's own contribution to the chain, not the total. (Eight shifts = ×256, handy for indexing 256-byte tables.)
  • Right: the same with LSR A. Loc_C10116 at $0116 leads into Battle_ShiftRight6 at $0118, then ShiftRight5 ($0119), ShiftRight4 ($011A) and ShiftRight3 ($011B–$011E, three LSR + RTS).

In asar this is one org block with a label at each entry; labels emit no bytes. After these four routines the project crossed 20,000 bytes for the first time: 20,119 across 4 banks.

Number display

A caller audit of the math helpers: Battle_Mul8 is called from 11 sites across the bank, Battle_MulAccum from 13, Battle_Divide from 13, the shift cluster from 3. Almost everything in the battle engine uses them: damage formulas, stat scaling, experience. The first caller of Battle_MulAccum is at $C1:021C, inside Battle_SinLookup, just past the shift cluster in address order. That set the path.

The three routines between the shift cluster and the sine lookup format numbers:

  • BattleMsg_FormatNumberDigits ($C1:011F, 85 bytes) splits a 16-bit value in a WRAM slot into hundreds, tens and ones by repeated subtraction, not hardware division: subtract 100 in a loop to count hundreds, undo the overshoot, do the same with 10, and the ones digit is what's left. Each digit is looked up in a glyph table at $CCF903 (bank $CC) to get its font tile, and the results go to four consecutive WRAM slots. The thousands slot always gets $FF, a blank marker that BattleMsg_BlankLeadingZeros later reads as "suppress this leading zero".
  • Battle_DivTen9499 ($C1:0174, 53 bytes) is the tens-and-ones half of the same logic, for two-digit numbers. Same subtraction loop, same glyph table, no hundreds pass.
  • BattleMsg_ReencodeTextBuffer ($C1:01A9, 80 bytes) copies a 16-character text buffer with MVN, then turns each character into a pair: a tile index in one buffer and a "plane" byte in a parallel buffer. $40–$68 get tile offset +$40 and plane $71; $69–$72 get +$17 and plane $72; $73 and up pass through with plane $FF. The plane byte tells the renderer which of two tilesets holds the glyph.

A new entry convention. These three enter with A already 16-bit (M=0), set by the caller. BattleMsg_ReencodeTextBuffer starts with its own REP #$20 anyway. FormatNumberDigits and DivTen9499 rely on the caller's M=0 for their 3-byte immediates (E9 64 00 for SBC #$0064 and so on). asar 1.91 has no "assume M:16" directive, so those instructions are written as raw db bytes. Everything else around them (LDA.w, STA.w, branches, stack operations) doesn't depend on M. make diff was byte-exact on the first attempt.

Trig and geometry

Battle_SinLookup ($C1:01F9, 41 bytes) computes sin(A) * $AE and returns the high byte of the product in $AA. It multiplies the angle by 4, wraps it to 0–1023 with AND #$03FF, and uses it as a byte index into a 512-entry sine table at $C0F900 in bank $C0. The table holds magnitudes only. For the second half of the cycle (indexes $0200–$03FF) the code negates the value with EOR #$FFFF plus 1 (16-bit two's complement). The result goes to $A7, $AE is copied to $A5, and Battle_MulAccum does the multiply. It is the first matched routine that calls another matched routine.

Calc_Delta16 ($C1:0222, 119 bytes) turns a 2D displacement into a direction angle. It takes signed 8-bit coordinate pairs, ($D3, $D5) for X and ($D4, $D6) for Y, subtracts them into signed 16-bit deltas (sign-extending with the borrow trick LDA #$00 / SBC #$00), takes absolute values by XOR-and-subtract, and mixes them into a table index, roughly (|ΔX| >> 3) + (|ΔY| & $FFF8) * 4, in $E2. That indexes a base direction byte at $C0F300. The sign bits of the high bytes ($D8 for ΔX, $DA for ΔY) pick one of four quadrant paths, which add or subtract $80 or negate the angle.

It has dead code, kept byte for byte: after computing $E2 it does ASL A / TAX (2 × $E2 into X), then immediately overwrites X with LDX $E2.

Bank $C1 conventions so far:

  • M=1 on entry for most routines
  • M=0 on entry for the number-display routines, set by the caller and noted in each routine's header comment
  • X=0 (16-bit X/Y) throughout; no matched routine changes it
  • DP=0: direct-page accesses reach $7E:00xx
  • DB varies: the math helpers use STA.l for hardware registers because DB is unknown; BattleMsg_ReencodeTextBuffer and Battle_SinLookup inherit DB=$7E from their callers and use absolute accesses to $94xx WRAM
  • no RTL: all of these are JSRed from inside bank $C1

The battle status bar

BattleUI_BuildStatusBarFrame

BattleUI_BuildStatusBarFrame ($C1:0299–$C1:05A6, 782 bytes) rebuilds the whole status panel whenever the battle needs it: entering a fight, a change of active character, HP/MP changes. It doesn't just draw numbers; it rebuilds the entire tilemap buffer.

The status bar is not a separate hardware layer. It is a rectangle of the same BG layer that shows the battle. The buffer at $7E:0CC0 is 192 word pairs (384 bytes) of (tile index, attribute byte); the attribute byte holds palette, priority and flip bits, and a DMA copies the buffer to VRAM every frame. In this buffer $29 is the normal palette and $2D the bright/highlight palette used for low HP or the active character. The font tiles for 0–9 live in VRAM, so a digit is just a tile index, chosen through the table at $CCF903.

Battle screenshot with four outlined regions: command window, names, HP and MP digits, ATB gauges

The battle windows in a frame captured from the rebuilt ROM: the command window, and the status bar's names, HP and MP digits and ATB gauges, all built in the tilemap buffer at $7E:0CC0.

The routine:

  1. clears all 192 pairs to (tile $00, attribute $29) in a tight loop
  2. writes border tiles (the separators between HP and MP columns, small corner tiles and so on) at offsets that depend on BattleGaugeDisplayType at $7E:9F20 (0, 1 or 2, three UI layouts)
  3. for each of the three PC slots that is active:
    1. writes the 5-character battle name from $7E:9412 (per-slot start from a table at $CCF837) into two tilemap rows: the tile row and a copy 64 word pairs earlier (the palette row)
    2. reads CurHp and MaxHp (per-slot pointer from $CCF8ED), formats CurHp into three digit tiles with BattleMsg_FormatNumberDigits, blanks leading zeros with BattleMsg_BlankLeadingZeros, and writes them at a gauge-type offset. It sets a low-HP flag at $7E:A10F when CurHp ≤ MaxHp/8, which switches the digit attribute from $29 to $2D, the palette that gives the flashing red
    3. for gauge type 0 (the "full" layout) does the same for MaxHp
    4. formats CurMp with Battle_DivTen9499 and writes it at another offset
    5. for gauge types 1 and 2, appends TP/ATB gauge tiles ($5D/$5E) and calls BattleUI_DrawSlotGaugeBar
  4. handles the enemy slots the same way for names (11 characters from $7E:ACBC)
  5. if the highlighted PC changed, updates the tracking variables and dispatches a column-attribute update

BattleMsg_BlankLeadingZeros ($C1:104E–$C1:106D, 32 bytes) is its helper.

The db $69 pattern. The routine runs mostly with 16-bit A and adds or subtracts small constants like #$000E or #$0040 to tilemap addresses. asar 1.91 sometimes emits the 2-byte M=1 form ($69 $0E) instead of the 3-byte M=0 form ($69 $0E $00), which shifts every byte after it. The fix is the one used for SBC #$0064: write the bytes.

65C816 Assembly
REP #$20
CLC
LDA.B $84
db $69,$0E,$00          ; ADC #$000E — explicit 3-byte M=0 encoding
TAY

It came up thirteen times in this one routine, every ADC/SBC with a 16-bit immediate in M=0, so it now has its own entry in ASAR_QUIRKS.md (quirk #4). The original compiler happily emitted 3-byte immediates in 16-bit mode even for small operands; asar tries to be clever about operand size and sometimes guesses wrong.

At this point bank $C1 stood at 1,342 of 63,904 bytes (2.10%).

The callees

All four callees stubbed by BuildStatusBarFrame (BattleUI_DrawSlotGaugeBar at $C106F0, BattleSys_SlotPanelRefresh at $C10785, BattleMenu_DrawReadyWindowEdges at $C1081E, BattleUI_SetPanelAttrColumn at $C108E8) are matched, plus one more:

BattleUI_DrawSlotGaugeBar ($C1:06F0, 149 bytes) draws the ATB gauge, the bar that fills while you wait for your turn (a chime plays when it's full). It computes the fill with the hardware multiply/divide: current ATB shifted left 8 (×256), divided by the maximum, gives 0–256; shifted right 3 (÷8) that is 0–32 units filled, of a 32-unit strip. The tiles count the units still to fill: each whole tile with 8 left gets $6F, the next gets $67 plus the units left in it, and the rest keep $67 (nothing left to fill). The attribute is $2D if there is any ATB and $29 if the gauge is empty, which is why an empty gauge looks dim and a filling one bright.

Worked example of the gauge arithmetic and the four tile numbers it produces

A worked example of BattleUI_DrawSlotGaugeBar. The tile numbers count the part of the gauge still to fill: $6F for a whole empty tile, $67 + n for one with n units left.

BattleSys_SlotPanelRefresh ($C1:0785, 153 bytes) redraws the panel behind one character's status when the active PC changes. It writes a 6-row × 7-column tile block from one of two tables: $CCFA41 for the "empty" panel and $CCFA6B for the "ready" indicator (the look when a character's ATB is full and they're waiting for input). The choice goes through a cascade: a panel-state flag at $95F1, per-slot presence flags, a timing/animation flag at $A117, whether the PC-change flag $A6DE equals 3, and whether this slot is the active PC. Outer loop 6 rows, inner loop 7 tiles with attribute $29, plus 64 (the row stride) between rows. No callees.

BattleMenu_DrawWindowEdgeStrip ($C1:0929, 52 bytes) copies one vertical strip of the command window border (the Fight / Magic / Item box) into its tilemap at $7E:0B40. The tiles come from $D159FC in bank $D1. dp $84 picks the side: zero is the left edge (14 bytes per row, $0E in the table index), non-zero the right edge (12 bytes per row, offset 2 with INX INX). 6 rows, +64 per row. It is the first matched routine to read bank $D1; until now data came from $CC (tables) or $7E (WRAM). Bank $D1 looks like the home of the window decoration graphics.

BattleUI_SetPanelAttrColumn ($C1:08E8, 65 bytes) updates the attribute column behind the active PC. It doubles the slot index in A and looks up the tilemap column from $CCFA29 (gauge types 0 and 2) or $CCFA23 (type 1), since the layouts place the columns differently. Then it writes $29 to 10 positions, a 5-row × 2-column block, with absolute indexed stores.

BattleMenu_DrawReadyWindowEdges ($C1:081E, 202 bytes) draws the full border. It checks the slot states $A6D9, $A6DA and $A6DB; for each present slot (non-negative state) it picks a column and calls BattleMenu_DrawWindowEdgeStrip. The columns depend on who is present: if slot 0 is absent, slot 1's strip shifts left; if slot 1 is absent, slot 2's does. The $84 flag alternates 0 (left) and 1 (right). Then it calls BattleUI_SetPanelAttrColumn for the active PC ($A6DD), increments a redraw flag at $A43F, and falls through into a sub-entry.

BattleUI_ClearActivePanelColumn (sub-entry at $C1:0872) is also called from $C10C6E, so it has its own label. It zeroes a 6-row × 2-column block for the current active slot ($95D5), and if the redraw flag is set, repaints four corner tiles ($60, $61, $62, $63) at the newly active PC ($A6DD): a small mark showing which character is acting.

With these, every callee of BattleUI_BuildStatusBarFrame is matched.

BattleUI_UpdateNextPcPanel

BattleUI_UpdateNextPcPanel ($C1:05A7–$C1:06EF, 329 bytes) is the incremental partner of BuildStatusBarFrame. Instead of a full redraw, it advances a rotating pointer ($A6E0) to one "next" PC slot, formats its HP/MP digits, picks the low-HP attribute, and draws the ATB bars for all three slots.

The first 28 bytes are early exits:

Text Only
LDA $A6DB      ; slot 2 state
BMI .common    ; absent → still update counter
LDA $A09A      ; update-inhibit flag
BNE .common    ; inhibited → skip digit refresh
LDA $9F20      ; gauge display type
BEQ .common    ; type 0 → fixed layout, skip
DEC A          ; type 1 → 0 (falls through); type 2 → 1 (branches)
BNE .common    ; type 2 → skip
JMP .tp_gauges ; type 1 → skip to ATB-bar draw only

Only gauge type 1 (HP+MP) reaches the digit refresh; type 0 (HP+MaxHP+MP) and type 2 (HP+MP+TP) skip it. .common increments $A6E0, wraps at 3, and checks slot presence before the digit update. The HP path reuses the low-HP test:

Text Only
LDA $5E30,X    ; CurHP (16-bit, M=0)
STA $9499
BEQ .set_low_hp
LDA $5E32,X    ; MaxHP
JSR Battle_ShiftRight3   ; maxHP >> 3
CMP $9499
BEQ .check_a110          ; tied: check secondary gate
BCC .hp_ok               ; maxHP/8 < curHP: not low
; else fall through to .check_a110
LDA $A110
BEQ .hp_ok
.set_low_hp:
INC.w $A10F    ; set low-HP flag (16-bit INC while M=0)

HP is low when curHP == 0, or when curHP <= maxHP/8 and $A110 is non-zero. Low selects $2D, normal $29. For gauge type 0, three extra attribute bytes go to $0CC9,Y / $0CCB,Y / $0CCD,Y, the MaxHP digit columns that layout shows.

The MP digits sit +$10 from the HP base for type 0 (which has the MaxHP column first) and +$08 for types 1 and 2, two more 3-byte immediates:

65C816 Assembly
CLC
LDA.b $86            ; base Y (saved earlier)
db $69,$10,$00       ; ADC #$0010 (type 0 path)
TAY
BRA .mp_dest_done
...
CLC
LDA.b $86
db $69,$08,$00       ; ADC #$0008 (types 1/2 path)
.mp_dest_done:
TAY
JSR Battle_DivTen9499

The tail draws the ATB bars for all three slots. It is a global sub-entry, Battle_DrawHpBars at $06DB, because it can be called directly:

65C816 Assembly
Battle_DrawHpBars:
    STX.b $80       ; slot counter = 0 (16-bit X = 0 stored to $80/$81)
.gauge_loop:
    LDX.b $80       ; load slot (16-bit DP load)
    LDA $96F5,X     ; slot presence flag
    BEQ .next_slot
    JSR BattleUI_DrawSlotGaugeBar
.next_slot:
    INC.b $80       ; 8-bit DP increment (only increments $80, not $81)
    LDA.b $80
    CMP #$03
    BNE .gauge_loop

INC.b $80 (E6 80) only touches $80, so $81 stays 0 and the 16-bit LDX.b $80 gets {$00, slot}.

Two matching notes. A global label inside a routine starts a new local-label scope, so after Battle_DrawHpBars: the earlier JMP .done and BEQ .done couldn't see a .done: defined after it. The shared exit became the global label UpdateNpc_exit:. And there are six LDX.w #$0029 / LDX.w #$002D (3-byte forms, M=1 with 16-bit X), each followed by TXA to get the attribute into A for the STA $0CCx,Y writes.

The disassembler's X-width bug, fixed

In the status-bar callees, tools/disasm.py read LDX #$000C (A2 0C 00, 3 bytes with 16-bit X) as LDX #$0C (2 bytes) followed by a 00, producing fake BRKs and phantom BRLs into the $31xx range. The reference disassembly and the raw bytes gave the right decoding. asar itself tracked X correctly from context, since no SEP #$10 ever runs in this cluster.

The root cause: trace() started every trace with CPUState(m=True, x=True, e=True), emulation mode with 8-bit everything. That was fine in bank $C0 (the reset vector starts in emulation mode, and routines there often set 8-bit X anyway). In bank $C1, routines are entered with 16-bit X and no routine in this region ever does SEP #$10, so the model was wrong for the whole bank. The first clear case was at $C1:0640, where A2 29 00 decoded as LDX #$29 plus a phantom BRK, with branch offsets shifted after it.

The fix: trace() now defaults to CPUState(m=True, x=False, e=False) (native mode, 8-bit A, 16-bit X/Y), with a new --state option to override it per call. Full-bank linear sweeps of banks below $C0 keep the emulation-mode default, since those do start in emulation mode. $C1:0640 A2 29 00 now shows as LDX #$0029.

Rendering the item and tech lists

The range $C1:095D–$C1:0C2C (720 bytes, 8 routines) is the battle menu's list rendering, the code that builds the visible item list and tech list in the command window. It has two branches, items ($C1:095D–$C1:0A87) and techs ($C1:0A88–$C1:0C2C), with shared helpers at the bottom.

Names via MVN. The name strings live in ROM bank $CC, and the code copies them to WRAM with the block-move instruction. asar's syntax puts the destination bank first:

65C816 Assembly
MVN $7E,$CC

That copies from bank $CC (ROM) to bank $7E (WRAM). The count is in 16-bit A, the source in X, the destination in Y. Each step copies a byte, decrements A, and increments X and Y, until A wraps from $0000 to $FFFF. Ten bytes per name (LDA.w #$000A before the MVN). Then the raw text at $7E:94A0 goes to BattleMsg_ReencodeTextBuffer.

A one-byte bug that shifted everything. BattleMenu_RenderItemRow computes an offset into the item-name table:

65C816 Assembly
SBC.b $84               ; * 11 (subtract factor)
db $69,$5E,$0B          ; ADC #$0B5E (M=0 three-byte form)
TAX

My first draft had a CLC between the SBC and the ADC:

65C816 Assembly
SBC.b $84
CLC                     ; ← wrong
db $69,$5E,$0B
TAX

make diff reported 185 bytes different, starting at $C1:09BD, the low byte of the JMP target for the empty-slot branch. CLC ($18) is one byte, so it pushed CODE_JP_C10A87 (the empty-slot RTS) from $0A87 to $0A88, the JMP target's low byte went from $87 to $88, and everything after moved by one, into the neighboring routines too. The original has no CLC because the SBC leaves carry in the state the ADC relies on. Removing it gave MATCH: 4194304 bytes identical. A one-byte insertion is not an assembler error; it just assembles to the wrong bytes, which is why make diff must pass before any commit.

A dead RTS. BattleMenu_RenderTechRow ends with an unconditional JMP CODE_JP_C10C00, and the next byte, at $C1:0BB5, is $60 (RTS). Nothing jumps to $0BB5, so it can never run. The diff compares the whole ROM, so it is emitted explicitly:

65C816 Assembly
    JMP CODE_JP_C10C00
    db $60                  ; dead RTS — unreachable, byte-exact pad

STA to address zero. The null terminator after the MVN is written with:

65C816 Assembly
STA.w $0000,Y           ; null terminate the name buffer

Without .w, asar emits opcode $97, the 2-byte STA [$00],Y (direct-page indirect long), a very different instruction. .w forces absolute indexed, opcode $99, the 3-byte form in the ROM.

The CODE_JP_ labels. CODE_JP_C10A87, CODE_JP_C10BB6 and CODE_JP_C10C00 are entry points that sit inside other routines but are jumped to from several places. CODE_JP_C10A87 is the plain empty-slot RTS at the end of BattleMenu_RenderItemRow. CODE_JP_C10BB6 and CODE_JP_C10C00 blank the MP-cost digit slots and copy the formatted name into the tilemap planes; BattleMenu_RenderTechRow jumps to one or the other depending on whether the slot holds a real tech or a skip.

Window upkeep: the battle menu comes alive

In battle, a "window" is a rectangle of tilemap: the command window at the bottom (Attack, Magic, Item and so on) and the tech window that opens over it, listing techniques with MP costs and a cursor. They are WRAM buffers copied to VRAM once per frame, not PPU hardware windows. These routines prepare the data; the copy happens in a separate VBlank DMA.

BattleMenu_UpdateWindows ($C1:0C2D, 28 bytes) runs every frame. It reads $95D5 (the active PC slot), calls JSL $CFFD9E (BattleFx_SetPtrA2FromTable, a cross-bank setup), and dispatches on the submenu type $95DB: 0 → main command window, 1 → tech window, anything else → return. It reads like a modern switch statement.

BattleMenu_UpdateMainWindow ($C1:0C49, 58 bytes) handles type 0. It compares the active PC $A6DD with last frame's $A6DF. If they differ, it reloads the command window map (BattleMenu_LoadCommandWindowMap) and rebuilds the whole status bar (BattleUI_BuildStatusBarFrame, matched earlier today). If not, it refreshes the panel strips and redraws the cursor column when a flag is set. Either way it ends with JSL $CFFD02 to queue the $0CC0 region for VRAM upload and INC $99E2 to mark pending display work. It calls BattleUI_BuildStatusBarFrame ($C10299), BattleUI_UpdateNextPcPanel ($C105A7) and BattleUI_ClearActivePanelColumn ($C10872), so this layer sits above the status-bar layer and drives it as needed.

BattleMenu_UpdateTechWindow ($C1:0C83, 471 bytes, the biggest of the group) handles type 1:

  1. Text. Calls BattleMenu_RenderTechListRows ($C10A88, matched earlier today) to fill a $180-byte buffer at $99E3 with tech names and MP digits.
  2. Copy. Copies that buffer into the tech window tilemap at $A6E1, stepping by 2 (tile, attribute).
  3. Border. A 6×9 loop from the $CCFA95 template to $A70B,Y: one border row per outer pass, 9 tiles per row. A 16-bit ADC #$002E moves to the next row (stride 46, written as db $69,$2E,$00).
  4. MP digits. For each PC with techs to show ($96F5/$96F6/$96F7 != 0), converts current MP to digit tiles with Battle_DivTen9499 and BattleMsg_BlankLeadingZeros and stores them at $A751/$A7D1/$A851. A PC with no techs gets $FF (blank).
  5. Cursor target. Reads the current PC's scroll position and cursor row and goes through $CCF38C and $9551 to find the battler slot the cursor points at. An empty slot ($FF) blanks the relevant cells. A real battler has seven bytes copied from its WRAM entry ($1A80–$1A86) to scratch at $9EE3–$9EE9, and its HP-range values turned into digits.

It falls straight into BattleMenu_DrawTechCursorRow ($C1:0E5A, 96 bytes), which clears the old cursor by writing $FF over all six rows' cursor tiles (through BattleMenu_ClearTechCursorTiles, $C1:0EBA, a 39-byte leaf), then draws the new cursor, tiles $60–$63, at the current row, unless targeting is active ($9609 != 0 hides it during multi-target selection). Then JSL $CD0027 updates the info panel with the selected tech's name or description, and JSL $CFFD36 queues the tech window for VRAM upload. Render, then update, then the VBlank DMA through a cross-bank JSL to bank $CF: a clean vertical chain.

BattleMenu_UpdateTechMpAvail ($C1:0EE1, 349 bytes) decides whether each tech row is bright (affordable, attribute $29) or grey (too expensive, $2D). It loops three times, once per row:

  • slot types $FC and $FD are special: check the party size instead of MP
  • otherwise, resolve the battler slot and check each PC's current MP against the cost fields at $1A83, $1A85 and $1A86
  • if everything is affordable, check that the slot is menu-ready (BattleSys_SlotMenuReadyPredicate, $C1:103E, 16 bytes) and not locked ($A0D1). If so, clear the grey flag in $1A82 and write $29 to 18 attribute positions in $A6E2 and $A722; if not, set the flag and write $2D

The affordability test is a 16-bit compare by subtraction in M=1, with $96/$97 holding one PC's MP as two bytes: SEC / LDA.b $96 / SBC.w $1A83,X / LDA.b $97 / SBC #$00 / BCC _L1001.

A shared exit, and label scope again. UpdateMainWindow and DrawTechCursorRow both JMP to one RTS byte at $C1:103D, and UpdateTechMpAvail falls through to it; it is labeled CODE_JP_C1103D. In asar 1.91 every global label resets the local (.dot) scope, and UpdateTechMpAvail has several global sub-labels inside it (CODE_JP_C10F28, CODE_C10F66_JMP, CODE_JP_C1100F, _L1001), so its internal branches use a family of global-style _ump_* labels.

BattleMenu_LoadCommandWindowMap ($C1:1C3A, 16 bytes) rounds out the group.

The day in numbers

Session Routine Address Bytes Running total quoted
34 Battle_Mul8 $C1:0089–$00A6 30
34 Battle_MulAccum $C1:00A7–$00D6 48
34 Battle_Divide $C1:00D7–$010C 54
34 Shift helpers $C1:010D–$011E 18 20,119 (102 functions)
35 BattleMsg_FormatNumberDigits $C1:011F 85
35 Battle_DivTen9499 $C1:0174 53
35 BattleMsg_ReencodeTextBuffer $C1:01A9 80
35 Battle_SinLookup $C1:01F9 41
35 Calc_Delta16 $C1:0222 119 20,497 (107)
36 BattleUI_BuildStatusBarFrame $C1:0299–$05A6 782
36 BattleMsg_BlankLeadingZeros $C1:104E–$106D 32
37 BattleUI_DrawSlotGaugeBar $C1:06F0 149
37 BattleSys_SlotPanelRefresh $C1:0785 153
37 BattleMenu_DrawWindowEdgeStrip $C1:0929 52
37 BattleUI_SetPanelAttrColumn $C1:08E8 65
37 BattleMenu_DrawReadyWindowEdges (+ BattleUI_ClearActivePanelColumn sub-entry) $C1:081E 202 21,932 (114)
38 BattleUI_UpdateNextPcPanel (+ Battle_DrawHpBars sub-entry) $C1:05A7–$06EF 329 22,261 (115)
39 Item/tech list rendering, 8 routines $C1:095D–$0C2C 720 22,981 (123)
40 BattleMenu_UpdateWindows $C1:0C2D 28
40 BattleMenu_UpdateMainWindow $C1:0C49 58
40 BattleMenu_UpdateTechWindow $C1:0C83 471
40 BattleMenu_DrawTechCursorRow $C1:0E5A 96
40 BattleMenu_ClearTechCursorTiles $C1:0EBA 39
40 BattleMenu_UpdateTechMpAvail $C1:0EE1 349
40 BattleSys_SlotMenuReadyPredicate $C1:103E 16
40 BattleMenu_LoadCommandWindowMap $C1:1C3A 16 24,054
Total 4,085

Session totals: 150, 378, 814, 621, 329, 720 and 1,073 bytes. make diff confirmed 4,194,304 identical bytes after each.

Bank $C1 by the end of each session: 150 bytes (0.2%), 528, 1,342 (2.10%), 1,963 (3.07%), 2,292 (3.59%; 17 matched regions, up from 16), 3,012 (4.71%) and 4,085 (6.39%) of 63,904. Share of all game code: 6.1% after session 37, 6.2% after 38, 6.4% after 39, 6.7% after 40. For comparison, all of bank $C0 matched so far (19,498 bytes) is about 31.6% of its bank; at today's start, bank $C1 looked like a multi-year project on its own.

Next

Bank $C1 has three broad areas: math and number formatting ($C1:0089–$C1:02xx), BattleUI and BattleMenu ($C1:02xx–$C1:1xxx), and enemy logic, animation and phase control ($C1:1xxx–$C1:FFFF, including EnemyAI_SpatialService at $C1:2986, a fully documented, isolated routine in the reference). The next neighbor is BattleMenu_RefreshIfDirtyL at $C1:10E3, the target of the entry vector at $C10012 (BattleSys_EntryVec12 in the dscotton reference): the dirty-flag gate that decides whether UpdateWindows runs at all. After it, the cursor-movement and input layer that starts around $C1:106E.

← Back to devlog