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

Last updated: October 9, 2026

How Sprite Init Works

A technical reference for how bank $C0 turns field objects into sprites, as recovered from byte-exact matched code. This page describes the system as it works now that every routine in it is matched and reviewed; older devlog posts describe it with earlier names and, in places, an earlier and partly wrong reading (a "sprite descriptor table", "palette copy loops", "init pass 2" markers). The headers in asm/bankC0/bankC0.asm are the reference.


Overview

Drawing an object takes two separate jobs:

  1. Building a frame puts the object's graphics in VRAM and writes its tile records. It is expensive, so it is queued and spread over frames: the animation tick queues the object when its frame changes, and Field_ProcessAnimQueue builds queued frames while the frame still has time.
  2. Appending to OAM happens for every visible object every frame: Oam_BuildShadow visits the objects in their draw buckets and Spr_AppendToOam copies each one's tile records, placed at its current screen position, into the OAM shadow, which the NMI uploads.
Field_EndOfFrame
  Vblank_UnkA810                  per object: move, view test,
    Obj_AnimTickAndQueue            animation tick → join the frame-build queue
    Obj_DrawUnlink / Obj_DrawLink   re-bucket by screen Y
  Field_ProcessAnimQueue          while there is time left in the frame:
    Obj_BuildSpriteFrameStep        by size class:
      Obj_BuildFrame4 / 8 / 12        copy graphics → SprBuf chunk → VRAM queue,
                                      write SprTileSrc records, INC Obj_State
  BRL Oam_BuildShadow             every object in every draw bucket:
    Spr_AppendToOam
      Spr_PrepareTiles              Obj_State → Spr_LoadN or Spr_PlaceN
      copy SprTile X/Y/Tile/Attr → OAM shadow range, high-table bits too

NmiHandler                        VramQ_Flush (the queued graphics), Oam_UploadShadow

Size classes

Obj_SprSize ($1201, per object) bits 0–1 give the object's size class: how many sprite tiles (OAM entries, one SprTile record each) it uses. A frame's graphics are 8×8 tiles: a 4-tile object's frame copies 16 of them.

Class Sprite tiles High-table bytes Load Place Frame builder
0 4 1 Spr_Load4 Spr_Place4 Obj_BuildFrame4 (one pass)
1 8 2 Spr_Load8 Spr_Place8 Obj_BuildFrame8 (two passes)
2 12 3 Spr_Load12 Spr_Place12 Obj_BuildFrame12 (three passes)
3 24 6 — Spr_Place24 Spr_LoadLargeObj (one go, at set-up)

Tile records

Each object owns a run of 8-byte records in bank $7F, starting at its Obj_TileRecOfs:

Struct Base Fields
SprTile $7F:4BC0 X, Y (OAM position after adding the object's base and clamping), OfsX (16-bit), OfsY, Tile, Attr
SprTileSrc $7F:4800 the same layout; the frame builders write OfsX, OfsY, Tile, Attr here

The packed OAM high-table bytes of each object (the 9th X bit and the size bit of each sprite tile) are kept in Obj_OamHiA/B/C ($7F:4F00, $7F:4B40, $7F:4F80, two bytes per object each).


Obj_State and Spr_PrepareTiles

Obj_State ($1B00, per object) counts the frame builder's finished passes in bits 0–6; $80 means the SprTile records are up to date. Spr_PrepareTiles ($C0:B701) reads it before each append:

Obj_State Action
$00 Nothing to draw (C=1)
$01–$7F, below the class's threshold Frame not finished yet (C=1); the threshold is 2 passes for class 1, 3 for class 2, none for class 0
at or above the threshold ($01–$7F or $81–$FF) Spr_LoadN: copy SprTileSrc into SprTile and place it; free the object's SprBuf chunks (SprBuf_Free1/2/3); Obj_State = $80
$80, or $81… below the threshold Spr_PlaceN: re-place the existing records at the object's position

Class 3 always goes to Spr_Place24 by BRL, whose carry becomes the result.

Placing. The Spr_Place* and Spr_Load* routines set each record's X = base X + OfsX (packing bit 8 into the high-table byte with Spr_PackHiBits4 in the 24-tile case) and Y = base Y + OfsY, then clamp Y to Oam_HiddenY ($E0, off screen) in one of three ways chosen by the base Y's high byte and bit 7. The variants differ in their branch orderings from routine to routine; each header spells out its own.


Building a frame

Obj_AnimTickAndQueue ($C0:C98A) counts an object's animation timer down; when it reaches 0 the object joins the frame-build queue (ObjQ_Head / ObjQ_Tail, linked through Obj_QueueNext) and only then advances to its next frame, whose duration it reads from bank $E4. Field_ProcessAnimQueue ($C0:CA76) takes the queue head while the V counter is at line 240 or later, or below ObjQ_ScanlineLimit, and runs Obj_BuildSpriteFrameStep ($C0:CAD9); C=1 from the builder means "call again" (another pass). It waits while an earlier build's VRAM upload is still pending.

A builder (Obj_BuildFrame4, for example, $C0:CBDC):

  1. looks the frame up (Obj_AnimFrameLookup: the frame number for the object's facing, row and column, from bank $E4), and stops if it is the frame already built (Obj_LastFrame);
  2. takes WRAM tile buffer chunks: SprBuf_Alloc1/2/3 give the object 1, 2 or 3 adjacent $200-byte chunks of the buffer at $7F:3800 (owners in SprBuf_Owner, $0BC0; $80 = free);
  3. copies the frame's 8×8 graphics tiles into the chunk with Spr_CopyTile or, for a tile whose bit 14 is set, Spr_CopyTileFlipped (mirrored left to right); the source bank picks one of the unrolled copiers (Spr_CopyTileD2/D3/D4, bank $D5, or Spr_CopyTile7F when the graphics are already in WRAM);
  4. queues the chunk for VRAM at the object's Obj_VramTile (the VramQ_* queue, which the NMI flushes with VramQ_Flush);
  5. writes the SprTileSrc records (offsets from the frame record, tile numbers from Obj_VramTile, attributes from the object's palette and priority) and increments Obj_State.

The 8- and 12-tile builders split this over passes (Obj_BuildFrame8Pass0/1, Obj_BuildFrame12Pass0/1/2 and their Alt variants for the alternate tile layout $68). Once Obj_State reaches the class's threshold, the next Spr_PrepareTiles loads the records and frees the chunks.

Obj_BuildFrameLayout ($C0:CB0A) and Obj_FrameLayout4/8/12 rewrite only the records, for objects whose graphics are already in VRAM (used after a battle). Spr_LoadLargeObj ($C0:E12A) loads a 24-tile object in one go: 96 graphics tiles into the whole buffer, one DMA to VRAM word $0400, and 24 records.


The OAM shadow

Oam_BuildShadow ($C0:B271) rebuilds the shadow (low table at $0700, high table at $0900) every frame. It is split into three ranges, each with a low-table and a high-table write pointer; an object's Obj_OamFlags ($0F80) bits 2–3 choose its range. Objects are visited bucket by bucket from Obj_DrawBucket ($0E00, 64 buckets, last first), following Obj_DrawNext chains; Obj_DrawLink puts an object in the bucket for its screen Y.

Spr_AppendToOam ($C0:B309) runs Spr_PrepareTiles, then copies the object's packed high-table bytes to its range's high-table pointer and aims the WRAM port (WMADD) at the range's low-table pointer, writing X, Y, Tile, Attr of each record through WMDATA with DP pointed at $2100. After the last object, every entry between a range's new end and last frame's end is parked at Y = $E0. NmiHandler uploads the shadow (Oam_UploadShadow, $220 bytes, DMA channel 7).


Quirks kept as in the original

  • Spr_Place4 and Spr_Load4 also clamp SprTile[4], the record after a 4-tile object's last one, on one path (harmless, per the header, since each object's records are placed again before they are copied).
  • On one path Spr_Place24 leaves the carry of the last tile's Y add, so a 24-tile object can be skipped for a frame.
  • Obj_BuildFrame12 returns C=1 for fixed-frame (mode 3) objects without building anything, so Field_ProcessAnimQueue keeps retrying them until its scanline window closes.
  • SprBuf_FreeAll clears all eight SprBuf_Owner entries, though only entries 0–3 are used.

The routines

Routine Address Role
Oam_BuildShadow $C0:B271 Rebuild the OAM shadow, bucket by bucket
Spr_AppendToOam $C0:B309 Append one object's sprite tiles to the shadow
Spr_PrepareTiles $C0:B701 Bring the object's SprTile records up to date
Spr_Place4 / Spr_Load4 $C0:B788 / $C0:B8CA Class 0 place / load
Spr_Place8 / Spr_Load8 $C0:BA65 / $C0:BCDC Class 1
Spr_Place12 / Spr_Load12 $C0:BFF2 / $C0:C2BF Class 2
Spr_PackHiBits4 / Spr_Place24 $C0:C6E7 / $C0:C73A Class 3
Obj_AnimTickAndQueue $C0:C98A Animation tick, frame-build queue
Field_ProcessAnimQueue $C0:CA76 Build queued frames while there is time
Obj_BuildSpriteFrameStep $C0:CAD9 One build step, by class
Obj_BuildFrameLayout, Obj_AnimFrameLookup, Obj_BuildFrame4 / 8 / 12 (+ passes), Obj_FrameLayout4 / 8 / 12 $C0:CB0A–$C0:E129 Frame builders and layout rewriters
Spr_LoadLargeObj $C0:E12A 24-tile object, in one go
Spr_CopyTile* $C0:E534–$C0:E934 Graphics tile copiers
SprBuf_Alloc1/2/3, SprBuf_Free1/2/3, SprBuf_FreeAll, SprBuf_FreeObj $C0:E935–$C0:EA62 Tile buffer chunks
VramQ_Flush $C0:B1B2 Upload the queued graphics (from the NMI)