Field / event script VM
The interpreter that makes Legaia's towns work. Everything that happens outside combat - a villager's dialogue, a door that warps you to the next room, a chest that gives you an item,
a cutscene trigger, a story flag flipping so the plot can advance - is a small script, and this VM runs them all. It is a bytecode interpreter: each script is a stream of instruction bytes
("opcodes"), and a central dispatcher reads the byte at the current position and jumps to its handler. It lives in PROT entry 0897_xxx_dat - the town/field overlay, a
chunk of code the game loads into RAM whenever you're walking around (PROT.DAT is the disc's single big archive of numbered entries) - at FUN_801DE840, Ghidra's name for the traced
function at that RAM address. It is the largest function in the corpus: ~17.5 KB / 4099 instructions / 357 outgoing calls, and the biggest of Legaia's five
runtime VMs. All 43 opcodes ported.
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 < 0xF0selects scene TMD indexmodel- retail registers the party/savepoint head into0x8007C018slots0..5and then the scene's TMD list in scene order from slot 5, so the byte is pool slotmodel + 5.model >= 0xF0selects global-pool head slotmodel - 0xF0(0xF0..0xF3= Vahn / Noa / Gala / savepoint).anim_idis installed into the actor's+0x5Chalfword 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:
| ID | Resolution |
|---|---|
0xF8 | Returns 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 |
| otherwise | Regular 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:
| Offset | Type | Meaning |
|---|---|---|
+0x10 | u32 | Flag word. Bit 0x400 = "halted". Multiple bits gate per-opcode behaviour. |
+0x14/16/18 | u16 | World X / Y / Z (in 0.5-tile units, formula (b & 0x7F) * 0x80 + 0x40). |
+0x50 | u16 | Script ID. 0xFB = "system" channel. |
+0x54 | u16 | Wait/timer accumulator. Cleared by YIELD; ticked by WAIT_FRAMES. |
+0x5C / +0x5E | u16/i16 | Move-table index, sentinel set by op 0x22. |
+0x62 | u16 | Local flag bank (16 bits). Manipulated by ops 0x2B / 0x2C / 0x2D. |
+0x94 | u32 | Saved 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 - 0x22–0x26
| Op | Mnemonic | Effect |
|---|---|---|
0x22 | EXEC_MOVE | Schedule move-table playback. Calls FUN_800204F8 - the move-table consumer that crates/mdt targets. |
0x23 | MOVE_TO | Teleport ctx to grid position. Player path also calls func_0x80017EC8 (camera/scroll). NPC path sets facing and calls movement init. |
0x26 | JMP_REL | Unconditional relative jump. |
Flag-manipulation triplets - 0x2B–0x33
The cleanest group - three separate 1-bit-flag banks each with set / clear / test+skip:
| Op | Mnemonic | Bank |
|---|---|---|
0x2B / 2C / 2D | LFLAG SET/CLR/TST | Per-script local flags at ctx[+0x62] (16 bits). Sub-routine state, conditional dialog branches. |
0x2E / 2F / 30 | GFLAG SET/CLR/TST | Global story flags at _DAT_1F800394 (32 bits, in PSX scratchpad). Persistent across script runs. |
0x31 / 32 / 33 | CFLAG SET/CLR/TST | Ctx flag word at ctx[+0x10] (32 bits). Halt state, move-chain state, render-gate state. |
Effects, music, scene transitions - 0x34–0x36
0x34 EFFECT- nibble-dispatched (sub-0/1/2/3): colour + intensity setup, effect/sprite spawn, actor-pool capture-and-yield, 3D animation playback viafunc_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 - 0x37–0x42
| Op | Mnemonic | Effect |
|---|---|---|
0x37 / 41 / 47 | YIELD 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. |
0x38 | CAM_CFG | If 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. |
0x39 | GIVE_ITEM | Adds 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. |
0x3A | ADD_MONEY | 24-bit signed delta, clamped to [0, 9999999]. |
0x3B | SET_ITEM_COUNT | Set inventory entry. Inventory pages of 0x414 bytes. |
0x3C / 3D | PARTY_ADD / PARTY_REMOVE | Caps at 4 members. Updates leader. Refreshes display. |
0x3E | WARP / INTERACT | Field 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. |
0x3F | SCENE_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). |
0x42 | COND_JMP | Multi-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 - 0x44–0x4F
| Op | Mnemonic | Notes |
|---|---|---|
0x44 | SPAWN_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. |
0x45 | CAMERA | Sub-dispatch on op0 & 0xC0: configure / LOAD / SAVE / APPLY. |
0x46 | RENDER_CFG | Fog/render params. |
0x49 | STATE_RESUME | Multi-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. |
0x4A | WAIT_FRAMES | Frame timer; ticks ctx[+0x54]. |
0x4B | ANIMATE | Multi-keyframe setup. Sets +0x10 bit 0x1000 (animation flag). |
0x4C | MENU_CTRL | Outer-nibble-dispatched (16 sub-dispatchers). The biggest single opcode. |
0x4D | BBOX_TEST | Inside-box advances PC by 7; outside-box jumps via FUN_801E3614. |
0x4E | INVENTORY_CMP | Compare-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. |
0x4F | SCENE_REGISTER_WRITE | Writes 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'sFUN_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 frombase + (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 agrid[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 nibble | SCUS dispatcher | Effect |
|---|---|---|
0x5x | func_0x8003CE08 | SET bit |
0x6x | func_0x8003CE34 | CLEAR bit |
0x7x | func_0x8003CE64 | TEST 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 0x2B–0x33 opcodes - likely "system" / engine-wide event flags.
- Region. It is not a wholly separate region: base
0x80085758falls inside the story-flag RAM window0x80085600..0x80085800(at+0x158) and the bank extends past0x80085800(indices up to ~0xFFFreach0x80085758 + 0x1FF). - Save mapping. In a retail SC save block the bank lives at SC offset
0x1618, overlapping the story-flag bitmap (SC0x14C0, 512 bytes) and continuing to the inventory array (SC0x1818); seedingWorld::system_flagsfromsc_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
0x4Cnibble-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
0x21–0x4Frange plus any byte whose high nibble is0x5,0x6, or0x7(potentially 192 more "wide" opcodes routed to three SCUS dispatchers). - Census tooling. A progress gate reads a SYSTEM flag (
0x7xTEST) in one scene, but the setter that opens it (0x5xSET) usually lives in a different scene's MAN.man_field_scripts::walk_partition_gflag_sitesreports every flag site in a MAN partition (tagged scratchpad-vs-system bank + full flag number + SET/CLEAR/TEST kind), andsystem_flag_censusruns it over every CDNAME scene across all partitions to buildflag -> [(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, probeautorun_flag_firehose.lua) shows every story-flag write returning to the dispatcher's own0x5x/0x6xarms (ra 0x801E3598/0x801E35C0); the remaining callers are engine systems touching low indices (0/3entity-SM staging,0x35battle-end victory,0xB/0xC/0x18locks,0xEspawn 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 standalonedata_field_streamingPROT entry (the chunk header is the ordinary[u24 size][u8 type=0x03]sub-asset descriptor; the payload parses withlegaia_asset::man_sectionlike any MAN). The resident copy byte-matches PROT0157_rikuroa's chunk, and it carries the story-flag0x142SET (51 42) at four record sites -P1[10..12]plus the post-victory cutscene recordP2[50], whose C1 gate is0x142itself (the self-latching one-shot). The carrier's records also pin howP2[50]runs: the boss stagerP1[3]SETs the transient marker0x289(52 89) right before its battle-entry op (3E FF 11), and the scene-entry system scriptP1[0]tests that marker on the post-battle scene re-entry (72 89at+0x13A) - its taken arm (+0x7E6: fade, BGM,44 5C) issues the op-0x44spawn of global record0x5C=P2[50], C1-gate-checked by the dispatcher. The same shape sits one branch level up:P1[0]'s first-arrival arm spawnsP2[43](44 55) while flag0x2FBis clear, and that record's own52 FBlatches 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 land0x142; disc-gated oracleorganic_beat_records_disc.rs. Thirteen retail blocks ship such a streaming variant MAN (extraction indices:dolk270,rikuroa2122,rikuroa157,rayman201,station228,balden2320,ropeway2339,taiku373,doman401,taiku2427,nilboa2648,edbalden792,eddoman817); for the v12-family dungeons (rikuroa/dolk2, whose own bundle is the MAN-lesscount=4form) the streaming carrier is the scene's only MAN.system_flag_census(and the motion / op-0x49censuses) walk every carrier per scene - the bundle MAN plus the streaming variants, enumerated byman_field_scripts::scene_man_carriers- so the variant-resident writers surface: the0x142setters above, the0x63Abeat 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 themVARIANT-MAN);--p2-gatesprints every partition-2 record's C1/C2 header gate lists + name (theFUN_8003BDE0spawn-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..=0x7Fflag ops (the full-width digit run82 54 82 4FaliasesSysFlag.Set idx=0x482; a repeating full-widthEXITlabel table aliases64 82clears). Every census site therefore carriesGFlagSite::clean:trueonly when at leastCLEAN_RESYNC_INSNSinstructions decoded error-free between the walker's last decode error (or record start) and the site. The CLI printsDESYNCED?on non-clean rows - treat those as byte noise until verified by hand disasm or a live capture. This falsified the earlier "0x482set by theother7pool / cleared by theedbalden/eddomanepilogue variants" reading: all 37 of0x482's census sites are non-clean text aliases, while the live-confirmed0x142writer 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: town01P2[3]SETs it from its own script bytes (52 25at body+0x3, the record its own C1 gates - theP2[50]/0x142self-latch shape), but with no width for the preceding4C EDop (_DAT_8007BA66write, retailparam_2 + 3) the walk mis-readsED 01 52as a phantom Clear and swallows the SET. Runtime-pinned by a reader-watch script-PC capture (SETra 0x801E3598,vmoffset+0xF) and confirmed statically: the whole4C 0xE_sub-op width family is pinned from the retail dispatcher'sparam_2 + Nadvances (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 VA0x801CF008) advance +3 - sub-0 through theaddiu s8,s8,0x3entry at0x801E00B8, sub-3 there or in thej 0x801E00BCbranch-delay slot. Neither is a halt (the decompile'sgoto LAB_801e00bcfolds both entries into the no-advance label). Anchorflag_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
0x4Couter nibble (9/A/C/D/F), every record crossing one (e.g. theCC 06 A1 ..extended nibble-A conditional jumps that pepper the jou-castle door records) desyncs exactly like the4C EDcase, 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'smenu_ctrlport (itself pinned from the retail dispatcher'sparam_2 + Nadvances); nibbleBis genuinely undefined in retail (nocase 0xb- the default arm halts) and stays a decode error. The pinned spine-flag verdicts are unchanged under the full-width walk:0x482stays all-alias, and the koin gates0x50A/0x5D6still have no script writer disc-wide. - Width blindness's third face is a wrong width in an already-decoded arm. The
0x4Cnibble-8 sub-widths are pinned from the raw asm of the nibble-8 switch (same overlay, base0x801CE818): sub-1 (actor model+anim set) advances +9 unconditionally (addiu fp,fp,9at0x801E1FC4), sub-3 +7 (0x801E2130), sub-5/E/F +5 on acquire (li s7,5at0x801E21B8,addu fp,fp,s7in thebeqzdelay slot - only the predicate-failure path halts), sub-6 +15 (0x801E21E8), sub-0 +3, sub-C +4 - matching the executing VM'smenu_ctrl/nibble_8.rsport. A sub-1 width one byte over is what thevozz P1[7].byte 0x05decode error was: eachCC 0B 81 ..op swallowed its follower's lead byte, minting a phantomClear 0x400where the retail stream reads the op's 10-byte extended form followed by a35BGM op /4AWaitFrames. Under the pinned widths those followers decode in place, the phantom rows disappear, and the spine verdicts hold row-identical (549/0x142site sets unchanged;0x482all-alias;0x50A/0x5D6writer-less,0x50Againing one more clean koin3 TEST reader and still no writer). - ASCII dialogue aliases survive the
cleantag. The US build's dialogue is plain ASCII, and the wide flag ops land exactly on the letter ranges:Setleads0x53..0x57=S..W,Clearleads0x61..0x67=a..g,Testleads0x71..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 carryclean=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/JmpRelbranch arms - reject neighbours that decode as further letter-pair flag ops); mirroredSet/Clearruns over the same band are self-proving even when taggedDESYNCED?. Hand-checks:0x32C's and0x461's "chapter-wide readers" are thes,/tabigrams (both flags are real but scene-local); Nivora's0x370shows the context-window rule cutting both ways inside one record (domanvariantP1[15]): threeSp=53 70sites are the "TimeSpace Bomb" dialogue (rejected), but the fourth, at MAN offset0x06397(+0x3018), sits in a choreography run (WaitFrames/MoveTo/Effect/4C CD) with a loop-backJmpRelto the record's gate-test head - and the head's ownTest 0x370 -> +0x301Ejump target lands on the very next op after thatJmpRel, so both sites decode on the same op grid: this is the genuine writer (the Dr. Usha briefing self-latch; pinned byman_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-reportedClear 0x400invozz 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 markerTEXT-ALIAS?, on both the census and--gflag-partitionrows):truewhen the site's raw operand byte is printable ASCII and the surroundingTEXT_ALIAS_WINDOW(16 bytes each side) contains a consecutive printable-ASCII run of at leastTEXT_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: theta/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 town01P2[3]52 25self-latch and the rikuroa51 42/61 42ladders 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 the0x527..0x52Eone-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. Likeclean, 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 thee'prose bigram (marked), separated row by row. The advisory direction also occurs: two runtime-pinned real0x142sites (the dolkP1[26]Clear and the dolk2 variant-carrierP1[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..0x531scene-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'sP1[0]entry script (and many exit-choreography arms) repeats two byte-stereotyped patterns (hand-verified indeene P1[0]): a one-hot selector0x527..0x52E(clear all eight, then SET exactly one, ahead of aSceneFade/4C 12fade - a departure-choice latch), and a fade handshake0x52F/0x530/0x531(set/test/clear ping-pong around4C CA/4C CBwidget open/close). The conc-family entry scripts keep an adjacent private slot in the same style (0x522). Treat census rows in0x522..0x531as mechanism traffic, like the0x00Fbusy-mutex. - Drake-castle interior beat band (
jouinb).jouinb P2[6..8]each end in a one-shot latch -0x44E/0x44F/0x450(Camera/WaitFrames/4C CDthenSet+ park-jump;P2[6]body+0x4A3).P2[8]additionally runs an in-body state machine on0x461(Testat+0xBCskipping to aCleararm at+0x1BC,Setat+0xBC2in the closing choreography). All four flags are jouinb-local; the census's wide0x461reader list is thetabigram. - Door-choreography record families: the
0x00Fbusy-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-gatesoutput but are mechanism state, not story state (C1 polarity: a C1 flag blocks the record while SET). The0x00Fbusy-mutex family:jouincP2[2..59] (SJIS names J01..J58) andjouindP2[0..1]/[6..9] are all gatedC1=[0x00F], and every record's first op SETs0x00Fwhile 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 channel0xF8runs the ExecMove walk-through, and the room transition is a SceneFade pair - an intra-scene reposition, not a0x3Fscene change). The jouind per-visit band0x4BE..0x4C2: reset byjouinaP1[0] on entry, so per-castle-visit door/lift state, not chapter progress - P2[10]/P2[11] (C1=[0x4C1]) SET0x4BE/0x4BFand share the first-use latch0x4C2; P2[12] (C1=[0x4C0,0x4C1]) one-shots itself via0x4C0; P2[14] SETs0x4C1, retiring the family for the visit; P2[13] re-applies door visuals from0x4BE/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 (teienis the one scene with a local copy).bgm_id ≥ 2000- global. The global pool is themusic_01bank (extraction entries 990..=1071), whose slot order is the debug sound-test order - so2000 + iplays sound-test trackiand every global id resolves to a curated human name (music-track table). Pinned by a per-scene op-0x35census joining ids to their scenes' known music (town01starts2016= “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.
| Helper | Original | Used by |
|---|---|---|
packet_length | FUN_8003CA38 | 0x4C nE sub-1, 0x49 |
party_flag_test | FUN_8003CE64 | 0x4C nC sub-1 (host-side) |
small_table_search | FUN_80042EE0 | 0x4C nD sub-C/E |
load_u16_le / load_u24_le / load_u32_le | FUN_8003CE9C / CEB8 / CED8 | LE immediate decoding across many 0x4C sub-ops |
tile_center | inline (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≥ 0x1Fas 1 each, and bytes whose top nibble is0xCconsume the next byte unconditionally (escape, counts as 2). The terminator is not included.party_flag_test(idx, flags)reads bitidxof a packed bit array, MSB-first per byte; returns0xFFwhen set,0otherwise. Exposed to0x4C nC sub-5/6via theop4c_n_c_party_flag_testhost hook.small_table_search(needle, table, lo, hi)searchestable[i*2](stride 2, low byte of each short) forneedleacross[lo, hi); returns the index orSEARCH_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_24for the few opcodes (notably0x4C nE sub-5's XP-add) needing a signed 24-bit immediate. tile_center(b)is the grid-byte → world-coord conversion:b == 0returns 0; otherwise(b & 0x7F) << 7 | 0x40, plus0x40if 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_MOVEdrives the move-table consumer atFUN_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+ pagerFUN_801D84D0, triggered by the field-interact op (0x3E op0<100). The textcrates/mesparses is that inline0x1F/glyph stream. (0x3Fis the named scene-change -func_0x8001FD44is the scene-change packet, not a dialog opener.) The engine's faithful runner (engine_core::inline_dialogue/World::step_inline_dialogue, PORT ofFUN_80039b7c) drives the record through the real field VM, pausing at each0x1Fsegment 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 VMAdvancejumping 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
0x34sub-op 3 plays 3D animations viafunc_0x800252EC. - crates/engine-vm - the clean-room Rust port at
crates/engine-vm/src/field.rs. Reuses the sameHosttrait 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 advanceparam_2via theaddiu s8, s8, Ninstruction in the MIPS branch-delay slot of thej 0x801df09cjump. So0x39,0x3B,0x44,0x4Cand friends DO advance the PC - just not in a way the C-level decompile makes obvious.LAB_801df09cis justj 0x801e3628; move v0, s8- returns8unchanged. Most callsites jump there with anaddiu s8, s8, Nin 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 pointLAB_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 (0x26JMP_REL, the0x7xflag-TEST conditional jump,0x42COND_JMP, the0x4Ecompare 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). Computingbase + deltain a wider int without the 16-bit truncation turns every backward jump into a+0xFFxxforward overrun. The clean-room port models this with arel_jump(base, lo, hi)helper that wraps inu16. - Intra-function label catalogue. Several
iVar = FUN_801xxxxx(); return iVar;patterns in the C decompile look like helper calls but are intra-functionjtargets Ghidra promoted to fake function names - each is aaddiu s8, s8, N; j epilogueblock 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-checkgrep -n "0x<addr>" overlay_0897_801de840.txtbefore treating aFUN_xxxxxxxxreference as a separate function - the misleadingly-named dump fileoverlay_0897_801e3620.txtactually has entry0x801e3578.
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:
| Label | Function | Mode-write addr | FMV-id source | Trigger 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_scriptsprescript was long assumed to carry field-VM scripts because its records open with0xFFFF 0x0000; the records are move-VM stager records (the lead ismodel_sel = -1). - Op
0x39carried aPLAY_SFXlabel in an earlier draft;FUN_800421D4is the inventory adder, so the op is the treasure-chest item-give. - The default-route flag-bank base was mislabelled
DAT_80086D70by double-counting the0x1618displacement onto0x80085758. The Ghidra symbolDAT_80085758is itself0x80084140 + 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.