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:
ReentryVectorsat$C0:0000: entry 0 is a warm restart (GameLoop_Main), entries 1–4 areJSLtargets that tail intoScrollStepAccum,Field_RefreshHdmaLong,MusicCueDispatchandAudioFadeDispatch(see Engine core).BankC2_Entry0000–000Cat$C2:0000(the scene mode and the text window calls) andBankC2_Entry8000/8002/8004at$C2:8000(the menu entry, the joypad reader and a command dispatcher).$C1:0000and on: the battle entry (JSL $C10000from the field), the exit vector, and a same-bank service dispatcher at$C1:0045with a 10-entry table at$C1:0051. The vectors themselves are not matched yet;BankC1_BattleStartVec($C1:8000) andBankC1_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_SaveStatecopies the field's save block ($7E:0920,$14E0bytes) to$7F:2000and records the party's positions; the field then callsBankC2_Entry8000with an argument (1 from the menu button, "very likely the main menu" in its header; 5 fromField_RunBankC2Mode5, meaning unverified). The menu installs its own interrupt handlers, runs its screens as cooperative threads (Menu_RunThreads) and returns withRTLthroughMenu_Exit, or restarts the game loop throughMenu_ExitToGame(see Menu). AfterwardsField_RestoreStaterebuilds the field, andParty_ReinitIfChangedruns 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_Mainjumps toBankC2_SceneBoot, which installs its own NMI and runsC2Scene_Main. It leaves the scene by jumping toReentryVectors(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_Idat$0100, the frame handshakeNmi_FramePending($52), the current objectObj_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.