How it works

If the actor VM animates sprites, the field VM scripts the world. Every NPC has a small bytecode program that says "walk to this corner, wait, walk back, when the player says hi say something". Every cutscene is a longer bytecode program that says "fade in, position the camera, play this animation, open this dialog box, wait for confirm, fade out, change scene". The field VM is what runs those programs.

The mental model: each running script has its own context struct (passed around as ctx_ptr). The context holds the script's current position in the world, its program counter, a 16-bit local flag bank, a halt flag, and a few timer slots. The dispatcher reads the opcode at the current PC and runs a handler that mutates the context (or the world). If a handler decides "I'm done for this frame", it returns the new PC and the next frame's tick comes back in.

One feature that distinguishes Legaia's field VM from typical event-script systems: cross-context targeting. The high bit of any opcode means "this instruction targets a different script context, identified by the next byte". The original script's context is preserved; the dispatch operates on the resolved one. So a cutscene script can issue "the king walks to position X" with the king's script ID as the target, without becoming the king. There are also two reserved system channels (0xF8 and 0xFB) for engine-wide effects.

Three of Legaia's flag-storage banks are exposed by clean opcode triplets: per-script local flags, global story flags (in PSX scratchpad), and ctx flag word. A fourth flag bank is hidden in the dispatcher's "default route" — opcodes whose high nibble is 0x5, 0x6, or 0x7 get dispatched to set / clear / test handlers operating on a 256-bit bitfield, almost certainly an "engine system flags" bank.

For the full opcode reference, the source markdown has the complete tables. This page summarises the structure; jump to docs/subsystems/script-vm.md for the definitive listing.

Function signature

int FUN_801DE840(int buffer_base, int pc_offset, int ctx_ptr);
  • buffer_base — bytecode buffer base address.
  • pc_offset — current program counter, byte offset into the buffer. The function returns the new PC offset.
  • ctx_ptr — script execution context (see below).

The VM is not a step-and-yield loop — each call executes from pc_offset until something forces a return (instruction halt, branch back, target script done). The host calls back at the next frame (or external event) with the returned PC.

Top-level dispatch

byte *pc = (byte*)(buffer_base + pc_offset);
byte *ops = pc + 1;

// Extended/script-target prefix
if (*pc & 0x80) {
    // Switch context to script targeted by pc[1]
    if (pc[1] != ctx_ptr[+0x50]) {
        ctx_ptr = func_0x8003C83C(pc[1]); // resolve by ID
        if (ctx_ptr == 0) return pc_offset + 1;
    }
    if (ctx_ptr[+0x10] & 0x400 && /* not opcode 0x32 with bit 0x400 */) {
        return pc_offset; // halted, don't dispatch
    }
    ops = pc + 2;
    pc_offset += 1;
}

switch (*pc & 0x7f) {
    // 43 unique opcodes 0x21-0x4F (with gaps at 0x27-0x2A)
}

The high bit of an opcode means "this instruction targets a different script context". func_0x8003C83C resolves a script ID to a context pointer, with two reserved IDs:

IDResolution
0xF8Returns the cached pointer at _DAT_8007C364 (player context)
0xFB"System" channel — walks the linked list at _DAT_8007C34C for the entry whose +0xC slot holds 0x801DA51C
otherwiseRegular script-table index

Context struct (selected fields)

Per-script state, passed as ctx_ptr. The full table is in the source markdown; the most-cited fields:

OffsetTypeMeaning
+0x10u32Flag word. Bit 0x400 = "halted". Multiple bits gate per-opcode behaviour.
+0x14/16/18u16World X / Y / Z (in 0.5-tile units, formula (b & 0x7F) * 0x80 + 0x40).
+0x50u16Script ID. 0xFB = "system" channel.
+0x54u16Wait/timer accumulator. Cleared by YIELD; ticked by WAIT_FRAMES.
+0x5C / +0x5Eu16/i16Move-table index, sentinel set by op 0x22.
+0x62u16Local flag bank (16 bits). Manipulated by ops 0x2B / 0x2C / 0x2D.
+0x94u32Saved PC (set by YIELD; the dispatcher reads this on resume).

_DAT_8007C364 is the player context pointer — many opcodes branch on ctx_ptr == _DAT_8007C364 to switch behaviour. _DAT_801C6EA4 is the current world/scene pointer.

Opcode groups

The 43 opcodes cluster into themed groups:

Shared NOPs — 0x21 / 0x24 / 0x25 / 0x48

Four distinct opcode bytes share one handler that just advances PC by 1. Likely reserved/historical.

Action & control flow — 0x220x26

OpMnemonicEffect
0x22EXEC_MOVESchedule move-table playback. Calls FUN_800204F8 — the move-table consumer that crates/mdt targets.
0x23MOVE_TOTeleport ctx to grid position. Player path also calls func_0x80017EC8 (camera/scroll). NPC path sets facing and calls movement init.
0x26JMP_RELUnconditional relative jump.

Flag-manipulation triplets — 0x2B0x33

The cleanest group — three separate 1-bit-flag banks each with set / clear / test+skip:

OpMnemonicBank
0x2B / 2C / 2DLFLAG SET/CLR/TSTPer-script local flags at ctx[+0x62] (16 bits). Sub-routine state, conditional dialog branches.
0x2E / 2F / 30GFLAG SET/CLR/TSTGlobal story flags at _DAT_1F800394 (32 bits, in PSX scratchpad). Persistent across script runs.
0x31 / 32 / 33CFLAG SET/CLR/TSTCtx flag word at ctx[+0x10] (32 bits). Halt state, move-chain state, render-gate state.

Effects, music, scene transitions — 0x340x36

  • 0x34 EFFECT — nibble-dispatched (sub-0/1/2/3): colour + intensity setup, effect/sprite spawn, actor-pool capture-and-yield, 3D animation playback via func_0x800252EC.
  • 0x35 BGM — sub-dispatcher with 11 sub-ops (start, pause, resume, stop, volume, etc.).
  • 0x36 SCENE_FADE — reads two 16-bit operands. 0xFFFF = wait for load flag.

Yield, sound, RPG state, dialog — 0x370x42

OpMnemonicEffect
0x37 / 41 / 47YIELD familySave PC, clear timer, set HALT flag. Player ctx propagates the halt to the caller.
0x38CAM_CFGIf op1 & 0x7F == 0: copies *(short*)(0x80073F04 + (op0 & 0xF) * 2) into ctx[+0x26]. Else: halt-acquire path — same predicate as 0x43 sub-0/1/A/B; on success set HALT, save PC, mirror to caller when ctx is the player, yield with resume_pc = pc + 3.
0x39PLAY_SFXCalls func_0x8004313C() then func_0x800421D4(sfx_id, 1).
0x3AADD_MONEY24-bit signed delta, clamped to [0, 9999999].
0x3BSET_ITEM_COUNTSet inventory entry. Inventory pages of 0x414 bytes.
0x3C / 3DPARTY_ADD / PARTY_REMOVECaps at 4 members. Updates leader. Refreshes display.
0x3EWARP / INTERACTField interact (op0 == 0xFF or op0 < 100) or scene transition (op0 >= 100).
0x3FDIALOGOpens a dialog box. Calls func_0x8001FD44 (the dialog/MES opener). Sets _DAT_1F800394 |= 0x40 ("dialog active" lock).
0x42COND_JMPMulti-mode conditional. Tests global story flag bank or screen-mode against an 8-entry table.

ACTOR_CTRL family — 0x43

22+ sub-ops, keyed on operand byte 0. Includes a halt-acquire dispatcher (sub-0/1/A/B), actor / sound / face / position cluster (sub-2 through sub-F), and an emitter setup family (sub-0x10 through sub-0x15) that dispatches into the FUN_801F8xxx particle/emitter cluster.

Counter / camera / render / state / move-block — 0x440x4F

OpMnemonicNotes
0x44COUNTERPer-frame counter / score / hit-counter tick.
0x45CAMERASub-dispatch on op0 & 0xC0: configure / LOAD / SAVE / APPLY.
0x46RENDER_CFGFog/render params.
0x49STATE_RESUMEMulti-frame state machine: tristate (Idle / Armed / Done). Done-state sub-0 walks an inline MES-shape payload (counts bytes > 0x1E with one-byte peek-extension for 0xCx prefix bytes) and advances PC by 5 + length + walked.
0x4AWAIT_FRAMESFrame timer; ticks ctx[+0x54].
0x4BANIMATEMulti-keyframe setup. Sets +0x10 bit 0x1000 (animation flag).
0x4CMENU_CTRLOuter-nibble-dispatched (16 sub-dispatchers). The biggest single opcode.
0x4DBBOX_TESTInside-box advances PC by 7; outside-box jumps via FUN_801E3614.
0x4EINVENTORY_CMPCompare-and-jump across page-banked inventory state and party-money/XP banks.
0x4FSCENE_REGISTER_WRITEWrites three u16 values to _DAT_801C6EA4 + 0x10/0x12/0x14.

The fourth flag bank (default-route)

The default arm of the dispatcher checks *pc & 0x70:

High nibbleSCUS dispatcherEffect
0x5xfunc_0x8003CE08SET bit
0x6xfunc_0x8003CE34CLEAR bit
0x7xfunc_0x8003CE64TEST bit (returns 0xFF if set)

All three operate on the same 256-bit bitfield array at DAT_80086D70. Each does index >> 3 to pick the byte and 0x80 >> (index & 7) to pick the bit.

// 0x8003CE08 (SET):
(&DAT_80085758)[(int)idx >> 3] |= (byte)(0x80 >> (idx & 7));
// 0x8003CE34 (CLEAR):
(&DAT_80085758)[(int)idx >> 3] &= ~(byte)(0x80 >> (idx & 7));
// 0x8003CE64 (TEST):
return ((&DAT_80085758)[(int)idx >> 3] & (0x80 >> (idx & 7))) ? 0xFF : 0;

This is a fourth flag bank, distinct from the three exposed by the explicit 0x2B0x33 opcodes — likely "system" / engine-wide event flags. Effective opcode space therefore includes the explicit 0x210x4F range and any byte whose high nibble is 0x5, 0x6, or 0x7.

BGM lookup table

There isn't really a "BGM → file" lookup table. The BGM ID is a PROT-relative offset. From FUN_800243F0:

if (_DAT_8007BAC8 < 2000) {
    _DAT_8007BAB8 = _DAT_80084540 + 6;            // scene-local: current scene PROT base + 6
} else {
    _DAT_8007BAB8 = _DAT_8007BC64 - 2000;          // global pool: separate base
}
_DAT_8007BAB8 = _DAT_8007BAC8 + _DAT_8007BAB8;     // final PROT index
  • bgm_id < 2000 — scene-local. Different scenes have different BGM at the same script ID.
  • bgm_id ≥ 2000 — global. Shared across scenes (cutscene / title / event music).

The "table" is the CDNAME.TXT name map's per-scene block layout.

Connection to other crates

  • crates/mdt — opcode 0x22 EXEC_MOVE drives the move-table consumer at FUN_800204F8. See Move-table VM.
  • crates/mes — opcode 0x3F DIALOG calls the dialog opener func_0x8001FD44. The bytecode inside the dialog buffer is what crates/mes parses.
  • crates/anm — opcode 0x34 sub-op 3 plays 3D animations via func_0x800252EC.
  • crates/engine-vm — the clean-room Rust port at crates/engine-vm/src/field.rs. Reuses the same Host trait pattern as the actor VM.

Decompile quirks worth knowing

If you read the function dump and something doesn't add up, these are the usual suspects:

  • switchD_801e00f4::default() is misleading. Ghidra renders the function-epilogue tail block as a synthetic function call; in the original asm, opcodes that "fall through to default" actually advance param_2 via the addiu s8, s8, N instruction in the MIPS branch-delay slot of the j 0x801df09c jump. So 0x39, 0x3B, 0x44, 0x4C and friends DO advance the PC — just not in a way the C-level decompile makes obvious.
  • LAB_801df09c is just j 0x801e3628; move v0, s8 — return s8 unchanged. Most callsites jump there with an addiu s8, s8, N in the delay slot of the j, supplying the per-callsite PC delta.
  • 0x42 mode 0 jump-take target is pc + 3 + LE_u16(operand[2..4]) (non-extended), found via the join point LAB_801e35fc.

For the exhaustive opcode reference and the full 0x4C outer-nibble dispatcher table, see docs/subsystems/script-vm.md on GitHub.