Actor / sprite VM
The interpreter that choreographs the game's UI windows: when the shop's gold box, vendor plate and Buy/Sell picker slide onto the screen together, that entrance is a tiny script, and this VM runs it. It is the simplest of Legaia's five runtime VMs - 13 opcodes, fixed 4-byte instructions, no control flow. Fully ported
At a glance
- Driver
FUN_801D6628, dispatch table at0x801CED70(13 entries)- Lives in
- Menu overlay, PROT
0899- resident whenever the pause menu, shop, inn or save UI can appear - Instruction
- Fixed 4 bytes:
[opcode u8][window id u8][operand u16 LE], terminated by opcode0x00 - Programs
- Data in the same overlay image - see window widget scripts
- Targets
- Windows from the menu overlay's 52-record descriptor table (field menu)
- Engine port
crates/engine-vm/src/lib.rs- the reference pattern for every other VM port- Confidence
- Confirmed - disassembly + live shop-transition captures (how we know)
How it works
A caller hands the VM a pointer to a program - a run of fixed 4-byte instructions ending in a zero opcode - and the VM executes the whole thing in one call. Each instruction names a UI window by id, reads that window's home position from the descriptor table, and calls the executable's window helpers. The sliding itself happens over the following frames: the VM installs targets; the per-frame window walker animates them.
The 13 jump-table slots are the minimum a window choreographer needs:
- Open a window at its descriptor-table home, or at a packed position from the operand.
- Close a window; a close / re-open / slide-back composite.
- Slide a window toward a target (home or packed).
- Write a window's style byte; clear its motion flag; tick the global window system.
- Terminator (
0x00); three slots are no-ops.
There is no branching, no subroutines, no cross-context targeting - a program is a flat command list. That contrast is the whole reason it is a separate VM from the field VM, a 43-opcode variable-length dispatcher with halt-acquire semantics and sub-dispatcher families: UI widgets at the presentation layer, scripts at the gameplay layer.
The programs are data resident in the overlay itself, not a scene file: the shop's picker runs the open script (slide in the vendor plate, picker, gold box and two panels) and the slide-away script on the Sell transition. The engine resolves the same programs off the user's disc and runs them through the ported interpreter on the same transitions.
History: "lives in the title-screen overlay"
An earlier reading placed the driver in an unindexed PROT.DAT gap after entry 0899. Recovering the menu overlay's base resolved the same image as PROT 0899 itself - which is why the shop, pause menu and save UI can all reach it.
The per-actor anim tick (a separate system)
This page also covers FUN_80021DF4, the per-actor animation and physics tick - related to the VM only in that both touch actor records. It lives in the main executable and runs once per frame for every active actor, laddering through a dispatch byte at actor+0x5A that selects which side-effects fire:
| Byte | Mnemonic | What it drives |
|---|---|---|
0x01 | Snap | Pose-snap variant. |
0x02 | KeyframeAlt | Per-bone keyframe-style; shares logic with 0x06. |
0x03 | Path | Three-axis velocity path with zoom envelope. |
0x04 | VramScroll | VRAM texture-rect wrap-scroll (scrolling water / conveyor textures). |
0x05 | PathAlt / SFX | Positional sound emitter: distance-based pan and volume. |
0x06 | Keyframe | The dominant path: per-bone keyframe interpolation, fully ported in legaia_anm::AnimPlayer. |
0x07 | Spline | Spline / curve-driven variant. |
The tick reads three fixed actor fields: the record pointer at +0x4C, the dispatch byte at +0x5A, and the frame counter at +0x68 (advanced by the per-actor frame delta). The port models the function as a layered pipeline - each dispatch byte selects a subset of side-effects in a fixed order - in crates/engine-vm/src/actor_tick.rs, with a typed ActorPhysics struct annotating every retail offset.
Per-arm physics
Every dispatch byte shares a common pre-update (timer drain, rotation accumulator) and late-update (envelope caps, optional move-VM kick, unlink request, per-arm render); the arms differ in the middle. The full arm-by-arm walkthrough:
Deep dive: the six arms, field by field
- 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>> 6into the shake envelopes at+0xB4..+0xC8, sign-clamped on+0xC8. - Positional SFX emitter (
0x05). Ramps a fade between(+0x90, +0x92)and(+0x94++0x98, +0x96++0x9A)over+0xBCframes, or integrates the velocity pair. Issues SsAPI key-on (FUN_80065034), volume-only (FUN_800657D0) or release (FUN_800250D4) calls from listener distance and channel authority. Surface:TickEvent::SfxUpdate / SfxRelease. - Path interpolation (
0x03). Velocity from+0x96..+0x9Ainto+0x90..+0x94; zoom envelope at+0x68(cap0x100); path state at+0x9C(cap 1000, skips default movement once non-zero). - Default movement (every byte except
0x05). Adds+0x80..+0x84into+0x24..+0x28; trig-LUT world-position update; camera-shake accumulation at+0x72/+0x78/+0x7A. - Common late-update. Caps the focal envelope at
0x1000, shake at 15000; firesMoveVmKick(+0x56set) andUnlinkRequest(flag0x2000); per-arm render - line draws for0x04, scene-graph triangle for0x07, keyframe pose write for0x06.
+0xB4 aliases two arms: the SFX emitter reads it as an i32 release-pending flag; the keyframe arms as two i16 shake values. The same actor never runs both in one frame, so the alias is benign; the port keeps both named views. The only intentional divergences from the retail arithmetic are an i64 multiply-shift for the MIPS MULT+MFLO pair and a saturation-clamp helper - both functionally equivalent.
VramScroll detail (0x04): StoreImage a band (jal 0x8005842C at 0x80022D68) → MoveImage the remainder (0x80022DB0) → LoadImage the band back at the far edge (0x80022DE8), all on the actor's +0xD0 rect; the countdown at +0xC6 drains by *(0x1F800393) and reloads from +0xC4; installed by move-VM op 0x1E. The earlier "damping / spring-decay" label for this arm was a decompiled-C-era reading; the call order above is read from the instructions.
One pointer, several meanings (actor+0x4C)
The actor field at +0x4C is a multi-purpose pointer whose meaning depends on which spawn path created the actor. Background actors spawned from a VDF record keep their spawn bytes there (consumed once, at spawn, to size and fill the vertex pool); keyframe-animated actors keep their pose-output buffer there, read by the renderer's per-bone interpolator. The retail engine relies on disjoint actor classes never colliding.
Two consequences for the port: the 13-opcode window VM never reads +0x4C (it walks an external command list and looks actors up by slot byte), and VDF-spawned actors need no "PC bootstrap" - they are driven by the render pipeline, not by ticking their spawn bytes as opcodes.
Deep dive: writers and readers of actor+0x4C
| Routine | Role | Payload / access |
|---|---|---|
FUN_801D77F4 | writer + one-shot reader | VDF body ([u32 record_count] then 12-byte records starting [u32 group_idx]); walked at spawn to size the vertex-pool malloc and copy vertices from the indexed TMD groups into actor+0x90. |
FUN_80024CFC | writer | Pose-output buffer bound on animation transition; also seeds the frame counter to 100. |
FUN_80021DF4 arm 0x06 | writer | Per-bone interpolated pose bytes: count at +0, ones at +0x02/+0x06, 8-byte-stride bones from +0x0F. |
FUN_8001BE80 | reader | The part's frame-0 entry at ptr + bone*8 + 8: the blend target on a looping clip's last frame. |
FUN_800495C8 | reader | Per-bone envelope curve walker at ptr + 4. |
FUN_8003A1E4, FUN_801DE840 | readers | Animation-period u16 at ptr + 2 (modulo target for the frame index). |
VDF body header: the first u32 is the record count and the 12-byte records start 4 bytes in. Actor::spawn_record in the engine is a retention slot mirroring the retail write; nothing feeds it back into a VM tick.
History: capture signature + superseded framings
Diffing the actor pool between an idle save and an active-art-strike save shows the dispatch byte and record pointer flipping in lockstep - 0x04 idle vs 0x06 playing, and the pointer flipping between a self-reference and a real address into the scene's ANM payload. Two superseded framings: "the actor VM bootstraps its PC from the spawn record" (it never dispatches on that buffer at all), and a "16-byte VDF body header" that was off by 12.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Window-script driver | FUN_801D6628 | 13-entry jump table at 0x801CED70; 4-byte instruction stride; window-id lookup by slot byte. | menu overlay dump |
| Anim / physics tick | FUN_80021DF4 | Dispatch-byte ladder 0x01..0x07; per-arm side-effect order; envelope caps. | ghidra/scripts/funcs/80021df4.txt |
| Keyframe registrar | FUN_80024CFC | Binds the pose buffer at +0x4C, seeds the frame counter. | 80024cfc.txt |
| VDF spawn walker | FUN_801D77F4 | Record-count-first body layout; one-shot spawn consumption into the vertex pool. | field overlay dump |
| Pose interpolator | FUN_8001BE80 | Reads the case-0x06 per-bone layout back for the two-keyframe lerp. | 8001be80.txt |
| Live captures | actor pool 0x801C9594.. | Dispatch byte + record pointer flip together across idle vs playing saves. | mednafen state diffs |
Full write-up: docs/subsystems/actor-vm.md; program byte-spec on window scripts.