At a glance

An actor is the engine's record for one on-screen entity (the player, an NPC, a prop, a camera target). Every frame the field actor tick visits every actor and, depending on flag bits in the actor's word at +0x10, hands it to one of two interpreters - both in the main executable.

VMEntryGateWhat a player seesBytecode source
Pursue / patrol / face-targetFUN_8003774C+0x10 & 0x400NPCs closing on a target, camera follow, "walk here and face that"Per-actor stream parked at +0x94
Scripted motion / story flagsFUN_80038158+0x10 & 0x80Ambient town wandering, idle turns, plus the story flags a scene flips as it playsMAN tail-section 1 (parser legaia_asset::man_motion)

Two traps

The NPC turning to look at you when you press the action button is neither VM - it is a single store in the dialog state machine (talk-time facing). And the scripted VM has a static decoder alongside its interpreter, because its bytecode arrives as disc data rather than through the actor tick's own buffer: engine-vm::ambient_motion runs the whole op table, engine-core::man_field_scripts::npc_motion answers questions about a stream without running it, and engine-vm::motion_vm is the other VM entirely.

Both are distinct from the window-widget VM and the move-table VM; the whole family is on the VM family table.

The driver: one tick, two arms

Per-actor tick: the height arm picks one law for the actor's Y, then the dispatch arm fires up to four routines on their own flag guards field actor tick FUN_8003BC08 height arm snap / hold / clamped turn dispatch arm skipped if suppress bit set FUN_80039B7C cutscene event-script pump +0x10 & 0x100, script at +0x90 FUN_8003774C pursue / patrol / face-target VM +0x10 & 0x400 FUN_80038158 scripted-motion / flag VM +0x10 & 0x80, bytecode at +0x80 FUN_800204F8 move-table consumer +0x5C > 0 or +0x10 & 0x1000
One actor, one frame. The height arm picks a law for the actor's Y at +0x16; the dispatch arm then fires any of four routines whose flag guards pass. The highlighted boxes are the two motion VMs.

The height arm runs first, for any live, non-frozen actor, and picks one law for its Y from the flag word: write a stored override, hold, step toward the sampled floor with a per-frame clamp, or snap straight onto it. The dispatch arm then fires the routines in the figure - unless a global suppress bit skips it wholesale.

The first routine, the cutscene pump, is not a motion VM: it steps a field/event-VM script one authored beat per cutscene phase tick. Any motion it causes is issued by the field-VM ops inside the script.

Deep dive: facing-arm flag bits and the cutscene pump

Height arm (live = +0x5C ≥ 0, not self-frozen = +0x10 & 2 clear). It writes the actor’s Y position +0x16, not a heading — FUN_80019278 is the bilinear ground-height sampler (it reads +0x14/+0x18 as X/Z and averages four .MAP corner nibbles through the 0x1F80035C ramp), and this VM’s real facing writes all land at +0x26. Bit 0x20000000 writes -(+0x8E) outright, skipping both floor arms; else if neither a target bit (0x20200) nor the ambient enable _DAT_8007B6A8 is set, +0x16 holds; else bit 0x2000 selects the clamped step toward the sample (rate = pad-held × 6, a raw signed-difference clamp distinct from the frame-budget ramp below) and its absence snaps to it. The dispatch arm’s suppress bit is _DAT_1F800394 & 0x400.

The pump FUN_80039B7C reads the script at *(actor+0x90) + *(actor+0x9E) - the same 0x21-terminated format the field VM reads - in lock-step with the global cutscene phase _DAT_801F2734. Its helper FUN_80038050 skips the structural ops (0x21 stop; 0x24/0x25/0x48 one byte; 0x27..0x2A multi-way jump by modal depth; 0x4C two or three bytes).

The pursue VM

A motion script is a stream of small instructions - "walk one tile south", "rotate to this angle", "move toward your target" - each addressed at an actor:

+0  u8 op_byte         ; bits 0x7F = opcode, bit 0x80 = "select target"
+1  u8 target_id       ; only present if bit 0x80 set
+N  u8 operand[...]    ; opcode-specific

With the high bit set, the VM resolves a target actor first: 0xF8 means "the player", 0xFB follows the system actor list for a class match, and any other id scans the actor list matching the placement bind index at actor +0x50. The per-frame speed budget comes from the shared frame-time scratchpad; each step returns Yield (resume next tick) or Done.

ByteNameSemantics
0x37 / 0x41CompassWalkFast / CompassWalkSlowOne arm: walk along one of eight compass directions on the X/Z plane (axis table at 0x80073F14), a tile per count at rate 0x80 or half a tile at 0x40, over a budget the operand scales.
0x38RotateToAngleYaw ramps toward an absolute compass angle over a frame budget; shortest-path or forced direction.
0x43NoOpBudget consumed, no mutation.
0x47MoveTowardTargetStep XZ toward the target, dominant axis first, snapping the facing to the compass every moving frame.
0x4CFaceTargetYaw ramps to the target's live bearing - tracks a moving target.
(other)DoneTerminate the leg.

How an actor's facing changes

Watch a villager walk and they only ever face one of eight directions, yet a scripted turn in a cutscene glides smoothly. That is two different laws, and which one an NPC gets is a property of the bytecode, not the situation.

  • Snap (walking). Every frame a 0x47 leg moves, it reduces the step to its axis signs, maps them through the eight-entry compass LUT, and writes the heading outright. Entry i is i × 0x200, so a walking actor's facing is always a compass point - there is no walk-turn interpolation anywhere in retail. A cold-boot town sample confirms it: every on-field actor's heading is a multiple of 0x200.
  • Ramp (the rotate ops). 0x38 and 0x4C interpolate over a frame budget: each tick, heading += arc × speed / remaining measured from the live heading, and the terminal frame snaps exactly onto the target so rounding can never leave the actor short.

Talk-time facing is not this VM

The NPC turning to face you on the action button is the easiest thing in the game to mis-attribute to FaceTarget - and it is not that op. It is a save / snap / restore triple in the per-actor dialog state machine: the heading is saved aside, one store snaps it toward the player in a single frame (no ramp), and the exit paths write the saved heading back. It runs only for moving-class actors - static props never turn.

Port trap

The dialog snap calls the bearing helper with the endpoints swapped relative to FaceTarget, and without FaceTarget's +0x800 half-turn - the swap is the half-turn, so the two agree. A port that copies the FaceTarget convention here and keeps the +0x800 faces the NPC exactly backwards. Engine side: World::face_field_npc_at_player / release_talk_facing.

The second motion VM: ambient life and story flags

This is the interpreter behind a town's ambient life - villagers wandering inside an invisible box, idle turns, waits, tile teleports during a scene change - and, less visibly, the story flags a scene flips as its choreography plays: op 0x07 sets a system story flag, op 0x08 clears one, written straight into the same flag bank the field VM uses.

Dispatch is a 32-entry jump table indexed by op - 1; grouped by instruction width:

WidthOps
10x01 end / loop-back
20x05 wait; 0x10..0x12 bit set / clear / wait
30x02 requested-move pair; 0x03/0x19/0x20 directional step; 0x04 facing ramp; 0x07/0x08 flag set / clear; 0x09 queue an SFX cue; 0x0A/0x0B translucency toggle; 0x0E model swap; 0x0F tile teleport + re-anchor; 0x17 default-move write
40x0D facing ramp + tween channel
50x06 one random full-tile step in a home-relative box; 0x14..0x16 tween render scale / pitch / roll; 0x18 AABB wander
8 / 130x0C tint + draw-mode fade; 0x13 MoveImage VRAM blit

The disc carrier is MAN tail-section 1 - a record chain in the scene's script-and-data bundle that binds each motion stream to a placed actor at scene entry. This is the only disc source of this bytecode. Parser: legaia_asset::man_motion.

Three behaviours worth knowing before reading the deep dive:

  • Walking uses an axis-bitmask table sitting sixteen bytes past the compass LUT - every walk op reduces its heading index through it, which is why diagonal steps move both axes by the same per-tick amount.
  • Walk ops collide with the player only - not the walkability grid, not other NPCs.
  • The ambient facing ops have no shortest-arc override, so an authored idle turn can deliberately take the long way round.
  • Most ops do not end the frame. The interpreter loops until an arm sets its did-work register, so a stream can raise a story flag, swap a model and start three tweens between two frames. Only the walks, the waits, the facing ramps' stepping ticks and the bit-wait ever stop it - and the bit-wait stops it even on the frame it retires.
  • Op 0x12 waits for its bit to change, not to be set: the first tick seeds the wait from the bit's current value and the op then waits for the opposite state.
Deep dive: stream binding, speed encoding, op 0x17, the touch post

Binding. FUN_8003AEB0 (scene-entry map init) walks the MAN's u24-length-prefixed tail-section chain (tail_base = MAN + 0x2B + 3×(N0+N1+N2) + u24_at_0x28); FUN_8003A9D4 walks section 1 as [u8 count][s16 next_delta][count × (u8 actor_id, u8 enable)][motion stream]. 0xF8 binds to the player, 0xFB to the world-map entity node, anything else matches actor +0x50 - which the placement spawner writes as N0 + placement_index (N0 = the MAN's partition-0 record count), so binding 0x30 in town01 (N0 = 36) is placement 12, not raw index 0x30. Streams open with a flag-selected variant header table re-evaluated every tick. The second byte of each pair is a suppression mask, not an enable: bit 0 set means the stream defers while the player is engaged, while the actor is busy, or while it sits parked off-map, and a zero byte runs the VM unconditionally.

Reader / flag writes. The stream is read at *(u32*)(actor+0x80) + *(u16*)(actor+0x84); op-7/op-8 write DAT_80085758 with byte/bit math matching FUN_8003CE08. Jump table at 0x80010FE8.

Walk half. Directional steps share a case body at 0x800383F8; the AABB wander lives at 0x80038B90. Compass LUT at 0x80073F04, axis-bitmask table at 0x80073F14 (1=+Z, 2=-Z, 4=+X, 8=-X). Collision probes go through FUN_801cf8ac (player-only): a single compass point for directional steps, a three-point fan for the wander.

Speed encoding. All walk ops step on the 0x80 >> (2 + bits) per-frame ladder; directional steps carry bits in operand byte 1's low nibble, while 0x06 and 0x18 scatter a 4-bit selector over their operand bytes' high bits ((b1&0x80)>>4 | (b2&0x80)>>5 | (b3&0x80)>>6 | b4>>7). Engine decode: man_field_scripts::placement_wander_step → World::npcs.glide_speeds.

Facing ops. 0x04 (in-VM ramp, 0x8003859C) and 0x0D (hand-off to the generic ramp scheduler, 0x800386A4..0x80038828) aim at LUT[b1 & 7], direction from b1 & 0x80. The & 7 mask means they cannot reach the adjacent SCUS data the pursue VM's & 0xF index can. Port: engine-vm::ambient_motion.

Op 0x17 and the touch post. [0x17, move_id, anim_id] writes the per-actor default-move table 0x801C6470[bind_index × 4], guarded < 0x8C (0x8C doubles as the "unset" sentinel). When two actors overlap, the collision probe posts the touched actor's bind index into the one-slot mailbox DAT_80073F1C, which the VM's wait-for-touch arm consumes; the post is dropped when the record's class byte is 0x8C, so a placement with no move assigned can be walked into without waking its script. Engine: man_field_scripts::motion_default_move_writes, engine-vm::motion_pause.

Flag census. legaia-engine man-scripts --motion-flag-census sweeps every scene MAN for op-7/op-8 sites. Disc-wide the surface is overworld walking-band choreography plus one town clear; the chapter-spine gate flags appear in no motion stream - their writers are field-VM script bytes (see script-vm).

History: the flag-549 carrier

An earlier reading had town01's opening one-shot flag 549 set by op-7 from its own motion bytecode. The census shows no such site; the write is a direct code path. See do-not-re-walk.

The port

  • engine-vm::motion_vm ports all six pursue opcodes; the shared facing law lives in one LUT module so spawn-time and runtime facings cannot drift apart. A per-step yaw_written signal keeps an idle leg from clobbering a heading posed by another writer (the interact bearing).
  • Two deliberate departures: LUT indices 8..=15 are no-ops rather than reproducing retail's overread into adjacent executable data, and an unrecognised FaceTarget sub-mode terminates the leg instead of yielding forever like retail's inert arm.
  • World::tick_field_npc_motions drives MAN-placed NPCs through the MoveTowardTarget step, one step per tick, moving the collision and interact boxes with them. Three start paths feed it: autonomous patrol routes (on by default in play-window and the browser play page; play-window --no-live-npcs turns them off, paused during dialogue), interaction-prologue walks, and the widget-VM glide.
  • The camera consumes the same machinery: field-VM op-0x45 events for high-level state, plus optional motion-VM scripts for cinematic paths (Camera::tick_script). The default mode follows a target actor at a configured distance and height - the retail "follow the player" shape.
  • NPC pacing is decoded, not guessed: each placement's glide speed comes from its real walk-kernel step operands (man_field_scripts::placement_glide_speed), falling back to the facing-nibble heuristic only when a placement has no walk-kernel op at all - see field locomotion.

How we know

FunctionAddressWhat it provesDump
Field actor tickFUN_8003BC08Height-arm law selection (Y at +0x16, not facing); the four dispatch guards; the global suppress bit.ghidra/scripts/funcs/8003bc08.txt
Pursue VMFUN_8003774COp set, target-select idiom (0xF8/0xFB), bind-index match at +0x50, compass LUT at 0x80073F04.8003774c.txt
Scripted VMFUN_8003815832-entry JT at 0x80010FE8; op-7/op-8 flag writes; walk / wander case bodies.80038158.txt
Stream binderFUN_8003A9D4 / FUN_8003AEB0MAN tail-section-1 record chain; the N0 + placement_index bind law.8003a9d4.txt
Dialog snapFUN_80039B7C (+ FUN_801D5B5C)Save / snap / restore triple; moving-class guard; swapped-endpoint bearing call.80039b7c.txt
Touch postFUN_8003D038One-slot mailbox; the class-0x8C drop guard.8003d038.txt
Walk-facing lawrecomp cold-boot sampleEvery on-field heading is a multiple of 0x200; all eight compass points appear.recomp trace

Full write-up: docs/subsystems/motion-vm.md.

See also