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.

The decompiled source is at ghidra/scripts/funcs/overlay_0897_801de840.txt. References below to func_0x80xxxxxx are calls into SCUS_942.54 (the game's main executable - always resident, unlike the overlays); FUN_801xxxxx are sister functions inside the same 0897 overlay.

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.

On-disc form: the scene MAN - not scene_event_scripts

The on-disc carrier for field-VM bytecode is the scene MAN - each scene's script + data container, shipped as a sub-asset (asset type 0x03, the third descriptor in each scene's asset-table bundle; see man-relocation and legaia_asset::man_section). FUN_8003A1E4 walks the MAN's partition-1 actor-placement records, installs each actor's script pointer at actor[+0x90], and runs the field VM (FUN_801DE840) on the body 1 + N*2 + 4 bytes into the record. Partition-1 record 0 is the scene-entry system script; records 1.. are per-actor interaction scripts. These decode cleanly as field-VM (~8% linear-walk error on the retail town MANs).

The scene_event_scripts / scene_v12_table prescript is a move-VM stager table - not field-VM bytecode. The [u16 count][u16 offsets[count]] prescript (offset 0, or +0x800 behind the v12 header) carries move-VM (FUN_80023070) records in the summon-stager format [i16 model_sel][u16 flags][move-VM bytecode]: the 0xFFFF 0x0000 lead is model_sel = -1 (a transform/pivot node) + flags = 0, and the 0x0008 terminator is move-VM Halt.

Runtime chain: the field VM itself calls the installer FUN_800252EC(id), which resolves record = _DAT_8007b8d0 + offsets[id] and hands it to the part-stager FUN_80021B04; the move VM then runs record+4 each frame. See legaia_asset::scene_event_scripts (move_stager_records) and the disc-gated prescript_move_stager_records_real test (78 entries / 1855 records, 100% valid stager leads).

Placement header: model + animation resolution

The 4-byte header after a partition-1 record's locals block is [model][anim_id][bx][bz] (legaia_asset::man_section::ActorPlacement). Runtime-pinned against the town01 field anchor (live actor pool at stride 0xD8, 53/53 animated actors byte-consistent):

  • model < 0xF0 selects scene TMD index model - retail registers the party/savepoint head into 0x8007C018 slots 0..5 and then the scene's TMD list in scene order from slot 5, so the byte is pool slot model + 5. model >= 0xF0 selects global-pool head slot model - 0xF0 (0xF0..0xF3 = Vahn / Noa / Gala / savepoint).
  • anim_id is installed into the actor's +0x5C halfword and names the actor's clip: scene-bundle ANM record index + 1 (0 = no clip). Special models resolve the id against the PROT 0874 §1 locomotion bundle instead - the Noa/Gala placements carry ids 9/16 = locomotion records 8/15, exactly their standing-idle bank slots.
  • Placements parked at world (16320, 16320) (tile (127, 127) + half-tile bits) are conditional spawns the scripts place later.

Disc-gated pin: engine-core/tests/field_npc_placements_disc.rs.

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.

Per-frame scheduling

There is no scheduler above the VM: retail walks its actor lists in full every frame and gives every live context one slice. FUN_8002519C iterates the five lists at _DAT_8007C34C..._DAT_8007C36C once per frame, dispatching each node through jalr node[+0x0C]; field actors land on FUN_8003BC08, which routes by the flag word +0x10 - bit 0x100 runs the field-VM slice (FUN_80039B7C), 0x400 the walk kernel (FUN_8003774C), a non-zero +0x80 the motion VM (FUN_80038158), +0x5C > 0 / bit 0x1000 the move-table VM. The slice is a run-until-yield loop: call FUN_801DE840, write the returned PC to ctx[+0x9E], repeat while the opcode byte has (byte & 0x7F) >= 0x20, stopping on opcode 0x21, on a PC that did not advance, or on a byte below the opcode range (FUN_8003CF7C is the run-to-text sibling).

The scene system script (each scene's P1[0], context id 0xFB) is one of those contexts rather than an exception to them: FUN_8003AB2C allocates its context at scene load, seats it on the record's first opcode, and runs the same three-condition loop inline. So a scene's entry prologue executes inside the load frame, and the per-frame body that follows it - the tail that jumps back over a 0x21 - executes once per frame. Rim Elm is the readable example: its prologue starts a BGM track and stops it again 32 instructions later on a first visit, all before anything is drawn, and its body is a 20-instruction loop of player bounding-box tests choosing the scene's camera parameters. Stepping that script one instruction per frame would play half a second of a track retail never lets you hear, and would track the camera zones at 3 Hz.

Three consequences: no budget and no round-robin - contexts are not time-sliced against each other, the list length is the only bound; presenters are contexts too - the narration crawl roller (FUN_80037174) and the cutscene camera mover (FUN_801DC0BC) are ordinary actors, so they advance in parallel and the script never blocks on either; and durations are display frames - op-0x4A WAIT_FRAMES adds DAT_1F800393 (the adaptive frame-skip factor, i.e. the logic tick's dt in display frames) to ctx[+0x54] per visit, so a wait of N elapses in N display frames whatever the skip factor. An engine ticking at a different rate has to pace the VM off a display-frame sub-clock, not its own tick.

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 family (motion ops)Save the op's own PC into +0x94, clear timer, set HALT flag (player ctx propagates the halt to the caller). The parked op is interpreted in place each frame by the walk kernel FUN_8003774C: 0x37/0x41 = directional glide-step (base step 4 << ((op0>>5 & 4)|(op1>>6))), 0x47 = walk-to-tile at base step 4 << (b2 & 7) with approach-mode nibble b2 >> 4. No separate motion bytecode exists - the record bytes are the stream.
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.
0x39GIVE_ITEMAdds one of inline item item_id to the inventory: func_0x8004313C() (inventory-window setup) then func_0x800421D4(item_id, 1) (add-item-by-id). PC += 2. This is the treasure-chest item-give path - the id is a single inline operand byte, not a table.
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 minigame door-warp (op0 >= 100): sub_id = op0 - 100 selects a mode-24 minigame overlay (FUN_80025980 backs up the active scene name, streams the overlay via FUN_8003EBE4(sub_id + 0x4D) → extraction PROT 0972..0977/0980 - fishing 0, slot machine 3 (0975), Baka Fighter 4 (0976), dance 6 (0980) - and FUN_80026018 restores the scene + commits winnings on exit). The op carries no destination name.
0x3FSCENE_CHANGE (named warp)Named scene-change, NOT dialog. Copies a length-prefixed destination scene NAME from the bytecode ([i16 index][u8 len][name][entry_x][entry_z][dir]) and calls func_0x8001FD44 - the scene-change packet (writes the name to 0x8007050C/0x80084548; sets transition flag _DAT_1F800394 |= 0x40) - then sets the entry tile. This only looks like dialog when the walk desyncs on a literal ? (0x3F) in text; field dialogue has no dedicated opcode (it's the actor's inline interaction-script MES, triggered by the field-interact op 0x3E op0<100 + the actor-dialog SM FUN_80039b7c / pager FUN_801D84D0).
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.

Record-spawn / camera / render / state / move-block - 0x440x4F

OpMnemonicNotes
0x44SPAWN_RECORD[44, global_index]: spawns a MAN partition-2 record as a new field-VM context via FUN_8003BDE0 (ra 0x801DF098, gate forced to 1); the operand is a GLOBAL record index re-based - N0 - N1 into partition 2. Live-probe-pinned on the New-Game opening (opdeene/opstati/opurud entry scripts); the earlier COUNTER reading is superseded.
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. The town01 opening's 49 03 00 (P2[3] +0x02C6) is the name-entry hand-off - see below.
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 party state. Every sub-op 0..9 shares the 7-byte compare-and-skip shape (raw jump table 0x801CEE30): 0/1 = char HP/MP pair (the only scaled form), 2 = char level byte +0x130, 3 = party gold _DAT_8008459C vs u16 - the inn/ticket gold gate, legaia_asset::inn_costs, 4 = BIOS Rand() & 0xFF (a random-chance branch), 5..8 = slot table 0x801C6460[sub - 5] (the read side of the 4C CA/CB/CC slot writes), 9 = coin bank 0x800845A4 vs u16. 10/11 are gold/coin u32 compares (9 bytes); 12..=15 fall through. The earlier "sub-5..8 absolute jump" / "sub-4 rand as next PC" readings were the collapsed decomp switch - falsified by the raw loader bodies.
0x4FSCENE_REGISTER_WRITEWrites three u16 values to _DAT_801C6EA4 + 0x10/0x12/0x14.

The name-entry screen (op-0x49 49 03 <char>)

The operand names the party slot (03 sub, 00 = Vahn); the field overlay's SM runs the screen and writes the typed name live into the character record's name field at +0x2A7. Renderer FUN_801E6B34; cursor cell _DAT_8007BB88, SM state _DAT_8007BB94 (1 = editing, 4 = the Yes/No confirm). Draw-stream-traced geometry (320×240 framebuffer pixels, overlay base (32, 99)):

  • Two pause-menu-skin filigree windows: the grid window at footprint (24, 91, 272, 120) and the name-field window at (196, 71, 88, 28) (the renderer's FUN_8002C69C(base_x+0xAC, base_y-0x14, 0x48, 0xC) centre rect plus the 8 px skin border).
  • Charset grid (7 rows × 17 at 0x801F29F0): glyphs from base + (4, 4), 15 px column pitch, 14 px row pitch, ink 7 white; blank cells are selectable space glyphs.
  • Working name at (208, 79); teal _ caret 6 px after it, 75%-duty blink (frame & 0x18), gated to the 57 px field.
  • Control bar at y = 191, ink 6 gold, resolved through a grid[cell + 2] sentinel read: "BS" (backspace), the quoted default name (restores the template name - there is no space button), and "Select" (end). Cursor anchors are cells 102/108/114 and the SM opens on Select.
  • Prompt "Select your name." at (176, 32); the confirm state replaces it with "Is this name okay?" at (172, 24) plus stacked teal Yes (204, 38) / No (204, 50) rows - the hand opens on No.

After the Done resume, the post-naming beats animate the lead: A2 F8 30 then A2 F8 31 (op-0x22 ExecMove) land the player's +0x4C anim pointer on scene-ANM records 47/48 (record = move_id - 1, the op-0x4B NPC-cue record space) for one playthrough each before the walk-out resumes the locomotion clips. Engine port: engine-core::name_entry, engine-ui name-entry builders, and the scripted-clip queue on FieldPlayerAnim.

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 bitfield array based at 0x80085758. 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;

The low 4 bits of the opcode plus the next operand byte form an 8-bit flag index, but with the “extended” prefix bit (0x80) preserved into the high bits the addressable space is 12-bit, suggesting per-script-context banks within the same array. The TEST dispatcher consumes two extra operand bytes (pc[2..4]) as the post-test action target when the bit is set.

This is a fourth flag bank, distinct from the three exposed by the explicit 0x2B0x33 opcodes - likely "system" / engine-wide event flags.

  • Region. It is not a wholly separate region: base 0x80085758 falls inside the story-flag RAM window 0x80085600..0x80085800 (at +0x158) and the bank extends past 0x80085800 (indices up to ~0xFFF reach 0x80085758 + 0x1FF).
  • Save mapping. In a retail SC save block the bank lives at SC offset 0x1618, overlapping the story-flag bitmap (SC 0x14C0, 512 bytes) and continuing to the inventory array (SC 0x1818); seeding World::system_flags from sc_block[0x1618..0x1818] reproduces the live bank as of the save.
  • Collision. The bank is not sufficient on its own to drive a scene's collision: the 0x4C nibble-7 wall paints reached through it are story-conditional collision deltas, not the base walkable grid (see field locomotion).
  • Opcode space. The effective opcode space is the explicit 0x210x4F range plus any byte whose high nibble is 0x5, 0x6, or 0x7 (potentially 192 more "wide" opcodes routed to three SCUS dispatchers).
  • Census tooling. A progress gate reads a SYSTEM flag (0x7x TEST) in one scene, but the setter that opens it (0x5x SET) usually lives in a different scene's MAN. man_field_scripts::walk_partition_gflag_sites reports every flag site in a MAN partition (tagged scratchpad-vs-system bank + full flag number + SET/CLEAR/TEST kind), and system_flag_census runs it over every CDNAME scene across all partitions to build flag -> [(scene, partition, record, op, kind)]. CLI: legaia-engine man-scripts --gflag-partition N (one scene) / --system-flag-census (disc-wide).
  • A second script-byte carrier - the streaming variant MAN. A live whole-playthrough capture (exec-bps on 0x8003CE08/0x8003CE34, probe autorun_flag_firehose.lua) shows every story-flag write returning to the dispatcher's own 0x5x/0x6x arms (ra 0x801E3598/0x801E35C0); the remaining callers are engine systems touching low indices (0/3 entity-SM staging, 0x35 battle-end victory, 0xB/0xC/0x18 locks, 0xE spawn ops). The executed bytes at the Mt. Rikuroa post-Caruban beat live in a heap-resident carrier that is not the scene's asset-table bundle MAN: it is a second, plain MAN shipped as the type-3 chunk of a standalone data_field_streaming PROT entry (the chunk header is the ordinary [u24 size][u8 type=0x03] sub-asset descriptor; the payload parses with legaia_asset::man_section like any MAN). The resident copy byte-matches PROT 0157_rikuroa's chunk, and it carries the story-flag 0x142 SET (51 42) at four record sites - P1[10..12] plus the post-victory cutscene record P2[50], whose C1 gate is 0x142 itself (the self-latching one-shot). The carrier's records also pin how P2[50] runs: the boss stager P1[3] SETs the transient marker 0x289 (52 89) right before its battle-entry op (3E FF 11), and the scene-entry system script P1[0] tests that marker on the post-battle scene re-entry (72 89 at +0x13A) - its taken arm (+0x7E6: fade, BGM, 44 5C) issues the op-0x44 spawn of global record 0x5C = P2[50], C1-gate-checked by the dispatcher. The same shape sits one branch level up: P1[0]'s first-arrival arm spawns P2[43] (44 55) while flag 0x2FB is clear, and that record's own 52 FB latches it. The clean-room engine executes this chain organically - the host re-runs the entry script on the battle-to-field mode edge (SceneHost::tick) and the spawned record's own script bytes land 0x142; disc-gated oracle organic_beat_records_disc.rs. Thirteen retail blocks ship such a streaming variant MAN (extraction indices: dolk2 70, rikuroa2 122, rikuroa 157, rayman 201, station 228, balden2 320, ropeway2 339, taiku 373, doman 401, taiku2 427, nilboa2 648, edbalden 792, eddoman 817); for the v12-family dungeons (rikuroa/dolk2, whose own bundle is the MAN-less count=4 form) the streaming carrier is the scene's only MAN. system_flag_census (and the motion / op-0x49 censuses) walk every carrier per scene - the bundle MAN plus the streaming variants, enumerated by man_field_scripts::scene_man_carriers - so the variant-resident writers surface: the 0x142 setters above, the 0x63A beat writers. Disc-gated pins: crates/engine-core/tests/man_variant_carrier_census_disc.rs. CLI: legaia-engine man-scripts --scene <name> --variant <entry_idx> targets a variant carrier directly (census rows tag them VARIANT-MAN); --p2-gates prints every partition-2 record's C1/C2 header gate lists + name (the FUN_8003BDE0 spawn-condition surface the inline-op censuses cannot see).
  • Decode-coherence flag. The census walker desyncs inside unframed Shift-JIS dialogue and inline data tables, where text bytes alias the 0x50..=0x7F flag ops (the full-width digit run 82 54 82 4F aliases SysFlag.Set idx=0x482; a repeating full-width EXIT label table aliases 64 82 clears). Every census site therefore carries GFlagSite::clean: true only when at least CLEAN_RESYNC_INSNS instructions decoded error-free between the walker's last decode error (or record start) and the site. The CLI prints DESYNCED? on non-clean rows - treat those as byte noise until verified by hand disasm or a live capture. This falsified the earlier "0x482 set by the other7 pool / cleared by the edbalden/eddoman epilogue variants" reading: all 37 of 0x482's census sites are non-clean text aliases, while the live-confirmed 0x142 writer arms decode clean.
  • Width blindness is the desync's second face. A missing/wrong sub-op width in the disassembler desyncs the walk even in clean non-dialogue code, and a site hidden that way looks identical to "no writer exists". Flag 549 (0x225, the Rim Elm opening one-shot) was exactly this: town01 P2[3] SETs it from its own script bytes (52 25 at body +0x3, the record its own C1 gates - the P2[50]/0x142 self-latch shape), but with no width for the preceding 4C ED op (_DAT_8007BA66 write, retail param_2 + 3) the walk mis-reads ED 01 52 as a phantom Clear and swallows the SET. Runtime-pinned by a reader-watch script-PC capture (SET ra 0x801E3598, vm offset +0xF) and confirmed statically: the whole 4C 0xE_ sub-op width family is pinned from the retail dispatcher's param_2 + N advances (subs 4/5/7/8/9/A/B/C/D/E), and the last two delay-slot-hidden legs (sub-0/3) from the raw asm: both arms (0x801E306C / 0x801E3108, case targets confirmed against the outer-0xE jump table at VA 0x801CF008) advance +3 - sub-0 through the addiu s8,s8,0x3 entry at 0x801E00B8, sub-3 there or in the j 0x801E00BC branch-delay slot. Neither is a halt (the decompile's goto LAB_801e00bc folds both entries into the no-advance label). Anchor flag_549_writer_is_the_rim_elm_p2_3_self_latch. Before trusting any "flag F has no script writer" verdict, confirm the ops around the expected site decode with known widths.
  • Width blindness also comes at whole-nibble granularity. With no decoder at all for a 0x4C outer nibble (9/A/C/D/F), every record crossing one (e.g. the CC 06 A1 .. extended nibble-A conditional jumps that pepper the jou-castle door records) desyncs exactly like the 4C ED case, hiding thousands of clean flag sites and minting phantom ones from the resync garbage. All sixteen outer nibbles decode (legaia_asset::field_disasm::decode_subops), with widths mirrored from the executing VM's menu_ctrl port (itself pinned from the retail dispatcher's param_2 + N advances); nibble B is genuinely undefined in retail (no case 0xb - the default arm halts) and stays a decode error. The pinned spine-flag verdicts are unchanged under the full-width walk: 0x482 stays all-alias, and the koin gates 0x50A/0x5D6 still have no script writer disc-wide.
  • Width blindness's third face is a wrong width in an already-decoded arm. The 0x4C nibble-8 sub-widths are pinned from the raw asm of the nibble-8 switch (same overlay, base 0x801CE818): sub-1 (actor model+anim set) advances +9 unconditionally (addiu fp,fp,9 at 0x801E1FC4), sub-3 +7 (0x801E2130), sub-5/E/F +5 on acquire (li s7,5 at 0x801E21B8, addu fp,fp,s7 in the beqz delay slot - only the predicate-failure path halts), sub-6 +15 (0x801E21E8), sub-0 +3, sub-C +4 - matching the executing VM's menu_ctrl/nibble_8.rs port. A sub-1 width one byte over is what the vozz P1[7] .byte 0x05 decode error was: each CC 0B 81 .. op swallowed its follower's lead byte, minting a phantom Clear 0x400 where the retail stream reads the op's 10-byte extended form followed by a 35 BGM op / 4A WaitFrames. Under the pinned widths those followers decode in place, the phantom rows disappear, and the spine verdicts hold row-identical (549/0x142 site sets unchanged; 0x482 all-alias; 0x50A/0x5D6 writer-less, 0x50A gaining one more clean koin3 TEST reader and still no writer).
  • ASCII dialogue aliases survive the clean tag. The US build's dialogue is plain ASCII, and the wide flag ops land exactly on the letter ranges: Set leads 0x53..0x57 = S..W, Clear leads 0x61..0x67 = a..g, Test leads 0x71..0x77 = q..w. Common English bigrams therefore mint flag ops (ta = Test 0x461, s, = Test 0x32C, Sp = Set 0x370), and because every such 2-byte pair decodes without error, prose keeps the error counter at zero and the sites carry clean=true. Triage rules: a flag whose operand byte is outside printable ASCII cannot be minted by dialogue (its rows are trustworthy - 0x382, 0x3EF, 0x304, 0x5DC); a letter/punctuation operand needs the site's context window checked in the record disasm (trust choreography contexts - Camera/WaitFrames/SceneFade/4C-family/JmpRel branch arms - reject neighbours that decode as further letter-pair flag ops); mirrored Set/Clear runs over the same band are self-proving even when tagged DESYNCED?. Hand-checks: 0x32C's and 0x461's "chapter-wide readers" are the s,/ta bigrams (both flags are real but scene-local); Nivora's 0x370 shows the context-window rule cutting both ways inside one record (doman variant P1[15]): three Sp = 53 70 sites are the "TimeSpace Bomb" dialogue (rejected), but the fourth, at MAN offset 0x06397 (+0x3018), sits in a choreography run (WaitFrames/MoveTo/Effect/4C CD) with a loop-back JmpRel to the record's gate-test head - and the head's own Test 0x370 -> +0x301E jump target lands on the very next op after that JmpRel, so both sites decode on the same op grid: this is the genuine writer (the Dr. Usha briefing self-latch; pinned by man_variant_carrier_census_disc.rs::flag_0x370_writer_is_the_doman_p1_15_usha_latch - the earlier "writer-less, the candidate is Space-Bomb prose" verdict predated the nibble-width pinning and adjudicated a prose sibling, not this site); the once-reported Clear 0x400 in vozz P1[7] was the nibble-8 sub-1 width bug - under the pinned width the bytes are the op's own operand tail and its BGM follower, and the row disappears entirely.
  • The census self-identifies the ASCII prose aliases. Every site also carries GFlagSite::text_alias (CLI marker TEXT-ALIAS?, on both the census and --gflag-partition rows): true when the site's raw operand byte is printable ASCII and the surrounding TEXT_ALIAS_WINDOW (16 bytes each side) contains a consecutive printable-ASCII run of at least TEXT_ALIAS_MIN_RUN (10) bytes and the window puts two lowercase letters side by side - the sentence signature prose always has and bytecode does not. This mechanizes the triage rules: the ta/Sp/s, bigram rows carry the marker even where they decode error-free (clean=true), while the runtime-pinned real sites with printable operands stay unmarked. The three conditions each carry weight: the town01 P2[3] 52 25 self-latch and the rikuroa 51 42/61 42 ladders render as printable byte-streams themselves (R.R.R.QB) but break the run every 1-5 bytes on a non-printable operand (run length beats printable density), and the 0x527..0x52E one-hot selector clears (65 27 65 28 ..) sustain a 16-byte printable run but alternate op/operand so they never form an adjacent lowercase pair. Like clean, the marker is triage, not suppression - non-printable operands are alias-immune by construction, mirrored Set/Clear runs stay self-proving, and a marked row means "check the record disasm", not "discard". The split it produces on a mixed flag is the point: 0x527's census population is real one-hot ladder sites (unmarked, clean) plus the e' prose bigram (marked), separated row by row. The advisory direction also occurs: two runtime-pinned real 0x142 sites (the dolk P1[26] Clear and the dolk2 variant-carrier P1[1] Set) sit inside dialogue-adjacent bytecode and carry the marker — exactly the "check by hand" case, and why the marker never suppresses a row.
  • The 0x527..0x531 scene-transition scratch band. A story-numbered band that is engine scratch, not story state - the census surfaces it as SETs in nearly every scene, the signature of a shared idiom. Every scene's P1[0] entry script (and many exit-choreography arms) repeats two byte-stereotyped patterns (hand-verified in deene P1[0]): a one-hot selector 0x527..0x52E (clear all eight, then SET exactly one, ahead of a SceneFade/4C 12 fade - a departure-choice latch), and a fade handshake 0x52F/0x530/0x531 (set/test/clear ping-pong around 4C CA/4C CB widget open/close). The conc-family entry scripts keep an adjacent private slot in the same style (0x522). Treat census rows in 0x522..0x531 as mechanism traffic, like the 0x00F busy-mutex.
  • Drake-castle interior beat band (jouinb). jouinb P2[6..8] each end in a one-shot latch - 0x44E/0x44F/0x450 (Camera/WaitFrames/4C CD then Set + park-jump; P2[6] body +0x4A3). P2[8] additionally runs an in-body state machine on 0x461 (Test at +0xBC skipping to a Clear arm at +0x1BC, Set at +0xBC2 in the closing choreography). All four flags are jouinb-local; the census's wide 0x461 reader list is the ta bigram.
  • Door-choreography record families: the 0x00F busy-mutex + the jouind per-visit band. Two partition-2 record shapes in the Drake-castle cluster (jouinc [43,18,60], jouind) look like story-gate families in the --p2-gates output but are mechanism state, not story state (C1 polarity: a C1 flag blocks the record while SET). The 0x00F busy-mutex family: jouinc P2[2..59] (SJIS names J01..J58) and jouind P2[0..1]/[6..9] are all gated C1=[0x00F], and every record's first op SETs 0x00F while its last CLEARs it - a mutual-exclusion lock over the door records; bodies are per-door walk-through choreography (an extended nibble-A conditional branches on the door actor's open state, the door animates, the player channel 0xF8 runs the ExecMove walk-through, and the room transition is a SceneFade pair - an intra-scene reposition, not a 0x3F scene change). The jouind per-visit band 0x4BE..0x4C2: reset by jouina P1[0] on entry, so per-castle-visit door/lift state, not chapter progress - P2[10]/P2[11] (C1=[0x4C1]) SET 0x4BE/0x4BF and share the first-use latch 0x4C2; P2[12] (C1=[0x4C0,0x4C1]) one-shots itself via 0x4C0; P2[14] SETs 0x4C1, retiring the family for the visit; P2[13] re-applies door visuals from 0x4BE/0x4BF.

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. Rare in retail: scenes carry almost no local SEQ data (teien is the one scene with a local copy).
  • bgm_id ≥ 2000 - global. The global pool is the music_01 bank (extraction entries 990..=1071), whose slot order is the debug sound-test order - so 2000 + i plays sound-test track i and every global id resolves to a curated human name (music-track table). Pinned by a per-scene op-0x35 census joining ids to their scenes' known music (town01 starts 2016 = “Rim Elm theme”). Engine resolver: legaia_engine_core::music_labels.

The "table" is the CDNAME.TXT name map's per-scene block layout. There's no separate BGM index in SCUS_942.54.

Helper functions

A set of small leaf helpers in the dispatcher's call graph are pure arithmetic - no globals, no overlay calls - so they get clean-room ports in crates/engine-vm/src/field_helpers.rs rather than host hooks, and the dispatcher arms call into them directly.

HelperOriginalUsed by
packet_lengthFUN_8003CA380x4C nE sub-1, 0x49
party_flag_testFUN_8003CE640x4C nC sub-1 (host-side)
small_table_searchFUN_80042EE00x4C nD sub-C/E
load_u16_le / load_u24_le / load_u32_leFUN_8003CE9C / CEB8 / CED8LE immediate decoding across many 0x4C sub-ops
tile_centerinline (multi-arm)0x4C nE sub-3/4, MOVE_TO, dialog spawn
  • packet_length(buf) measures one variable-length packet of the in-game text encoding: walks until any byte ≤ 0x1E (terminator), counts bytes ≥ 0x1F as 1 each, and bytes whose top nibble is 0xC consume the next byte unconditionally (escape, counts as 2). The terminator is not included.
  • party_flag_test(idx, flags) reads bit idx of a packed bit array, MSB-first per byte; returns 0xFF when set, 0 otherwise. Exposed to 0x4C nC sub-5/6 via the op4c_n_c_party_flag_test host hook.
  • small_table_search(needle, table, lo, hi) searches table[i*2] (stride 2, low byte of each short) for needle across [lo, hi); returns the index or SEARCH_NOT_FOUND (0x100) on miss.
  • The LE byte-load family assembles results from sequential bytes and returns 0 for missing bytes; the 24-bit version pairs with sign_extend_24 for the few opcodes (notably 0x4C nE sub-5's XP-add) needing a signed 24-bit immediate.
  • tile_center(b) is the grid-byte → world-coord conversion: b == 0 returns 0; otherwise (b & 0x7F) << 7 | 0x40, plus 0x40 if the high bit is set. The original inlines this in nine separate dispatcher arms.

The Rust ports are exhaustively tested alongside the helpers in field_helpers.rs.

0x4C nibble-D sub-4 / sub-5 - VRAM STP-bit set/clear

The 6-byte [4C, 0xD4|0xD5, x_lo, x_hi, y_lo, y_hi] operand is a (vram_x, vram_y) pair; the rect is hard-coded to w = 0x10, h = 1. The original runs the PsyQ libgs StoreImage → per-pixel STP-bit edit → LoadImage read-modify-write over 16 u16 pixels: sub-4 (op 0xD4) sets STP on non-zero pixels, sub-5 (op 0xD5) clears STP unless the pixel is already STP-only. The 16-element buffer lives on the dispatcher stack, not in the bytecode - it's pixels read from VRAM at runtime. The host hooks op4c_n_d_sub_4_vram_stp_set(x, y) / op4c_n_d_sub_5_vram_stp_clear(x, y) receive only the rect origin; a clean-room renderer that maintains its own framebuffer can emulate the read-modify-write itself.

0x4C nibble-D sub-3 - SCHEDULE_TIMED_FLAGS

A 14-byte timed-flag scheduler: [4C, 0xD3, expiry_flag: u16, below_flag: u16, duration: u32, threshold: u32] writes the flag pair into _DAT_800845C0, the duration into _DAT_800845B8/_DAT_800845A0, the threshold into _DAT_800845BC, and snapshots the clock. The per-tick consumer FUN_801d2ebc (field overlay 0897) decrements by the clock delta, SETs the expiry flag + disarms on expiry, and SETs the below-threshold flag while under it. Retail use: chitei2's collapsing-dungeon escape timer (flag 0x4C7, duration 2400, threshold 910), with disarm records in chitei2/map03. The flag slots live in the 0x80084140 save-scratch block, so a mid-timer save persists it.

Connection to other crates

  • crates/mdt - opcode 0x22 EXEC_MOVE drives the move-table consumer at FUN_800204F8. See Move-table VM.
  • crates/mes - field dialogue has no dedicated opcode: it is the actor's inline interaction-script MES text, shown by the actor-dialog SM FUN_80039b7c + pager FUN_801D84D0, triggered by the field-interact op (0x3E op0<100). The text crates/mes parses is that inline 0x1F/glyph stream. (0x3F is the named scene-change - func_0x8001FD44 is the scene-change packet, not a dialog opener.) The engine's faithful runner (engine_core::inline_dialogue / World::step_inline_dialogue, PORT of FUN_80039b7c) drives the record through the real field VM, pausing at each 0x1F segment to show a box. Interaction records are resident conversation drivers: a top flag-selector picks the branch for the current story state (town01's Val record - "hands are full" sets its own one-shot SysFlag, later talks give "(Silence)", then the permanent line), and each branch exits via a shared tail that jumps back to the selector; retail parks the context there until the next talk. The runner ends one conversation pass at that loop-back - a VM Advance jumping backward onto an already-executed PC - rather than replaying the branch forever; the map is cleared on every picker commit so menu records that re-emit their menu by jumping back after a branch reply still cycle.
  • 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.
  • Relative-jump deltas wrap at 16 bits. Each script's PC is stored as a signed 16-bit value (*(short*)(ctx + 0x9e)), so every relative branch (0x26 JMP_REL, the 0x7x flag-TEST conditional jump, 0x42 COND_JMP, the 0x4E compare jumps) computes (base + delta) mod 0x10000. A delta with the high bit set is a backward jump - e.g. 0xFFFE = -2, the per-frame "park here" wait-loop idiom ([21] [26 FE FF] ping-pongs two bytes until a story flag flips a guarded TEST). Computing base + delta in a wider int without the 16-bit truncation turns every backward jump into a +0xFFxx forward overrun. The clean-room port models this with a rel_jump(base, lo, hi) helper that wraps in u16.
  • Intra-function label catalogue. Several iVar = FUN_801xxxxx(); return iVar; patterns in the C decompile look like helper calls but are intra-function j targets Ghidra promoted to fake function names - each is a addiu s8, s8, N; j epilogue block supplying a PC delta. Notable entries: 0x801df098 (PC += 2), 0x801df09c (PC unchanged, the epilogue), 0x801df8dc (PC += 6), 0x801e00b8 (PC += 3), 0x801e212c (PC += 7), 0x801e3614 (BBOX outside-box, pc + 5 + skip), 0x801e3620 (PC += 4). Always cross-check grep -n "0x<addr>" overlay_0897_801de840.txt before treating a FUN_xxxxxxxx reference as a separate function - the misleadingly-named dump file overlay_0897_801e3620.txt actually has entry 0x801e3578.

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

Disassembler tool: field-disasm

crates/engine-vm/src/bin/field_disasm.rs walks a field-VM bytecode buffer and prints one mnemonic per encoded instruction. The decoder mirrors the width logic of step() without executing host calls or mutating ctx state, so it's safe to point at any byte buffer - it stays linear, recovers from unknown sub-ops one byte at a time, and never follows jumps.

# Walk a raw script body, print each opcode + operand:
cargo run -p legaia-engine-vm --bin field-disasm -- file <PATH>

# Detect a [u16 count][u16 offsets[count]] prescript at the start of <PATH>
# and walk every record body individually:
cargo run -p legaia-engine-vm --bin field-disasm -- scene-event-scripts <PATH> [--summary]

# Walk every PROT.DAT entry and report 0x4C 0xE2 byte-pattern hits with
# their CDNAME label and decoded fmv_id (filtered to the retail valid
# range 0..=8 unless --no-filter is passed; the FMV dispatch table at
# 0x801D0A6C carries 23 32-byte slots - the nine retail slots 0..=8
# dispatch every movie on the disc, slots 9..=22 point at dev files):
cargo run -p legaia-engine-vm --bin field-disasm -- scan-prot \
    --disc <PROT.DAT> --cdname <CDNAME.TXT> --bytewise

The library exposes legaia_engine_vm::field_disasm::{decode, LinearWalker, find_fmv_triggers, format_instruction} for downstream tooling. The InsnInfo::MenuCtrl { kind: MenuCtrlKind::FmvTrigger { fmv_id }, .. } variant carries the operand of the 0x4C 0xE2 op for callers who want to grep for cutscene triggers across the corpus.

CAVEAT - scene-event-scripts / scan-prot walk a NON-field-VM structure. The 0xFFFF 0x0000 lead is the stager-record header (model_sel = -1), and the mode skips it before walking the record body - but those records are move-VM stager records, not field-VM bytecode (see the "On-disc form" note above), so the field-VM disassembly is mostly decode error with coincidental matches. Any 0x4C 0xE2 FMV trigger these modes report inside a prescript record is a false positive. The genuine FMV triggers are the literal fmv_id operands in the scene MAN scripts (recovered for all eight trigger scenes - see below) plus the disc-decoded fmv_dispatch table.

FMV-trigger sites - exhaustive backward sweep

A grep across every Ghidra dump in the corpus for writes to the global game-mode word _DAT_8007B83C = 0x1A (the StrInit mode that boots the str_fmv overlay) finds only the field-VM op plus the title-attract path (pinned at two PCs in the title overlay). The sites are codified in legaia_engine_vm::cutscene_trigger as FMV_TRIGGER_SITES:

LabelFunctionMode-write addrFMV-id sourceTrigger condition
field_vm_op_4c_e2 FUN_801DE840 0x801E3104 decode_u16_be(pc+1) from field-VM bytecode Field-VM hits the byte sequence 0x4C 0xE2 lo hi; reached via JT chain 0x801CEE60 (high nibble 0xE) → 0x801CF008 (low nibble 0x2) → label 0x801E30E4.
title_attract_loop FUN_801DE234, case 0x10 0x801E0F50 Hardcoded 0 (= MV1.STR, intro) Title-screen idle countdown DAT_801ef16c underflows.
title_tick_inline FUN_801DD35C 0x801DDCF0 Hardcoded 0 (= MV1.STR, intro) Same attract countdown, inline fall-through past the decrement at 0x801DDCCC - the PC a live watchpoint reports.

FUN_801E30E4 has zero static callers. It is a label inside FUN_801DE840, not a callable subroutine. Ghidra promotes it to a FUN_ symbol because the JT entry at 0x801CF008[2] resolves there; the actual control flow is the dispatch chain above. A direct grep -rn 'jal 0x801e30e4' ghidra/scripts/funcs/ returns zero matches.

The per-scene 0x4C 0xE2 trigger assignment is disc-sourced: the ops live LZS-compressed inside each scene's MAN (which is why a raw bytewise PROT scan missed them), and walking the decompressed partition-1 scripts recovers the literal fmv_id operands for all eight trigger scenes (town01 / garmel / deroa / chitei2 / dohaty / town0d / uru / jouine) - see the cutscene page.

Superseded readings
  • The scene_event_scripts prescript was long assumed to carry field-VM scripts because its records open with 0xFFFF 0x0000; the records are move-VM stager records (the lead is model_sel = -1).
  • Op 0x39 carried a PLAY_SFX label in an earlier draft; FUN_800421D4 is the inventory adder, so the op is the treasure-chest item-give.
  • The default-route flag-bank base was mislabelled DAT_80086D70 by double-counting the 0x1618 displacement onto 0x80085758. The Ghidra symbol DAT_80085758 is itself 0x80084140 + 0x1618, and the array indexes directly from there.
  • The per-scene FMV-trigger bytecode was read as "reconstructed at scene-load time from the field-pack preamble"; the ops are simply LZS-compressed inside each scene's MAN and decode statically.

See also