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

12 August 2026: the sprite-init system closes, and a first look at bank C1

The triple-slot routines, the dispatcher layer above the sprite-slot cluster and the animation queue that drives it are all matched, so the sprite system from $C98A to $E129 is one continuous run of matched code. Then a scouting pass over bank $C1, the battle engine. 3,509 new bytes in 10 functions (sessions 31 to 33).

The last triple-slot routines

Yesterday left the pass-1 and pass-2+ tails of the triple-slot dispatch unmatched: four routines at $C0:D68B–$DD27, 1,693 bytes. Sub_D4F7 dispatches on a 2×2 grid: pass counter (1 vs 2+) × $0D00,X type ($68 vs not), each cell a BRL to its own routine.

Pass 1: Sub_D68B and Sub_D738. These are close to Sub_D608 (pass 0, $68), shifted in WRAM. Sub_D68B (not $68) loads WRAM slots at base + $0100 and base + $0300; Sub_D738 ($68) uses base + $0200 and base + $0400. Each runs two 8-tile DMA loops. They differ from the pass-2+ routines in two ways:

  • They reload LDX $6D at the top. The pass-2+ routines don't; they inherit X from Sub_D4F7, which still holds the entity slot. The raw bytes at $D7E5 settled it: C2 20 BD 80 0D, no A6 6D.
  • They end with SEP #$30 (A and X/Y both 8-bit before the INC/RTS). Pass 2+ uses SEP #$10: X/Y go 8-bit but A stays 16-bit for the OAM staging that follows.

Pass 1 exits INC $1B00,X; SEC; RTS: SEC means "not done, call me again".

Pass 2+: Sub_D7E5 and Sub_DA69. These also write the full OAM staging buffer: 12 Y entries with sign extension, the X entries, then attribute bytes. The sign-extension pattern:

65C816 Assembly
LDA [$D3],Y
STA $7F4BC2,X
BPL .pos
LDA #$FF
BRA .done
.pos:
LDA #$00
.done:
STA $7F4BC3,X   ; sign-extension byte

That pair of stores repeats 6 times, then come the X positions. Here the $68 difference shows: Sub_D7E5 (not $68) writes 8 X positions, then a CLC; ADC #$10 gap, then 4 more; Sub_DA69 ($68) writes 4, the gap, then 8. Different slot geometry for different sprite widths. The ADC #$10 is 69 10 (2 bytes) because A is 8-bit after SEP #$20, and asar's REP/SEP tracking got it right.

Pass 2+ exits INC $1B00,X; SEP #$10; CLC; RTS: CLC means "done". make diff: 4,194,304 bytes identical, once the SEP encoding and the LDX $6D asymmetry were right.

Text Only
Sprite-Slot Init Cluster: $CB3A–$DD27, 4,232 bytes, 12 routines — COMPLETE
  Sub_CB3A   $CB3A–$CBDB    162 B   Animation-frame gate
  Sub_CBDC   $CBDC–$CDC7    492 B   Single-slot, gated 16-tile init
  Sub_CDC8   $CDC8–$CEF4    301 B   Single-slot OAM-only update
  Sub_CEF5   $CEF5–$D123    559 B   Dual-slot 3-way dispatch
  Sub_D28A   $D28A–$D30C    131 B   Dual-slot pass-0
  Sub_D30D   $D30D–$D4F6    490 B   Dual-slot pass-1+
  Sub_D4F7   $D4F7–$D607    273 B   Triple-slot 4-way dispatch
  Sub_D608   $D608–$D68A    131 B   Triple-slot pass-0, $68
  Sub_D68B   $D68B–$D737    173 B   Triple-slot pass-1, non-$68
  Sub_D738   $D738–$D7E4    173 B   Triple-slot pass-1, $68
  Sub_D7E5   $D7E5–$DA68    644 B   Triple-slot pass-2+, non-$68
  Sub_DA69   $DA69–$DD27    703 B   Triple-slot pass-2+, $68

Four sessions earlier I had scoped this cluster at "12+ routines, 4,000+ bytes". It is done.

The dispatcher layer

Scouting callers turned up the main sprite-init dispatcher at $CB0A–$CB30 and two more BRL targets: $D124–$D289 (~358 bytes, which I guessed was a single-slot OAM-only variant) and $DD28–$E129 (~1,026 bytes, a quad-slot or extended variant). All three are matched:

  • Sub_CB0A ($CB0A–$CB39, 48 bytes): the top-level sprite-type dispatcher
  • Sub_D124 ($D124–$D289, 358 bytes): an 8-slot OAM-staging initializer (type 1)
  • Sub_DD28 ($DD28–$E129, 1,026 bytes): a 12-slot OAM-staging initializer (type 2)

The whole sprite-init system

Sessions 28–32 matched 15 routines spanning $CB0A–$E129 (5,664 bytes). As a system:

Every visible entity (character, NPC, enemy) is a "sprite slot", and each slot uses one, two or three hardware OAM entries depending on how many tile groups its art needs. When an entity appears, the game must (1) upload its tile graphics from ROM to VRAM and (2) describe the OAM layout: which tiles, where, in what priority and palette.

The VRAM-DMA cluster ($CB3A–$DD27, 12 routines, sessions 28–31) moves tile data:

  1. Sub_CB3A, the animation-frame gate: if the frame hasn't changed since the last init, return CLC (skip).
  2. Allocate sprite slot(s) from the free list via Sub_E952/E97A/E9AA.
  3. DMA loop: for each 16×16 tile entry, branch on a flag bit to Sub_E687 (normal tile copy) or Sub_E534 (described in this session's notes as a flip-transformed copy), and send it to VRAM.
  4. Increment the pass counter ($1B00,X) and return SEC (call again for the next pass).

It varies along two axes: slot count (single Sub_CBDC; dual Sub_CEF5/D28A/D30D; triple Sub_D4F7/D608/D68B/D738/D7E5/DA69) and layout, where $0D00,X == $68 picks a "left-heavy" 8+4 or "right-heavy" 4+8 X-position pattern.

The OAM-staging dispatcher layer ($CB0A–$CB39 + $D124–$D289 + $DD28–$E129, sessions 28/32) does no DMA. Sub_CDC8 (matched yesterday) was the first hint. Sub_CB0A reads bits 0–1 of $1201,X (the entity's sprite type) and tail-calls one of three routines with BRL:

Value Target What it does
0 Sub_CDC8 1-group OAM staging
1 Sub_D124 8-group OAM staging
2 Sub_DD28 12-group OAM staging
other CLC + RTS no-op

First it validates the entity and bails if the sprite state flag has bit 7 set, if $1A81,X is zero or negative, or if $0F00,X is zero. All four early exits branch backward to one shared RTS at $CB11 instead of using a second RTS later. You only notice from the raw offsets, which are all negative and all hit the same byte.

Sub_D124 and Sub_DD28 run later in the frame, after the tiles are uploaded, and refresh the OAM staging buffer from precomputed animation data:

  • Sub_D124 multiplies the frame number by $50 (80), reads 8 tile-index/attribute pairs from Y=$40, and writes 8 OAM groups at $7F:4BC2–$7F:4BFC (stride $08). It reads the OAM buffer index from WRAM at $001700,X (a long access to bank $00, not the ROM table at $C01700).
  • Sub_DD28 does the same for 12 groups ($7F:4BC2–$7F:4C1C), frame × $78 (120), starting at Y=$60. If $0D00,X == $68 it takes an alternate layout with the X gap after the 4th slot instead of the 8th. Both paths read the index from the ROM table at $C01700,X (absolute mode).

Throughout, each tile-index byte is followed by $00 or $FF (bit 7 of the index), the "tile number high bit" for 512-tile sprite banks.

The animation queue: the last gap

One gap was left next to the sprite system: $C0:C98A–$CB09, 384 bytes between the six-tile renderer (ending $C0:C989) and Sub_CB0A. It holds three routines, the animation clock behind everything above.

Obj_AnimTickAndQueue ($C0:C98A, 236 bytes) is the per-frame animation tick, called once per sprite slot. It reads the countdown at $1601,X, decrements it, and returns until it hits zero. Then it arbitrates focus slots: dp $76, $77 and $78 hold the primary focus, secondary focus and a tertiary mode flag. The sprite type in $1100,X decides the path: type 0 competes for $76, types 1 and 2 for $77. A winner increments its column counter $1681,X. Then it builds an address into a bank $E4 frame table: the row (from $1680,X or $1781,X by mode) × 4, plus the slot's base pointer from $1580,X, plus the column. A non-zero byte there becomes the new timer. A zero byte (end of row) falls back to the row base for the timer and adjusts or zeroes the column counter depending on whether the slot's loop counter has run out. Each sprite slot animates at its own pace.

Field_ProcessAnimQueue ($C0:CA76, 99 bytes) is the VBlank-time partner, called once per frame from the VBlank handler. It bails if $09A0 is non-zero (this stops it running twice). Then it latches the scanline ($2137 SLHV, then $213D OPVCT) and checks it against a threshold in $6B: if $6B ≤ V < 240 it exits, too close to the active display. Otherwise it takes the focus chain ($76, $77) and calls Obj_BuildSpriteFrameStep for the current slot. If the step reports "done" (carry clear), the slot leaves the chain ($1081,X set to $80) and the next one loads. It re-latches the scanline on every pass, so if VBlank ends mid-loop it stops. Re-reading the hardware counter, instead of counting cycles in software, is how an SNES VBlank task stays time-safe.

A bar of the frame's scanlines: building is allowed below the $6B limit and from line 240, and stopped in between

When Field_ProcessAnimQueue may work, by scanline. It re-reads the V counter before every step and stops once the beam reaches the limit in dp $6B, until line 240.

Obj_BuildSpriteFrameStep ($C0:CAD9, 49 bytes) is called by Field_ProcessAnimQueue and by the map-load pass at $C0:B109. It checks $1100,X, $1A81,X and $0F00,X in turn (carry clear if any fails), reads bits 0–1 of $1201,X, and tail-calls with BRL:

  • type 0 → Sub_CBDC (single-slot frame builder; this session's notes call the other two 8-slot and 12-slot builders)
  • type 1 → Sub_CEF5
  • type 2 → Sub_D4F7

All three targets are already matched; they return carry set when a frame was produced. With this, $C98A to $E129 is contiguous matched code.

Call tree from Field_ProcessAnimQueue through Obj_BuildSpriteFrameStep to the 1, 2 and 3 slot builders and their passes, plus the layout-only dispatcher

The whole sprite-init system as a call tree. The six triple-slot passes are picked by pass number and by whether $0D00,X is $68; Sub_CB0A is the separate layout-only path.

Where bank C0 stands

With the dispatcher layer, bank $C0 reached 19,114 bytes (30.9% of 61,779), and with the animation queue 19,498 (31.6%). The biggest uncharted blocks:

  • $59D9–$B191: ~22,425 bytes, between Sub_597D (ends $59D8) and Sub_B192. Likely more entity management, game-mode setup or map traversal.
  • $2E1E–$595B: ~11,070 bytes, after ClearRAMDMA and before the entity dispatchers.

Smaller gaps remain near the E cluster ($EA42–$EC5F, ~542 bytes), probably close relatives of matched code.

Scouting bank C1

Bank $C1 is 63,904 bytes of code with only 1,632 bytes of incbin data: 97% code, the densest bank in the ROM. The dscotton reference names 481 routines in it, so almost nothing is uncharted. It is one integrated system.

The field engine enters it through one JSL $C10000 (Battle_Main), a JMP.W to a bootstrap that sets up the battle NMI/IRQ handlers, starts the battle main loop, and chains to banks $CF, $CC and $CD for the frame-by-frame battle tick. Bank $C1 is the coordination layer: battle menu, battler panels, stat bars, number display, enemy spatial queries and battle-phase sequencing.

It opens with a six-stub JMP vector table ($C10000–$C10018) and a bootstrap/dispatch block ($C10019–$C10088). Those 137 bytes mix data (the incbin jump table for the service dispatcher) and JML stubs, which makes them messier to verify. Right after, at $C10089, is a tight cluster of math leaf routines:

  • Battle_Mul8 (29 bytes): 8×8 hardware multiply via $4202/$4203/$4216. Input $AD/$AE, output $AF.
  • Battle_MulAccum (48 bytes): multiply-accumulate on the same hardware, into a 32-bit result at $A9/$AA. Sets DB=0 for the register access.
  • Battle_Divide (54 bytes): 16÷8 hardware divide via $4204–$4206/$4214/$4216. Input $B1/$B2/$B3, quotient $B5, remainder $B7.
  • Shift helpers (18 bytes, six overlapping stubs): Battle_ShiftLeft7, ShiftLeft4 and ShiftLeft3 fall through into each other; Battle_ShiftRight6 to ShiftRight3 do the same.

They touch only the hardware math registers and a few direct-page temporaries, with no WRAM layout to understand: the cleanest possible way to open a new bank. After them comes BattleMsg_FormatNumberDigits at $C1:011F (decimal formatting for damage numbers), then the BattleUI and BattleMenu code.

The day in numbers

Session Routine Address Bytes Running total quoted
31 Sub_D68B $C0:D68B–$D737 173
31 Sub_D738 $C0:D738–$D7E4 173
31 Sub_D7E5 $C0:D7E5–$DA68 644
31 Sub_DA69 $C0:DA69–$DD27 703 18,153 (86 functions)
32 Sub_CB0A $C0:CB0A–$CB39 48
32 Sub_D124 $C0:D124–$D289 358
32 Sub_DD28 $C0:DD28–$E129 1,026 19,585 (89)
33 Obj_AnimTickAndQueue $C0:C98A 236
33 Field_ProcessAnimQueue $C0:CA76 99
33 Obj_BuildSpriteFrameStep $C0:CAD9 49
Total 3,509

Session totals: 1,693, 1,432 and 384 bytes. After session 31 the quoted figures were 28.6% of bank $C0 and 5.1% of all game code; after session 32, bank $C0 went from 17,682 bytes (28.6%) to 19,114 (30.9%) and all code from 18,153 (5.1%) to 19,585 (5.5%).

Next

Bank $C0 still has the large $2E1E–$595B and $59D9–$B191 regions, but the sprite system is fully closed. The next arc is bank $C1: start at $C1:0089 with Battle_Mul8, open the file, and work through the named routines in order.

← Back to devlog