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

Last updated: October 9, 2026

Engine Architecture

A high-level map of the Chrono Trigger SNES engine as recovered from matched code. Everything here comes from byte-exact matched routines and their reviewed headers; where a header only infers a meaning ("probably", "not traced"), so does this page. The Systems pages go into each part in more detail.

The banks and how they call each other

The code that is matched so far lives in a handful of banks, each with its own job and its own direct-page layout:

Bank Role Direct page
$00 (= top of $C0) Reset, interrupt stubs, ROM header and vectors —
$FD MainInit, battle helpers called with JSL, field HDMA and animation set-up $4200/$2100 in MainInit; the caller's elsewhere
$C0 The field engine: game loop, NMI, maps, objects, event scripts, sprites $0100 (field page), $1D00 for the map code
$C2 The scene mode (Loc_Id $01F0 and up), the menu's entry, thread scheduler and shared helpers, and the text window decoder $0000 ($0200 for the text decoder, $0D00 for the menu's NMI)
$C1 The battle engine $0000, with DB = $7E

Other banks enter $C0, $C1 and $C2 only through small fixed vector tables:

  • ReentryVectors at $C0:0000: entry 0 is a warm restart (GameLoop_Main), entries 1–4 are JSL targets that tail into ScrollStepAccum, Field_RefreshHdmaLong, MusicCueDispatch and AudioFadeDispatch (see Engine core).
  • BankC2_Entry0000–000C at $C2:0000 (the scene mode and the text window calls) and BankC2_Entry8000/8002/8004 at $C2:8000 (the menu entry, the joypad reader and a command dispatcher).
  • $C1:0000 and on: the battle entry (JSL $C10000 from the field), the exit vector, and a same-bank service dispatcher at $C1:0045 with a 10-entry table at $C1:0051. The vectors themselves are not matched yet; BankC1_BattleStartVec ($C1:8000) and BankC1_Entry8003 ($C1:8003, the inventory and gold services the field's event scripts use) are.

Boot sequence

RESET vector → Reset ($00:FF00)
  SEI / CLC / XCE                 native mode
  JML MainInit                    (soft resets come here too)

MainInit ($FD:C000, 215 bytes)
  1. CPU mode, stack S=$06FF, DB=$00, DP=$4200
  2. CPU I/O registers through DP=$4200
  3. DP=$2100, forced blank
  4. PPU registers through DP=$2100
  5. JML GameLoop ($C0:000E)

GameLoop ($C0:000E) — cold init
  InstallNMI / InstallIRQ         JML NmiHandler / IrqHandler into $7E:0500 / $0504
  DP=$0100, InitHW                interrupts off, forced blank
  ClearRAMDMA ×3                  $7E:0000–04FF, $7E:0700–FFFF, $7F:5080–A0FF
  JSL Audio_DriverInit            sound driver (bank $C7)
  JSL BankC2_Entry8004            bank $C2's command dispatcher, A = BankC2_BootArg
  falls into GameLoop_Main

GameLoop_Main ($C0:005D) is also the warm-restart entry: it runs InitHW, reinstalls the interrupt trampolines and dispatches on Loc_Id:

Loc_Id Goes to
$0000–$01EF GameLoop_LoadField: a field location
$01F0–$81EF JML BankC2_Entry0000: the bank-$C2 scene mode
$81F0–$81FE Sys_HaltWithColor: stops the game on one colour
$81FF–$FFFF GameLoop_LoadField

Both tests are CPX then BMI, so they branch on bit 15 of the difference, not on a signed compare; what would set bit 15 of Loc_Id is not traced. The independent review of MainInit noted that GameLoop is probably misnamed, since it starts with an init sequence.

The field frame

GameLoop_LoadField ($C0:007D) sets DP=$0100 and loads the location once: Field_InitLoadState (reset the field page), LoadLocation (ten load steps and two bank-$FD vectors), Obj_ResetStates, Scene_PostLoadInit (the objects and their init scripts), TileAnimList_Clear and Scene_SettleFrames (the fade-in). Then it falls into the per-frame loop:

GameLoop_FrameBody ($C0:0097) — never returns
  latch newly pressed buttons
  Field_PauseAndMenuInput     Start pauses; menu button → bank $C2 menu round trip
  Field_SceneChangeTick       fades; warps, bank-$C2 calls, tile animations, battles
  Field_FrameUpdate           D-pad, the leader's collision (probably), layer scroll
  Field_ActionButton          action button: an object or treasure in front of the leader
  JSL Field_Unk1F87           the message window: open, draw the text, wait, close
  Field_EventHookDispatch     one of 16 window-effect hooks
  Field_ServiceUnk54          staff credits lines, map redraw requests
  Field_EndOfFrame            object scripts, HDMA, motion, frame builds, OAM shadow
  Field_WaitFrame             wait for the NMI to finish the frame's uploads
  BRA GameLoop_FrameBody

Field_EndOfFrame ($C0:00BF) is not an interrupt handler: it is the end of the frame's game logic. It runs the object script scheduler (Vblank_Unk59D9, Vblank_ReadScanlineCounters), EngFD_UnkC2C1 (HDMA), FdVec_FFF7, the object pass Vblank_UnkA810 (movement, view culling, animation), Field_ProcessAnimQueue (frame builds while there is time left), and tail-jumps to Oam_BuildShadow, which rebuilds the OAM shadow for the next frame.

The NMI handshake

Field_WaitFrame ($C0:EC60) increments Nmi_FramePending and spins until it is 0 again. NmiHandler ($C0:EA63, entered through the $7E:0500 trampoline) does the frame's uploads only when Nmi_FramePending is set; on a lag frame it skips straight to the register writes. Usually it runs the common uploads (Pal_UploadCgram, Oam_UploadShadow, VramQ_Flush and the map edge uploads), after any extra upload that was requested (a Field_Unk47 upload, or a staff credits line and the Field_Unk36 upload); a map redraw step (Field_MapRedrawDone) is uploaded instead of them. Every NMI then calls the joypad reader (JSL BankC2_Entry8002), writes the window and colour shadows, HDMAEN and INIDISP, and returns with RTI. See Frame cycle and NMI.

Scene changes

Field_SceneChangeTick ($C0:0C76) steps the fades in Field_FadeFlags, then acts on Field_SceneFlags:

Request What happens
bit 7 Fade out, then warp: copy Loc_Dest* into Loc_Id/Loc_Entry*, reset the stack and restart through ReentryVectors
bit 6 Fade out, save the field (Field_SaveState), call BankC2_Entry8000 with an argument from Field_BankC2Arg, restore and fade in
bit 4 Run the tile-animation handler for the map tile's state byte (ModeE6_Handler … ModeFC_Handler)
otherwise DefaultHandler

DefaultHandler ($C0:16DC) handles three more requests in order: bit 1 steps one map tile and queues its VRAM addresses; bit 5 redraws map layers (running Field_Unk885A frame after frame first); bit 0 is the battle: unless Scene_Unk024C returns carry it calls JSL EngCall_BattleMain, reinstalls the interrupt handlers and rebuilds the field (Scene_Unk0283).

The five tile-animation handlers share one shape: advance the state byte of each affected map tile in Map_TileProps by one, play Audio_SfxTileAnimA, compute the VRAM word address of each map tile's four 8×8 tiles in the 64×32 BG tilemap (Bg_TilemapIndex64x32) into TileAnim_VramAddrs, set the VRAM queue flag and continue in DefaultHandler. They differ in the block they animate: $E6 a 1×2 column, $EC/$EE a 2×2 block, $FA/$FC a 2-wide, 3-tall block (the second of each pair mirrored).

Bank $C2 round trips

The field hands control to bank $C2 in two ways:

  • The menu and the bank-$C2 calls. Field_SaveState copies the field's save block ($7E:0920, $14E0 bytes) to $7F:2000 and records the party's positions; the field then calls BankC2_Entry8000 with an argument (1 from the menu button, "very likely the main menu" in its header; 5 from Field_RunBankC2Mode5, meaning unverified). The menu installs its own interrupt handlers, runs its screens as cooperative threads (Menu_RunThreads) and returns with RTL through Menu_Exit, or restarts the game loop through Menu_ExitToGame (see Menu). Afterwards Field_RestoreState rebuilds the field, and Party_ReinitIfChanged runs the init function of each character object again if the party is no longer the one taken at load.
  • The scene mode. For Loc_Id $01F0–$81EF, GameLoop_Main jumps to BankC2_SceneBoot, which installs its own NMI and runs C2Scene_Main. It leaves the scene by jumping to ReentryVectors (C2Scene_Mode2), which restarts the field's game loop. See Scene engine.

The battle

JSL $C10000 (from DefaultHandler, at $C0:18A7) reaches the battle entry, which per BattleSys_PumpFrames' header goes JMP $001B → JML $CF:FB65 (clears battle RAM) → $C1:8000 → BattleSys_Main. BattleSys_Main ($C1:8006) never returns: it sets the battle up, then loops pass after pass over the 13 turn lists until a victory, defeat or other end, and leaves through BattleSys_ExitVec ($C1:0006 → $001F → JML $CF:FBE5). Back on the field, Scene_Unk0283 reads FieldBtl_Result and either rebuilds the location in place from the field/battle hand-off block ($7E:29B0–$2C7B), reloads it, or restarts the game.

Inside bank $C1, the service dispatcher at $C1:0045 is reached only by same-bank JSR. The services matched so far: 1 BattleMenu_EnqueueReadyBattler, 2 BattleMenu_RemoveBattlerFromReady, 3 BattleSys_UpkeepTwoFrames, 4 BattleSys_RunAction, 5 BattlePos_Query, 7 BattleTgt_RunAreaQuery, 8 BattleSys_VictoryPose, 9 BattleSys_DefeatPose; service 0 ends in Battle_StartPose, and service 6 is not analysed. See Battle engine.

RAM-resident interrupt handlers

The native NMI and IRQ vectors can only point into bank $00, so they land on 4-byte stubs that jump on through trampolines in WRAM:

WRAM Stub Installed by Target
$7E:0500 NMI_Stub ($00:FF10) InstallNMI (field), C2Scene_InstallInterrupts (scene mode), Menu_InitSystems (menu) NmiHandler ($C0:EA63), C2Scene_NmiHandler ($C2:015E), Menu_Nmi ($C2:840E)
$7E:0504 IRQ_Stub ($00:FF14) InstallIRQ, C2Scene_InstallInterrupts, Menu_InitSystems IrqHandler ($C0:ECCC), C2Scene_IrqHandler ($C2:0345), Menu_Irq ($C2:8472)

The trampolines are not set by MainInit. Sys_HaltWithColor sets both to RTI (at $C0:2E4B), and unmatched code in bank $FD ($FD:EAF4) repoints both into that bank (see !NmiTrampoline in ram_engine.inc).

The menu in bank $C2 has handlers of its own, Menu_Nmi ($C2:840E) and Menu_Irq ($C2:8472, a bare RTI): Menu_InitSystems copies the two JMLs at $C2:8406 (Menu_InterruptVectors) over the trampolines. See Frame cycle and NMI.

Key field structures

  • The field direct page ($0100–$01FF, DP=$0100): Loc_Id at $0100, the frame handshake Nmi_FramePending ($52), the current object Obj_Cur ($6D), the OAM range pointers ($7B–$8B, $DB–$DF) and the fade and scene flags.
  • Per-object tables indexed by object number × 2 (Obj_Cur): screen position ($0A00), draw buckets ($0E00, 64 words), size class (Obj_SprSize, $1201), draw state (Obj_State, $1B00), position in 1/16 pixel (Obj_PosX/Y), and the event script position.
  • The OAM shadow: low table at $0700 (4 bytes per sprite), high table at $0900, split into three ranges, uploaded by the NMI (Oam_UploadShadow). See How Sprite Init Works.
  • The map page ($1D00, DP=$1D00) for the map code, and the map's metatile buffers in bank $7E/$7F.