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 (gap 0x27-0x2A), plus every byte in 0x50-0x7F as a "wide" story-flag op. Bit 0x80 on 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; CLI field-disasm).
Confidence
Confirmed - disassembly of the overlay dump plus live captures (how we know).

How a frame runs

Every display framewalk 5 actor lists Each field actorscript bit set? One script slicerun until a stop The interpreter read opcode at PC 0x80 prefix: retarget ctx switch on (op & 0x7F) handler returns new PC repeat Yield: motion opparks PC, halts Wait: 0x4A / 0x49PC unchanged, retry Text byte < 0x20dialogue pager runs 0x21 end of frameresume next frame
There is no scheduler and no instruction budget: every live script gets one slice per frame, and each slice runs instruction after instruction until one of the four exits on the right stops it.
  • 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 a 0x21.
  • 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_FRAMES adds 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.

PartitionRecord headerHolds
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
2name + three condition blocksCutscene 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

ByteMeaning
model < 0xF0Scene TMD index (pool slot model + 5; slots 0..4 are the party and save point)
model >= 0xF0Global head slot model − 0xF0: Vahn, Noa, Gala, save point
animScene-bundle ANM record index + 1 (0 = none); party models resolve it against the locomotion bundle instead
bx, bzTile 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 idResolves to
0xF8The player's context
0xFBThe scene's system context (record 0)
otherThat 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)

OffsetTypeMeaning
+0x10u32Flag word: 0x400 halted, 0x100 "touched", bits 0/1 collision-exempt, 0x1000 animating
+0x14/16/18u16World X / Y / Z (tile byte → (b & 0x7F) × 0x80 + 0x40)
+0x50u16Script id (0xFB = system)
+0x54u16Wait accumulator (cleared by yield, ticked by WAIT_FRAMES)
+0x5Cu16Move-table index (EXEC_MOVE)
+0x62u16Local flag bank, 16 bits
+0x72u16Render scale, 0x1000 = 1.0; 0 = invisible (trigger gizmos)
+0x94u32Saved PC of a parked motion op
+0x9Ei16Program 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.

OpMnemonicBytesWhat it does
21 24 25 48NOP1Advance one byte; 0x21 also ends the frame's slice
22EXEC_MOVE2Play move-table clip id on this actor (99 = cancel)
23MOVE_TO3Teleport to tile (x, z); player path also snaps the camera
26JMP_REL3Unconditional relative jump (16-bit wrap)
2B 2C 2DLFLAG set/clr/test2Local flag bank ctx+0x62; test halts when clear
2E 2F 30GFLAG set/clr/test232-bit scratchpad word - transient, not saved
31 32 33CFLAG set/clr/test2The context flag word ctx+0x10 (halt, collision-exempt, contact class)
34EFFECTvarSub 0 colour/intensity; 1 spawn effect; 2 capture-and-yield; 3 play a 3D animation
35BGM311 sub-ops: start, pause, stop, resume, timed release, swap-commit (ids)
36SCENE_FADE5Two u16 operands; 0xFFFF waits for the load flag
37 41GLIDE3Motion op: directional glide-step; parks the PC and hands the bytes to the walk kernel
38CAM_CFG3Copy a camera constant into ctx+0x26, or halt-acquire
39GIVE_ITEM2Add one of item id - the treasure-chest path; the id is the inline byte
3AADD_MONEY4Signed 24-bit gold delta, clamped 0..9,999,999
3BSET_ITEM_COUNT3Write one inventory slot, refresh the display
3C 3DPARTY_ADD / REMOVE2Sorted insert / remove, cap 4, leader fix-up
3EINTERACT / WARPvarop0 < 100: arm NPC interaction op1; op0 >= 100: enter minigame op0 − 100 (0 fishing, 3 slots, 4 Baka Fighter, 6 dance)
3FSCENE_CHANGEvarNamed warp: [idx:i16][len][name][x][z][dir] - a door, not dialogue
40DATA_BLOCK2+lenSkip len inline data bytes
42COND_JMP5Mode 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
43ACTOR_CTRLvar22+ sub-ops: halt-acquire, 3-actor talk, camera zone ramps, facing, position tween, actor allocation, emitters
44SPAWN_RECORD2Spawn partition-2 record global_index as a new context if its flag gates pass
45CAMERAvarop0 & 0xC0: configure / load / save / apply
46VIEW_WINDOW2 / 5Camera visible-tile window (0x1F8003E8..EB): long form writes the four signed tile offsets, short form a half-width window; not fog
47WALK_TO4Motion op: walk to tile at step 4 << (b2 & 7), approach mode in the high nibble
49STATE_RESUMEvarIdle / armed / done machine over the entry context; 49 03 n opens the name-entry screen for party slot n
4AWAIT_FRAMES2Accumulate display frames into ctx+0x54; retry until the operand is reached
4BANIMATE3+4nMulti-keyframe setup, sets the animating bit
4CMENU_CTRLvarSixteen sub-dispatchers on the high nibble (below)
4DBBOX_TEST7Player inside the box: continue; outside: jump
4EINVENTORY_CMP7 / 9Compare-and-skip on HP/MP, level, gold (the inn gate), a d256 roll, slot table, coins
4FSCENE_REGISTER_WRITE7Three u16 writes into the scene record
5x 6x 7xSYSFLAG set/clr/test2 / 4The 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.

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.

NibbleTheme
0Party-leader change
1A 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
2Camera-octant / pad-rotation setter (gp+0x2D8 = sub_op & 7)
3Input lock, player resync, party-state clear
4Immediate-or-ramped writes: render scale (40), scalar, mirrored Y
5Model select, NPC move-to-tile, TAKE_ITEM (sub 2), dialogue polls
6Emitter setup, halt-acquire
7Rectangular wall paint into the walkability grid - story-conditional collision (locomotion)
8Party 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
9Floor-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.
AConditional jump on a ctx / local / global flag bit
BUndefined - the default arm halts
CSlot table writes (CA CB CC), sound trigger, control-word toggle
DField SE, synchronous spawn, party search, VRAM STP-bit edits (D4 D5), timed flags (D3)
EText balloon, FMV trigger (4C E2 lo hi), camera teleport / zoom, casino coin delta
FOnly 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.

LeadEffectBytes
0x5xSet flag2
0x6xClear flag2
0x7xTest flag; jump to the u16 target when set4

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.

  1. 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 0x3E with op0 < 100 is the scripted-battle install, which points the system context at a formation-table row, not at an interaction script.
  2. 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.
  3. 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.

IdResolves to
< 2000Scene-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)
>= 2000The 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 / dataAddressWhat it provesDump
The dispatcherFUN_801DE840The 43-way switch, prefix retarget, every handler and PC deltaoverlay_0897_801de840.txt
Frame driverFUN_8002519CFive actor lists at _DAT_8007C34C..6C, jalr node+0x0Cfuncs/8002519c.txt
Field actor tickFUN_8003BC08Flag-word routing: 0x100 script slice, 0x400 walk kernel, +0x80 motion VMfuncs/8003bc08.txt
Script sliceFUN_80039B7CRun-until-stop loop, ctx+0x9E write-back; also the actor-dialogue SMfuncs/80039b7c.txt
System context seatFUN_8003AB2CRecord 0 allocated and run inline at scene loadfuncs/8003ab2c.txt
Scene loaderFUN_8003A1E4Script pointer at actor+0x90, body at 1 + 2N + 4funcs/8003a1e4.txt
Target resolverFUN_8003C83C0xF8 player, 0xFB system, else tablefuncs/8003c83c.txt
Story-flag bankFUN_8003CE08 / CE34 / CE64Set / clear / test on DAT_80085758, MSB-first per bytefuncs/8003ce08.txt
Flag writers, liveexec breakpointsEvery story write returns to the dispatcher's own 0x5x/0x6x armsautorun_flag_firehose.lua
Record spawnFUN_8003BDE0Partition-2 C1/C2 gate check for op 0x44funcs/8003bde0.txt
BGM resolverFUN_800243F0The < 2000 / − 2000 splitfuncs/800243f0.txt
Placements, livetown01 actor pool53/53 animated actors match the placement header decodefield_npc_placements_disc.rs
Mode-write sweep_DAT_8007B83C = 0x1AOnly the field op 4C E2 and the title attract loop start a moviecutscene_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 with addiu s8, s8, N in the delay slot of j 0x801df09c - that N is the op's PC advance. Ops 0x39, 0x3B, 0x44, 0x4C all 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.
  • 0x42 mode-0 taken target is pc + 3 + u16, joined at LAB_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 in rel_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):

ElementGeometry
Grid window / name window(24, 91, 272, 120) / (196, 71, 88, 28), pause-menu skin
Charset7 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 bary = 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
HelperOriginalBehaviour
packet_lengthFUN_8003CA38Length of one text packet: stop at a byte ≤ 0x1E; a 0xCx byte consumes its follower
party_flag_testFUN_8003CE64Bit idx of a packed array, MSB-first; 0xFF when set
small_table_searchFUN_80042EE0Find needle in the low byte of table[i*2] over [lo, hi); 0x100 on miss
load_u16/u24/u32_leFUN_8003CE9C / CEB8 / CED8Little-endian immediates; the 24-bit form pairs with sign_extend_24 for the casino coin delta
tile_centerinlined in nine arms0 → 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..W read as Set, a..g as Clear, q..w as Test, so bigrams like ta and Sp mint phantom sites. Rows carry clean (enough error-free decodes since the last desync) and text_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 0x4C nibble 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 tagged VARIANT-MAN). --p2-gates prints 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 / Exit in Jeremi's inn record, Clear all flags on the Sebucus overworld, an On / Off / Exit toggle 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 carry debug_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
SiteFunctionMode writeMovie id
field_vm_op_4c_e2FUN_801DE8400x801E3104u16 operand of 4C E2
title_attract_loopFUN_801DE234 case 0x100x801E0F500 (intro)
title_tick_inlineFUN_801DD35C0x801DDCF00 (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.

See also