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

11 August 2026: the sprite-slot cluster takes shape, and an honest progress number

Five body routines of the sprite-slot init cluster are matched, then the triple-slot dispatch. In between, the progress number gets a real denominator: matched code against the game's code, not against the whole 4 MB ROM. 2,377 new bytes in 7 functions (sessions 29 and 30).

A word on yesterday's correction

Session 27's notes made it sound as if routines like $CF2D and $D299 went through a "midlayer" around $47xx before calling the slot allocators. Disassembling them showed they call Sub_E97A directly; the $47xx block is a separate, unrelated region. I had sketched a call graph from cross-reference data without checking the bytes at each address. Corrections like this are cheap, because make diff enforces the bytes and a wrong description of who calls what can't corrupt the output. The discipline is to check the disassembly before writing prose about call graphs.

The template, confirmed

Yesterday's scoping described a template: gate → same-frame check → allocate slots → tile-data pointer → DMA loop → OAM write → return. Matching five routines showed it is real. The routines differ in:

  • how many slots: one (Sub_E952), two (Sub_E97A) or three (Sub_E9AA)
  • whether the animation gate runs: Sub_CBDC calls Sub_CB3A; Sub_CDC8 doesn't
  • tile count and stride: 16 tiles × $28, 16 tiles × $50, or 32 tiles × $50
  • the starting Y offset in the tile data: $00 for the first pass, $20 for the second
  • which OAM staging buffer: $7F:4802+slot or $7F:4BC2+slot
  • what goes into $1B00,X: INC, INC twice, or set to $80
  • the return carry: SEC on first-pass success, CLC on OAM-only or second-pass

Once you see the template, each routine is a checklist. The differences are often a single byte: a multiply constant, a Y start, an OAM base address.

Sub_CBDC: single slot, gated, 16 tiles

Sub_CBDC ($CBDC–$CDC7, 492 bytes) calls Sub_CB3A first, unless $1780,X == $03, in which case it reads the frame byte straight from $1301,X. If the gate passes and the frame is new, it claims one VRAM slot with Sub_E952, runs a 16-pass DMA loop with multiplier $28 (40 bytes per frame), writes four tile entries to $7F:4802+slot, and ends with CLC.

One detail: after STA $D9 (the palette-and-priority value), A still holds that value, and the first ORA $0C00,Y uses it directly. My first draft had an extra LDA $D9; the diff caught it.

Sub_CDC8: OAM only

Sub_CDC8 ($CDC8–$CEF4, 301 bytes) is the simplest: no gate, no slot allocation, no DMA loop. It computes the tile-data pointer from $0F01,X × $28 + $1380,X, loads the OAM slot index from $1700,X, and writes four Y entries (with sign extension into the high byte) and four X entries (entity base plus 2-step increments) to $7F:4BC2+slot. Then $1B00,X = $80 and CLC. It updates screen positions for entities whose tiles are already loaded, a light pass after the heavy init routines.

Sub_CEF5: the three-way dispatch

Sub_CEF5 ($CEF5–$D123, 559 bytes; scoping had guessed ~660 for $CEF5–$D289) loads tile-table pointers in its header ($CEF5–$CF20) and checks $1780,X:

  • $1780,X == $03: a full 32-tile dual-slot init inline ($CF21–$D123). It allocates two slots with Sub_E97A, runs 32 passes (multiplier $50), writes eight OAM entries starting at tile-data offset Y=$40, increments $1B00,X twice (at $D11A and $D11D) and returns CLC. The double increment records that both halves loaded in one call.
  • otherwise, check $1B00,X & $7F. Zero (first pass): BRL $0369 to Sub_D28A. Non-zero (second pass or later): BRL $03EF to Sub_D30D.

Both BRLs are tail calls; the target's RTS returns to Sub_CEF5's caller. They need raw offsets because asar's BRL takes a literal displacement when the target is in another org block; see asar Quirks.

Sub_D28A and Sub_D30D: a load split across two calls

Sub_D28A ($D28A–$D30C, 131 bytes) is the first-pass dual-slot routine. It calls Sub_CB3A, compares the frame byte with $0F01,X, allocates two slots with Sub_E97A, and runs a 16-pass tile loop from Y=0 with multiplier $50 to VRAM base $0D80,X. On success it increments $1B00,X and returns SEC.

Sub_D30D ($D30D–$D4F6, 490 bytes; scoping had guessed ~569) is the second pass. No gate, no allocation: it reuses what Sub_D28A set up on the previous call. It uses VRAM base + $0200 for the second slot, starts the tile loop at Y=$20 (the second half of the 32-tile frame), and writes eight OAM entries from tile-data offset Y=$40. Then it increments $1B00,X and returns CLC.

So Sub_CEF5 does the load in two calls (corrected later: not one per frame; the animation queue calls again at once while carry is set and the blanking period has time left, so both can run in the same frame). First call ($1B00,X & $7F == 0): Sub_D28A loads tiles 0–15 and returns SEC. Second call (== 1): Sub_D30D loads tiles 16–31 and returns CLC. The pass counter in $1B00,X tracks which half is pending. Sub_D30D is much longer because it has the eight-entry OAM block that Sub_D28A leaves to it.

Sub_CEF5 dispatching to Sub_D28A on pass 0 and Sub_D30D on pass 1, with their return values

One 8-tile frame in two calls. Pass 0 returns carry set, "call me again"; the animation queue calls again while it has time, so pass 1 can run in the same blanking period or a later one.

The denominator problem

I had been quoting progress like "0.38% of the ROM". That is true and misleading: most of a 4 MB SNES RPG is character sprites, tilesets, enemy graphics, maps, battle backgrounds, music samples and compressed audio, not code.

So I scanned the dscotton reference disassembly. Each bank file pulls raw asset data in with incbin. Summing those ranges and subtracting from each bank's 65,536 bytes gives the code per bank:

  • ROM total: 4,194,304 bytes
  • Asset data (incbin): ~3,835,026 bytes (91.4%)
  • 65816 code: ~359,278 bytes (8.6%)

Against 359,278 instead of 4,194,304, the 16,460 matched bytes are 4.6%, not 0.38%. Bank $C0 has 61,779 bytes of code, so 15,989 matched there is 25.9%, a quarter of the bank.

Two bars: 16,460 bytes as a sliver of the 4 MB ROM, and as 4.6% of the code estimate

The same 16,460 matched bytes against two totals. Against the whole ROM they barely show; against the code they are 4.6%.

The breakdown also shows which banks are worth targeting:

  • $C1 and $CB are 97% and 94% code, nearly as dense as $C0: the obvious next big targets.
  • $C2 is 84% code.
  • $CA, $CC and $FD mix significant code with data.
  • $C4, $C5, $C8, $C9, $D0 and most of $D2–$EF are pure asset banks, 0% code. No point disassembling them.

The full per-bank density table is on the ROM Map page.

Triple-slot dispatch: Sub_D4F7 and Sub_D608

The two "triple-slot stubs" from yesterday's table were estimates. Disassembling at $C0:D546 showed more structure. The code at $D4F7 is a dispatch routine, the triple-slot counterpart of Sub_CEF5. It:

  1. sets up graphics-bank parameters ($CF, $D2, $CD, $D8)
  2. bails at once for type 3 sprites, like the rest of the cluster
  3. reads $1B00,X & $7F, the pass counter
  4. checks whether $0D00,X equals $68, a sprite-size flag that picks between two load strategies

The paths:

  • Pass 0, not $68: falls through to Sub_D546 in the same body: two 8-tile DMA loops, to the WRAM slot base and base + $0200.
  • Pass 0, $68: BRL to Sub_D608, one 16-tile loop to the WRAM base.
  • Pass 1, not $68: BRL to Sub_D68B (unmatched).
  • Pass 1, $68: BRL to Sub_D738 (unmatched).
  • Pass 2+: two more BRL targets, unmatched.

Sub_D546 is reached by a short BRA, not a JSR, so it is an internal label. It is written as part of Sub_D4F7's body (273 bytes in all), with Sub_D546 kept as a global label for tracking. Sub_D608 is a real routine (131 bytes): animation gate, triple-slot allocator, 16-tile DMA, pass counter increment, SEC.

Both assembled cleanly, with a full 4,194,304-byte match on the first try.

Where the cluster stands

Routine Range Bytes Status
Sub_CB3A $CB3A–$CBDB 162 Matched — animation-frame gate
Sub_CBDC $CBDC–$CDC7 492 Matched — single-slot, gated, 16-tile
Sub_CDC8 $CDC8–$CEF4 301 Matched — single-slot, OAM-only
Sub_CEF5 $CEF5–$D123 559 Matched — three-way dispatch
Sub_D28A $D28A–$D30C 131 Matched — dual-slot first pass
Sub_D30D $D30D–$D4F6 490 Matched — dual-slot second pass
Sub_D4F7 (incl. Sub_D546) $D4F7–$D607 273 Matched — triple-slot dispatch
Sub_D608 $D608–$D68A 131 Matched — triple-slot pass 0, $68
Sub_D68B, Sub_D738, Sub_D7E5, Sub_DA69 — — Identified (pass 1 and pass 2+)

Pass 1 and pass 2+ load tiles for the second and third slots of a triple-slot sprite in separate calls. They should look like Sub_D28A/Sub_D30D, writing to WRAM base + $100 and base + $200 in turn.

The day in numbers

Session Routine Address Bytes Running total quoted
29 Sub_CBDC $C0:CBDC–$CDC7 492
29 Sub_CDC8 $C0:CDC8–$CEF4 301
29 Sub_CEF5 $C0:CEF5–$D123 559
29 Sub_D28A $C0:D28A–$D30C 131
29 Sub_D30D $C0:D30D–$D4F6 490 16,056 (80 functions)
30 Sub_D4F7 (incl. Sub_D546) $C0:D4F7–$D607 273
30 Sub_D608 $C0:D608–$D68A 131 16,460 (82)
Total 2,377

End of day: 16,460 / 359,278 bytes of game code (4.6%); bank $C0 15,989 / 61,779 (25.9%).

Next

The four remaining triple-slot routines: Sub_D68B, Sub_D738, Sub_D7E5 and Sub_DA69.

← Back to devlog