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

Last updated: October 9, 2026

Frame cycle and NMI

How one frame of the field runs: the game logic in GameLoop_FrameBody, the end-of-frame work that prepares the next picture, the frame wait, and the NMI handler that uploads it during vertical blank. The scene mode in bank $C2 and the battle have frame cycles of their own, summarised at the end.

See also: Engine core, Boot, Architecture.

One field frame

GameLoop_FrameBody ($C0:0097)
  input, scene changes, field update, event hooks …   game logic (see Engine core)
  Field_EndOfFrame ($C0:00BF)
    Vblank_Unk59D9               queue the objects whose script timer ran out
    Vblank_ReadScanlineCounters  run their scripts while the frame has time
    JSL EngFD_UnkC2C1            one HDMA table builder (two table sets, alternate frames)
    JSL FdVec_FFF7               the $0520 animation records (not analysed)
    Vblank_UnkA810               move every object, cull to the view, tick animations
    Field_ProcessAnimQueue       build queued sprite frames while there is time
    BRL Oam_BuildShadow          rebuild the OAM shadow for the next frame
  Field_WaitFrame ($C0:EC60)     handshake with the NMI
  BRA GameLoop_FrameBody

The Vblank_ names are historical: none of these routines runs inside the interrupt. Field_EndOfFrame's header says so plainly: it is "not an interrupt handler and waits for nothing itself". Two loops in this list stop on the PPU's V counter rather than a fixed count, so the work spreads over frames when there is a lot of it: the script runner (Vblank_ReadScanlineCounters) and the frame builder (Field_ProcessAnimQueue).

Field_EndOfFrameShort ($C0:00DE) runs only the two bank-$FD calls, for the fade and idle loops, and Field_IdleFrame ($C0:00EB) is one frame of upkeep without game logic: Field_FrameUpdate, Field_EndOfFrameShort, Field_WaitFrame.

The handshake

Field_WaitFrame ($C0:EC60, 23 bytes) counts Field_Unk58 up, increments Nmi_FramePending ($0152) and spins until it reads 0 again. Only NmiHandler clears it, after the frame's uploads (the "frame handshake" of ram_engine.inc). The NMI must be on, or Field_WaitFrame never returns.

NmiHandler

NmiHandler ($C0:EA63, 509 bytes) is entered through the WRAM trampoline at $7E:0500 that InstallNMI writes. It saves A, X, Y, DP and DB, sets DB=$00, acknowledges the NMI (RDNMI), turns HDMA off and writes NMITIMEN and BGMODE. Then:

  • Lag frame (Nmi_FramePending = 0: the game has not reached its wait): no uploads at all.
  • Otherwise, with DP=$0100, the first that applies:

    1. Field_Unk47 nonzero: the first of its bits 0–3 that is set is cleared and its upload runs (Field_UploadUnk1D00, 1C00, 1F00, 57E0), one per frame; then the common uploads.
    2. A map redraw step in Field_MapRedrawDone: the 2 KB buffer Map_BufC800 to one of the map's tilemaps (Map_UploadBuf2K), or to VRAM $7000 / $7400; then Hdma_InitChannelsFD, skipping the common uploads.
    3. Otherwise one line of the staff credits if requested (Credits_UploadLine) and the Field_Unk36 upload if requested (Field_UploadUnk5800), then the common uploads.

    The common uploads are Pal_UploadCgram (the palette buffer at $7E:2200 to CGRAM), Oam_UploadShadow ($220 bytes from $0700 to OAM), VramQ_Flush (queued sprite tile uploads), then, with DP=$1D00, the map edge uploads for each layer and direction marked in Map_BuiltRowYInc … Map_BuiltColXDec (Map_UploadRowYInc1 … Map_UploadColXDec3, $C0:8445–$C0:87F0), Hdma_InitChannelsFD and Nmi_FramePending = 0. - Every NMI: JSL BankC2_Entry8002 (the joypad reader Menu_ReadPad), the window shadows Ppu_W12SelShadow … Ppu_CgwSelShadow to W12SEL … CGWSEL, the fixed colour, HDMAEN from Field_HdmaEnable and INIDISP from Fade_Brightness (forced blank when it is 0). These read the field bytes with absolute addresses, because on a lag frame DP is still the interrupted code's. Then it restores everything and returns with RTI.

The uploads use DMA channel 7. HDMA is set up by Hdma_InitChannelsFD ($FD:C1EE): eight indirect channels writing the BG tilemap bases, the three layers' scroll registers, the main and sub screens, two window positions and colour math, from one of two table sets in bank $7F ($7F:0F80 or $7F:1238).

The field IRQ

IrqHandler ($C0:ECCC) is entered through the trampoline at $7E:0504 that InstallIRQ writes. It does nothing when Field_Unk0F bit 7 is set (who sets it is not traced) or when TIMEUP says this is not a timer IRQ. Otherwise it turns the V-count IRQ off until the next NMI, waits for H-blank, forces blank and turns HDMA off, then by Field_Unk53 bit 0, which EngFD_UnkC2C1 flips each frame, runs Irq_UploadTileAnim or FdVec_FFFD (bank $FD, not matched). The tile writes therefore happen every other frame.

Irq_UploadTileAnim ($C0:ED15) runs one queued tilemap job per call: either the new metatiles of a tile animation (the four tilemap words of each map tile, to the VRAM addresses a Mode*_Handler queued in TileAnim_VramAddrs) or DefaultHandler's single tile step. A step queued together with an animation waits for the next IRQ.

Before that, while Field_Unk63 is not negative, it calls Irq_DrawChoiceCursor ($C0:F05E). That routine draws a 2×2-tile picture (tiles $0FC–$0FF) at choice row Field_Unk63 (0–3) of the tilemap that Field_UploadUnk1C00 sends to VRAM $1C00, and puts Field_Unk2B78's grid entries back in the other three rows (Irq_ChoiceCursorClearRow0–3). A value of 4 or more clears all four rows and sets Field_Unk63 idle, so the next IRQs skip it. Its header calls this the choice cursor as "probable, not traced on screen": Field_Unk63 is the index Sub_1ADF steps with Up and Down while a message choice is active. What the four tiles show (the hand cursor, probably) and which layer uses the $1C00 tilemap are open in NEXT.md.

The OAM shadow

Oam_BuildShadow ($C0:B271) rebuilds the OAM shadow before the frame wait: low table at $0700, high table at $0900, split into three ranges that each object chooses with its Obj_OamFlags bits 2–3. It visits objects bucket by bucket, last bucket first (Obj_DrawBucket, 64 buckets; Obj_DrawLink puts an object in the bucket for its screen Y), calls Spr_AppendToOam for each, and parks every entry between a range's new end and last frame's end off screen (Y = $E0). The NMI copies the whole shadow to OAM. See How Sprite Init Works.

Other frame cycles

  • Scene mode (bank $C2). C2Scene_InstallInterrupts points the trampolines at C2Scene_NmiHandler ($C2:015E) and the one-byte C2Scene_IrqHandler (nothing in the scene code enables IRQs). Here the NMI does the frame's work itself: when C2Scene_NmiFlags bit 0 is set it uploads OAM, the palette (bit 1) and the PPU shadows, flushes the scene's VRAM queue, reads the joypad, then empties the sprite list, runs every task (C2Scene_TaskRunAll) and draws the list into the OAM shadow for the next NMI. The main loop only waits (C2Scene_WaitFrame: spin until C2Scene_FrameCounter changes) and dispatches on the scene mode. See Scene engine.
  • Menu (bank $C2). Menu_Nmi ($C2:840E) runs on its own stack with DP=$0D00; when the menu's threads have a frame ready it runs the upload (Menu_Unk85D6, not matched) and sets Menu_FrameReady to $FF. The thread scheduler Menu_RunThreads waits for that, runs each thread that is due until it yields, then clears the flag. Every NMI reads the pad and ticks the play-time clock (Menu_NmiPad). Menu_Irq is a bare RTI. See Menu.
  • Battle (bank $C1). BattleSys_PumpFrames ($C1:007E) sets Battle_FramePending and calls a bank-$CD vector until the flag is cleared. Its header reads this as a frame wait, but the clearing code in bank $CF is not traced, so "wait for the frame" is inferred, not proven.