Move-table opcode VM
The interpreter that plays out a combat move's choreography. When a Tactical Arts input resolves to a move, everything you see - the actor stepping forward, the swing, the tween back to idle - is a small bytecode program executed one instruction at a time by this VM, once per frame, per actor. It lives in the always-resident main executable and drives animation, motion and state for every animated actor on screen.
At a glance
- Driver
FUN_80023070inSCUS_942.54(the main executable - always resident, unlike overlays)- Opcodes
- 71 (
0x00..0x46), plus 61 sub-ops in an overlay extension via op0x2F - Program
- Per-actor "move buffer" at
actor+0x48, program counter atactor+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 viaMoveHost- 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.
| VM | Driver | Where | Opcodes | Operands |
|---|---|---|---|---|
| Window-widget VM | FUN_801D6628 | Menu overlay (PROT 0899) | 13 | byte stream |
| Move VM (this page) | FUN_80023070 | SCUS_942.54 | 71 + 61 via 0x2F | u16 stream |
| Motion VMs (two) | FUN_8003774C / FUN_80038158 | SCUS_942.54 | see page | byte stream |
| Field / event VM | FUN_801DE840 | Field overlay (0897) | 43 + default routes | byte stream |
| Effect VM | FUN_801E0088 | Battle overlay (0898) | per-slot SM | state tokens |
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:
0x08HALT - sets flag bit0x8and exits without advancing.0x09WAIT_SET - arms the wait timer at+0x54; the actor tick counts it down across frames before re-entering.- End of buffer - any opcode value
≥ 0x47reads 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:
| Offset | Field | Written by |
|---|---|---|
+0x10 | Actor flag word | 0x08 HALT (bit 8); 0x3A/0x3B set / clear bit 2 |
+0x14/16/18 | World X / Y / Z | 0x07 set, 0x01 add, 0x03 rotate-add (sin/cos table) |
+0x22 | Y-rotation | per-frame ramps (0x2D / 0x35 / 0x37 family) |
+0x24/26/28 | Render-bank slots | 0x05 add, 0x39 set |
+0x3C/3E/40 | Animation bank | 0x00 ANIM_BANK_SET (value << 3) |
+0x54 | Wait timer | 0x09 WAIT_SET; ticked down by the actor tick |
+0x62 | Local 16-bit flag bank | 0x31 AND / 0x32 OR |
+0x70 | The PC (u16 units) | the dispatcher epilogue |
+0x90..+0xC8 | Tween / keyframe block | 0x34 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/0x26round-trip world coords;0x27/0x28the tween-source triple at+0x90(>> 12fixed-point,[-0xFF, 0xFF]clamp on read);0x31/0x32the render-bank section;0x34/0x35the byte atactor+0x72. - Globals.
DAT_801F22F4: u32 predicate (0x08/0x09set/clear,0x0A/0x0Btest).DAT_801F22F6: u16 counter mod 16 (0x0Fclear,0x10read-and-increment intoactor.field_86,0x11write world coords to the indexed slot). - Lerps.
0x24/0x2Ashareaxis = 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.
0x04writes actor XYZ intobuffer[pc + op[2] + 3..];0x1Eis read-modify-write on one u16;0x1Ban in-bytecode copy loop.MoveHost::move_bytecode_{read,write}_u16expose the buffer to these. - HSV ramps.
0x1F/0x20decompose the packed RGB atactor+0xA0/+0xA4, convert via the SCUS RGB↔HSV pair (FUN_8001A78C/FUN_8001A8DC), add per-channel deltas (H wraps mod0x168, 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/0x14predicate branches,0x1C/0x1Dset/clear - theDAT_80085758bitfield, MSB-first (0x80 >> (idx & 7)); idx ranges over0..=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.
| Kind | Handler | Per-frame behaviour |
|---|---|---|
| sprite | FUN_801F7A9C | Widget-script-driven tweened 2D sprite |
| mask | FUN_801F811C | 4-edge rectangle tween + 4 black border quads (the iris) |
| panel | FUN_801F849C | Five-channel tween + 1-2 textured quads |
| letterbox | FUN_801F8A34 | Two 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/mdtparses the MDT format; the per-frame data inside an MDT record is exactly this VM's bytecode.crates/engine-vm/src/move_vm.rsis the from-scratch port - all 71 outer + 61 extension ops, actor callbacks behind theMoveHosttrait, and anactor_tickmirroring the retail per-frame entry gate.- Field-VM op
0x22 EXEC_MOVEis the gateway in: it resolves a move id to a record and stages the buffer + PC on the actor.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Dispatcher | FUN_80023070 | u16 PC at +0x70, JT at 0x80010778, the < 0x47 bound, loop-exit ops. | ghidra/scripts/funcs/80023070.txt |
| Actor tick | FUN_80021DF4 | Per-frame entry, wait-timer countdown, physics-before-bytecode order. | 80021df4.txt |
| Move stager | FUN_800204F8 | How field-VM op 0x22 resolves a move id into buffer + PC. | 800204f8.txt |
| Extension dispatcher | FUN_801D362C | 61-entry JT at 0x801CE868; bounds check before the jr; field overlay only. | overlay_0897 dumps |
| HSV pair | FUN_8001A78C / FUN_8001A8DC | The exact RGB↔HSV algorithms the colour-ramp ops call. | 8001a78c.txt |
| Widget family | FUN_801F7A9C..FUN_801F8A34 | Four widget kinds, spawn APIs, shared interpolator; static decode of PROT 0900 at link base 0x801F69D8. | overlay_0900 dumps |
| Summon render path | FUN_80048A08 / FUN_8004998C | Live 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.