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 is the tick the field/battle scenes call once per frame for every active actor. It reads three fixed offsets on the per-actor record:
| Offset | Type | Field | Notes |
|---|---|---|---|
+0x4C | u32 | record_ptr | Per-record byte pointer; written by FUN_80024CFC when a new animation is registered. |
+0x5A | u16 | dispatch_byte | Selects the per-opcode handler block (0x01..=0x07). |
+0x68 | u16 | frame_counter | Initialised to 100 by FUN_80024CFC; advanced each tick by actor[+0x6A] (per-actor frame delta). |
The crates/engine-vm constants ACTOR_RECORD_PTR_OFFSET, ACTOR_DISPATCH_BYTE_OFFSET, and ACTOR_FRAME_COUNTER_OFFSET mirror those addresses.
Dispatch byte values
FUN_80021DF4 ladders through the dispatch byte (actor[+0x5A]) and routes to per-opcode handler blocks:
| Byte | Mnemonic | Notes |
|---|---|---|
0x01 | Snap | Pose-snap variant (handler block TBD). |
0x02 | KeyframeAlt | Per-bone keyframe-style; shares with 0x06. |
0x03 | Path | State-write logic shared with 0x05. |
0x04 | Damp | Damping / spring-decay variant. |
0x05 | PathAlt | Reads geometry from actor[+0x80] and writes pose state. |
0x06 | Keyframe | The dominant path. Per-bone keyframe interpolation; fully ported in legaia_anm::AnimPlayer. |
0x07 | Spline | Spline / curve-driven variant. |
crates/engine-vm's DispatchByte enum exposes those values as a typed dispatch and reports handled_natively() for the cases the keyframe pose decoder can drive on its own (currently only Keyframe). The per-actor physics arms - the position / velocity / acceleration math common to every dispatch byte - are ported in crates/engine-vm/src/actor_tick.rs.
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. 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. The arithmetic mirrors the retail decompilation field-for-field; the only intentional simplifications are an i64 multiply-shift in place of the MIPS MULT + MFLO pair (functionally equivalent) and a saturation-clamp helper in place of the explicit if (val < 0) / if (val > N) pairs the compiler emitted.
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.
Mednafen-state diff signature
Diffing the actor pool (0x801C9594..0x801C9F7F, 0x60-byte stride per anim slot) between a battle-intro idle save and an active-art-strike save shows the dispatch byte and the per-record pointer flipping in lockstep - the dispatch byte's lane (record +0x0F/+0x10) carries values like 0x04 (idle) and 0x06 (playing) across the same slot. The per-record pointer (+0x00 of each anim slot, mirroring actor[+0x4C]) similarly flips between a self-reference (idle / sentinel pose) and a real RAM address that points into the scene-loaded ANM payload.
Spawn-record consumption (actor[+0x4C] is overloaded)
actor[+0x4C] is a multi-purpose pointer field whose meaning depends on which spawn path created the actor, not on a per-frame dispatch lookup. Two writers and several readers populate it with structurally distinct payloads; the retail engine relies on disjoint actor classes for them never to collide.
Writers
| Writer | Payload | When |
|---|---|---|
FUN_801D77F4 (overlay actor allocator, field-VM 0x4C 0xD8 host hook) | VDF body bytes ([u32 record_count][record_0]…[record_n], each record 12 bytes starting [u32 group_idx]) | Synchronous spawn of a background actor whose mesh comes from the global TMD pool. See script VM. |
FUN_80024CFC (ANM keyframe registrar) | Pose-output buffer (per-bone 8-byte data at +0x0F) | Animation transition - bound when the engine starts a new keyframe arm. |
Readers
| Reader | What it does with actor[+0x4C] |
|---|---|
FUN_801D77F4 itself | Walks the VDF body's record table at spawn time to compute the per-actor vertex-pool malloc size and to copy per-vertex bytes out of the indexed TMD groups into actor[+0x90]. The body is consumed once at spawn; the persisted pointer is a retention reference, not actively re-read. |
FUN_80021DF4 case 0x06 (Keyframe arm) | Writes per-bone interpolated pose bytes into the buffer at +0x00 (count), +0x02..+0x03 (= 1), +0x06 (= 1), +0x0F.. (per-bone 8-byte stride). |
FUN_8001BE80 (per-bone pose interpolator, GTE render path) | Reads *(int*)(actor + 0x4C) + bone_idx*8 + 8 as a second pose snapshot for per-vertex lerp between two keyframes - matching the case-0x06 writer's per-bone layout. |
FUN_800495C8 (animation envelope sampler) | Reads *(int*)(actor + 0x4C) + 4 as a per-bone curve walker (4-byte header skip; per-record byte ranges describe interpolation envelopes). |
FUN_8003A1E4 (foreground actor spawner) and FUN_801DE840 (field VM) | Both read *(ushort*)(actor[+0x4C] + 2) as an animation-period u16 (modulo target for the current frame index). Matches the case-0x06 writer's = 1 at +0x02..+0x03. |
Implications for the clean-room port
- The actor VM at
FUN_801D6628is not a consumer ofactor[+0x4C]. That function walks an external 4-byte-stride bytecode stream (passed in asparam_1), dispatching each command through the 13-entry jump table and routing side-effects to actor records looked up by the slot byte (param_1[+1]), not by followingactor[+0x4C]. - No PC-bootstrap entry is needed. The earlier framing - "the actor VM starts by resetting PC to 0 of the spawn record" - doesn't apply: VDF-spawned actors are driven by the vertex-pool render pipeline (
actor[+0x90]), not by ticking their+0x4Cbody bytes as actor-VM opcodes. Actor::spawn_recordinlegaia_engine_coreis a retention / observation slot. Mirroring the retailactor[+0x4C] = VDF_body_ptrwrite keeps the bytes alive for diagnostic inspection but doesn't need to be fed back into any clean-room VM tick. The downstream consumer that would matter is the per-actor vertex-pool allocator (mirror ofFUN_801D77F4's second pass) - already wired in the host hook.legaia_engine_vm::actordoes not need anentry_with_spawn_recordconstructor. The 13-opcode dispatcher consumes an external command list, not the VDF body; the host hook already mirrors the retail spawn-time writes.
VDF body header
Read against FUN_801D77F4's walker - *puVar11 = record_count, then *puVar10 = group_idx advancing +12 bytes per record - the first u32 is the record count and the records start 4 bytes in. An earlier "16-byte header" framing was off-by-12. The actor VM does not skip any metadata header before dispatch, because the actor VM never dispatches on this buffer at all (per the first implication above).