Actor / sprite VM
A small fixed-width bytecode VM driving the title screen's animated sprite cluster. The simplest of Legaia's runtime VMs — 13 opcodes, byte-stream operands, no cross-context targeting. Fully ported.
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
+0x54and 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+0xBCframes, or simply integrates+0x98 / +0x9Ainto+0x90 / +0x92. Issues SsAPIkey-on(FUN_80065034),vol-only(FUN_800657D0), orrelease(FUN_800250D4) calls based on listener distance, channel authority, and arelease_pendingflag aliased at+0xB4asi32. Surface:TickEvent::SfxUpdate/TickEvent::SfxRelease. - Path interpolation (
0x03). Three-axis velocity from+0x96..+0x9Ainto+0x90..+0x94; zoom envelope at+0x68(clamped at0x100); path state machine at+0x9C(caps at1000, triggers a "skip default movement" shortcut once non-zero). - Default movement (every byte except
0x05). Adds+0x80..+0x84into+0x24..+0x28; runs the trig-LUT-driven world-position update viaapply_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 at15000. Optionally firesTickEvent::MoveVmKick(when+0x56is set),TickEvent::UnlinkRequest(when visibility flag0x2000is set), and the per-arm render: line-draws for0x04(SplineDraw/DampDrawevents), scene-graph triangle for0x07. For0x06with a present record pointer, writes the keyframe pose (KeyframePoseWrittenevent).
+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.