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

VMDriverWhereOpcodesOperand width
Actor / sprite VMFUN_801D6628Title-screen overlay (0971)13byte stream
Move VMFUN_80023070SCUS_942.5471 (0x00..0x46) + 61 sub-ops via 0x2Fu16 stream
Motion VMFUN_8003774CSCUS_942.54per-actor pursue / patrol / face-targetbyte stream w/ high-bit target
Field / event VMFUN_801DE840Town overlay (0897)43 (0x21..0x4F with gaps) + 0x5x/6x/7x default-routebyte stream
Effect VMper-slot state walkerBattle overlayper-slot SM (no opcode table)inline state tokens

How the move VM wires to the others:

  • Field-VM op 0x22 EXEC_MOVE calls FUN_800204F8, which finds the move record for move_id and stages it into the actor at actor[+0x48] (buffer base) / actor[+0x70] (PC).
  • Actor tick (FUN_80021DF4, per frame) and actor spawn (FUN_80021B04, one-shot) both call FUN_80023070(actor) to step the move buffer.
  • Move-VM opcode 0x2F calls FUN_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/0xFB system-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
0x80010778 in SCUS_942.54 — 71 entries × 4 bytes

Actor-side state

The move VM rewrites a wide swath of the actor struct. Selected fields:

OffsetTypeUse
+0x10u32Actor flag word. Bit 0x8 set by op 0x08; 0x2/0x1000/0x10000/0x40000000 toggled by various opcodes.
+0x14/16/18u16World X / Y / Z (op 0x07 absolute set, op 0x01 add, op 0x03 rotate-add).
+0x22u16Y-rotation (per-frame ramp by op 0x2D / 0x35 / 0x37).
+0x24/26/28u16Camera/render slots.
+0x3C/3E/40u16Animation bank (op 0x00: [v << 3]).
+0x54u16Wait/timer (op 0x09 set; ticked down by FUN_80021DF4).
+0x62u16Local flag bank (16 bits). AND/OR by ops 0x31 / 0x32.
+0x70i16The move-VM PC (in u16 units).
+0x90/92/94u16Tween source (op 0x35 / 0x37 absolute / increment).
+0x96/98/9Au16Tween scale (op 0x2E, [v << 3]).
+0x9C..+0xC8mixedTween/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

OpMnemonicSizeEffect
0x01WORLD_ADD4actor[+0x14] += v1; +0x16 += v2; +0x2A += v2; +0x18 += v3
0x03WORLD_ROTATE_ADD2Adds rotated v1 into world X/Z using sin/cos table indexed by actor[+0x96] & 0xFFF.
0x07WORLD_SET4Absolute actor[+0x14..+0x18] = v1..v3.
0x2DWORLD_INC_VARIANT4actor[+0x90] += v1; +0x92 += v2; +0x94 += v3.
0x37WORLD_SET_VARIANT23actor[+0x90] = v1; +0x92 = v2.

Animation banks & render

OpMnemonicSizeEffect
0x00ANIM_BANK_SET4actor[+0x3C..+0x40] = v1..v3 << 3
0x04ANIM_BANK_24actor[+0x80..+0x84] = v1..v3 << 3
0x05RENDER_BANK_ADD4actor[+0x24..+0x28] += v1..v3
0x39RENDER_BANK_SET4Absolute actor[+0x24..+0x28] = v1..v3

Control flow

OpMnemonicEffect
0x08HALTactor[+0x10] |= 0x8. Sets bVar3 = false so the dispatcher exits without advancing PC.
0x09WAIT_SETactor[+0x54] = v1 << 3. Wait timer; FUN_80021DF4 ticks it down each frame.
0x16STUBCalls FUN_80024C80 — body is a pure jr ra. Pure no-op.
0x2FOVERLAY_EXTEscape to FUN_801D362C (town overlay): 16-bit sub-opcode, JT at 0x801CE868, 61 entries.

Flag banks & tween setup

OpMnemonicEffect
0x31 / 0x32LFLAG_AND / LFLAG_OR16-bit per-actor local flag bank at +0x62.
0x33CLEAR_BIT_40000000actor[+0x74] &= ~0x40000000.
0x34TWEEN_SETUPLoads 8 i16 operands into actor[+0xAC..+0xC8]. 9-u16 instruction.
0x36TWEEN_DURATION_SET+0x98 = v1 << 3; +0x9A = v2 << 3; +0xB8 = 0.
0x3A / 0x3BFLAG_2_SET / FLAG_2_CLEARactor[+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:

  • 0x08 HALT — clears the loop flag.
  • 0x09 WAIT_SET — wait timer is set; further opcodes deferred to next frame.
  • A few epilogue cases (e.g. 0x30 KEY_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-ops 0x25/0x26 round-trip world coords; 0x27/0x28 the tween-source triple at +0x90 (with >> 12 fixed-point + [-0xFF, 0xFF] clamp on read); 0x31/0x32 the render-bank section at +0x24..+0x2C; 0x34/0x35 the byte at actor[+0x72].
  • Globals. DAT_801F22F4 is a u32 predicate (sub-ops 0x08/0x09 set/clear, 0x0A/0x0B test). DAT_801F22F6 is a u16 counter mod 16: 0x0F clears, 0x10 reads-and-increments, capturing the low byte into actor.field_86; 0x11 then writes world coords to slot_table[field_86 & 0xFF].
  • World-position lerp. 0x24 / 0x2A share axis = 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 / 0x07 are bbox-vs-player gate variants on the canonicalised box [xa..xb] × [za..zb].
  • Midpoint blends. 0x0E / 0x12 back the FUN_801E45BC "midpoint to actor world" idiom. 0x36/0x37 are axis predicates against 0x8E - DAT_8007C348; 0x38/0x39 are squared-distance gates around the player. 0x23 is the anim-bank lerp toward operand world coords using the scratchpad ramp ratio at _DAT_1F800393.
  • Self-modifying bytecode. 0x04 writes actor world XYZ into buffer[pc + op[2] + 3..]; 0x1E is read-modify-write on a single u16 (buffer[pc + op[2] + 4] += op[3]); 0x1B is an in-bytecode copy loop. The MoveHost::move_bytecode_{read,write}_u16 callbacks expose the actor's move buffer to these ops.
  • HSV ramps. 0x1F / 0x20 ramp a packed 24-bit RGB color stored in actor[+0xA0..+0xA3] / actor[+0xA4..+0xA7]: decompose, RGB→HSV via the SCUS algorithm at FUN_8001A78C, add op[2..4] per channel (H wraps mod 0x168, S/V clamp), HSV→RGB via FUN_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-vm ships clean-room rgb_to_hsv / hsv_to_rgb mirroring the SCUS algorithms exactly.
  • Fourth flag bank. 0x13 / 0x14 predicate, 0x1C / 0x1D set / clear — backed by the same DAT_80086D70 bitfield the field VM's 0x5x/0x6x/0x7x default routes touch. engine-core::World exposes it as a single lazily-grown system_flags: Vec<u8> with MSB-first bit ordering (0x80 >> (idx & 7), mirroring FUN_8003CE08). The field VM's idx encoding ranges over 0..=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/mdt can 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; the MoveHost trait abstracts every actor-side callback. actor_tick mirrors the per-frame entry gate at FUN_80021DF4 + 0x80022B94.
  • Field VM op 0x22 (EXEC_MOVE) — the gateway from script-VM into move-VM; calls FUN_800204F8 to 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] × 2 is the byte offset.
  • param_3 is the size in u16 units, not bytes. The dispatcher epilogue does actor[+0x70] += param_3.
  • 0x47-bound check: sltiu v0, v1, 0x47. Out-of-range opcodes silently fall through to the loop-exit. Treat any opcode ≥ 0x47 as "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).