Script VM internals
The reference half of the field / event script VM: the context record, every opcode with its byte width, the sub-op tables of the sub-dispatchers, all sixteen 0x4C nibbles, and the traps that catch anyone reading the dump. Widths are in bytes and include the opcode; an 0x80-flagged (targeted) form adds one target byte after the opcode.
At a glance
- Dispatcher
FUN_801DE840(buffer, pc, ctx)in PROT 0897, returns the new PC. Dumpghidra/scripts/funcs/overlay_0897_801de840.txt.- Three exits
- Advance (add the op's width), halt at PC (return the PC unchanged, so the op re-runs next frame - how every wait works), halt-acquire (freeze another actor by setting its halt bit and parking its resume pointer).
- PC
- A signed 16-bit halfword at
ctx+0x9E: every relative jump is(base + delta) mod 0x10000, so0xFFFEis "back 2". - In the port
engine-vm::field(step.rsper opcode),engine-vm::field::dispatch_leaves(the pure leaf helpers),legaia_scene_formats::field_disasm(widths for every opcode and every0x4Cnibble).
The context record
Each running script has a context, which for a placement is the actor record itself. Selected fields:
| Offset | Type | Meaning |
|---|---|---|
+0x10 | u32 | Flag word: 0x100 engaged (the script runs), 0x400 halted (mid-walk or halt-acquired), bits 0/1 collision- and touch-exempt (a door runs 31 00 as its swing starts), 0x20000 / 0x40000000 the contact class (button-gated props), 0x1000 animating |
+0x14/16/18 | i16 | World X / Y / Z; a tile byte b becomes (b & 0x7F) × 0x80 + 0x40, plus 0x40 if the high bit is set |
+0x26 | u16 | Heading (12-bit angle) |
+0x50 | u16 | Script id / bind index (0xFB = system) |
+0x54 | u16 | Wait accumulator: cleared by a motion park, ticked by WAIT_FRAMES |
+0x5C | i16 | Move-table index (EXEC_MOVE) |
+0x62 | u16 | Local flag bank, 16 bits; also the clip-control word whose bit 8 the clip tick latches at a clip's end |
+0x72 | u16 | Render scale, 0x1000 = 1.0; 0 = invisible (trigger gizmos run 4C 40 00 00) |
+0x74 / +0x78 | u32 / u16 | Draw word (tint colour + mode bits) and tint blend, written by 4C 81 |
+0x90 | u32 | Script buffer base |
+0x94 | u32 | Resume pointer of a parked motion op (or a halt-acquire payload) |
+0x9C | u8 | Dialog state: 1 waiting for the box, 2 owns the box |
+0x9E | i16 | Program counter |
The player context pointer is _DAT_8007C364; many opcodes branch on "is the context the player". The full field table is in script-vm.md.
Every opcode
| Op | Mnemonic | Bytes | What it does |
|---|---|---|---|
21 24 25 48 | NOP | 1 | One shared handler. 0x21 also ends the slice and the interaction pass (the runner clears the engaged bit) |
22 | EXEC_MOVE | 2 | Play move-table record id on the actor (99 = cancel); hands off to the move VM |
23 | MOVE_TO | 3 | Teleport to tile (x, z); the player path also snaps the camera, the NPC path re-runs the visibility cull |
26 | JMP_REL | 3 | Unconditional relative jump, pc + 1 + u16, 16-bit wrap |
2B 2C 2D | LFLAG set / clr / test | 2 | Local bank ctx+0x62. The test spins (holds the PC) while the bit is clear |
2E 2F 30 | GFLAG set / clr / test | 2 | The 32-bit scratchpad word 0x1F800394: transient, not saved, partly engine state (bit 22 enables per-frame camera-zone re-query). The test spins while the bit is set, like 33 |
31 32 33 | CFLAG set / clr / test | 2 | The context flag word. 31 08 copies +0x26 to +0x5A instead of setting; 32 0x400 (un-halt) is the one op allowed through a halted target; 33 spins while the bit is set |
34 | EFFECT | var | Sub-ops on op0 >> 4 (below) |
35 | BGM | 4 | Eleven sub-ops (below) |
36 | SOUND_CUE | 5 | XA voice clips, SFX cues and the side-band sound bank (below); the legacy SCENE_FADE label is wrong - every arm is sound |
37 41 | GLIDE | 3 | Motion op: compass glide. Parks the script (halt bit, resume pointer at +0x94) and the walk kernel reads the op bytes in place each frame; 0x41 is half speed |
38 | CAM_CFG (facing) | 3 | Set the actor's heading from the 8-direction compass at 0x80073F04, or halt-acquire it; despite the legacy name it is not a camera op |
39 | GIVE_ITEM | 2 | Add one of item id - the treasure-chest path; the id is the inline byte, not a per-scene table (the chest's "There is a ..." text names the item separately) |
3A | ADD_MONEY | 4 | Signed 24-bit gold delta, clamped to 0..9,999,999 |
3B | SET_EQUIP_SLOT | 3 | Write one equipment-slot byte of a character record, then rebuild the derived stats |
3C 3D | PARTY_ADD / REMOVE | 2 | Sorted insert / remove, cap 4, leader fix-up |
3E | BATTLE / MINIGAME | 3+ | op0 == 0xFF or < 100: scripted battle on formation row op1; op0 ≥ 100: minigame door-warp (below) |
3F | SCENE_CHANGE | 7 + len | Named warp [idx:i16][len][name][x][z][dir]: destination scene name, entry tile and arrival facing |
40 | DATA_BLOCK | 2 + len | Skip len inline data bytes (payloads that other ops capture) |
42 | COND_JMP | 5 | Mode 0 tests a bit of a global flag word; mode 1 the held pad (compass direction for op1 < 8, Circle / Cross / Square / Triangle for 8..11, always taken from 12). Taken = pc + 3 + u16, else skip 5 |
43 | ACTOR_CTRL | var | 22 sub-ops (below) |
44 | SPAWN_RECORD | 2 | Spawn partition-2 record global_index as a new context if its flag gates pass (the opening scenes launch their timelines this way) |
45 | CAMERA | var | Four arms on op0 & 0xC0 (below) |
46 | VIEW_WINDOW | 3 / 6 | The camera's visible-tile window at 0x1F8003E8..EB: long form 46 24 n0 n1 f0 f1 writes the four signed tile offsets, short form a half-width window. Not fog |
47 | WALK_TO | 4 | Motion op: walk to tile at base step 4 << (b2 & 7), approach mode b2 >> 4 |
49 | STATE_RESUME | var | Idle / armed / done machine that opens a menu-class screen and waits for it (below) |
4A | WAIT_FRAMES | 2 | Add the frame-skip factor to ctx+0x54; halt at PC until it reaches the operand |
4B | ANIMATE | 3 + 4n | Arm n VDF morph lanes (sub-entry, up / down ramp rates) and set the animating bit - the field sibling of move-VM op 0x0A, not a clip select |
4C | MENU_CTRL | var | Sixteen sub-dispatchers on the high nibble of op0 (below) |
4D | BBOX_TEST | 7 | Player inside the tile box: advance 7; outside: skip the trailing u16 less two, counted from the end of the instruction |
4E | INVENTORY_CMP | 7 / 9 | Compare-and-skip on party state (below) |
4F | SCENE_REGISTER_WRITE | 7 | Three u16 writes into the scene record at +0x10/+0x12/+0x14 |
5x 6x 7x | SYSFLAG set / clr / test | 2 / 2 / 4 | The persistent story-flag bank (tour); the test jumps pc + 2 + u16 when the flag is set. 78..7F are tests too |
No branch is absolute. Every conditional jump measures its operand from the instruction: 26 from after the opcode, 42 from three bytes in, the flag test from after its index byte, 4E from five bytes in at both its widths, and 4D and the 4C skip arms (A0..A2, 8B..8D, D1, DC / DE, E4, EB) through one shared exit that adds the halfword less two to a PC already past the op. A disc-wide test runs the port's interpreter over every shipped script and fails on any computed PC that lands between two instructions; the full table is in script-vm.md.
Any other masked opcode halts at PC. A ? (0x3F) inside message text is what makes a naive linear walk "see" scene changes in dialogue.
Sub-dispatchers
0x34 EFFECT
Sub (op0 >> 4) | Bytes | Effect |
|---|---|---|
0 | 7 | Effect-global colour and intensity (the fade pipeline) |
1 | 13 | Attached light: a coloured rim on a target actor, extents in view-space units |
2 | 2 | Re-script the attached light: when the actor has one and the next op is a 0x40 block, the light takes the block as its new script and the PC steps past it. No yield; with no light the block runs as an ordinary 0x40 |
3 | 4 | Spawn a scene-local 3D animation from the scene's prescript window at the actor |
| other | - | Halts at PC |
0x35 BGM [35 lo hi sub]
| Sub | Effect |
|---|---|
1 | Start field BGM id (the loader resolves it asynchronously, ids) |
2 / 3 | Stop (notes killed, cursor back to the start) / pause (notes keyed off, cursor kept) |
4 | Replay: re-attach the slot and play the track again from its first event - no script word resumes mid-phrase (audio) |
5 | Arm a timed sound-source auto-release after id vsyncs |
6 / 7 | Flag set / target-sound-set |
8 | Re-attach and re-apply the field volume |
9 | Start behind a load barrier - how a cutscene changes music part-way |
0xA | Swap-commit: wait for the load to settle, release the paused slot |
0xB | Arm sub-op 9's barrier |
0x36 SOUND_CUE [36 sel:i16 arg:i16]
sel | Arm |
|---|---|
0xFFFF | Wait on the load flag |
| bit 15 clear, non-zero | Start a CD-XA voice clip (arg >> 3, arg & 7) |
| bit 15 clear, zero | Wait while the clip is playing |
| bit 15 set, sub 0 / 4 | Queue SFX cue arg / give the parked cue a delay |
| bit 15 set, sub 1 / 2 / 3 | Request, wait for and tear down the side-band sound bank |
0x3E battles and minigames
Every op0 < 100 (and 0xFF) runs one body: re-derive the player's region (backdrop, Door of Light / Wind gates, world-map return point), point the system context at formation row op1, re-roll the step counter and request battle mode. 3E 00 02 installs row 2 exactly as 3E FF 02 would. op0 ≥ 100 enters minigame op0 − 100 through mode 24, which backs up the current scene name and restores it on exit:
| Sub-id | Overlay | Minigame |
|---|---|---|
0 | PROT 0972 | Fishing |
1 / 2 | 0973 / 0974 | Dev modules OTHER2 / OTHER3 |
3 | 0975 | Casino slot machine |
4 | 0976 | Baka Fighter |
5 | 0977 | Monster-roster arena hub |
6 | 0980 | Noa's dance |
0x43 ACTOR_CTRL
| Sub | Bytes | Effect |
|---|---|---|
0 1 A B | var | Scripted arc jump (halt-acquire, target tile, floor lookup) |
2 | 8 | Three-actor talk |
3..6 | 10 | Camera-register zone ramp: while the player is in a tile box, lerp pitch / yaw / depth / projection on his Z. Every disc site is sub-3, a field-of-view ramp |
7 / 8 | 17 / 2 | Spin-and-shrink deformation / reset: writes a row of the break-up table and ramps the actor's word, which hands its draw to the shatter renderer |
9 | 10 | Position: immediate, or an eased move over ticks |
C | 5 | Shutter blackout: two black bars close, hold and open |
D / F | 6 | Screen frame: a w-wide opaque band round the screen in one colour, bevelled at the inner corners |
E | 2 | Retire the first live screen frame |
10..15 | 21 / 12 / 14 / 14 / 10 / 14 | Screen widgets of the ending scenes: sprite, iris mask, VRAM rect copy, image panel, panel move / scale, letterbox |
16+ | - | Halts at PC |
0x45 CAMERA
op0 & 0xC0 | Arm | Bytes |
|---|---|---|
0x00 | Configure: write the staged camera fields named by a mask, optionally glide | 5 + 2 × set bits |
0x40 | Load a saved shot | 20 |
0x80 | Save the current shot | 2 |
0xC0 | Apply: compose the follow shot for the player and commit it (a release, not a jump - its s16 is a glide length) | 4 |
A record that issues no 0x45 leaves the field camera's follow shot alone; a scripted camera only owns the frame once some beat has staged a shot.
0x49 STATE_RESUME
Idle: spawn the subsystem actor and park the operand pointer. Armed: return the same PC every frame. Done (the screen wrote 1): clear and advance. The sub-op picks the screen:
| Sub-op | Screen |
|---|---|
0 | No screen of its own; carries the town-shop stock record 49 00 00 [count][ids..][name\0]. Done reads the count as the payload length and walks the name, so the talk resumes on the line after the record |
1, 7, D | No screen (the save point's 49 01 opens the menu session) |
2 | Code lock (five-symbol code in the operands) |
3 n | Name entry for party slot n (below) |
5 | Tile-board walk |
6 | Casino coin counter |
8 / 9 | Start / confirm menu; a prompt |
B | Incense wear-off notice (reached from the walk tick, not a script) |
C | Return-to-title soft reset (the ending) |
Before any screen runs, the subsystem actor's installer (FUN_801F1278) stands the player on the party leader’s idle clip, and takes down a dialog box a 0x24 continuation left on screen: it rewinds the pager’s state word DAT_801F2734 from its row-keeping idle 4 to 1, which draws nothing.
0x4E INVENTORY_CMP
| Sub-op | Compares |
|---|---|
0 / 1 | A character's HP / MP against a fraction of its max |
2 | Level |
3 | Gold against a u16 - the inn's price gate |
4 | A random byte (chance branch) |
5..8 | Script counter slot sub − 5 (written by 4C CA / CB / CC) |
9 | Casino coins against a u16 |
10 / 11 | Gold / coins against a u32 (9 bytes) |
BGM ids
A script's "play track 2016" is an offset into one bank, not a table lookup.
| Id | Resolves to |
|---|---|
>= 2000 | The global music_01 bank in sound-test order: 2000 + i = sound-test track i (names) |
< 2000 | Not a scene-block entry: the resolver uses the block index only as its change test and loads global slot 2 (the track 2002 plays). Retail stages no scene bank (audio) |
The name-entry screen (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 320×240 pixels:
| Element | Geometry |
|---|---|
| Grid window / name window | (24, 91, 272, 120) / (196, 71, 88, 28), pause-menu skin |
| Charset | 7 rows × 17; glyphs from base + (4, 4), 15 px column pitch, 14 px row pitch |
| Working name | (208, 79); teal caret 6 px after, 75%-duty blink |
| Control bar | y = 191: BS, the default name (restores the template; there is no space key), Select |
| Prompts | "Select your name." at (176, 32); the confirm prompt at (172, 24), cursor opens on No |
Reading the dump
- Label-calls. Ghidra renders the shared epilogue as a call. In the assembly each arm exits with
addiu s8, s8, Nin the delay slot ofj 0x801df09c, and thatNis the op's PC advance. Labels promoted to fake functions:0x801df098(+2),0x801df09c(+0),0x801df8dc(+6),0x801e00b8(+3),0x801e212c(+7),0x801e3614(BBOX outside),0x801e3620(+4). Grep the address in the dump before treating it as a callee. - Delay slots hide advances. The motion ops advance past themselves for every target (
li s7, 4in a delay slot); only a player target also parks the caller. The decompiled C hides this. - 16-bit jumps.
[21] [26 FE FF]is the park-here idiom. A port that adds the delta in a wider integer turns every backward jump into a forward overrun. - Pure helpers.
packet_length(FUN_8003CA38),party_flag_test(FUN_8003CE64),small_table_search(FUN_80042EE0), the little-endian loaders (FUN_8003CE9C / CEB8 / CED8) and the inlinedtile_centerare ported as functions inengine-vm::field::dispatch_leaves.
Flag census: when a row lies
The census walks bytecode linearly, and four things mislead it. A marked row means "check the record disassembly", never "discard".
- Text aliases. Dialogue bytes land on the flag ops:
S..Wread as Set,a..gas Clear,q..was Test. Rows carrycleanandtext_aliasmarks; a non-printable operand cannot be minted by prose. - Width blindness. A wrong sub-op width desyncs the walk and hides a real site behind a phantom; every
0x4Cnibble decodes with the executing VM's widths. - Variant carriers. The census walks the bundle MAN and the streaming variant MANs (
--variant <entry>);--p2-gatesprints the partition-2 spawn conditions. - Developer flag menus. Several shipped records carry a debug picker whose options write the scene's flags (nine bundle MANs label theirs as flag menus, Jeremi's inn among them, and the streaming carriers hold more). Their rows are clean and real, and mean "an option nobody picks in retail play"; they carry
debug_menu.
FMV triggers and the disassembler
Only two code paths start a movie: the field op 4C E2 (its u16 operand is the FMV id) and the title attract loop (movie 0). Eight scenes carry a 4C E2: town01, garmel, deroa, chitei2, dohaty, town0d, uru, jouine; the operands decode statically from each MAN (cutscenes).
asset field-disasm file <PATH>
asset field-disasm scan-prot --prot <PROT.DAT> --cdname <CDNAME.TXT> --bytewise
The walker mirrors the executing VM's widths without side effects, so any byte buffer is safe input. A 4C E2 hit inside a move-VM prescript record is a false positive.
Readings this page replaced
- The prescript table was read as field-VM bytecode; it is move-VM stager records.
- Op
0x39was labelledPLAY_SFX; it is the item give. Op0x36was labelledSCENE_FADE; every arm is sound. Op0x3Ewithop0 < 100was read as an NPC-interaction opener; it is the scripted-battle install. 4C 30was read as an input lock; it raises the ambient-particle gate. Nibble 9 was read as a fade family; it is the floor-height ladder.- The story-flag base was mislabelled
DAT_80086D70by double-counting the save-block displacement.
More on do-not-re-walk.