At a glance

Driver
FUN_80023070 in SCUS_942.54, jump table at 0x80010778
Opcodes
71 (0x00..0x46), plus 61 field-overlay sub-ops behind op 0x2F
Program
Move buffer at actor+0x48, program counter at actor+0x70
Operand width
u16 stream - the PC and every size count in 16-bit units, not bytes
Fed by
The summon / cast stagers and the per-scene prescript stager table; both share one record format. Field-VM op 0x22 EXEC_MOVE is not a source: it drives an ANM clip, not this buffer
In the port
engine-vm::move_vm, host callbacks via the MoveHost trait
Confidence
Confirmed - per-case disassembly (how we know)

The runtime VM family

Legaia runs five bytecode / state VMs that share the actor model, and confusing them is the classic mistake in this corpus. Five is an orientation, not a census - the full list is the VM inventory.

VMWhat it drives in the gameDriverWhereOpcodesOperands
Field / event VMTowns, NPC talk, doors, cutscenes, story flagsFUN_801DE840Field overlay (0897)43 + flag routesbyte stream
Motion VMs (two)Scripted walks, NPC wandering, facing rampsFUN_8003774C / FUN_80038158SCUS_942.546 / 26byte stream
Move VM (this page)Per-actor animation programs: poses, tweens, loops, render setupFUN_80023070SCUS_942.5471 + 61 via 0x2Fu16 stream
Window-widget VMMenu and shop window choreographyFUN_801D6628Menu overlay (0899)134-byte instructions
Effect VMBattle sparks and spell spritesFUN_801E0080Battle overlay (0898)none - a slot state machinespawn records

How a frame runs

summon / cast stagerspart actors from overlays scene prescript tableambient props, field FX move buffer+0x48 base, +0x70 PC each frame: dispatch op = u16 at PC; op < 0x47 PC += size (u16 units) next op, same frame 0x08 HALTprogram done, PC stays 0x09 WAIT_SETtick drains +0x54, then resume op >= 0x47skipped, loop goes on escapes 0x2F: field overlay, 61 sub-ops 0x17: battle overlay 0x20: slot-B module hook
One move program, one frame. The loop keeps dispatching in the same frame until a stop; the three escapes call code that is resident only in one overlay, so 0x2F works only in the field and 0x17 only in battle.

The u16 trap

Operands, sizes and the PC all count in 16-bit units. The other Legaia VMs use byte streams, so a disassembler that assumes bytes here decodes garbage from the second instruction on. Handlers that look like no-ops in decompiled C still advance the PC - the increment hides in a branch-delay slot.

Every record starts [i16 model_sel][u16 reserved] before its bytecode: −1 makes a transform node, 0x4000 / 0x4001 a render-mode node, ≥ 0 a library mesh. Bytecode ends at op 0x08.

An opcode past the table. A word of 0x47 or more (read signed, so 0x8000 and up too) does not end the frame. It branches to the dispatcher's epilogue, which adds the width the previous instruction left in its size register and loops on while the flag stands, so the stray word is stepped over by that width, the same way 0x0B is. Ahead of a pass's first instruction the width is whatever the caller left. The port's move_vm::FrameStep carries the width across the frame, and the retail-kernel differential holds it to retail.

A part seated mid-walk. The per-frame list walk reads each node's next before it runs the node, and a new part is linked at the list's tail. So a part one part seats from inside its own tick runs on the same frame only when something already stood behind the seating part: an emitter that is the newest node seats parts that wait a frame, one with older parts behind it seats parts that move on the frame they appear. Barra's cloud stream is the second kind. A part seated from a seater's own first run lands behind the nodes still to be walked as well: a new node runs its program once as it is seated, so an op 0x20 call it opens with seats parts the same walk reaches. Viguro's ring emitter (PROT 0909) calls its stager before its first wait, and retail's first ring shows one radius step on the frame it opens; an emitter that opens on WAIT 0 seats from the walk instead. The port models the rule on both hosts.

The instruction set

Grouped by what they touch. Sizes are in u16 words and include the opcode; v1..vN are the operand words.

GroupOpcodesWhat they do
Position01 03 07 2D 35 37 2BAdd to / rotate-add into / set world X Y Z; add or set the tween-source triple and its rates
Pose banks00 02 04 05 06 39Set the animation banks (value << 3) and the render-bank rotation slots, absolute or additive
Scale and colour0C..12 14 2E 28..2A 33Composite draw word, depth-cue and scale rates, per-axis scales, colour channels
Control08 09 18..1B 0B 22Halt; wait (timer at +0x54); two nestable counted loops (count bit 0x4000 = loop forever); continue
Flags31 32 3A 3B 43 15AND / OR the local flag bank at +0x62; set / clear actor flag bit 2; camera-relative node bits
Tweens and keys0A 2C 30 34 36 3C..3F 46VDF morph lanes; allocate / free a key buffer; tween setup and duration; seat or retarget a keyframe pose; blend cursor and rate; tween-delta seed
Render setup13 1C 1E 1F 23 42 44 45Draw-kind-4 nodes (default emitter, sprite arm, lightning-style ribbon) and the emitter-select word, a beam length, the VRAM wrap-scroll, a morph install, a framebuffer grab
Sprites24 26 27 38 41Sprite position and UV, absolute or additive
VRAM40MoveImage a literal rect: the animated-texture strip primitive (field scenes cycle 4-frame strips this way)
Spawn and escape25 20 17 2F 21 1D 16Spawn a child from the prescript table; call the slot-B module hook, the battle extension, or the field extension; a shatter-row write and a global write; a no-op stub

Per-opcode stores and sizes are in docs/subsystems/move-vm.md; the format of a move record is on MDT.

Rates land a pass late. Ops 0x22 / 0x30 capture a VRAM rect for the CLUT-cell cycler, and the cycler's integrate runs in the actor tick ahead of the VM call. A rate an op stores therefore takes effect on the next pass: a program of +32 / -32 rate stores turns at the top and bottom of its triangle one pass after the op. The port integrates battle parts' cyclers in the same order.

Reading a program. asset move-disasm lists a move program without running it: every instruction's width, mnemonic, operands and the actor fields it stores, the 0x2F sub-ops included, for one record, every record of a cast / summon module (slot-b) or a scene's prescript table. Loops and the ten conditional branches are reported, not followed. The widths come from the disassembly, and the engine's tests hold its executing VM to the same widths opcode by opcode.

The loop pair, the child spawn and the render-mode-4 emitters

Loops. 0x18 latches its own PC into +0x88 and a count into +0x8C; 0x19 decrements and jumps back to the body start (past the 0x18) until the count underflows, so a count of N runs the body N + 1 times. A count with bit 0x4000 never decrements - the infinite loop ambient effects idle on. 0x1A / 0x1B are the same on a second register pair, so programs can nest two loops.

Child spawn. 0x25 seats a child part from the per-scene prescript stager table at the parent's world position and render slots - how one move program spawns another as a sub-actor.

Shatter row. 0x21 writes one row of the deformation table at 0x8007BE60 (a burst offset and a flag word) and points the part at it, so when the part's model breaks into triangles it breaks about that offset and under those flags. On Mt. Rikuroa two spawned meshes write rows with the shrink flag, so their triangles collapse in place as the shatter ramps rather than flying apart at full size; both play hosts draw them that way. A field-effect part’s model slot resolves against the scene’s environment pack on both hosts, not against the entry the scene bundle names: in Mt. Rikuroa and about twenty other scenes the pack streams from an entry the bundle does not name, and a bundle-keyed list left every field-effect part there undrawn.

Render-mode-7 is a framebuffer grab, not a billboard. 0x45 sets render mode 7. Each tick the part tick projects the part's position with one RTPS (FUN_8003D368) and links a GP0 0x80 VRAM-to-VRAM copy (FUN_800468A4) of the screen rectangle round that point into a fixed VRAM rect, where a textured mesh can sample the drawn frame. Seven field prescript records carry it (jou, kor5, korb3, noaru, uru, uru2), each grabbing 64×64 pixels into (832, 0); in kor5 it runs during the Genesis Tree beat. Both play hosts project the request through the camera of the frame they last drew and land the copy through the same queue as the field VM's 43 12 photo grab.

Render-mode-4. 0x42 sets render mode 4 with selector 0x2000 (the random-walk ribbon: lightning, beams, whips) and fills the ribbon's inputs; 0x23 is the 0x4000 sprite sibling; 0x13 the default emitter. On the disc, 0x42 appears only in slot-B cast / summon images (effect VM).

The field extension (opcode 0x2F)

Op 0x2F reads a 16-bit sub-opcode and dispatches through a 61-entry jump table in the field overlay (FUN_801D362C, table 0x801CE868). The sub-opcode is bounds-checked before the jump, so there is no out-of-bounds path. Only the field overlay carries this dispatcher, so battle-side move records cannot reach it. This is where the scene-specific tricks live:

ClusterSub-opsWhat it does
Shared scratch table25..28 31 32 34 35 0F..11A 16-slot table actors use to hand each other positions, tween sources, render banks and scales; a cycle counter can pick the slot
Player-relative moves24 2A 0E 12 23 3A 3BLerp toward the player or the camera focus, place the actor on a quadratic Bezier, lerp the anim bank, write the bearing to the player or a party member's position into the bytecode
Branches06 07 0A 0B 13 14 36..39Ten conditional branches: player inside / outside a box or radius, a shared predicate word, a story flag set or clear
Story flags13 14 1C 1DTest, set and clear the field VM's story-flag bank; a backward branch onto a wait is the spin the ambient cyclers idle on
Self-modifying04 1B 1E 06 07Write the actor's position, a copy loop or an addition into the bytecode a few words ahead; the box branches canonicalise their own operands in place
Colour1F 20 3CHSV ramps on a packed RGB (the ambient "pulsating flesh" and lightning effects); a screen fade
Object effects17..1A 2B..2DThe object-effect parameter table; the scanline strip emitter (2C, which no shipped record issues)

Every in-range sub-op has its own width (2 to 16 words); there is no size-1 arm. The widths sit in branch-delay slots, so the decompiled C reports the bounds-check default for every arm - the reason an earlier reading of the HSV ramps as a "size-1 density trick" was wrong. Per-sub-op detail: move-vm-overlay-ext.md.

A sibling that is not the move VM: puzzle rooms run a discrete tile-board mode in the same overlay, a byte-cell grid where each d-pad press moves exactly one cell. It is not town locomotion (that is field locomotion).

Screen-effect widgets (PROT 0900)

The resident slot-B overlay PROT 0900 hosts four 2D screen widgets - the presentation layer of iris wipes, letterboxes and tweened panels. They are ordinary actors on the effect list with a per-frame handler, and all share one four-mode interpolator.

KindHandlerPer-frame behaviour
spriteFUN_801F7A9CWidget-script-driven tweened 2D sprite
maskFUN_801F811CA tweened screen rectangle with four black border bands (the iris)
panelFUN_801F849CFive-channel tween, one or two textured quads
letterboxFUN_801F8A34Two black bands plus two gradient feather strips
  • Sprite script waits. The handler tests a record's 0x40 wait byte once, on entry. A wait that passes loops back to dispatch the next record's sub-op without testing its first byte again, so a passed wait followed by a zero byte runs sub-op 0 and kills the widget.
  • Panel tweens draw from their targets. There is no latch for a zero duration: a move with dur = 0 shows at once, while the current values stay where the last tween left them, so the next tween starts from there.

On the disc only the ten ending-sequence (ed*) scenes invoke the family, through field-VM op 0x43 sub-ops 0x10..0x15. The mask widget was once read as a summon-part renderer; a live capture of a Gimard cast showed the summon draws as an ordinary battle actor through the rigid-keyframe pipeline (battle action).

How we know

FunctionAddressWhat it provesDump
DispatcherFUN_80023070u16 PC at +0x70, JT at 0x80010778, the < 0x47 bound, the loop-exit opsghidra/scripts/funcs/80023070.txt
Actor tickFUN_80021DF4Per-frame entry, wait-timer countdown80021df4.txt
Clip driverFUN_800204F8Field-VM op 0x22's callee stores only the ANM clip fields (+0x4C / +0x56 / +0x5E / +0x62 / +0x68), never +0x48 / +0x70: not a move-buffer source800204f8.txt
Prescript stagerFUN_800252EC → FUN_80021B04The second record source; spawn with an immediate first step800252ec.txt
Field extensionFUN_801D362C61-entry JT at 0x801CE868; bounds check; per-arm widths in delay slots; field overlay onlyoverlay_0897_801d362c.txt
HSV pairFUN_8001A78C / FUN_8001A8DCThe RGB↔HSV algorithms the colour ramps call8001a78c.txt
Widget familyFUN_801F7A9C..FUN_801F8A34Four widget kinds, spawn APIs, shared interpolator; static decode of PROT 0900 at link base 0x801F69D8overlay_0900 dumps

See also