Per-actor motion VMs
The interpreters that walk NPCs around. When a villager wanders inside an invisible box, an actor turns toward a target, a cutscene marches someone across the screen, or a scene flips a story flag as its choreography plays, one of two small bytecode VMs in the main executable is stepping that actor. This page covers which VM does what, how an actor's facing changes, and where the bytecode lives on the disc.
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 its word at +0x10, hands it to one of two interpreters.
| VM | Entry | Runs when | What a player sees | Bytecode source |
|---|---|---|---|---|
| Pursue / walk kernel | FUN_8003774C | +0x10 & 0x400 | A scripted walk or glide, "walk here and face that", camera follow paths | The field-VM motion op itself, read in place through +0x94 |
| Ambient motion / flags | FUN_80038158 | +0x10 & 0x80 | Town wandering, idle turns, model swaps, fades - and the story flags a scene flips as it plays | MAN tail-section 1 (parser legaia_scene_formats::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 ambient VM's bytecode arrives as disc data, so it has a static decoder beside its interpreter: engine-motion-vm::ambient_motion runs it, engine-field::man_field_scripts::npc_motion answers questions about a stream without running it, and engine-motion-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
0x400 and hands the actor to the pursue VM until it lands.The height arm runs first for any live actor inside the camera's visible window. It writes the actor's Y position, not its heading: a stored override, a hold, a per-frame clamped step toward the sampled floor, or a snap onto it. Bit 1 of the flag word is a visibility cull rewritten every frame by the pre-update, so an actor outside the window holds its Y. The dispatch arm then fires the routines in the figure, unless the global suppress bit (_DAT_1F800394 & 0x400) skips it wholesale.
The pursue VM
This is the walk kernel the field VM hands its motion ops to. When a script runs 0x37, 0x41 or 0x47, it parks the op's address on the actor and sets the halt bit; each frame this VM interprets those same bytes in place until the leg finishes, then clears the halt bit so the script can resume. There is no separately synthesised motion bytecode.
With the high bit set, the target resolves first: 0xF8 means the player, 0xFB the system actor, and any other id matches the actor's placement bind index at +0x50. Each tick spends the frame scalar's worth of progress; the leg returns "still running" or "done".
| Byte | Name | What it does |
|---|---|---|
0x37 / 0x41 | Compass walk | Walk along one of eight compass directions (axis table at 0x80073F14), a tile per count for 0x37, half a tile for 0x41, over a budget the operand scales |
0x38 | Rotate to angle | Ramp the yaw toward a compass angle over a frame budget; shortest arc or a forced direction |
0x43 | No-op | Budget consumed, nothing moves |
0x47 | Move toward target | Step X/Z toward a tile, facing snapped to the compass; the mode byte's high nibble picks the path (below) |
0x4C | Face target | Ramp the yaw to the target's live bearing - tracks a moving target |
| other | Done | End the leg, clear the halt bit |
The opcode numbers are the field VM's own: the jump table is indexed (op & 0x7F) − 0x37 over 0x37..0x4C, and every slot not listed above ends the leg.
The 0x47 approach mode. While both axes are still open, the high nibble of the mode byte decides which ones a step moves. Mode 0 closes the dominant axis alone, clamped to the difference, until the two deltas are equal, then cuts the diagonal. Any other mode masks the open axes at the full step: 3 walks the diagonal from the first frame and finishes on the longer axis, 1 closes X first, 2 Z first. The two laws end an uneven leg facing different ways - mode 3 along the longer axis, mode 0 on the diagonal - and cutscene walks carry 3.
A frame is several steps. The arm takes the frame step's count of steps, each re-reading both deltas, so one frame can close the dominant axis and start the diagonal. A step that lands on the target in the frame's last step leaves the leg running; the next frame finds it there and ends it, which is when the script resumes.
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. Those are two different laws, and which one an actor gets is a property of the bytecode, not the situation.
- Snap (walking). A
0x47leg reduces its step to axis signs, maps them through the eight-entry compass LUT at0x80073F04and writes the heading outright, on every frame the leg runs. Entryiisi × 0x200, so a walking actor's facing is always a compass point, one value per leg - there is no walk-turn interpolation anywhere in retail. A cold-boot town sample confirms it: every on-field heading is a multiple of0x200. - Ramp (the rotate ops).
0x38and0x4Cinterpolate over a frame budget: each tick,heading += arc × speed / remainingmeasured from the live heading, and the terminal frame snaps exactly onto the target so rounding never leaves the actor short.
Talk-time facing is not this VM
The NPC turning to face you on the action button is easy to mis-attribute to Face target, 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.
A talk record can also turn its talker with the pursue VM's facing op aimed at itself. The arm raises the talker's busy bit and moves on; the dispatcher then refuses any extended op whose target carries that bit, so the talker's own next op waits for the turn's terminal frame, while plain ops and text leads run under it. A wait op behind the turn counts on the leg's own timer, so it runs at twice the rate and the turn's last frame zeroes it.
Port trap
The dialog snap calls the bearing helper with the endpoints swapped relative to Face target, and without Face target's +0x800 half-turn - the swap is the half-turn, so the two agree. Copying the Face target convention here and keeping the +0x800 faces the NPC exactly backwards.
The ambient motion VM: town 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 - and, less visibly, the story flags a scene flips as its choreography plays: op 0x07 sets a story flag and op 0x08 clears one, in the same bank the field VM uses.
Most ops do not end the frame. The interpreter is a loop that runs until an op says it did work, 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 and the bit-wait stop it.
| Op | Bytes | Ends the frame | What it does |
|---|---|---|---|
01 | 1 | no | Jump back to the stream's first op |
02 | 3 | no | Write the requested move / clip pair (only while the actor's default-move record is unset) |
03 19 20 | 3 | yes | Directional step along a compass heading |
04 | 3 | until done | Facing ramp inside the VM |
05 | 2 | yes | Wait, cut short by a touch |
06 | 5 | yes | One random full-tile step inside a home-relative box |
07 / 08 | 3 | no | Set / clear story flag (s16)(b1 | b2 << 8) |
09 | 3 | no | Queue an SFX cue |
0A / 0B | 3 | no | Raise / drop the translucent-draw bit |
0C | 8 | no | Tint and draw-mode fade |
0D | 4 | until done | Facing ramp handed to the shared ramp scheduler |
0E | 3 | no | Model swap |
0F | 3 | no | Tile teleport + re-anchor the home box |
10 / 11 | 2 | no | Set / clear one bit of a selectable halfword |
12 | 2 | always | Wait for that bit to change (the first tick seeds from its current value) |
13 | 13 | no | MoveImage VRAM blit |
14 15 16 | 5 | no | Tween render scale / pitch / roll |
17 | 3 | no | Write the actor's default-move record |
18 | 5 | yes | Wander inside an axis-aligned box |
Dispatch is a 32-entry jump table at 0x80010FE8 indexed op − 1; slots 0x1A..0x1F point back at the loop test and re-dispatch forever, as a rejected byte does.
- The disc carrier is MAN tail-section 1 - a record chain in the scene's script-and-data bundle that binds each stream to a placed actor at scene entry. It is the only disc source of this bytecode.
- Walk ops collide with the player only - not the walkability grid, not other NPCs. A wanderer can stroll through a fence; it cannot stroll through Vahn.
- The binding byte is a suppression mask, not an enable. With bit 0 set the stream defers while the player is engaged (talk, menu, beat), while the actor is busy, or while it is parked off-map; a zero byte keeps choreographing through a cutscene.
- The ambient facing ops have no shortest-arc override, so an authored idle turn can deliberately take the long way round.
- The tint fade starts from whatever the actor wears. An actor has one tint record, which the field script and this op both write, and op
0x0Cramps from it. In frozen Buma one of the frozen figures stands tinted blue from the scene’s entry script, and once a story flag is up its stream pulses that blue between two strengths without ever dropping it.
The draws are the world's stream
The two ops that roll dice - the 0x18 wander and the 0x06 home step - call the BIOS rand(), so they advance the one process-wide seed and nothing per actor. Which villager gets which draw is therefore set by the order the actors tick, and that is the order of the actor list: a tail-appended chain the tick walks from head to tail. So the tick order is spawn order, not slot order, and scripted seats reorder it.
- What moves a node. At scene entry partition 1's records are seated in order; a re-seat allocates a fresh node at the tail; and the non-player arms of the field VM's
MOVE_TOand4C 51seat ops unlink the actor and link it back at the tail. During a scene's spawn prologue the list is still being built, so a placement seating an earlier one puts it just behind the running slot. - The engagement release lands mid-walk. The deferral gate reads the player's engaged bit on each stream's own tick, and the bit is cleared inside the walk, on the node of the context whose script ended the engagement. So on that pass the streams ahead of the owner skip one more tick and the streams behind it start.
- The spawner draws too. Binding a placement's header clip seats a random clip phase,
(rand() % frames) << 4, once per animated placement at scene entry and once per re-seat. - In the port the channel borrows
World::rng_statefor each tick and hands it back;FieldNpcState::ambient_orderkeeps the list order and the seat ops move it. Seeded from a retail capture's seed and stream state, the engine's seed equals retail's and each draw names the same actor across the capture's free-walk stretch.
Stream binding, speed encoding, op 0x17 and the touch post
Binding. FUN_8003AEB0 walks the MAN's tail-section chain and FUN_8003A9D4 walks section 1 as [u8 count][s16 next_delta][count × (u8 actor_id, u8 mask)][motion stream]. 0xF8 binds to the player, 0xFB to the world-map entity node, anything else matches actor +0x50 - which the spawner writes as N0 + placement_index (N0 = the MAN's partition-0 record count), so binding 0x30 in town01 (N0 = 36) is placement 12. Every binding's store is unconditional, so when two records name the same actor the later one in the chain is the stream it runs: dolk2 binds placement 0x31 twice, and retail runs the second record (a wait, then two wanders it stands outside and retires), not the first (a wander round its spawn tile). Streams open with a flag-selected variant header re-evaluated every tick. The stream is read at *(u32*)(actor+0x80) + *(u16*)(actor+0x84).
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. The axis-bitmask table sixteen bytes past the compass LUT (1 = +Z, 2 = −Z, 4 = +X, 8 = −X) is why diagonal steps move both axes equally.
Op 0x17 and the touch post. [17, move_id, anim_id] writes the per-actor default-move table 0x801C6470[bind × 4], guarded < 0x8C. When two actors overlap, the collision probe posts the touched actor's bind index into a one-slot mailbox the VM's wait 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.
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.
In the port
engine-motion-vm::motion_vmis the pursue VM, with one LUT module for the facing law so spawn-time and runtime facings cannot drift apart. Two deliberate departures: LUT indices8..=15are no-ops rather than retail's over-read into adjacent executable data, and an unrecognised Face-target sub-mode ends the leg instead of yielding forever.engine-motion-vm::ambient_motionruns the whole ambient op table.- NPC pacing is decoded, not guessed: each placement's glide speed comes from its real walk operands (field locomotion).
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Field actor tick | FUN_8003BC08 | Height-arm law selection (Y at +0x16, not facing); the four dispatch guards; the global suppress bit | ghidra/scripts/funcs/8003bc08.txt |
| Visibility cull | FUN_801D79E8 | Bit 1 rewritten each call from the region box and the camera's tile window | field overlay dump; autorun_field_actor_cull.lua |
| Pursue VM | FUN_8003774C | Op set, target idiom (0xF8/0xFB), bind-index match at +0x50, compass LUT at 0x80073F04 | 8003774c.txt |
| Ambient VM | FUN_80038158 | 32-entry JT at 0x80010FE8; the did-work loop; op-7 / op-8 flag writes | 80038158.txt |
| Stream binder | FUN_8003A9D4 / FUN_8003AEB0 | MAN tail-section-1 record chain; the N0 + placement_index bind law | 8003a9d4.txt |
| Actor list | FUN_80020454 / FUN_800204A4 / FUN_8002519C | Tail append, unlink and the head-to-tail walk that orders the ticks and the draws | 80020454.txt, 8002519c.txt |
| Placement spawner | FUN_8003A1E4 | The clip-phase draw per animated placement | 8003a1e4.txt |
| Dialog snap | FUN_80039B7C | Save / snap / restore triple; moving-class guard; swapped-endpoint bearing call | 80039b7c.txt |
| Touch post | FUN_8003D038 | One-slot mailbox; the class-0x8C drop guard | 8003d038.txt |
| Walk-facing law | recomp cold-boot sample | Every on-field heading is a multiple of 0x200; all eight compass points appear | recomp trace |
Full write-up: docs/subsystems/motion-vm.md.