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

7 August 2026: the last mode handler, screen transitions, and the sprite init pass

The $0C76 mode dispatch is closed with the default handler. Then the whole screen-transition path, from fade-out through scene save and restore to fade-in, is matched, two early labels are corrected, and the 1,034-byte sprite init pass Sub_E12A goes in: 2,568 new bytes in 15 functions over sessions 21 to 25.

DefaultHandler closes the mode dispatch

DefaultHandler at $C0:16DC–$18D8 (509 bytes) is the last arm of Sub_0C76's per-frame dispatch. I already knew it used dp $5D/$5E instead of $5B/$5C and wrote VRAM slots $09B2–$09B8 instead of $09CA–$09F8. Almost nothing else matches the named handlers either. Those follow a tight template (advance some $7E:3000 entries, run N passes of 4-corner writes via Sub_1B36, BRL back to $16DC). DefaultHandler is $16DC, the landing zone, so there is no tail call.

Three bit flags in dp $17 control three separate sections:

  • Bit 1 (VRAM update). Clears the flag, calls Sub_1BA7, reads the layer entry at $7E:3000,X (index dp $5D) and advances it only for the terminal mode bytes $FE or $E0. Then one 4-corner VRAM pass to $09B2/$09B4/$09B6/$09B8, and bit 1 of dp $5F set as a done flag. The 16-bit math is the same as in the named handlers, with other sources and destinations.
  • Bit 5 (display-mode transition). The surprise. Clears the bit, sets dp $38 = 1, and loops: JSR $885A / JSR $00BF / check dp $38. After the loop (dp $38 = 0) it reads dp $45 and dispatches to one of four transition sub-modes (values 1–4). Each calls a mode-specific renderer with DP set to $1D00 for the call, adjusts dp $46, clears dp $45 and tail-calls Sub_EC60. Mode 3 runs three renderer passes with PHD/PLD framing and swaps dp $7C/$82 between them. No named handler has anything like this.
  • Bit 0 (scene swap). Checked only when bit 5 is clear. Calls a probe at $024C. Carry clear starts a full scene swap: JSL $C02C41, JSL $C10000 (bank $C1 init), reinstall NMI/IRQ, reset dp $17/$18, call three post-init helpers. Carry set takes a quick path: flip bits in $17/$18 and BRL to the $00EB fragment (JSR $881E + JSR $00DE + BRL $EC60).

Sub_1BA7 ($C0:1BA7–$1BAA, 4 bytes) sends an SPC audio command:

65C816 Assembly
LDA $FB        ; load dp:$FB as audio arg 0
BRA Sub_1B90_body  ; join Sub_1B90 at STA $1E01

Sub_1B90 uses dp $FC for the same argument. Sub_1BA7 loads $FB instead and jumps into Sub_1B90 at its second instruction, skipping only LDA $FC. A new Sub_1B90_body label marks that spot; labels emit no bytes, so Sub_1B90 is unchanged. $FB versus $FC presumably picks a different command from the SPC tables, which fits a different kind of layer transition.

$885A, deferred. The bit-5 loop calls it every frame until dp $38 clears. The disassembler traced over 2,700 unique instructions from it before hitting the sweep limit, into a large tree across bank $C0 ($7612, $77E4, $79CF, $7BA9 and many more). It is a major frame-work routine and is in the bank map as Identified/deferred. Sub_EC60 stays the always-deferred post-VBlank worker. DefaultHandler tail-calls it for sub-modes 1, 2 and 3 (raw BRL offsets $D49B, $D472 and $D3FE), and mode 4 reaches it with two JSRs.

Handler Status
ModeE6_Handler Matched
ModeEC_Handler Matched
ModeEE_Handler Matched
ModeFA_Handler Matched
ModeFC_Handler Matched
DefaultHandler Matched
Sub_1B36 (leaf callee) Matched
Sub_1B90 (leaf callee) Matched
Sub_1BA7 (leaf callee) Matched
Sub_EC60 (black-box) Identified, always-deferred
Sub_885A (black-box) Identified, deferred

The mode-dispatch part of the $0C76 cluster is complete. Two subsystems are now fully closed: the sprite renderer cluster (fourteen functions, sessions 7–17) and the $0C76 mode dispatch (thirteen functions, sessions 18–21).

The screen-transition manager

Past DefaultHandler's last byte ($18D8) sit routines that are not part of the Sub_0C76 tree.

Sub_18D9 ($C0:18D9–$1984, 172 bytes) is the outer gate for screen fades and mode transitions, called from the startup sequence at $C0:00A1. It reads two flags:

  • $00F0 bit 0: Start, newly pressed (corrected later: $00F0 is the newly-pressed button word the pad reader writes, not a BG mode 5 flag)
  • $00F6 bits 0, 6 and 7: a second mode-control byte

If $00F0 bit 0 is set (with dp $11 zero and dp $1F non-zero), it pauses: it halves the brightness in dp $19 and loops a frame at a time, calling JSR $EC60 and JSL $FDC2C1, until Start is pressed again, then restores the brightness. (Corrected later: this is the Start pause, first read as a fade loop.) A "busy" flag at $0407 is written $FF at the top of every pass and reset to zero on exit. Then: if $00F6 bit 0 is set it calls Sub_1985. If $00F6 bit 6 is clear, it calls Sub_1ADF when dp $62 is non-zero. If bit 6 is set, it takes a long reinit path: the fade-down loop again, then $0B4E, $011B, JSL $C28000, $0B4E, $0B64, $0B75, DP=$0100, $01A5, $1A03, $B192, Sub_2824, and $0407 cleared. That is a full screen transition in this engine: fade out, tear down hardware state, reinit everything, run the post-transition fade.

Sub_1985 ($C0:1985–$19C6, 66 bytes) is the narrower mode-5 probe, called from Sub_18D9 at $191D. It checks:

  1. dp $26: if non-zero, XOR with $03, store and return (this flips between two palette modes)
  2. $7F0000: if below $49, bail
  3. dp $1F, dp $62, dp $10 in turn: any non-zero returns

If every guard passes, it runs the same fade-to-black loop (dp $19 down, JSL $FDC2C1, JSR $EC60), then calls $0B4E and $011B. There is no RTS. It falls straight into Sub_19C7, the mode-5 hardware reinit, so calling Sub_1985 when a reinit is needed runs Sub_19C7 on the way out. The first estimate said 322 bytes because tracing without knowing Sub_19C7 was already matched counts 66 + 60 bytes of Sub_19C7 and more. Seeing org $C019C7 / Sub_19C7: in the source made the boundary obvious.

Sub_1ADF ($C0:1ADF–$1B35, 87 bytes) is a pure leaf that runs an on-screen menu cursor, depending on dp $62:

  • $62 = 1: if dp $63 is not negative and not $04, copy it to dp $66 and set dp $63 = $04. Then, if $00F6 bit 7 is set, zero dp $34 (a word write).
  • $62 = 2: read joypad $00F7. Bit 2 (up) increments dp $63, clamped at dp $65; bit 3 (down) decrements it, floored at dp $64; bit 7 of $00F8 (Start) sets dp $62 = $03 and returns. Both clamps use BCS.
  • anything else: the same "check $63, update $66, set $63=$04" block as $62 = 1.

The fade-loop label bug. The first make diff had two wrong bytes, at 0x001957 and 0x0019C0: the BRA offsets in the fade loops of Sub_18D9 and Sub_1985, $EB (−21) instead of $E6 (−26). I had put .fade_loop after LDA #$FF / STA $0407, but the ROM re-runs those two instructions every pass. Moving the label back five bytes fixed both.

Two names that were backwards

Cross-checking against dscotton's disassembly turned up two early labels that were wrong.

EntryTable → ReentryVectors. The table at $C0:0000 was commented as "boot modes". Entry 0, BRA GameLoop_Main, is right. But entries 1–4 are three-byte BRL stubs to ScrollStepAccum, AudioDrvSync, MusicCueDispatch and AudioFadeDispatch. These are JSL re-entry points. A caller in another bank does JSL $C0:0002 (or $0005, $0008, $000B), which pushes three bytes. The BRL jumps to the real target without pushing anything, and the target's RTL returns straight to the caller. The stubs give other banks fixed entry points. Callers turn up in bank $CD ($CD:043E, $CD:0C8C) and bank $D1 ($D1:F405, $D1:F4E2), and every target ends in RTL ($6B).

FrameUpdate → LoadLocation. The routine at $C0:00F4 was commented "per-frame logic dispatch", because GameLoop_Main calls it. But GameLoop_Main is the outer loop that runs once per mode entry; the per-frame loop is GameLoop_FrameBody at $C0:0097. The routine's ten JSRs and two JSLs load a location.

Both renames reached every call site and label stub. No bytes changed (ReentryVectors $C0:0000–$000D, 14 bytes; LoadLocation $C0:00F4–$011A, 39 bytes), and make diff was clean.

Saving and restoring the scene

Sub_18D9's reinit path left four callees. Estimates were Sub_011B ~138 bytes (calls $0918, also called from Sub_0C76), Sub_01A5 unknown (calls $0905, also called from Sub_0C76), Sub_1A03 ~160 bytes (calls $597D), Sub_B192 ~32 bytes (BRL to $E935). All four are matched, plus three smaller callees:

  • Sub_011B ($C0:011B–$01A4, 138 bytes) saves the scene context to $7F:1Dxx. It skips the save if dp $29 is zero. It calls Sub_0918 to copy a 5,344-byte ($14E0) WRAM block from $00:0920 to $7F:2000 with MVN, then copies scene table pointers and scroll positions to $7F:1D09–$1D24.
  • Sub_01A5 ($C0:01A5–$024B, 167 bytes) is the full location-engine init. It calls Sub_0905 (the reverse copy), runs an audio tick (SEP #$10 / JSL $FDC2C1 / REP #$10 / JSL $FDC1EE), sets OAM base pointers ($7D=$0900, $7F=$0770, $7B=$08A0), calls eight scene-setup helpers, restores the scroll registers, calls Sub_595C, and calls $E12A depending on a flag.
  • Sub_0905 and Sub_0918 ($C0:0905–$0917 and $C0:0918–$092A, 19 bytes each) are the MVN pair. In the opcode bytes the destination bank comes first: Sub_0918 (save) is $54 $7F $00, from $00 to $7F; Sub_0905 (restore) is $54 $00 $7F. The disassembler also got the LDX/LDY sizes wrong here, assuming 8-bit X; the entry state is 16-bit X, so LDX #$2000 is three bytes.
  • Sub_1A03 ($C0:1A03–$1AAB, 169 bytes) restores the scene after a transition. It compares the current palette colors ($7E:2980–$2982) with the cached dp $94–$96; if they match, it returns early. Otherwise it calls Sub_597D for each of seven active sprite slots, updates the cache, copies scene table pointers back from $7F:1D09/$0F/$15, and restores dp $AB/$AC/$AD from $7F:1D1B/$1C/$1D. The addresses match Sub_011B's exactly: the pair is closed.
  • Sub_B192 ($C0:B192–$B1AF, 30 bytes) zeroes the $1Bxx table. It sets DP to $1B00, loads a loop count from $7F:2000, zeroes 16-bit words at $00,Y (Y steps by 2, count down to zero), restores DP and tail-calls Sub_E935 with BRL. The operand is the raw offset $3783, applied from the address after the BRL. I couldn't make the hand arithmetic land on $E935, but make diff passes with that raw offset.
  • Sub_E935 ($C0:E935–$E951, 29 bytes) sets DP to $0B00 and stores $80 into dp $C0–$C7, which is WRAM $0BC0–$0BC7. $80 means "no sprite" in the sprite system. This links back to Sub_E9E2/Sub_E9FF/Sub_EA1F, which search $0BC0 and mark slots with $80.

The full transition, now byte-exact end to end:

Nine numbered steps from fade out through saving the scene, the call into bank $C2, reinstalling the interrupts, restoring the scene and fading back in

The nine steps of Sub_18D9's long path, in the order the code calls them. The orange steps are the save and restore pair built on MVN.

The MVN idiom. The only MVN instructions in matched code are in Sub_0905 (54 00 7F: $7F:2000 → $00:0920) and Sub_0918 (54 7F 00: $00:0920 → $7F:2000), called from Sub_01A5 and Sub_011B. The address pair appears nowhere else. The two routines are back to back; matched code resumes at $0B4E (InitHW), and $092B–$0B4D is uncharted. A small symmetric pair with one MVN each is a recognizable shape: if another turns up, it is probably a save/restore pair for a different workspace.

Two boxes, $00:0920 and $7F:2000, with arrows for the save and restore copies and their opcode bytes

The save and restore pair: one MVN each way over the same $14E0-byte (5,344-byte) block. In the opcode the destination bank comes first.

The entity dispatchers

The scene path left three callees: Sub_595C (33 bytes), Sub_597D (92 bytes) and Sub_E12A. The first two have no external calls; they only dispatch through an indirect jump table at $5D6E.

Sub_595C ($C0:595C–$597C, 33 bytes), called from Sub_01A5 once per scene load, walks the entity list:

  1. In 16-bit A, STZ $6D clears dp $6D and $6E in one instruction: the sprite-slot counter and the sprite-update flag.
  2. Load a 16-bit base pointer from $7F:2003 into X.
  3. Loop: load the entity type from $7F:2001,X; stop on zero. Otherwise save X to Y, compute type × 2, load the handler from ($5D6E + type×2), JSR to it, repeat.

Before the JSR the entity pointer is in Y; after it, the loop reads $7F:2001,X with the current X. So each handler must return with X pointing at the next entity.

Sub_597D ($C0:597D–$59D8, 92 bytes), called from Sub_1A03 once per active sprite slot (slot index in dp $6D), does the same for one slot's sub-list, found at $7F:2001 + slot×16 (four ASLs in 16-bit mode). After the terminator it:

  • steps X past the terminator and stores the pointer at $1180 + slot, recording where the slot's list ends
  • zeroes one 16-bit entry for the slot in each of eight arrays at $7F:0580, $7F:0600, $7F:0680, $7F:0700, $7F:0780, $7F:0800, $7F:0880 and $7F:0900 (stride $0080: eight parallel attribute tables)
  • stores $07 (three flags set) at $1C00 + slot, perhaps a "slot ready" or type-flags start state; not clear yet

The $5D6E table. Both functions use it. The first words are $5F74, $5F6E, $5FB6, $607E, ...: pointers to entity-type handlers in bank $C0, at least 30 before the first value that isn't obviously code. They are a future frontier. make diff was clean on the first pass; both org blocks went after Sub_1A03's RTS, and the two JSR $595C and seven JSR $597D call sites now use the labels.

Sub_E12A: the sprite init pass

The first look at Sub_E12A reported "829+ instructions". That was the whole reachable subgraph. Its own body ends at the first RTS, at $E533: 1,034 bytes, $E12A–$E533. Its only two external calls go to $E534 and $E687, right after it:

  • $E534 ends at $E686 (341 bytes) and calls $E739, $E7BC, $E83F and $E8C2, each about 131 bytes, each a tight copy loop from a different ROM bank into WRAM.
  • $E687 ends at $E738 (178 bytes) and dispatches through internal jump targets.

In all, about 2,370 bytes in seven routines from $E12A to $E934 (1,034 own body + ~1,336 callee cluster). I deferred it, matched the entity dispatchers, and then came back to it the same evening as the head of its own arc.

It runs once per sprite slot during scene load, called from Sub_01A5 when dp $AE holds a valid slot. Four phases:

  1. Pointers. Builds two indirect-long pointers from four descriptor arrays ($1200, $1280, $1300, $1380, indexed by slot): [$CD] into $7F:XXXX, the sprite-data table (palette source), and [$D3] into bank($D5):XXXX, the scene-data table (tile layout source). $0D80+slot gets $3800 (the WRAM staging base), dp $D0 tracks the running WRAM write address, and $0F01+slot is cleared (a sprite-state flag). Early instructions set $D2=$7F and write $2181 (WMADDL).
  2. The 96-entry WRAM fill. After setting $2183=$01 and $2181=$3800, it reads 96 16-bit words from [$D3]+Y (Y from 0, two bytes per pass). Bit 14 picks the path: clear → JSR Sub_E687 (one WMDATA write), set → JSR Sub_E534 (multi-tile fill). The inner loop at $E173 does this test against $4000. The callee writes what it needs at the current address, and $D0 advances by $0020 on every pass except the first.
  3. DMA to VRAM. Channel 7: source $7F:3800, size $0C00, destination VRAM word $0400, port $2118 (VMDATA, BBAD7=$18), mode DMAP7=$01 (CPU→PPU, word increment), VMAIN=$80. It programs $4370–$4375 and triggers MDMAEN, copying 3,072 bytes.
  4. OAM staging. About 800 bytes. A lookup from $1700+slot gives an offset into $7F:4BC0, and the code fills 24 groups of 8 bytes (stride $08, bases $4BC2, $4BCA, $4BD2, …, $4C7A). Each group gets, from [$D3]+$C0 on: +0 a signed 8-bit value, +1 its sign extension ($FF or $00, via BPL / LDA #$FF / BRA / LDA #$00, 24 times), +2 a raw 8-bit value. Y walks $C0 to $EF. Then two constant passes: tile indexes at +4 ($40, $42, …$4E for groups 1–8, $60, $62, …$6E for 9–16, $80, $82, …$8E for 17–24, as 24 LDA #$xx / STA.l $7F4BCn,X pairs) and attribute $22 at +5 (one LDA #$22 and 24 STAs). $22 = %00100010: in the sprite renderer's attribute format, bit 5 is priority and bits 1–0 the palette, so priority 1 and palette 2.

It is big because it is fully unrolled: the 24-group fill alone is 24 × ~14 bytes = ~336 bytes. It runs once per slot at scene load, and on a 2.68 MHz CPU the loop overhead over 24 iterations is not trivial, so speed won over size. make diff passed on the first assembly; the local labels (.sp01–.sp24, .se01–.se24, .next_iter, .first_iter, .big_fill, .dma) resolved cleanly.

Block Range Bytes Status
Sub_E12A $E12A–$E533 1034 Matched
Sub_E534 $E534–$E686 341 Identified
Sub_E687 $E687–$E738 178 Identified
Sub_E739 $E739–$E7BB 131 Identified
Sub_E7BC $E7BC–$E83E 131 Identified
Sub_E83F $E83F–$E8C1 131 Identified
Sub_E8C2 $E8C2–$E934 115 Identified

The day in numbers

Session Routine Address Bytes Running total quoted
21 DefaultHandler $C0:16DC–$18D8 509
21 Sub_1BA7 $C0:1BA7–$1BAA 4 10,697 (52 functions)
22 Sub_18D9 $C0:18D9–$1984 172
22 Sub_1985 $C0:1985–$19C6 66
22 Sub_1ADF $C0:1ADF–$1B35 87 11,022 (55)
23 Sub_011B $C0:011B–$01A4 138
23 Sub_01A5 $C0:01A5–$024B 167
23 Sub_0905 $C0:0905–$0917 19
23 Sub_0918 $C0:0918–$092A 19
23 Sub_1A03 $C0:1A03–$1AAB 169
23 Sub_B192 $C0:B192–$B1AF 30
23 Sub_E935 $C0:E935–$E951 29 11,593 (62)
24 Sub_595C $C0:595C–$597C 33
24 Sub_597D $C0:597D–$59D8 92 11,718 (64)
25 Sub_E12A $C0:E12A–$E533 1,034 12,752 (65)
Total 2,568

Session totals: 513, 325, 571, 125 and 1,034 bytes. Bank $C0 ended the day at 12,281 bytes. (The quoted function count goes from 32 after session 19 to 52 after session 21; the notes don't say why.)

Next

Sub_E534 (341 bytes) and Sub_E687 (178 bytes), then the four bank-copy routines, to close the Sub_E12A arc. The broader $881E callee tree is still waiting.

← Back to devlog