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:

OffsetTypeFieldNotes
+0x4Cu32record_ptrPer-record byte pointer; written by FUN_80024CFC when a new animation is registered.
+0x5Au16dispatch_byteSelects the per-opcode handler block (0x01..=0x07).
+0x68u16frame_counterInitialised 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:

ByteMnemonicNotes
0x01SnapPose-snap variant (handler block TBD).
0x02KeyframeAltPer-bone keyframe-style; shares with 0x06.
0x03PathState-write logic shared with 0x05.
0x04DampDamping / spring-decay variant.
0x05PathAltReads geometry from actor[+0x80] and writes pose state.
0x06KeyframeThe dominant path. Per-bone keyframe interpolation; fully ported in legaia_anm::AnimPlayer.
0x07SplineSpline / 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 +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.

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

WriterPayloadWhen
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

ReaderWhat it does with actor[+0x4C]
FUN_801D77F4 itselfWalks 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_801D6628 is not a consumer of actor[+0x4C]. That function walks an external 4-byte-stride bytecode stream (passed in as param_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 following actor[+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 +0x4C body bytes as actor-VM opcodes.
  • Actor::spawn_record in legaia_engine_core is a retention / observation slot. Mirroring the retail actor[+0x4C] = VDF_body_ptr write 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 of FUN_801D77F4's second pass) - already wired in the host hook.
  • legaia_engine_vm::actor does not need an entry_with_spawn_record constructor. 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).

See also