Move-table opcode VM
A bytecode VM dedicated to per-actor animation, motion, and state. Distinct from the field/event VM and the actor / sprite VM: this one lives in SCUS_942.54, runs on each actor's "move buffer", and is invoked every frame from the actor tick. 71 opcodes in the main exe, plus 61 sub-opcodes in a town-overlay extension reachable via opcode 0x2F.
How it works
If the actor VM is the title-screen sprite walker and the field VM is the cutscene / NPC scripter, the move VM is the layer in between — a tiny per-actor animation engine. Every actor (a party member, an enemy, an NPC) has a "move buffer" and a PC into it. Every frame, the actor tick calls into the move VM and runs opcodes until something forces it to pause (a HALT, a wait timer, or running out of buffer).
The move VM is what reads MDT (move-table) records and turns them into world-space motion, render-bank writes, animation-bank selection, tween setup, and per-actor flag manipulation. It's the bytecode that drives Tactical Arts execution — when a Tactical Arts directional input maps to a move ID, the move VM is what plays out the move record one opcode at a time.
One quirk worth noting: operands are 16-bit, not 8-bit. The PC counts in u16 units. Each handler returns its size also in u16 units, which the dispatcher epilogue adds to the PC. Coming from byte-stream VMs (the actor VM and field VM both use byte streams), this is easy to misread.
Opcode 0x2F is the overlay-extension escape. It calls into a separate dispatcher inside the town overlay, which has its own 61-entry jump table. So the actual opcode count is "71 + 61 sub-ops accessible through opcode 0x2F". The town-overlay sub-handlers are scene-specific, which is why they live in the overlay rather than in the always-resident exe.
The runtime VM family
| VM | Driver | Where | Opcodes | Operand width |
|---|---|---|---|---|
| Actor / sprite VM | FUN_801D6628 | Title-screen overlay (0971) | 13 | byte stream |
| Move VM | FUN_80023070 | SCUS_942.54 | 71 (0x00..0x46) + 61 sub-ops via 0x2F | u16 stream |
| Motion VM | FUN_8003774C | SCUS_942.54 | per-actor pursue / patrol / face-target | byte stream w/ high-bit target |
| Field / event VM | FUN_801DE840 | Town overlay (0897) | 43 (0x21..0x4F with gaps) + 0x5x/6x/7x default-route | byte stream |
| Effect VM | per-slot state walker | Battle overlay | per-slot SM (no opcode table) | inline state tokens |
How the move VM wires to the others:
- Field-VM op
0x22 EXEC_MOVEcallsFUN_800204F8, which finds the move record formove_idand stages it into the actor atactor[+0x48](buffer base) /actor[+0x70](PC). - Actor tick (
FUN_80021DF4, per frame) and actor spawn (FUN_80021B04, one-shot) both callFUN_80023070(actor)to step the move buffer. - Move-VM opcode
0x2FcallsFUN_801D362C(actor, opcode_ptr)for scene-specific extension opcodes that live in the town overlay. - The motion VM runs alongside but doesn't share opcodes — it consumes a separate per-actor "motion buffer" through the
0xF8/0xFBsystem-channel idiom.
Function signature & dispatch
int FUN_80023070(int actor);
No PC argument: the PC lives on the actor at +0x70. The function loops, executing opcodes until one clears the loop flag (e.g. opcode 0x08 HALT). Each handler updates actor[+0x70] by writing its size into a register the loop epilogue commits.
short* op = (short*)(actor[+0x48] + actor[+0x70] * 2); // u16-aligned PC
short v1 = op[0];
if (v1 >= 0x47) goto epilogue; // out-of-range → end loop
v0 = jt[v1]; // JT at 0x80010778
goto v0; // computed jump
- Buffer base
actor[+0x48](_DAT_8007B888= MOVE root,_DAT_8007B840= MOVE2)- PC
actor[+0x70](i16, in u16 units)- Jump table
0x80010778inSCUS_942.54— 71 entries × 4 bytes
Actor-side state
The move VM rewrites a wide swath of the actor struct. Selected fields:
| Offset | Type | Use |
|---|---|---|
+0x10 | u32 | Actor flag word. Bit 0x8 set by op 0x08; 0x2/0x1000/0x10000/0x40000000 toggled by various opcodes. |
+0x14/16/18 | u16 | World X / Y / Z (op 0x07 absolute set, op 0x01 add, op 0x03 rotate-add). |
+0x22 | u16 | Y-rotation (per-frame ramp by op 0x2D / 0x35 / 0x37). |
+0x24/26/28 | u16 | Camera/render slots. |
+0x3C/3E/40 | u16 | Animation bank (op 0x00: [v << 3]). |
+0x54 | u16 | Wait/timer (op 0x09 set; ticked down by FUN_80021DF4). |
+0x62 | u16 | Local flag bank (16 bits). AND/OR by ops 0x31 / 0x32. |
+0x70 | i16 | The move-VM PC (in u16 units). |
+0x90/92/94 | u16 | Tween source (op 0x35 / 0x37 absolute / increment). |
+0x96/98/9A | u16 | Tween scale (op 0x2E, [v << 3]). |
+0x9C..+0xC8 | mixed | Tween/keyframe block (op 0x34 9-word setup; op 0x2C configures per-frame slots). |
Opcode reference (highlights)
Sizes are in u16 units. The full per-case reference is in docs/subsystems/move-vm.md; this is the highlight reel.
World motion / position
| Op | Mnemonic | Size | Effect |
|---|---|---|---|
0x01 | WORLD_ADD | 4 | actor[+0x14] += v1; +0x16 += v2; +0x2A += v2; +0x18 += v3 |
0x03 | WORLD_ROTATE_ADD | 2 | Adds rotated v1 into world X/Z using sin/cos table indexed by actor[+0x96] & 0xFFF. |
0x07 | WORLD_SET | 4 | Absolute actor[+0x14..+0x18] = v1..v3. |
0x2D | WORLD_INC_VARIANT | 4 | actor[+0x90] += v1; +0x92 += v2; +0x94 += v3. |
0x37 | WORLD_SET_VARIANT2 | 3 | actor[+0x90] = v1; +0x92 = v2. |
Animation banks & render
| Op | Mnemonic | Size | Effect |
|---|---|---|---|
0x00 | ANIM_BANK_SET | 4 | actor[+0x3C..+0x40] = v1..v3 << 3 |
0x04 | ANIM_BANK_2 | 4 | actor[+0x80..+0x84] = v1..v3 << 3 |
0x05 | RENDER_BANK_ADD | 4 | actor[+0x24..+0x28] += v1..v3 |
0x39 | RENDER_BANK_SET | 4 | Absolute actor[+0x24..+0x28] = v1..v3 |
Control flow
| Op | Mnemonic | Effect |
|---|---|---|
0x08 | HALT | actor[+0x10] |= 0x8. Sets bVar3 = false so the dispatcher exits without advancing PC. |
0x09 | WAIT_SET | actor[+0x54] = v1 << 3. Wait timer; FUN_80021DF4 ticks it down each frame. |
0x16 | STUB | Calls FUN_80024C80 — body is a pure jr ra. Pure no-op. |
0x2F | OVERLAY_EXT | Escape to FUN_801D362C (town overlay): 16-bit sub-opcode, JT at 0x801CE868, 61 entries. |
Flag banks & tween setup
| Op | Mnemonic | Effect |
|---|---|---|
0x31 / 0x32 | LFLAG_AND / LFLAG_OR | 16-bit per-actor local flag bank at +0x62. |
0x33 | CLEAR_BIT_40000000 | actor[+0x74] &= ~0x40000000. |
0x34 | TWEEN_SETUP | Loads 8 i16 operands into actor[+0xAC..+0xC8]. 9-u16 instruction. |
0x36 | TWEEN_DURATION_SET | +0x98 = v1 << 3; +0x9A = v2 << 3; +0xB8 = 0. |
0x3A / 0x3B | FLAG_2_SET / FLAG_2_CLEAR | actor[+0x10] |= 2 / &= ~2. |
Control flow
The interpreter loops: read opcode at actor[+0x70], dispatch, write the new PC actor[+0x70] += param_3, then either continue or break depending on bVar3. Break opcodes:
0x08HALT — clears the loop flag.0x09WAIT_SET — wait timer is set; further opcodes deferred to next frame.- A few epilogue cases (e.g.
0x30KEY_BUFFER_FREE) jump to the epilogue without advancing.
After the loop exits, the function returns; the caller (FUN_80021B04 or FUN_80021DF4) gets a "tick complete for this actor" signal. The next frame's FUN_80021DF4 updates physics, then re-enters from the saved PC.
Extension dispatcher (0x2F) — ported sub-ops
Op 0x2F reads op[1] as a 16-bit sub-opcode (range 0x00..0x3C) and dispatches via the JT at 0x801CE868. crates/engine-vm/src/move_vm.rs ports all 61 sub-opcodes; the highlights:
- Slot table at
&DAT_801F3498— 16 slots × 8 bytes, shared across actors. Sub-ops0x25/0x26round-trip world coords;0x27/0x28the tween-source triple at+0x90(with>> 12fixed-point +[-0xFF, 0xFF]clamp on read);0x31/0x32the render-bank section at+0x24..+0x2C;0x34/0x35the byte atactor[+0x72]. - Globals.
DAT_801F22F4is a u32 predicate (sub-ops0x08/0x09set/clear,0x0A/0x0Btest).DAT_801F22F6is a u16 counter mod 16:0x0Fclears,0x10reads-and-increments, capturing the low byte intoactor.field_86;0x11then writes world coords toslot_table[field_86 & 0xFF]. - World-position lerp.
0x24/0x2Ashareaxis = base + ((target - base) * t) >> 12. The Y axis always lerps toward the player Y; X / Z target the fixed map origin (0x24) or the player position (0x2A).0x06/0x07are bbox-vs-player gate variants on the canonicalised box[xa..xb] × [za..zb]. - Midpoint blends.
0x0E/0x12back theFUN_801E45BC"midpoint to actor world" idiom.0x36/0x37are axis predicates against0x8E - DAT_8007C348;0x38/0x39are squared-distance gates around the player.0x23is the anim-bank lerp toward operand world coords using the scratchpad ramp ratio at_DAT_1F800393. - Self-modifying bytecode.
0x04writes actor world XYZ intobuffer[pc + op[2] + 3..];0x1Eis read-modify-write on a single u16 (buffer[pc + op[2] + 4] += op[3]);0x1Bis an in-bytecode copy loop. TheMoveHost::move_bytecode_{read,write}_u16callbacks expose the actor's move buffer to these ops. - HSV ramps.
0x1F/0x20ramp a packed 24-bit RGB color stored inactor[+0xA0..+0xA3]/actor[+0xA4..+0xA7]: decompose, RGB→HSV via the SCUS algorithm atFUN_8001A78C, addop[2..4]per channel (H wraps mod0x168, S/V clamp), HSV→RGB viaFUN_8001A8DC, re-pack. Both are size-1 by design — the operand stream re-interprets as the next outer opcode (a bytecode-density trick that simultaneously seeds the anim-block update).crates/engine-vmships clean-roomrgb_to_hsv/hsv_to_rgbmirroring the SCUS algorithms exactly. - Fourth flag bank.
0x13/0x14predicate,0x1C/0x1Dset / clear — backed by the sameDAT_80086D70bitfield the field VM's0x5x/0x6x/0x7xdefault routes touch.engine-core::Worldexposes it as a single lazily-grownsystem_flags: Vec<u8>with MSB-first bit ordering (0x80 >> (idx & 7), mirroringFUN_8003CE08). The field VM's idx encoding ranges over0..=0x87FF, so the bank can't be a fixed-size array.
Connection to other crates
- crates/mdt — parses the MDT format. The per-frame data inside an MDT record is exactly the move-VM bytecode this VM consumes. With the move-VM opcode set documented,
crates/mdtcan grow a disassembler. - crates/engine-vm — clean-room Rust port lives at
crates/engine-vm/src/move_vm.rs. All 71 outer opcodes plus all 61 inner ext sub-ops are ported; theMoveHosttrait abstracts every actor-side callback.actor_tickmirrors the per-frame entry gate atFUN_80021DF4 + 0x80022B94. - Field VM op
0x22(EXEC_MOVE) — the gateway from script-VM into move-VM; callsFUN_800204F8to set up the per-actor buffer.
Decompile quirks worth knowing
- Operand units are u16, not bytes.
op[1]is the 16-bit operand at offset 2 from the opcode word. - PC is also in u16 units.
actor[+0x70] × 2is the byte offset. param_3is the size in u16 units, not bytes. The dispatcher epilogue doesactor[+0x70] += param_3.0x47-bound check:sltiu v0, v1, 0x47. Out-of-range opcodes silently fall through to the loop-exit. Treat any opcode≥ 0x47as "end of move buffer" rather than "unknown opcode".- Cases that "look like NOPs" in the C decompile still advance the PC via
param_3— the increment is set in MIPS branch-delay slots and is invisible at the C level (same pattern as the field VM).