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

8 October 2026: who can you hit? The target modes

The code that answers "who does this action hit?" for the battle menu is matched: a 33-entry table of target modes and the eighteen small routines behind it, plus the routine that puts a character into the ready queue. 1,033 bytes in 26 functions (session 43).

Cyclone hits whoever is near the enemy you picked. Aura heals one ally. Luminaire hits everyone. Every action ends with that question, and this is the code that answers it.

Joining the queue

BattleMenu_EnqueueReadyBattler ($C1:1B19–$C1:1B54, 60 bytes) is the opposite of DequeueReadyBattler from 19 September. Nothing calls it by address, which was confusing at first. The answer is the small service table at the top of the bank: code calls $C10045 with a service number, and service 1 is this routine; service 2 is RemoveBattlerFromReady. I first guessed that other banks used the table. A later review searched the whole ROM and found that every call to it is a JSR from inside bank $C1 itself. So it is the battle engine's own internal dispatcher, not a door for other banks.

It does four things:

  1. skips characters already in the active roster
  2. sets a "waiting for a command" bit on the character
  3. appends the character to the queue and resets their menu cursor to its default row
  4. plays a sound through the same audio command as Battle_PlaySfx0 (then called Battle_StopSfx), with sound id $42, very likely the "turn ready" cue

That bit fixed an old mistake. ConsumePartnerSlot's comments called bit 7 of that flag byte the "queued" bit. Now that I can see what sets it, bit 7 means "waiting for a command", and bit 6 (set by CommitAction) means "command queued". The comments are corrected; no bytes changed.

Thirty-three modes, eighteen routines

The target-mode table (BattleTgt_ModeTable, $C1:1FF8–$C1:2039) has 33 entries but only 18 distinct handlers, and 12 of the 33 point at the default "pick one ally" handler. That looks like room left for modes nobody needed.

The simple modes share one list builder. Each picks a slot range (allies 0–2, enemies 3–10), optionally sets "target everything", and jumps into BattleTgt_CollectValidTargets. It walks the range and keeps each battler that is present, not flagged out and not hidden. A battler marked KO'd only gets in when the mode is $04. If the caster is in range, they always go first, so when you heal, the cursor starts on yourself.

A few modes are more particular:

  • Self: just the active character. Three instructions.
  • KO'd ally: an unrolled check of each party member's status byte for the "down" bit. That is the revive target list.
  • A specific character: two modes search the party for one character id (4 and 5) and target only them. If that character isn't in the party or can't be targeted, the mode reports "no target". This is how a combo tech can insist on a particular partner.

Area attacks

The other modes are area modes, and they share one recipe:

  1. Pick an anchor: the caster, an enemy you aim at, or a party member found by character id.
  2. Fill a small parameter block: source, aim point, a shape/size code and a variant flag.
  3. Call a geometry routine that works out who is inside the area.
  4. Select the whole result.

The enemy-anchored modes still let you move the cursor. Watching the pad bits shows a quirk: the three "line from the caster to the enemy" modes step the cursor forward whether you press left/up or right/down, while the other area modes step backward for one of the two. I don't know yet whether players ever notice.

The radius modes use two size codes, and one mode in each pair swaps in a bigger one ($19): two different techs sharing a handler, told apart by mode number.

The geometry routines (four of them, roughly 1.1 KB from $C1:2332) aren't matched yet. For now they are label stubs, so the handlers can call them by name.

A second door into the same room

BattleTgt_RunAreaQuery is service 7 in the same service table. It is a 13-byte dispatcher with its own 7-entry table pointing at the same four geometry routines ($C1:1FDD–$C1:1FF7 together). So the menu isn't the only thing asking "who is in this area?". Other code in the battle engine asks too, most likely for enemy attacks, which need the same answer.

Little things

  • BattleTgt_CycleNext/CyclePrev duplicate the menu's TargetNext/TargetPrev (19 September) instead of sharing them.
  • BattleTgt_EnemyArea2332 has a dead RTS after its final JMP. Nothing reaches it, but it is in the ROM, so it is in the source.
  • BattleTgt_ClearLists blanks 12 entries of lists that only ever use 11.

A better check

Building on top of the original ROM has one blind spot: if a routine somehow didn't get written at all, the original bytes would still be there and make diff would report a match. So I also rebuilt over a copy of the ROM with every newly matched range zeroed first, and compared again. All 1,033 bytes came back from source.

Rows of cells: the ROM copy with new ranges zeroed, the rebuild filling them, and the failure case

The zeroed-range check: if a routine is not emitted, its bytes stay $00 and the diff fails instead of quietly passing.

The day in numbers

Routine Address Bytes
BattleMenu_EnqueueReadyBattler $C1:1B19–$C1:1B54 60
BattleTgt_RunAreaQuery + area table $C1:1FDD–$C1:1FF7 27
BattleTgt_ModeTable (33 entries) $C1:1FF8–$C1:2039 66
18 target-mode handlers (SingleAlly … Area23A4Mode) $C1:203A–$C1:2331 760
6 candidate-list helpers $C1:27C5–$C1:283C 120
Total 1,033

Bank $C1: 8,061 of 63,904 bytes (12.61%). Project-wide: 28,030 bytes (7.80% of all game code). make diff confirms 4,194,304 bytes identical.

Readable before it counts

The rest of the day changed what "done" means. Until now a routine was finished once its bytes matched. From today, there are three levels, and only the last one counts as done:

  1. Matched: the source assembles to the exact bytes of the ROM.
  2. Readable: the source passes tools/lint_readability.py. Named structures instead of raw offsets (SprTile[3].OfsY or !Pc_MenuRow,X, not a bare address), named calls instead of addresses, named constants for anything that isn't obviously a count, an explicit .b/.w/.l width wherever asar can't know it, and no test plumbing inside the game's code.
  3. Verified: someone other than the author read the routine against the ROM and approved it. The approval is tied to a hash of the routine's source, header comments included, so editing a single comment sends the routine back for review.

Pipeline from matched to readable to independent review to verified, with a loop back for changes

The three levels. A review verdict is tied to a hash of the routine's source, so any later edit sends it back.

The gate went into the pre-commit hook the same evening. Then every routine written so far was brought up to it: bank $C0, bank $C1, the boot code in $FD and the vectors in $00. All 256 routines (29,032 bytes) are now readable, and the readability baseline file, which listed the routines still waiting, is empty.

Review is the slow part, and that's how it should be. Each reviewer gets only the source, the ROM and the style guide, and checks every comment against the bytes. The first two rounds turned up real mistakes:

  • The OBSEL decode was wrong.
  • The "TP gauge" is the ATB gauge.
  • The sine table holds 1,024 one-byte entries, not 512 words.
  • The tech header text in the ROM reads "Double Technique" and "Triple Technique".
  • Three target-mode headers claimed bytes that belong to the routine after them.

The second round of bank $C1 mostly asks for the same thing everywhere: every header must state the direct page, because the code clears A with TDC and that only works because D is 0. 68 routines are verified so far. The rest are going through another round of fixes.

All the numbers on this wiki now come from one script, tools/progress.py, instead of being kept by hand. The first time it ran, it showed that bank $C0 had been over-counted by 169 bytes.

Faster, and in parallel

By the afternoon all 244 earlier routines were verified, so the workflow itself got an overhaul. Seven new tools:

  • tools/xref.py finds every real caller of an address and ignores false hits inside other instructions' operands.
  • The lint now insists that every header has Entry and Exit lines and a complete list of callers. Reviews used to send most routines back for exactly those gaps.
  • REVIEW.md writes down the review standard, so every round applies the same bar.
  • A duplicate finder that ignores jump and call addresses.
  • A data-table tool.
  • tools/draft.py, which turns any address into a draft that already assembles byte-for-byte and already uses every name the project knows.

With those, three matchers ran at once in different banks, each batch reviewed by subsystem before it went in. Most batches passed review on the first try.

What they found:

  • Enemy movement ($C1:3714–$C1:4057, 2,372 bytes). A routine stubbed as "status effect visuals" turned out to drive how enemies move. Nine movers: approach, charge, keep range on a ring, orbit, loop round an ellipse, line up on a row or column, eight fixed directions, head for the screen centre, and idle.
  • Battle position queries and collision boxes, plus the battler frame decoder and the PC and enemy animation ticks. One routine in there has a real bug in the original game: a check that is supposed to measure the path to another battler compares against a value left over from the sine lookup instead.
  • The field main loop's per-frame work ($C0, 1,882 bytes). It includes the window effects that shrink and grow the screen, the action button, and the staff credits: one routine writes the credits text from $FD:A000 ("PRODUCER//KAZUHIKO AOKI"...) one line per frame.
  • A fatal-error stop. One routine blanks the screen to a solid colour, disables interrupts and loops forever. Each of its 13 callers passes its own colour. It came from following up a duplicate.
  • A small quirk in battle. Holding L+R counts frames toward a message ($75). Reviews took three rounds to pin down exactly when that counter resets.

A last batch came from following the duplicate finder's leads into banks $C0, $C2, $CF and $FD: another 1,037 bytes, all reviewed.

Three more batches went in during the afternoon:

  • Battle actions ($C1:4058–$C1:49FE, 2,471 bytes). Service 4 plays one action's script frame by frame, then loads what the action needs: its script, the attack or tech record, graphics sets and the sprite frames. Three bytes at $CF:FFFD-FFFF, all 0 in the shipped game, can force the action flags.
  • What's in front of the leader ($C0, 1,370 bytes). The checks for the object and the tile in front of the party leader, the eight D-pad handlers, and the limits that stop the leader at the edge of a map. The reach test is off by at most one unit, because a missing SEC and a missing INC partly cancel out.
  • Bank $C2 boot (1,229 bytes). The bank's entry points, its interrupt handlers, and the new-game setup that fills in each character's starting record.

Then three more:

  • Map scrolling ($C0, 2,425 bytes). Each of the three background layers keeps its own scroll accumulators in 1/16 pixel; every frame they move one pixel at a time and, when a layer crosses a tile edge, a new row or column of the map is built. Layer 2 has a half-speed mode.
  • Battle action scripts ($C1, 2,231 bytes). Each action runs up to 13 script threads, one opcode at a time. A breadth-first path search finds the way to the target across a grid of occupied cells, giving up after 30 steps.
  • The $C2 scene engine (1,936 bytes). A task system of 64 slots run every frame, a linked list of 64 sprites, the text window, the joypad reader with its soft reset on L+R+Select+Start, and the play-time clock.

And the evening's batches:

  • Map redraws and collision ($C0, 5,951 bytes). The full redraw of each background layer, the row and column writers, and the leader's collision check: each frame's step is tested against walls and other objects, slid around corners, and a blocked diagonal is retried as a straight step. Reviews found five places where the original code adds without clearing the carry first, so a probe lands 1/256 of a tile off; all are documented, none "fixed".
  • Battle action script, opcodes $13-$85 ($C1, 4,267 bytes). Curving moves, path walks, waits and subscripts, script variables, the damage numbers that bounce over battlers, palette flashes and cycles, and sound effects.
  • The $C2 scene loader (2,430 bytes). Each location has a header in bank $C6 naming the packed graphics, palettes, maps and script it needs; the loader unpacks them all and redraws the background from 16x16 metatiles.

Late in the day:

  • Location map setup ($C0, 2,078 bytes). Unpacking a location's map properties, tile planes, exit and treasure tiles (a treasure already taken shows its opened tile), the scroll limits and the scroll origin around the entry point.
  • The $C2 scene script (2,598 bytes). The interpreter for the scripts that drive scene tasks, with 54 of its 83 opcodes, and a second small interpreter for sprite animations.
  • Arc moves and moves along a heading ($C1, 2,485 bytes). Review found that on thread 4 one of them sets every actor's angle and facing but never their "moving" flag, so none of them moves; the header now says so.

main now stands at 71,206 bytes, 19.82% of the game's code, every byte of it independently verified.

In the evening, after a pause for the usage limit:

  • Battle action opcodes $C0-$DA and the opcode table ($C1, 2,704 bytes). Circular and elliptical moves, pointing at a calculated target, counters and attribute bits, and the full 224-entry opcode table with its four loaders. Review caught that the step opcode, which adds in 8 bits until it hits its target, can miss forever: a step of 0, or an even step across an odd gap, never lands on it. The header now says exactly when.
  • The rest of the $C0 location load (2,127 bytes). Tileset, metatile and palette steps, the event data and object init, and the scene reload with its palette and upload helpers.
  • All of the $C2 scene script opcodes (2,606 bytes). The last 29 opcodes, the tile upload task, palette fades, screen fades and mosaic. One opcode that compares a task's age turned out not to stop "for good": the 16-bit frame counter keeps counting while it waits, so it resumes once that counter wraps round to 0.

  • Battle action position calculations ($C1, 2,138 bytes). The 18 ways a battle script picks a point: an entry's position, midpoints, the centroid of a group, a spot ahead of an actor, and smooth moves toward a target, plus the four facing rules. The 963 bytes after them turned out not to be code at all: they are cut-off remains of older, longer builds of the same tables, each shifted by a fixed amount, which a newer, shorter build never overwrote. They are matched as labelled data.

Into the night:

  • Scene layer scrolling, modes and sound zones ($C2, 2,268 bytes). Scenes scroll a layer one pixel at a time and draw each newly visible tile column or row as it comes into view. Modes 2-8 cover a location change, leaving the scene, the menu and the mode-7 sub-program. Sound zones pick the music from a 4-bit map packed at the front of each scene's data. A reviewer found four real calls that the caller tool had dismissed as doubtful, so the tool is on the list for a fix.
  • The battle's main routine ($C1, 2,109 bytes). This one routine runs a whole battle: it shuffles the turn order, counts down 13 per-slot turn lists, checks for victory and defeat, hands out gold and items, and leaves. Next to it are the battle setup and the 16-bit multiply, divide and random-range helpers the rest of the battle uses more than 200 times. A debug "instant win" check reads a flag byte that is 0 in the retail ROM, so it never fires.

  • The field/battle hand-off ($C0, 2,506 bytes). Before a battle the field saves its objects, the party's positions and the PPU settings. Afterwards it rebuilds itself according to the battle's result: wait for a button and restart, reload the location, or rebuild in place. The batch also covers linking objects into and out of their draw lists, the location's audio step, and two tilemap builders. One quirk: a test meant as BIT #$10 reads memory at $0010 instead.

The same batches also tidied 66 older headers that still called now-matched code "unmatched". Each of them went back through review. One turned out to be not just stale but wrong: a hook banner credited code with setting the event hook, when its stores actually land on the map's step counters.

  • Battle turn lists ($C1, 2,065 bytes). The battle keeps 13 per-slot countdown lists. List 12 is the battlers' turns: ready characters get a menu, ready enemies act, queued commands run, and then each enemy's script gets a chance to react. The other lists are status timers: a poison-like hit for a share of max HP, timers that halve or double (probably haste and slow), and plain countdowns that clear a status bit. The 988-byte turn routine needed four review rounds before every claim about its reaction scripts held up against the ROM.

main now stands at 89,729 bytes, 24.97% of the game's code, all 1,026 matched routines independently verified.

Then the workflow got its overhaul. Generated files no longer travel in pull requests, so parallel branches stop conflicting. Callers lines are now written by a tool and left out of the review hash, so matching new code no longer voids older reviews. Three new lint checks catch the header mistakes reviewers kept finding: wrong byte ranges, stale "unmatched" mentions, and Entry lines that leave out DP or DB. The caller-finding tool also learned to follow code through routines that change register widths, which found 39 more real calls in a held-out test.

The first batch through the new pipeline:

  • Scene triggers and the text decoder ($C2, 2,328 bytes). Two watcher state machines for scene objects and tile triggers, and the whole text decoder: its four states and every control code from $00 to $20, covering numbers, character and party-member names, wide glyphs, and tech, enemy and item names. An empty party slot prints the first character's name, because an 8-bit shift drops the "empty" bit. Of 87 routines, 84 passed review the first time, and the lint had already caught four header mistakes before any reviewer saw them.

  • Object motion and view culling ($C0, 2,342 bytes). The per-frame object pass, deciding which objects are in view, stepping objects along straight lines and arcs, and turning an angle into a velocity through the sine table and the hardware multiplier. Also the copy of a map region and the draw pass run at load time. All 21 routines passed review on the first try. The branch also came up to date with a plain merge from main, with no conflicts.

main now stands at 94,399 bytes, 26.27% of the game's code, all 1,130 matched routines independently verified.

Late in the evening, three more batches went through review and merged:

  • The NMI handler and frame wait ($C0, 5,396 bytes). Everything the game does once per frame during vertical blank: palette and sprite uploads, the queued VRAM writes, map redraw steps and the staff-credits line, plus the tiny loop the field spins in until the NMI has done its work. That loop used to be called Sub_EC60. A reviewer pointed out that its header already said exactly what it does, so it is now Field_WaitFrame, and the 15 routines that mention it went back through review. The batch also covers the event commands that walk an object to a tile (they aim for X $80 / Y $F0 inside it: centred, near the bottom), and the sprite tile slots that objects borrow as they come into view. One quirk: an upload address is computed with an ADC that has no CLC before it, so a stray carry adds 1.
  • The party leader and followers ($C2, 5,182 bytes). In the $C2 scenes, the leader walks in 8-pixel steps and scrolls the background as it goes. Its buttons switch scene modes, and it idles, animates, and walks onto objects. The other members follow it with their own small route planner, which scans rows and columns of the scene map for a way round obstacles. The batch also covers the glyph drawing behind the text decoder: 2bpp and 4bpp blitters, the width tables and the number formatting.
  • Enemy behaviour scripts ($C1, 2,415 bytes). The core that reads each enemy's script: a list of blocks, each one or two tests followed by actions, plus a separate reaction part. A block whose tests hold chooses its next action, and later turns step through the block's actions in order. Two finds: one action code jumps back into the middle of the test-passed routine after picking a random action (25/50/75% thresholds for one, two or three steps further along), and one target picker loops forever if an enemy's target marks disagree. The routine that turns a hit into a record offset also wraps for the last two slots of the sixth hit set. Thirty of its 39 routines passed review the first time; the other nine needed a second round, and one a third.

main now stands at 107,392 bytes, 29.89% of the game's code, all 1,373 matched routines independently verified.

Around midnight, four more batches went through review and merged:

  • Player commands ($C1, 5,174 bytes). What happens after a party member picks a command: the battler checks each pass makes (the low-HP pose bit, which turn lists a battler takes part in, the KO check), the command runner for attacks, techs and items, the tech MP costs and who pays them, and the end-of-battle copies back to the field. The batch was written before the workflow overhaul, so it was ported onto the new layout first. Review found that a tech's first user never gets its extra wait: the divisor is never stored, so the code divides by 0, which the divide routine answers with 0. A KO'd party member with the right flag is revived with a heal that is recorded twice, once as a positive amount for the action and once negated for the hit set.
  • Battle helpers in bank $FD (5,364 bytes). The enemy rewards, the battle item list, restoring enemies, stat boosts from equipment, the first gauge values and the turn-list timers, plus the field's HDMA set-up and location animation loaders. One routine builds a brightness gradient for the screen as 90 pairs of colour-math bytes. Merging it with the player-command batch turned up 18 names defined twice, which the assembler accepts silently, so each now has one definition.
  • Field event commands ($C0, 5,016 bytes). Dozens of event-script opcodes: calls into other objects' scripts, jumps and conditions, button tests, loads, stores and block copies, arithmetic and bit operations, animation and wait commands, and the message commands. The message commands halt the game with a coloured screen if no message pointer was set.
  • Scene objects ($C2, 5,272 bytes). Two objects in the $C2 scenes that the party can get into (probably vehicles), their position markers, two companions that glide after object B, and a task that draws a label (probably a place name) as a sprite above the party. One of them reads absolute address $0028 instead of its own facing byte.

main now stands at 128,218 bytes, 35.69% of the game's code, all 1,715 matched routines independently verified.

Next

Matching continues with the rest of the field event commands in $C0, the mode 6 and mode 8 sub-programs in $C2, and the item and tech handling in $C1. The wiki pages are also being brought up to date one by one.

← Back to devlog