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.

VMEntryRuns whenWhat a player seesBytecode source
Pursue / walk kernelFUN_8003774C+0x10 & 0x400A scripted walk or glide, "walk here and face that", camera follow pathsThe field-VM motion op itself, read in place through +0x94
Ambient motion / flagsFUN_80038158+0x10 & 0x80Town wandering, idle turns, model swaps, fades - and the story flags a scene flips as it playsMAN 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

field actor tick FUN_8003BC08 height arm Y: snap, hold or step dispatch arm unless suppressed field-script slice +0x10 & 0x100 - not a motion VM pursue / walk kernel +0x10 & 0x400 - FUN_8003774C ambient motion / flag VM +0x10 & 0x80 - FUN_80038158 move-table consumer +0x5C > 0 or +0x10 & 0x1000
One actor, one frame. The highlighted boxes are the two motion VMs; any of the four routines whose guard passes runs. The field-script slice can start a walk by parking a motion op, which sets bit 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.

+0opbit 0x80 = target
+1target idonly if 0x80
+Noperandsper opcode

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".

ByteNameWhat it does
0x37 / 0x41Compass walkWalk 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
0x38Rotate to angleRamp the yaw toward a compass angle over a frame budget; shortest arc or a forced direction
0x43No-opBudget consumed, nothing moves
0x47Move toward targetStep X/Z toward a tile, facing snapped to the compass; the mode byte's high nibble picks the path (below)
0x4CFace targetRamp the yaw to the target's live bearing - tracks a moving target
otherDoneEnd 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 0x47 leg reduces its step to axis signs, maps them through the eight-entry compass LUT at 0x80073F04 and writes the heading outright, on every frame the leg runs. Entry i is i × 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 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 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.

OpBytesEnds the frameWhat it does
011noJump back to the stream's first op
023noWrite the requested move / clip pair (only while the actor's default-move record is unset)
03 19 203yesDirectional step along a compass heading
043until doneFacing ramp inside the VM
052yesWait, cut short by a touch
065yesOne random full-tile step inside a home-relative box
07 / 083noSet / clear story flag (s16)(b1 | b2 << 8)
093noQueue an SFX cue
0A / 0B3noRaise / drop the translucent-draw bit
0C8noTint and draw-mode fade
0D4until doneFacing ramp handed to the shared ramp scheduler
0E3noModel swap
0F3noTile teleport + re-anchor the home box
10 / 112noSet / clear one bit of a selectable halfword
122alwaysWait for that bit to change (the first tick seeds from its current value)
1313noMoveImage VRAM blit
14 15 165noTween render scale / pitch / roll
173noWrite the actor's default-move record
185yesWander 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 0x0C ramps 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.

scene entry: spawn order P1 P2 P3 P4 objects after a seat op places P2: unlinked, appended at the tail P1 P3 P4 objects P2
Each tick walks the list head to tail, and each wanderer's roll takes the next draw of the one seed. A seat op relinks the actor it places at the tail, so a capture's tick order can run against the flat placement indices.
  • 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_TO and 4C 51 seat 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_state for each tick and hands it back; FieldNpcState::ambient_order keeps 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_vm is the pursue VM, with one LUT module for the facing law so spawn-time and runtime facings cannot drift apart. Two deliberate departures: LUT indices 8..=15 are 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_motion runs 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

FunctionAddressWhat it provesDump
Field actor tickFUN_8003BC08Height-arm law selection (Y at +0x16, not facing); the four dispatch guards; the global suppress bitghidra/scripts/funcs/8003bc08.txt
Visibility cullFUN_801D79E8Bit 1 rewritten each call from the region box and the camera's tile windowfield overlay dump; autorun_field_actor_cull.lua
Pursue VMFUN_8003774COp set, target idiom (0xF8/0xFB), bind-index match at +0x50, compass LUT at 0x80073F048003774c.txt
Ambient VMFUN_8003815832-entry JT at 0x80010FE8; the did-work loop; op-7 / op-8 flag writes80038158.txt
Stream binderFUN_8003A9D4 / FUN_8003AEB0MAN tail-section-1 record chain; the N0 + placement_index bind law8003a9d4.txt
Actor listFUN_80020454 / FUN_800204A4 / FUN_8002519CTail append, unlink and the head-to-tail walk that orders the ticks and the draws80020454.txt, 8002519c.txt
Placement spawnerFUN_8003A1E4The clip-phase draw per animated placement8003a1e4.txt
Dialog snapFUN_80039B7CSave / snap / restore triple; moving-class guard; swapped-endpoint bearing call80039b7c.txt
Touch postFUN_8003D038One-slot mailbox; the class-0x8C drop guard8003d038.txt
Walk-facing lawrecomp cold-boot sampleEvery on-field heading is a multiple of 0x200; all eight compass points appearrecomp trace

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

See also