How it works

If you've ever written a sprite-walk script for an old arcade game, this is exactly that. There's an array of "actors" (a sprite is an actor here), each one carries a small amount of per-instance state and a cursor into a bytecode buffer. Every frame, the VM reads the next byte at each actor's cursor and runs the corresponding handler. The handler might move the sprite, trigger an animation, set a flag, or wait for N frames. After the handler runs, the cursor advances and we're done with this actor for this frame.

The whole VM has 13 opcodes — the minimum you need for a sprite-animation system: spawn / despawn, position writes, motion, animation triggers, flag set / clear / test, wait, and a terminator. Operands are simple: typically zero or one byte after the opcode. There is no "switch context to a different actor" or "call this other script as a subroutine"; an actor's bytecode runs against itself only.

Because everything is fixed-width and self-contained, the actor VM was the first VM ported in the clean-room engine, and it's used as the reference implementation pattern (a Host trait abstracts the platform-side hooks; the dispatcher is a plain match statement over opcode bytes).

Where it lives

Driver
FUN_801D6628
Dispatch table
0x801CED70 (13 entries)
Lives in
Title-screen overlay (PROT entry 0971)
Operand format
Byte stream (1 byte opcode + zero-or-more operand bytes per instruction)

Opcodes

The 13 opcodes cover the basics every sprite-animation system needs:

  • Spawn / despawn actors.
  • Set / clear a per-actor flag bit (mirrors the lower script-VM banks).
  • Position writes (immediate and packed).
  • Motion: linear interpolation between two endpoints.
  • Trigger an animation (an ANM container indexed by id).
  • Wait / yield.
  • Conditional skip on a flag.
  • Terminator.

Full opcode table and Rust port: crates/engine-vm/src/lib.rs.

Why it's separate from the field VM

The actor VM is a fixed-width 13-opcode dispatcher tailored to the title screen's sprite-walk loop. The field VM (FUN_801DE840) is a 43-opcode variable-length dispatcher with cross-context targeting, halt-acquire semantics, sub-dispatcher families, and far richer ctx state. They serve different layers of the engine — actors at the rendering primitive level, scripts at the gameplay-event level — and were almost certainly written by different people on the dev team.

Connection to ANM

The "trigger animation" opcode hands off an ANM container ID to the animation runner. crates/anm parses the container header + per-bone keyframe tables, and legaia_anm::AnimPlayer drives per-frame keyframe interpolation against the actor's bone count (tick() returns a PoseFrame the engine uploads as a posed mesh).

The per-frame anim driver itself lives in SCUS_942.54, not in an overlay. FUN_80021DF4 (4732 bytes, 1183 instructions) is the tick the field/battle scenes call once per frame for every active actor; it reads actor[+0x4C] (record pointer, written by FUN_80024CFC), actor[+0x5A] (dispatch byte), and actor[+0x68] (frame counter, init = 100) and ladders through seven opcodes (0x01..=0x07).

The function is best modelled as a layered pipeline rather than a per-opcode jump table — each dispatch byte selects which subset of side-effects fires in a fixed order. crates/engine-vm/src/actor_tick.rs ports the full pipeline: a typed ActorPhysics struct mirrors the retail actor record's tick-relevant fields (+0x10 through +0xD0, with offset annotations on every field); tick_actor(physics, scalars, listener) -> TickResult is the entry point.

Per-arm physics

  • Common pre-update (every byte). Drains the per-frame timer at +0x54 and the rotation accumulator at +0x22.
  • Keyframe accel (0x02 / 0x06). Folds +0xC0..+0xCA * scalars >> 6 into the shake envelopes at +0xB4..+0xC8, with a sign clamp on +0xC8.
  • Positional SFX emitter (0x05). Distance-based pan / volume engine. Either ramps a fade between (+0x90, +0x92) and (+0x94 + +0x98, +0x96 + +0x9A) over +0xBC frames, or simply integrates +0x98 / +0x9A into +0x90 / +0x92. Issues SsAPI key-on (FUN_80065034), vol-only (FUN_800657D0), or release (FUN_800250D4) calls based on listener distance, channel authority, and a release_pending flag aliased at +0xB4 as i32. Surface: TickEvent::SfxUpdate / TickEvent::SfxRelease.
  • Path interpolation (0x03). Three-axis velocity from +0x96..+0x9A into +0x90..+0x94; zoom envelope at +0x68 (clamped at 0x100); path state machine at +0x9C (caps at 1000, triggers a "skip default movement" shortcut once non-zero).
  • Default movement (every byte except 0x05). Adds +0x80..+0x84 into +0x24..+0x28; runs the trig-LUT-driven world-position update via apply_world_rotation (engine supplies sin / cos LUTs); accumulates the camera-shake envelopes at +0x72 / +0x78 / +0x7A.
  • Common late-update. Caps the focal envelope at 0x1000, the shake envelope at 15000. Optionally fires TickEvent::MoveVmKick (when +0x56 is set), TickEvent::UnlinkRequest (when visibility flag 0x2000 is set), and the per-arm render: line-draws for 0x04 (SplineDraw / DampDraw events), scene-graph triangle for 0x07. For 0x06 with a present record pointer, writes the keyframe pose (KeyframePoseWritten event).

+0xB4 aliases two arms. The 4-byte slot at +0xB4 is read as i32 by the SFX emitter (the "key-on done, release pending" flag) and as two i16s by the keyframe arms (kf_shake[0] and kf_shake[1]). The retail layout aliases these uses; the same actor record never runs the SFX emitter and the keyframe arms in the same frame, so the alias is benign. The Rust port keeps both views as named fields (release_pending: i32, kf_shake: [i16; 4]) and documents the alias inline.

The legacy anim_vm.rs module continues to wrap AnimPlayer for the keyframe pose decoder and surfaces a Host::on_opaque_record hook for record-level side-effects (sprite swaps, voice cues). The new actor_tick module covers the per-arm physics that's the same for every record kind.