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:
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.
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 withJSL $C10012, whichJMPs within the bank to$C110E3without touching the stack, so the routine must return withRTLto match the long call.BattleMenu_RefreshIfDirtyAndTick($C1:10FA, 27 bytes) is a plainJSRtarget, called from several places in the battle-phase state machine. It adds a cross-bank tick (JSL $CD0009) before an ordinaryRTS.
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 (
$95D5negative): return - target-select mode (
$9609 != 0, you're aiming at a target): hand over toBattleMenu_TargetSelectInput - submenu type
$95DBis 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) andBattleMenu_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$A6D9entry 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) andBattleMenu_CursorDown($C1:1264, 23 bytes) move$95DCfor 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$960Dto$07, calls the target-list builder, and increments$9609, the flagProcessInputchecks 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) andBattleMenu_OpenItemList($C1:12ED, 51 bytes, row 2) render their lists (with the routines matched on 14 August), set the submenu type$95DBand return. The item version also copies a fixed$180-byte graphic into the command-window tilemap. The reference calls itItemBoxBattles, in bank$D1next to theTechBoxBattlesgraphic thatBattleMenu_LoadCommandWindowMapuses. Presumably the item list needs a different border. Both gate on a flag at$A0A7that 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:
The ROM loads it once, before:
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.
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 (
$9609and 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/$95D5never 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.
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.
