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

19 September 2026: the battle command menu, from dirty flag to committed action

The biggest single day so far: 1,478 bytes in 22 routines, all in bank $C1's battle command menu. It now runs as matched code from the gate that decides whether to redraw the menu, through controller input and confirm, to the 8-byte action record the battle engine executes. This is the first stretch of session 41, worked in eight increments.

The dirty-flag gate

The battle menu doesn't redraw every frame. It waits until something changes, then does one full pass. Two sibling routines gate that pass, both on the WRAM flag $993A:

Text Only
LDA $993A
BEQ <exit>
STZ $993A
STZ $E5
JSR BattleMenu_DequeueReadyBattler
JSR BattleMenu_UpdateWindows
JSR BattleMenu_ProcessInput
JSR BattleMenu_UpdateCursorOverlay

If the flag is clear they return at once. If it is set they clear it and run the rebuild: pull the next ATB-ready battler into the queue, refresh the window contents (matched on 14 August), process controller input, redraw the cursor overlay.

Flowchart of one menu rebuild: the dirty flag, the four calls, and the decisions inside ProcessInput down to CommitAction

One menu rebuild from the dirty flag to a committed action. Each branch inside ProcessInput ends in a jump, so only one fires per frame.

They differ in how they are reached:

  • BattleMenu_RefreshIfDirtyL ($C1:10E3, 23 bytes) is in the bank's cross-bank entry-vector table. Other banks reach it with JSL $C10012, which JMPs within the bank to $C110E3 without touching the stack, so the routine must return with RTL to match the long call.
  • BattleMenu_RefreshIfDirtyAndTick ($C1:10FA, 27 bytes) is a plain JSR target, called from several places in the battle-phase state machine. It adds a cross-bank tick (JSL $CD0009) before an ordinary RTS.

Same logic, two calling conventions. Two near-identical routines are not necessarily duplicated code; they can serve call sites with different stack contracts.

Three of those callees weren't matched yet (BattleMenu_DequeueReadyBattler at $C1:1B67, BattleMenu_ProcessInput at $C1:1153 and BattleMenu_UpdateCursorOverlay at $C1:17DD), so they went in as label stubs: an org line with a bare label and no bytes, the trick used earlier in bank $C0 for forward references. asar resolves the JSRs, and a later increment fills in the body at the same org.

BattleMenu_DrawCursorSprites ($C1:1115, 62 bytes) sits between the gate routines and ProcessInput. It builds the four OAM entries of the cursor that points at the selected command. The shape is a fixed 4-tile template in ROM ($CCF604–$CCF607); the position comes from per-slot coordinates at $1D0C/$1D23, indexed by the active PC. The attribute byte's palette bits are replaced from $9F46 each time, almost certainly what makes the cursor flash or change color. What drives that byte isn't traced yet.

Reading the controller

BattleMenu_ProcessInput ($C1:1153, 142 bytes) reads two WRAM bytes, $EE and $EF. They are pad-edge bytes: a bit is set only on the frame a button goes from released to pressed, not while it is held. $EE holds buttons (confirm is $80), $EF the D-pad ($02 left, $08 up, $04 down, $01 right). The routine:

  • no active PC ($95D5 negative): return
  • target-select mode ($9609 != 0, you're aiming at a target): hand over to BattleMenu_TargetSelectInput
  • submenu type $95DB is the tech or item list: hand over to that list's input handler
  • otherwise, the main command menu: check left, up, down, right, confirm, in that order

Each branch ends in a JMP, not a fall-through, so only one fires per frame. If two D-pad bits were somehow set on the same frame, only the first checked (left) would act. The hardware makes that close to impossible, but the dispatch is exclusive, not layered.

Four small leaves:

  • BattleMenu_CycleActivePcPrev ($C1:11E1, 55 bytes) and BattleMenu_CycleActivePcNext ($C1:1218, 56 bytes), for left and right, decrement or increment the active-PC index $A6DD, wrapping across the party (0..2). They skip any slot whose $A6D9 entry is negative (fainted or unavailable) by repeating until they land on a valid one. Both carry the cursor row ($95DC) from the old slot to the new one, so switching characters keeps your place in the menu.
  • BattleMenu_CursorUp ($C1:1250, 20 bytes) and BattleMenu_CursorDown ($C1:1264, 23 bytes) move $95DC for the active PC, wrapping over the three rows, and set a redraw flag ($A43F) so the cursor sprite moves next frame.

A branch that probably never runs. Both cycle routines branch on $A0D4 right after finding the new slot. Clear (normal): copy the cursor row across. Set: read a per-PC default row from $9916 and write it to the old slot's $95DC (through Y instead of X), which looks backwards. The reference disassembly flags this path as unreachable in the shipped game, and the odd target fits: leftover logic from an earlier version. It is reproduced byte for byte. The goal is a ROM that reassembles identically, oddities included.

Two borrowed leaves. Battle_ZeroResultEE ($C1:179C, 5 bytes) is STZ $EE, STZ $EF, RTS: the shared exit for nearly every routine here, clearing both pad-edge bytes once the input is used. Battle_StopSfx ($C1:1B55, 18 bytes) plays (or, by its name, cancels) a sound on cursor moves and confirms by writing $1E00–$1E02 and calling JSL $C70004, exactly like Sub_1B90 in bank $C0. The audio-command protocol is the same across the engine.

The same increment closed a tooling gap: tools/progress.py --update-index now syncs mkdocs.yml's stats block and index.md's matched-regions table from PROGRESS.md, instead of both being hand-edited every session (which had already drifted more than once).

Pressing confirm

BattleMenu_ConfirmCommand ($C1:127B, 33 bytes) reloads the active-PC pointer, reads that PC's cursor row (0, 1 or 2: Attack, Tech, Item) and tail-jumps to the matching handler. On the way it writes $FF to $A0D7. Nothing here reads it back, but the reference shows it compared inside BattleUI_BuildStatusBarFrame, so it is probably a marker that forces a status-bar refresh next time.

  • BattleMenu_ChooseAttack ($C1:129C, 32 bytes, row 0) sets the target mode $960D to $07, calls the target-list builder, and increments $9609, the flag ProcessInput checks first. Confirming Attack doesn't open a submenu. It changes what the next frame's input handler means.
  • BattleMenu_OpenTechList ($C1:12BC, 49 bytes, row 1) and BattleMenu_OpenItemList ($C1:12ED, 51 bytes, row 2) render their lists (with the routines matched on 14 August), set the submenu type $95DB and return. The item version also copies a fixed $180-byte graphic into the command-window tilemap. The reference calls it ItemBoxBattles, in bank $D1 next to the TechBoxBattles graphic that BattleMenu_LoadCommandWindowMap uses. Presumably the item list needs a different border. Both gate on a flag at $A0A7 that reads like "list locked"; what would lock it (a status effect? a command restriction?) isn't clear, so it is a loose end.

Building the target list

BattleMenu_BuildTargetList ($C1:1F79, 100 bytes) is what ChooseAttack calls. According to the reference, the tech-confirm and item-confirm paths call it too, so it is a shared dependency for three submenus.

It resets a handful of target-selection variables and fills two 11-entry lists, candidates and selection, with $FF. Eleven fits the layout: 3 party members plus up to 8 enemies. If target-select isn't already active in a "return here on cancel" state, it saves the current submenu type to $A86B before clearing it, so cancelling brings you back to the menu you were in (main, tech or item) instead of the top.

Then it clamps the target mode $960D into range, doubles it into a word index and jumps through a table: JSR ($1FF8,X). Each entry is a handler that knows how to collect targets for one mode (Attack, an area tech, a party-wide heal...). That table and its dozen-plus handlers (BattleTgt_CollectValidTargets, BattleTgt_ListEnemiesSingle and others in the reference) are a separate subsystem. BuildTargetList refers to the table by raw address and stops there, the same deferral used for bank $C0's $5D6E entity table.

The bug. An early draft put LDA #$FF inside the clearing loop:

Text Only
; wrong
.clear_loop:
    LDA #$FF
    STA $99C0,X
    STA $A62D,X
    DEX
    BPL .clear_loop

The ROM loads it once, before:

Text Only
; correct
LDA #$FF
.clear_loop:
    STA $99C0,X
    STA $A62D,X
    DEX
    BPL .clear_loop

Both behave the same, but moving the load changes where the loop starts, so the BPL offset changes. make diff flagged one byte at $C1:1F9C, the offset byte. Byte-exact matching checks the encoding, not just the logic: a change any reviewer would call "obviously equivalent" still fails, at once.

The ready queue

ATB means battlers act when their gauge fills, not in strict turns, so several can become ready in the same frame or while the menu is busy. BattleMenu_DequeueReadyBattler ($C1:1B67, 67 bytes) handles that with a 3-slot FIFO at WRAM $95D6-$95D9 and a count at $95DA. Dequeuing:

  • restores the battler's saved cursor row (if you left it on "Item", it's still on "Item")
  • marks the slot present in the active roster
  • shifts the remaining entries down one
  • if nobody is the active menu PC, makes this battler the active one

One line looks odd out of context: STA $A6D9,X with A and X both holding the slot's own index. Elsewhere today $A6D9,X is a presence check (BMI after loading it means "slot empty"). Writing the slot's index there is the "occupied" marker, not a translation table.

The shift reads $95D8 twice, writing it to $95D7 and then $95D9, instead of reading $95D9 for the second write. Since the count never exceeds 3, it probably never matters, and it is reproduced as is. This closed the last stub callee of the dirty-flag gate.

The four-byte ready queue before and after a dequeue, with the copy arrows

Dequeuing from a full queue: slot 0 leaves for the roster and the rest shift down. The last byte is copied from $95D8, not $95D9, but only the first entries count.

BattleMenu_RemoveBattlerFromReady ($C1:1BAA, 144 bytes), next door, is the inverse. A battler may be waiting in the queue or already promoted to the active menu character, and it tells them apart by $A6D9,X (negative: not promoted). From the queue it scans for the entry and shifts the rest down, starting anywhere, not just at the head. For the active PC it also has to leave the menu in a sane state:

  • cancel target selection ($9609 and a couple of related flags)
  • reset the submenu type to the main menu ($95DB)
  • clear the battler's roster slot
  • search the roster for another battler to promote, or explicitly set "no active PC" if there is none, so $A6DD/$95D5 never point at stale data

The same bug, again. An early draft put a one-time load (LDA $80, the slot to search for) inside the scan loop. make diff flagged one byte on the loop's closing branch, as with BuildTargetList. A lone one-byte diff on a branch operand near loop code is now my first guess.

Target-select input

BattleMenu_TargetSelectInput ($C1:1561, 185 bytes) reads the controller while you point at an enemy or ally. Unlike the main menu, it calls BattleMenu_BuildTargetList on every call, because valid targets can change mid-selection (an enemy can die from a status tick). If the rebuild finds no target, or you cancel, it leaves target-select and restores the previous menu (main, tech or item), repainting its window as needed. Otherwise it polls the pad: confirm tail-jumps to BattleMenu_CommitAction; the D-pad cycles forward or backward through targets, unless the mode targets everyone, which disables cycling.

Battle screenshot: the pointing hand sits on Gato while the player picks a target

Target selection after choosing Att.: the hand cursor over Gato. Captured from the rebuilt ROM.

Eighteen bytes that never run. In the middle of the cancel path is something shaped like a second copy of the item-window graphic loader: same source table, same destination. But its loop branch points forward, into the confirm/cycle section, not back to itself. If it ran it would copy one byte and leave, nowhere near the 384 bytes the working copy loop elsewhere in the routine moves. And nothing reaches it: every path before it ends in an unconditional branch past it. These are bytes the CPU can never execute under any input or game state. The single orphaned RTS on 14 August was the only dead code seen before; this is the first dead multi-instruction block. The likely story is a branch target changed during development and the old live code left behind. Either way, it is reproduced exactly.

Committing the action

Every confirm press ends in BattleMenu_CommitAction ($C1:161A, 308 bytes), the largest routine in the command-menu cluster. It builds a uniform 8-byte record, "character X does Y to target Z", whether Y is an attack, a tech or an item. It dispatches on $95DB to fill in the parts that differ, then joins shared code that writes the record and hands off. Attack is almost trivial: a type flag and a marker for "no tech or item id". Item decrements the item's stock and clears some UI scroll state. Tech is the interesting one, because dual and triple techs involve more than one character.

BattleMenu_ConsumePartnerSlot ($C1:174E, 30 bytes) takes a combo partner out of the turn pool so it doesn't also act on its own later: it clears the slot's presence in the active roster (decrementing the active-PC count if it was on screen) and clears a flag bit on that battler's sprite data (called the "queued" bit at the time). CommitAction calls it once for a dual tech's partner (low nibble of $9EE7) and again for a second partner only if the high nibble says triple tech. The combo bookkeeping was built to scale from two to three, not bolted on.

Then the same cleanup seen in RemoveBattlerFromReady: clear the confirming character's slot, then find another active menu character or mark "no active PC".

BattleMenu_TargetNext ($C1:176C, 26 bytes) and BattleMenu_TargetPrev ($C1:1786, 22 bytes) move the target cursor through valid targets, skipping invalid slots, with the same wrap-and-retry shape as CycleActivePcPrev/Next. TargetPrev has no RTS of its own: it sits right before Battle_ZeroResultEE and runs into it.

With CommitAction, everything from the dirty-flag gate to a confirmed target is one unbroken span of matched code. Working a cluster in order pays off: callees stop being stubs one by one until the whole subsystem is visible.

Fixing the progress bookkeeping

Before any code, the standing staleness audit (checking that PROGRESS.md's headline, its per-bank headings and the generated homepage snippet agree) found two real bugs. The bank $C0 heading had read 384 bytes low since session 33, when that session's total went in without updating it. And tools/progress.py choked on comma-formatted byte counts, so bank $C1 had been missing from the generated summary since it opened. Both are fixed: the heading corrected, and the parser strips commas before converting.

Right after the first routines, the same class of bug came back: the bank $C1 table got its new rows, but the heading's byte count didn't move until the audit ran again. Twice in one sitting means a process gap, not a slip; the fix belongs in tooling, which is what the --update-index change above does.

The day in numbers

Increment Routine Address Bytes
1 BattleMenu_RefreshIfDirtyL $C1:10E3 23
1 BattleMenu_RefreshIfDirtyAndTick $C1:10FA 27
1 BattleMenu_DrawCursorSprites $C1:1115 62
2 BattleMenu_ProcessInput $C1:1153 142
2 BattleMenu_CycleActivePcPrev $C1:11E1 55
2 BattleMenu_CycleActivePcNext $C1:1218 56
2 BattleMenu_CursorUp $C1:1250 20
2 BattleMenu_CursorDown $C1:1264 23
2 Battle_ZeroResultEE $C1:179C 5
2 Battle_StopSfx $C1:1B55 18
3 BattleMenu_ConfirmCommand $C1:127B 33
3 BattleMenu_ChooseAttack $C1:129C 32
3 BattleMenu_OpenTechList $C1:12BC 49
3 BattleMenu_OpenItemList $C1:12ED 51
4 BattleMenu_BuildTargetList $C1:1F79 100
5 BattleMenu_DequeueReadyBattler $C1:1B67 67
6 BattleMenu_RemoveBattlerFromReady $C1:1BAA 144
7 BattleMenu_TargetSelectInput $C1:1561 185
8 BattleMenu_CommitAction $C1:161A 308
8 BattleMenu_ConsumePartnerSlot $C1:174E 30
8 BattleMenu_TargetNext $C1:176C 26
8 BattleMenu_TargetPrev $C1:1786 22
Total 1,478

Increment totals: 112, 319, 165, 100, 67, 144, 185 and 386 bytes. make diff confirmed 4,194,304 identical bytes after each one, dead bytes included.

Bank $C1 after each increment: 4,197 (6.57%), 4,516 (7.07%), 4,681 (7.33%), 4,781 (7.48%), 4,848 (7.59%), 4,992 (7.81%), 5,177 (8.10%) and 5,563 (8.71%, up from 4,085 and 6.39% at the start of the day) of 63,904. Project-wide: 24,166 (6.73% of all game code), 24,485 (6.82%), 24,650 (6.86%), 24,750 (6.89%), 24,817 (6.91%), 24,961 (6.95%), 25,146 (7.00%) and 25,532 (7.11%).

The notes written during the day also quote "763 bytes across 8 routines" after the ready-queue pair and "1,478 bytes across 14 routines" at the end. The tables add up to 907 bytes and 17 routines after the ready-queue pair, and 1,478 bytes and 22 routines for the day; the table above follows the tables.

Next

Two submenu-input stubs remain: BattleMenu_TechListInput and BattleMenu_ItemListInput, which mirror each other and TargetSelectInput. Behind them, per the reference, sit routines like BattleMenu_TechConfirm and an item-confirm equivalent. Also still open: BattleMenu_UpdateCursorOverlay ($C1:17DD), the 117-byte periodic/idle-check service at $C1:106E, and the target-mode handler table at $C1:1FF8, a dozen-plus BattleTgt_* handlers referenced only by address so far, which deserves a session of its own.

← Back to devlog