At a glance

Driver
FUN_80023070 in SCUS_942.54 (the main executable - always resident, unlike overlays)
Opcodes
71 (0x00..0x46), plus 61 sub-ops in an overlay extension via op 0x2F
Program
Per-actor "move buffer" at actor+0x48, program counter at actor+0x70
Operand width
u16 stream - PC and sizes count in 16-bit units, not bytes
Fed by
Field-VM op 0x22 EXEC_MOVE; records come from MDT move tables
Engine port
crates/engine-vm/src/move_vm.rs - all 71 outer + 61 extension ops, host callbacks via MoveHost
Confidence
Confirmed - full per-case disassembly (how we know)

The runtime VM family

Legaia runs five bytecode / state VMs that share the actor model (an actor is the engine's record for one on-screen entity - a party member, an enemy, a menu window). Confusing them is the classic mistake in this corpus. Five is an orientation, not a census - the full list is the VM inventory.

VMDriverWhereOpcodesOperands
Window-widget VMFUN_801D6628Menu overlay (PROT 0899)13byte stream
Move VM (this page)FUN_80023070SCUS_942.5471 + 61 via 0x2Fu16 stream
Motion VMs (two)FUN_8003774C / FUN_80038158SCUS_942.54see pagebyte stream
Field / event VMFUN_801DE840Field overlay (0897)43 + default routesbyte stream
Effect VMFUN_801E0088Battle overlay (0898)per-slot SMstate tokens
1Field VM op 0x22EXEC_MOVE stages a move record into the actor (+0x48 buffer, +0x70 PC)FUN_800204F8 2Actor tickevery frame, per actor: physics, then step the move bufferFUN_80021DF4 3Move VMruns opcodes until HALT / WAIT / end of buffer; u16 operandsFUN_80023070 4Op 0x2Fescapes to the overlay extension dispatcher (61 sub-ops, field overlay only)FUN_801D362C

The extension dispatcher exists in many overlays (town, world-map, dialog, cutscene) at the same RAM address; each overlay supplies its own jump-table contents. The motion VMs run alongside but consume a separate per-actor motion buffer - no shared opcodes.

Dispatch and control flow

short* op = (short*)(actor[+0x48] + actor[+0x70] * 2);   // u16-aligned PC
if (op[0] >= 0x47) goto exit;                             // out-of-range = end of buffer
goto jump_table[op[0]];                                   // JT at 0x80010778

The interpreter loops: read the opcode at the PC, dispatch, add the handler's size (in u16 units) to the PC, continue - until an op clears the loop flag. Three things break the loop:

  • 0x08 HALT - sets flag bit 0x8 and exits without advancing.
  • 0x09 WAIT_SET - arms the wait timer at +0x54; the actor tick counts it down across frames before re-entering.
  • End of buffer - any opcode value ≥ 0x47 reads as "done", not "unknown opcode".

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 NOPs in decompiled C still advance the PC - the increment hides in a MIPS branch-delay slot.

What the opcodes touch

The move VM rewrites a wide swath of the actor struct. The important fields, with the ops that write them:

OffsetFieldWritten by
+0x10Actor flag word0x08 HALT (bit 8); 0x3A/0x3B set / clear bit 2
+0x14/16/18World X / Y / Z0x07 set, 0x01 add, 0x03 rotate-add (sin/cos table)
+0x22Y-rotationper-frame ramps (0x2D / 0x35 / 0x37 family)
+0x24/26/28Render-bank slots0x05 add, 0x39 set
+0x3C/3E/40Animation bank0x00 ANIM_BANK_SET (value << 3)
+0x54Wait timer0x09 WAIT_SET; ticked down by the actor tick
+0x62Local 16-bit flag bank0x31 AND / 0x32 OR
+0x70The PC (u16 units)the dispatcher epilogue
+0x90..+0xC8Tween / keyframe block0x34 setup (9 operands), 0x2C buffer alloc, 0x2E scale, 0x36 duration

The full per-opcode reference (all 71, with sizes and exact stores) is in docs/subsystems/move-vm.md.

The overlay extension (opcode 0x2F)

Op 0x2F reads a 16-bit sub-opcode and dispatches through a 61-entry jump table inside the field overlay. The sub-opcode is bounds-checked before the indirect jump, so this escape has no out-of-bounds path even though some sub-ops write into the move buffer itself. The extension is where the scene-specific tricks live:

  • A 16-slot shared scratch table that actors use to hand positions and render banks to each other.
  • World-position lerps and gates - glide an actor toward the player or a map origin; bounding-box and squared-distance predicates around the player.
  • Self-modifying bytecode - ops that write the actor's world position, or arithmetic results, back into the move buffer a few instructions ahead.
  • HSV colour ramps - cycle a packed RGB colour through hue/saturation/value space (the ambient "pulsating flesh" and lightning effects idle on these).
  • The fourth flag bank - conditional branches on the same system bitfield the field VM's story flags live in.
Deep dive: extension sub-op groups, addresses and encodings

Dispatcher FUN_801D362C, JT at 0x801CE868, sub-op range 0x00..0x3C; sltiu sub_op, 0x3D gates the jr, and the sign-extended lh load means negatives also fail the unsigned compare and fall to the plain size = 1 return. The port mirrors the guard with a _ => default_arm() catch-all.

  • Slot table &DAT_801F3498 - 16 × 8 bytes. 0x25/0x26 round-trip world coords; 0x27/0x28 the tween-source triple at +0x90 (>> 12 fixed-point, [-0xFF, 0xFF] clamp on read); 0x31/0x32 the render-bank section; 0x34/0x35 the byte at actor+0x72.
  • Globals. DAT_801F22F4: u32 predicate (0x08/0x09 set/clear, 0x0A/0x0B test). DAT_801F22F6: u16 counter mod 16 (0x0F clear, 0x10 read-and-increment into actor.field_86, 0x11 write world coords to the indexed slot).
  • Lerps. 0x24/0x2A share axis = base + ((target - base) * t) >> 12; Y always lerps toward the player, X/Z toward the map origin (0x24) or the player (0x2A). 0x06/0x07: bbox-vs-player gates on the canonicalised box. 0x23: anim-bank lerp using the scratchpad ramp ratio at _DAT_1F800393. 0x38/0x39: squared-distance gates.
  • Self-modification. 0x04 writes actor XYZ into buffer[pc + op[2] + 3..]; 0x1E is read-modify-write on one u16; 0x1B an in-bytecode copy loop. MoveHost::move_bytecode_{read,write}_u16 expose the buffer to these.
  • HSV ramps. 0x1F/0x20 decompose the packed RGB at actor+0xA0 / +0xA4, convert via the SCUS RGB↔HSV pair (FUN_8001A78C / FUN_8001A8DC), add per-channel deltas (H wraps mod 0x168, S/V clamp), re-pack. Both are size-1 by design: the operand stream re-interprets as the next outer opcode - a density trick that simultaneously seeds the anim-block update.
  • Fourth flag bank. 0x13/0x14 predicate branches, 0x1C/0x1D set/clear - the DAT_80085758 bitfield, MSB-first (0x80 >> (idx & 7)); idx ranges over 0..=0x87FF, so the engine grows it lazily. A negative branch delta onto a preceding wait forms the spin-wait idiom the ambient cyclers idle on.

Per-sub-op semantics: docs/subsystems/move-vm-overlay-ext.md. Only the field overlay carries this dispatcher, so battle-side move records cannot reach it.

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 reads the pad too, which is how it was once mistaken for town locomotion (that is field locomotion). See tile-board.md.

Screen-effect widget family (PROT 0900)

The resident slot-B overlay PROT 0900 hosts a four-kind family of 2D screen widgets - the cutscene-style presentation layer of iris wipes, letterboxes and tweened panels. Widgets are ordinary actors on the effect-actor list, spawned with a per-frame handler bound at actor+0xC. Engine port: engine-core::screen_fx.

KindHandlerPer-frame behaviour
spriteFUN_801F7A9CWidget-script-driven tweened 2D sprite
maskFUN_801F811C4-edge rectangle tween + 4 black border quads (the iris)
panelFUN_801F849CFive-channel tween + 1-2 textured quads
letterboxFUN_801F8A34Two solid black bands + two gradient feather strips

On disc, only the ten ending-sequence (ed*) scenes invoke the family (field-VM op 0x43 sub-ops); battle casts leave it dormant. The count used to read "eight" here and in the subsystem docs, which omitted edbubu and eddoman; it is now a measurement, taken by decoding every partition record of every MAN carrier of every scene. All tweens share one four-mode interpolator, ported as screen_fx::interp. The engine ticks the family per Field/Cutscene frame and composites it above the 3D scene on both hosts, feather strips included - their subtractive blend rides the same screen-primitive path the browser page already used for the battle transition.

Summon part glide, and how the mask widget was mis-filed as a summon renderer

The engine's summon driver (engine-core::summon) applies an interpreted translation glide to each move-VM part-actor - the tween shape only, not a port of a retail function: tween time at actor+0x9C advances toward the duration at +0x9E; world position lerps toward the anim-bank target ((target - cur) * t / dur + cur, truncating divide); on completion the position latches and the duration clears. The anim banks are summon-local, so the engine adds the cast origin to seat the part in world space.

The retail player-summon render is a different path entirely: the summon draws as an ordinary battle actor through the rigid-TRS keyframe pipeline (FUN_80048A08 → decoder FUN_8004998C → TMD dispatcher FUN_80043390) - see battle action. A live capture of a Gimard Burning Attack cast pinned this: the keyframe decoder fires once per live actor per frame while the earlier render candidate FUN_801F7088 fires zero times. A full static decode of PROT 0900 then resolved FUN_801F811C - once read as a summon-part renderer - as the screen-mask widget: its four tweened channels are the edges of a screen rectangle, and its "4 render quads" are the black border bands.

Where the pieces live

  • crates/mdt parses the MDT format; the per-frame data inside an MDT record is exactly this VM's bytecode.
  • crates/engine-vm/src/move_vm.rs is the from-scratch port - all 71 outer + 61 extension ops, actor callbacks behind the MoveHost trait, and an actor_tick mirroring the retail per-frame entry gate.
  • Field-VM op 0x22 EXEC_MOVE is the gateway in: it resolves a move id to a record and stages the buffer + PC on the actor.

How we know

FunctionAddressWhat it provesDump
DispatcherFUN_80023070u16 PC at +0x70, JT at 0x80010778, the < 0x47 bound, loop-exit ops.ghidra/scripts/funcs/80023070.txt
Actor tickFUN_80021DF4Per-frame entry, wait-timer countdown, physics-before-bytecode order.80021df4.txt
Move stagerFUN_800204F8How field-VM op 0x22 resolves a move id into buffer + PC.800204f8.txt
Extension dispatcherFUN_801D362C61-entry JT at 0x801CE868; bounds check before the jr; field overlay only.overlay_0897 dumps
HSV pairFUN_8001A78C / FUN_8001A8DCThe exact RGB↔HSV algorithms the colour-ramp ops call.8001a78c.txt
Widget familyFUN_801F7A9C..FUN_801F8A34Four widget kinds, spawn APIs, shared interpolator; static decode of PROT 0900 at link base 0x801F69D8.overlay_0900 dumps
Summon render pathFUN_80048A08 / FUN_8004998CLive PCSX-Redux capture: keyframe pipeline draws the summon; FUN_801F7088 never fires.capture CSVs

Full opcode-by-opcode reference: docs/subsystems/move-vm.md and move-vm-overlay-ext.md.

See also