Field / event script VM
The interpreter that makes Legaia's towns work. A villager's dialogue, a door that warps you to the next room, a chest that hands over an item, a cutscene, a story flag flipping so the plot can advance - each is a small bytecode script, and this one interpreter runs them all, every frame, for every actor in the scene. All 43 opcodes ported
At a glance
- Lives in
- The field overlay (PROT
0897) - code the game streams into RAM whenever you walk around. The dispatcher is the largest function on the disc. - Scripts come from
- Each scene's MAN, the script-and-data bundle shipped with the scene: record 0 is the scene's own script, records 1.. belong to NPCs, a second partition holds cutscenes spawned on demand.
- Opcode space
- 43 opcodes
0x21-0x4F(gap0x27-0x2A), plus every byte in0x50-0x7Fas a "wide" story-flag op. Bit0x80on any opcode aims it at another actor's script. - Dialogue
- Has no opcode - talking pages the NPC's own inline text (below).
- Engine
crates/engine-vm/src/field.rs(executing VM);legaia_asset::field_disasm(side-effect-free disassembler; CLIfield-disasm).- Confidence
- Confirmed - disassembly of the overlay dump plus live captures (how we know).
How a frame runs
- Every live script runs every frame. The frame driver walks the five actor lists; a field actor whose flag word has the script bit set runs its slice. Scripts are not time-sliced against each other - the list length is the only bound.
- A slice runs to a stop, not one step. The slice loop calls the interpreter, stores the returned PC, and calls again while the next opcode is in range, the PC moved, and the opcode is not
0x21. A scene's entry prologue therefore runs inside the load frame; the per-frame body after it is whatever the script loops back over a0x21. - Presenters are scripts too. The narration crawl and the cutscene camera mover are ordinary actors, so a cutscene never blocks on them.
- Waits are display frames.
WAIT_FRAMESadds the frame-skip factor per visit, so "wait 30" is 30 display frames whatever the logic rate. A port must pace the VM from a display-frame clock.
The scene's own script (record 0, context id 0xFB) is one more context, seated at scene load. Rim Elm's starts a track and stops it 32 instructions later on a first visit, all before anything is drawn - stepping one instruction per frame would play half a second of music retail never lets you hear.
Where scripts live
Nothing about a town's behaviour is in the main executable. Each scene's bundle carries a MAN (asset type 0x03); the scene loader installs each placement record's script pointer on its actor and starts the VM on the record body.
| Partition | Record header | Holds |
|---|---|---|
0 | [n][n×2 name][attr] | Named objects |
1 | [N][N×2 locals][model][anim][bx][bz] | Record 0 = scene script; records 1.. = one per NPC / trigger, script body follows the header |
2 | name + three condition blocks | Cutscene records, spawned by op 0x44 when their flag gates pass |
Thirteen scenes ship a second, plain MAN inside a streaming PROT entry (the "variant MAN"); for the two v12-family dungeons it is the scene's only MAN. A story-flag writer that seems to be missing is usually there. The prescript table that precedes some scenes (scene_event_scripts) is not field-VM bytecode - it holds move-VM stager records.
Placement header: model and idle pose
| Byte | Meaning |
|---|---|
model < 0xF0 | Scene TMD index (pool slot model + 5; slots 0..4 are the party and save point) |
model >= 0xF0 | Global head slot model − 0xF0: Vahn, Noa, Gala, save point |
anim | Scene-bundle ANM record index + 1 (0 = none); party models resolve it against the locomotion bundle instead |
bx, bz | Tile position; (127, 127) marks a conditional spawn a script places later |
Dispatch and context
The interpreter is a plain function: new_pc = run(buffer, pc, ctx). Before the 43-way switch, one prefix check: if the opcode's high bit is set, the next byte names the script this instruction acts on, and the handler runs against that context. A cutscene can say "the king walks to X" without becoming the king.
| Target id | Resolves to |
|---|---|
0xF8 | The player's context |
0xFB | The scene's system context (record 0) |
| other | That actor's script context; a halted target (flag 0x400) is left alone unless the op is 32 0x400 (un-halt) |
The context record (selected fields)
| Offset | Type | Meaning |
|---|---|---|
+0x10 | u32 | Flag word: 0x400 halted, 0x100 "touched", bits 0/1 collision-exempt, 0x1000 animating |
+0x14/16/18 | u16 | World X / Y / Z (tile byte → (b & 0x7F) × 0x80 + 0x40) |
+0x50 | u16 | Script id (0xFB = system) |
+0x54 | u16 | Wait accumulator (cleared by yield, ticked by WAIT_FRAMES) |
+0x5C | u16 | Move-table index (EXEC_MOVE) |
+0x62 | u16 | Local flag bank, 16 bits |
+0x72 | u16 | Render scale, 0x1000 = 1.0; 0 = invisible (trigger gizmos) |
+0x94 | u32 | Saved PC of a parked motion op |
+0x9E | i16 | Program counter - 16-bit, so relative jumps wrap (0xFFFE = back 2) |
The full field table is in script-vm.md.
Opcode reference
Most of a town is built from a handful of these - flag tests, waits, walks, scene changes. Two opcodes (0x43, 0x4C) are sub-dispatchers hiding dozens of sub-ops each.
| Op | Mnemonic | Bytes | What it does |
|---|---|---|---|
21 24 25 48 | NOP | 1 | Advance one byte; 0x21 also ends the frame's slice |
22 | EXEC_MOVE | 2 | Play move-table clip id on this actor (99 = cancel) |
23 | MOVE_TO | 3 | Teleport to tile (x, z); player path also snaps the camera |
26 | JMP_REL | 3 | Unconditional relative jump (16-bit wrap) |
2B 2C 2D | LFLAG set/clr/test | 2 | Local flag bank ctx+0x62; test halts when clear |
2E 2F 30 | GFLAG set/clr/test | 2 | 32-bit scratchpad word - transient, not saved |
31 32 33 | CFLAG set/clr/test | 2 | The context flag word ctx+0x10 (halt, collision-exempt, contact class) |
34 | EFFECT | var | Sub 0 colour/intensity; 1 spawn effect; 2 capture-and-yield; 3 play a 3D animation |
35 | BGM | 3 | 11 sub-ops: start, pause, stop, resume, timed release, swap-commit (ids) |
36 | SCENE_FADE | 5 | Two u16 operands; 0xFFFF waits for the load flag |
37 41 | GLIDE | 3 | Motion op: directional glide-step; parks the PC and hands the bytes to the walk kernel |
38 | CAM_CFG | 3 | Copy a camera constant into ctx+0x26, or halt-acquire |
39 | GIVE_ITEM | 2 | Add one of item id - the treasure-chest path; the id is the inline byte |
3A | ADD_MONEY | 4 | Signed 24-bit gold delta, clamped 0..9,999,999 |
3B | SET_ITEM_COUNT | 3 | Write one inventory slot, refresh the display |
3C 3D | PARTY_ADD / REMOVE | 2 | Sorted insert / remove, cap 4, leader fix-up |
3E | INTERACT / WARP | var | op0 < 100: arm NPC interaction op1; op0 >= 100: enter minigame op0 − 100 (0 fishing, 3 slots, 4 Baka Fighter, 6 dance) |
3F | SCENE_CHANGE | var | Named warp: [idx:i16][len][name][x][z][dir] - a door, not dialogue |
40 | DATA_BLOCK | 2+len | Skip len inline data bytes |
42 | COND_JMP | 5 | Mode 0 tests a flag bit, mode 1 the held d-pad (against a compass) or a face button; taken = pc + 3 + delta, else skip 5 |
43 | ACTOR_CTRL | var | 22+ sub-ops: halt-acquire, 3-actor talk, camera zone ramps, facing, position tween, actor allocation, emitters |
44 | SPAWN_RECORD | 2 | Spawn partition-2 record global_index as a new context if its flag gates pass |
45 | CAMERA | var | op0 & 0xC0: configure / load / save / apply |
46 | VIEW_WINDOW | 2 / 5 | Camera visible-tile window (0x1F8003E8..EB): long form writes the four signed tile offsets, short form a half-width window; not fog |
47 | WALK_TO | 4 | Motion op: walk to tile at step 4 << (b2 & 7), approach mode in the high nibble |
49 | STATE_RESUME | var | Idle / armed / done machine over the entry context; 49 03 n opens the name-entry screen for party slot n |
4A | WAIT_FRAMES | 2 | Accumulate display frames into ctx+0x54; retry until the operand is reached |
4B | ANIMATE | 3+4n | Multi-keyframe setup, sets the animating bit |
4C | MENU_CTRL | var | Sixteen sub-dispatchers on the high nibble (below) |
4D | BBOX_TEST | 7 | Player inside the box: continue; outside: jump |
4E | INVENTORY_CMP | 7 / 9 | Compare-and-skip on HP/MP, level, gold (the inn gate), a d256 roll, slot table, coins |
4F | SCENE_REGISTER_WRITE | 7 | Three u16 writes into the scene record |
5x 6x 7x | SYSFLAG set/clr/test | 2 / 4 | The persistent story-flag bank (below); test carries a 2-byte jump target |
Per-sub-op encodings for 0x34, 0x35, 0x43, 0x49 and 0x4E are tabulated in script-vm.md.
0x4C MENU_CTRL: sixteen dispatchers in one opcode
Despite the name, this is where most of a scene's "everything else" lives. The first operand's high nibble picks the sub-dispatcher; the low nibble picks the sub-op.
| Nibble | Theme |
|---|---|
0 | Party-leader change |
1 | A five-entry sub-table, not sixteen arms: only 10..14 index it and slot 1 is the common exit, so 11 and 15..1F advance seven bytes and do nothing. The four real arms are a global write (10), the screen tint (12) and its sibling triple (13), and the actor clone (14) - the VM's after-image, and the one eight-byte form in the nibble |
2 | Camera-octant / pad-rotation setter (gp+0x2D8 = sub_op & 7) |
3 | Input lock, player resync, party-state clear |
4 | Immediate-or-ramped writes: render scale (40), scalar, mirrored Y |
5 | Model select, NPC move-to-tile, TAKE_ITEM (sub 2), dialogue polls |
6 | Emitter setup, halt-acquire |
7 | Rectangular wall paint into the walkability grid - story-conditional collision (locomotion) |
8 | Party full heal, guard-slot jump, actor model + anim set, actor-search jumps, and the reflection controller (86 installs it, 87 retires every live one). 86 names the actor to mirror and makes the executing script the image - ten sites, all in talk records whose text is a single parenthesised beat |
9 | Floor-height ladder: sub-0xE installs all sixteen elevation rungs at 0x1F80035C, sub-0..2 sets one rung oscillating, sub-0xF retires the oscillators. Not a fade family — the tick's destination is the scene elevation LUT. |
A | Conditional jump on a ctx / local / global flag bit |
B | Undefined - the default arm halts |
C | Slot table writes (CA CB CC), sound trigger, control-word toggle |
D | Field SE, synchronous spawn, party search, VRAM STP-bit edits (D4 D5), timed flags (D3) |
E | Text balloon, FMV trigger (4C E2 lo hi), camera teleport / zoom, casino coin delta |
F | Only FF is valid (pass-through) |
Every sub-op body and its byte width: script-vm-menuctrl.md.
The story-flag bank (wide opcodes 0x5x / 0x6x / 0x7x)
"Has the player beaten this boss?" lives in a 4096-bit array at RAM 0x80085758, inside the save block, reached through the dispatcher's default arm. The low nibble of the opcode plus the next byte form the flag index.
| Lead | Effect | Bytes |
|---|---|---|
0x5x | Set flag | 2 |
0x6x | Clear flag | 2 |
0x7x | Test flag; jump to the u16 target when set | 4 |
Some story-numbered flags are mechanism, not progress: the 0x527..0x531 scene-transition scratch band every entry script rewrites, the 0x00F door busy-mutex in Drake Castle, and per-visit lift state. Read them as traffic. The disc-wide "who sets flag F" question is answered by legaia-engine man-scripts --system-flag-census, which walks every carrier of every scene.
Field dialogue has no opcode
Press the action button in front of a villager and a text box opens - but there is no "show dialogue" instruction. Talking is a three-step pipeline over the NPC's own bytes.
- Trigger. The touch / button-press interaction: the facing probe picks the actor and the entity state machine resumes its parked script. No opcode is involved - op
0x3Ewithop0 < 100is the scripted-battle install, which points the system context at a formation-table row, not at an interaction script. - Text. The NPC's record carries two scripts back to back under one cursor: a prologue (turn, walk) and the conversation - a
0x1F-led glyph stream in the MES encoding. - Display. A per-frame actor-dialogue state machine walks the glyph bytes and feeds the pager one box at a time. Each branch ends by jumping back to the record's flag selector, so the NPC is parked ready for the next talk.
Engine: engine_core::inline_dialogue drives the record through the real VM, pausing at each 0x1F segment (default in play-window; --simple-dialogue opts out).
BGM ids
A script's "play track 2016" is an offset into one bank, not a table lookup.
| Id | Resolves to |
|---|---|
< 2000 | Scene-local: not a scene-block entry. The resolver keeps the block index (base + 6 + id) only as its change test and loads global slot 2 (the track 2002 plays) instead; retail stages no scene bank (audio) |
>= 2000 | The global music_01 bank in sound-test order: 2000 + i = sound-test track i (names); the bank is piecewise in extraction space |
How we know
| Function / data | Address | What it proves | Dump |
|---|---|---|---|
| The dispatcher | FUN_801DE840 | The 43-way switch, prefix retarget, every handler and PC delta | overlay_0897_801de840.txt |
| Frame driver | FUN_8002519C | Five actor lists at _DAT_8007C34C..6C, jalr node+0x0C | funcs/8002519c.txt |
| Field actor tick | FUN_8003BC08 | Flag-word routing: 0x100 script slice, 0x400 walk kernel, +0x80 motion VM | funcs/8003bc08.txt |
| Script slice | FUN_80039B7C | Run-until-stop loop, ctx+0x9E write-back; also the actor-dialogue SM | funcs/80039b7c.txt |
| System context seat | FUN_8003AB2C | Record 0 allocated and run inline at scene load | funcs/8003ab2c.txt |
| Scene loader | FUN_8003A1E4 | Script pointer at actor+0x90, body at 1 + 2N + 4 | funcs/8003a1e4.txt |
| Target resolver | FUN_8003C83C | 0xF8 player, 0xFB system, else table | funcs/8003c83c.txt |
| Story-flag bank | FUN_8003CE08 / CE34 / CE64 | Set / clear / test on DAT_80085758, MSB-first per byte | funcs/8003ce08.txt |
| Flag writers, live | exec breakpoints | Every story write returns to the dispatcher's own 0x5x/0x6x arms | autorun_flag_firehose.lua |
| Record spawn | FUN_8003BDE0 | Partition-2 C1/C2 gate check for op 0x44 | funcs/8003bde0.txt |
| BGM resolver | FUN_800243F0 | The < 2000 / − 2000 split | funcs/800243f0.txt |
| Placements, live | town01 actor pool | 53/53 animated actors match the placement header decode | field_npc_placements_disc.rs |
| Mode-write sweep | _DAT_8007B83C = 0x1A | Only the field op 4C E2 and the title attract loop start a movie | cutscene_trigger.rs |
Full write-up, including the overlay's menu-support functions and the per-scene flag families: docs/subsystems/script-vm.md.
Details
Reading the dump: label-calls, PC deltas and 16-bit jumps
- Ghidra renders the epilogue as a call (
switchD_801e00f4::default()). In the asm each arm exits withaddiu s8, s8, Nin the delay slot ofj 0x801df09c- thatNis the op's PC advance. Ops0x39,0x3B,0x44,0x4Call advance this way. - Intra-function labels promoted to fake functions:
0x801df098(+2),0x801df09c(+0),0x801df8dc(+6),0x801e00b8(+3),0x801e212c(+7),0x801e3614(BBOX outside,pc + 5 + skip),0x801e3620(+4). Grep the address in the dump before treating it as a callee. 0x42mode-0 taken target ispc + 3 + u16, joined atLAB_801e35fc.- The PC is a signed 16-bit halfword, so every relative jump is
(base + delta) mod 0x10000.[21] [26 FE FF]is the park-here idiom. The port wraps inrel_jump(base, lo, hi).
Name-entry screen (op 49 03 n)
The field overlay's state machine runs the screen (renderer FUN_801E6B34, cursor cell _DAT_8007BB88, state _DAT_8007BB94: 1 editing, 4 confirm) and writes the typed name live into the character record at +0x2A7. Geometry in 320x240 pixels, overlay base (32, 99):
| Element | Geometry |
|---|---|
| Grid window / name window | (24, 91, 272, 120) / (196, 71, 88, 28), pause-menu skin |
| Charset | 7 rows x 17 at 0x801F29F0; glyphs from base + (4, 4), 15 px column pitch, 14 px row pitch, ink 7 |
| Working name | (208, 79); teal _ caret 6 px after, 75%-duty blink, 57 px field |
| Control bar | y = 191, ink 6: BS, the default name (restores the template - there is no space key), Select; cursor anchors 102 / 108 / 114 |
| Prompts | "Select your name." at (176, 32); confirm "Is this name okay?" at (172, 24) with Yes (204, 38) / No (204, 50), hand opens on No |
After the Done resume the opening plays A2 F8 30 then A2 F8 31 (EXEC_MOVE on the player) - scene-ANM records 47 / 48 once each - before the walk-out. Engine: engine-core::name_entry + the engine-ui name-entry builders.
Two 0x4C nibble-D sub-ops: VRAM STP bits and the escape timer
4C D4 / D5 - VRAM STP-bit set / clear. Operand [x_lo x_hi y_lo y_hi] is a VRAM origin; the rect is always 16x1 pixels. Retail does StoreImage → per-pixel edit → LoadImage: D4 sets the transparency bit on non-zero pixels, D5 clears it unless the pixel is STP-only. How a scene toggles a see-through detail without a second texture. Host hooks op4c_n_d_sub_4_vram_stp_set(x, y) / sub_5_…clear.
4C D3 - SCHEDULE_TIMED_FLAGS. [expiry_flag:u16][below_flag:u16][duration:u32][threshold:u32]: writes the pair into _DAT_800845C0, the duration into _DAT_800845B8 / A0, the threshold into _DAT_800845BC. The per-tick consumer FUN_801D2EBC counts down, sets the expiry flag on zero and the below flag while under the threshold. Retail use: chitei2's collapsing-dungeon escape (flag 0x4C7, 2400 frames, threshold 910). The slots sit in the save block, so a mid-timer save keeps it.
Pure helpers ported as functions
| Helper | Original | Behaviour |
|---|---|---|
packet_length | FUN_8003CA38 | Length of one text packet: stop at a byte ≤ 0x1E; a 0xCx byte consumes its follower |
party_flag_test | FUN_8003CE64 | Bit idx of a packed array, MSB-first; 0xFF when set |
small_table_search | FUN_80042EE0 | Find needle in the low byte of table[i*2] over [lo, hi); 0x100 on miss |
load_u16/u24/u32_le | FUN_8003CE9C / CEB8 / CED8 | Little-endian immediates; the 24-bit form pairs with sign_extend_24 for the casino coin delta |
tile_center | inlined in nine arms | 0 → 0; else (b & 0x7F) << 7 | 0x40, +0x40 if the high bit is set |
All in crates/engine-vm/src/field_helpers.rs.
Flag census: when a row lies
The census walks bytecode linearly, and three things desync it. A marked row means "check the record disassembly", never "discard".
- Text aliases. Dialogue bytes land on the wide flag ops:
S..Wread as Set,a..gas Clear,q..was Test, so bigrams liketaandSpmint phantom sites. Rows carryclean(enough error-free decodes since the last desync) andtext_alias(printable operand inside a prose-shaped window). A non-printable operand cannot be minted by prose; a mirrored Set/Clear run is self-proving. - Width blindness. A wrong sub-op width desyncs the walk even in clean code and hides a real site behind a phantom. Every
0x4Cnibble decodes with widths mirrored from the executing VM (nibble B stays a decode error by design). - Variant carriers. The census walks the bundle MAN and the streaming variant MANs (
--variant <entry>; rows taggedVARIANT-MAN).--p2-gatesprints the partition-2 spawn conditions the inline walk cannot see. - Developer flag menus. The fourth shape is not a desync at all: several shipped records carry a debug picker whose options write the scene's own story flags, so the rows are clean, their operands real, and their meaning "an option nobody picks in retail play". The labels say so -
Set all flags/Clear/Exitin Jeremi's inn record,Clear all flagson the Sebucus overworld, anOn/Off/Exittoggle in half a dozen more. What separates them from a beat is the block: the label list, the record's stop, then two or more arms that each write flags and jump back to the picker. Rows carrydebug_menu.
Worked cases (flag 0x142 in the Rikuroa variant MAN, 0x370 in Nivora, 549 in Rim Elm) are in script-vm.md.
FMV trigger sites and the field-disasm tool
| Site | Function | Mode write | Movie id |
|---|---|---|---|
field_vm_op_4c_e2 | FUN_801DE840 | 0x801E3104 | u16 operand of 4C E2 |
title_attract_loop | FUN_801DE234 case 0x10 | 0x801E0F50 | 0 (intro) |
title_tick_inline | FUN_801DD35C | 0x801DDCF0 | 0 (intro) |
Eight scenes carry a 4C E2 op (town01, garmel, deroa, chitei2, dohaty, town0d, uru, jouine); the operands sit LZS-compressed inside each MAN and decode statically (cutscenes). FUN_801E30E4 is a label inside the dispatcher, not a callee.
cargo run -p legaia-engine-vm --bin field-disasm -- file <PATH>
cargo run -p legaia-engine-vm --bin field-disasm -- scan-prot --disc <PROT.DAT> --cdname <CDNAME.TXT> --bytewise
The walker mirrors the executing VM's widths without side effects, so any byte buffer is safe input. scene-event-scripts / scan-prot walk the move-VM prescript table, so a 4C E2 hit inside a prescript record is a false positive.
History: readings this page replaced
The prescript table was read as field-VM bytecode (it is move-VM stager records). Op 0x39 was labelled PLAY_SFX (it is the item give). The story-flag base was mislabelled DAT_80086D70 by double-counting the save-block displacement. FMV triggers were thought to be rebuilt at scene load (they decode statically from the MAN). More on do-not-re-walk.