How it works

A motion script is a stream of small instructions - "walk along Y", "rotate to this angle", "move toward your target" - each addressed at an actor. Each script entry is 1 + N bytes:

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

When the high bit is set, the VM resolves a target actor before applying the body. 0xF8 resolves to "this actor", 0xFB follows a linked list at _DAT_8007c34c looking for a matching record-class signature, and any other id linearly scans the actor list at _DAT_8007c354 matching against the actor's id field at +0x14.

Opcodes

bytecase bodynamesemantics
0x370x8003789CTranslateYaccumulate Y axis by per-frame speed
0x380x800379FCRotateToAngleyaw ramps toward an absolute compass angle over a frame budget; 12-bit fixed-point, shortest-path select (body0 & 0x80) or forced direction (body1 & 0x80), eight-entry heading LUT at DAT_80073F04
0x410x8003789CTranslateXaccumulate X axis by per-frame speed
0x430x80037FF0NoOptick budget consumed, no actor mutation
0x470x80037B84MoveTowardTargetstep actor XZ toward (tx, tz), closing the dominant axis first and snapping the facing to the compass every moving frame
0x4C0x80037DE0FaceTargetyaw ramps to the target's live bearing (FUN_80019B28) over a frame budget; 0x85 / 0x8E / 0x8F are the three sub-mode bytes retail accepts, and 0x8F alone forces the decreasing direction instead of the shortest arc
(default)0x80037FECDoneterminate

How an actor's facing changes

Three opcodes write the actor's 12-bit heading at +0x26, and they split into two laws. Which one an NPC gets is a property of the bytecode, not of the situation.

Snap - walking

The tail of the 0x47 case runs on every frame the actor moved. It reduces the step to its axis signs, maps them to an index in the heading LUT at 0x80073F04, and writes the entry outright:

index01234567
direction-Z-X -Z-X-X +Z+Z+X +Z+X+X -Z

Entry i is i * 0x200, so a walking actor's facing is always one of eight compass points and never an in-between angle. There is no walk-turn interpolation anywhere in retail. A cold-boot town01 sample off the static recompilation bears this out: across the on-field actors every +0x26 is a multiple of 0x200 and all eight points appear, the only exceptions being actors parked on the off-field sentinel tile.

Ramp - the two rotate ops

0x38 and 0x4C interpolate toward a target angle over a frame budget carried in their own operands, running the same arithmetic each tick:

remaining = budget - cursor                  ; cursor at actor +0x54
if remaining - speed <= 0:                   ; terminal frame
    heading = target                         ; exact snap, leg is Done
else:
    cursor += speed
    arc  = (target - heading) mod 0x1000     ; or (heading - target) when decreasing
    heading += arc * speed / remaining       ; or -=

Because the arc is measured from the live heading every tick, the step is a linear ease that lands exactly on the target, and the terminal frame snaps rather than steps so rounding can never leave the actor short. 0x38 aims at a LUT index; 0x4C re-reads the bearing to its target actor each tick, so a FaceTarget leg tracks a target that is itself moving.

Clean-room port

legaia_engine_vm::motion_vm in crates/engine-vm/src/motion_vm.rs is the clean-room port. All opcodes are ported: 0x37 TranslateY, 0x38 RotateToAngle, 0x41 TranslateX, 0x43 NoOp, 0x47 MoveTowardTarget, 0x4C FaceTarget. The facing law lives in heading_lut_engine (the eight compass entries), walk_facing_index / walk_facing_yaw (the 0x47 sign-to-index table) and rotate_step (the shared ramp arithmetic); engine-core's facing_index_to_engine_heading delegates to the same LUT so spawn-time and runtime facings cannot drift apart.

Camera integration

The runtime Camera in engine-core::camera consumes:

  • The field-VM op-0x45 event stream (CameraConfigure / CameraSave / CameraLoad / CameraApply) for the high-level camera state.
  • The motion VM (optional) for cinematic pre-baked camera paths via Camera::tick_script.

The default mode follows a target actor slot at a configured distance + height - same shape as the retail "follow the player" camera.

Field-NPC walking

World::tick_field_npc_motions (engine-core) drives MAN-placed field NPCs through the 0x47 MoveTowardTarget pursue step, one motion-VM step per field tick, writing the live position back into World::field_npc_positions so the moving NPC's ±40-unit collision box and its interact box follow it.

Three start paths feed it:

  • Autonomous patrol routes - each placement's own pre-text 0x4C 0x51 move-to-tile ops, decoded by man_field_scripts::placement_motion_route; opt-in via play-window --live-npcs, paused while a dialogue is up.
  • Interaction-prologue runs - a record's own 0x4C 0x51 walks the interacted NPC.
  • Actor-VM start_motion glide - op 0x09, retail FUN_800358c0.

The retail speed encoding is pinned: the walk kernel's ops carry their own base-step selector ((op0>>5 & 4)|(op1>>6) for 0x37/0x41, b2 & 7 for 0x47; numerator 0x80, or 0x40 for 0x41), and the ops are the field-VM record's yield-class bytes interpreted in place from the pointer parked at actor +0x94 - see field-locomotion § NPC glide speed. Per-actor field-VM channels are not executed - the engine loops the decoded waypoint list, pacing each placement by its real decoded walk-kernel step (man_field_scripts::placement_glide_speed: the bound tail-section-1 stream's wander/step ops first, then the record's own yield ops, then the facing-nibble heuristic only for placements with no walk-kernel op at all).

The second motion VM - FUN_80038158

A separate interpreter reading its stream at *(u32*)(actor+0x80) + *(u16*)(actor+0x84). It choreographs scripted motion (directional steps, facing ramps, tweens, teleports, waits) and writes the system story-flag bank DAT_80085758 directly: op-7 [07, lo, hi] sets flag lo | hi << 8, op-8 clears (matching FUN_8003CE08's byte/bit math).

Disc carrier - MAN tail-section 1. 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) and installs section 1 at the field control block; FUN_8003A9D4 walks the body as a record chain [u8 count][s16 next_delta][count × (u8 actor_id, u8 enable)][motion stream] and binds each stream to its actor (0xF8 = player, 0xFB = the world-map entity SM node, else matched against actor+0x50 - which the placement spawner FUN_8003A1E4 writes as N0 + placement_index, N0 = the MAN's partition-0 record count, so a binding resolves to partition-1 placement actor_id - N0, NOT the raw index; town01: N0 = 36, binding 0x30 = placement 12). Streams open with a flag-selected variant header table re-evaluated every tick. Parser: legaia_asset::man_motion. This is the only disc source of this bytecode - all other actor+0x80 writers are eliminated.

Walk-op speed encoding. The walk ops step on the same 0x80 >> (2 + bits) per-frame ladder as the yield ops, with the selector in their own operands: the directional steps 0x03/0x19/0x20 carry bits in operand byte 1's low nibble; the pad-echo step 0x06 and the AABB wander 0x18 scatter a 4-bit selector over their four operand bytes' high bits ((b1&0x80)>>4 | (b2&0x80)>>5 | (b3&0x80)>>6 | b4>>7). This is the disc source of a town NPC's ambient wander pace; the engine decode is man_field_scripts::placement_wander_stepWorld::field_npc_glide_speeds.

Op 0x17 - the per-actor default-move table. [0x17, move_id, anim_id] writes 0x801C6470[actor(+0x50) * 4] / +1, guarded +0x50 < 0x8C (0x8C is also the "unset" sentinel the variant-swap preamble reseeds a record to). The walk/anim ops reload the actor's requested-move pair +0x88/+0x5C from the record, and the interaction motion-pause kick FUN_8003C9AC (ported at legaia_engine_vm::motion_pause) sweeps the same table on the touch-event post. The engine statically harvests each stream's first 0x17 per bound placement (man_field_scripts::motion_default_move_writesWorld::field_npc_default_moves).

Flag census. legaia-engine man-scripts --motion-flag-census sweeps every scene MAN (v12-embedded included) for op-7/op-8 sites - the sibling of --system-flag-census (MAN field-VM ops 0x50/0x60/0x70). Disc-wide the surface is overworld walking-band choreography (map02/map03) plus one town0b clear; the spine gate flags appear in no motion stream - their writers are field-VM script bytes in the streaming variant MAN carriers (0x142/0x482/0x1BE; see script-vm) or, for the town01 opening one-shot 549, a direct code path (the earlier "549 is set by op-7 from its own script bytecode" carrier reading is falsified; see world-map).

See also