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 runtime VMs - 13 jump-table slots, fixed 4-byte instructions, no control flow. This page also covers the per-actor animation tick, a separate routine that shares the name "actor".
At a glance
- Driver
FUN_801D6628, dispatch table at0x801CED70- 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]; opcode0x00ends the program - Programs
- Data in the same overlay image - see window widget scripts
- Targets
- Windows from the menu overlay's 52-record descriptor table (field menu)
- In the port
engine-vm(runover aHosttrait), hosted byengine-menus::menu_widget::MenuWidgetState; live on both play hosts- Confidence
- Confirmed - disassembly + live shop-transition captures (how we know)
How it works
A caller hands the VM a pointer to a program and the VM executes the whole thing in one call. It installs targets; the sliding itself happens over the following frames, in the window system's per-frame walker.
| Opcode | What it does to the named window |
|---|---|
00 | End of program |
01 | Open (create if missing) and slide toward its home position; motion cleared if it is already there |
02 | Open and slide toward the packed position in the operand |
03 | Write the operand's low byte as the window's style byte |
04 | Begin an animated close |
05 | Close every window |
06 | Clear the window's motion flag |
07 | No-op |
08 | Destroy immediately (free its buffers, unlink it) |
09 | Open and snap to the packed position, or to home when the operand is zero |
0A | Re-create in place: remember its position, destroy, re-create, snap back there |
0B..0D | No-op |
A packed position is x = (w >> 7) & 0x1FE, y = w & 0xFF, so x is always even. The programs are data resident in the overlay itself, not a scene file: the shop's picker runs its open program (slide in the vendor plate, picker, gold box and two panels) on entry and the slide-away program on the Sell transition.
Creation and the park edge
Every window is created off screen. The create helper keeps the window list sorted by id, re-initialises a window already on it, and parks a new one past an edge of the display picked by the descriptor's park class: 0 below the bottom, 2 past the left, 4 above the top, 6 past the right, each by the window's size plus a small clearance. The odd classes are diagonals that move both axes, and a class of 8 or more creates the window at home. The live position and the slide source take the park, the target the home, and every slide lasts 12 frames. Op 01's "already home" test therefore compares the real park with the home.
Closing reverses the slide. Begin-close leaves a closing window alone; a still window starts its close from the beginning, and one still sliding in turns round from where it is, its progress mirrored (12 − progress), so it backs out along the way it came.
Menus wait on the slides. The per-frame motion pass that animates them counts the windows it moved, and every menu sub-screen waits for that count to fall to zero before it reads the pad (field menu). In the port the pass is MenuWidgetState::step_motion, windows are created at menu_widget::park_position, and the kernel differential runs the interpreter and all seven helpers against the retail bytes.
There is no branching and no cross-context targeting - a program is a flat command list. That contrast is the 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.
History: "lives in the title-screen overlay", and the helper names
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 same pass found the slide and snap helpers' names swapped (read off their tails, FUN_800357FC starts a slide and FUN_800358C0 snaps) and what had been called a "global tick" helper to be close-all.
The Rust opcode names are the old ones; the host calls are not
legaia_engine_vm::Opcode was written under the sprite-engine reading and keeps its variant names: SpawnDefault (01), SpawnAt (02), SetField1d (03), DeleteSprite (04, really begin-close), GlobalUpdate (05, really close-all), ClearField20 (06), Effect (08, really immediate destroy), MotionAt (09) and EffectMotion (0A). The Host methods each arm calls carry the corrected meaning - slide_to on 01 / 02, snap_to on 09 / 0A, begin_close, close_all and destroy - and the menu window list keeps each window’s live position, slide source and target.
The per-actor anim tick (a separate system)
FUN_80021DF4 is the per-actor animation and physics tick, related to the VM above only in that both touch actor records. It lives in the main executable and runs once per game tick for every active actor, laddering through a dispatch byte at actor+0x5A that selects which side-effects fire. It is also where the move VM gets stepped.
| Byte | Name | What it drives |
|---|---|---|
0x01 | Plain | Common stages only: plain kinematics, no extra arm (the ladder never tests 1) |
0x02 | KeyframeAlt | Per-bone keyframe-style; shares logic with 0x06 |
0x03 | Path | Integrates a velocity triple and hands it to the CLUT-cell HSV cycler - the scripted colour pulses; skips the default motion |
0x04 | VramScroll | Wrap-scrolls a VRAM texture rect (water, conveyors); installed by move-VM op 0x1E |
0x05 | PathAlt / SFX | Positional sound emitter: distance-based pan and volume; skips the default motion |
0x06 | Keyframe | The dominant path: per-bone keyframe interpolation |
0x07 | Spline | Spline / curve-driven variant; installed by move-VM op 0x45 |
The tick reads three fixed fields: the record pointer at +0x4C, the dispatch byte at +0x5A, and the frame counter at +0x68 (seeded to 100 when an animation is registered). In the port it is engine-vm::actor_tick, a layered pipeline with a typed ActorPhysics struct; the keyframe arm is legaia_anm::AnimPlayer.
Per-arm physics
Every dispatch byte shares a common pre-update (timer drain, rotation accumulator) and late-update (envelope caps, optional move-VM kick, visibility cull, per-arm render); the arms differ in the middle.
- Pre-updateEvery byte: drain the per-frame timer at
+0x54and the rotation accumulator at+0x22. - Arm
0x02/0x06fold acceleration into the shake envelopes;0x05runs the SFX emitter;0x03integrates the path velocity. - Default movementEvery byte except
0x03and0x05: add the angular rates, the trig-LUT world update, and step render scale and depth-cue by their rates. - Late-updateCap the envelopes; kick the move VM; cull; render the arm (line draws for
0x04, a triangle for0x07, the keyframe pose for0x06).
The arms field by field
- Keyframe accel (
0x02/0x06). Folds+0xC0..+0xCA× scalars>> 6into the shake envelopes at+0xB4..+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. - Path (
0x03). Velocity from+0x96..+0x9Ainto+0x90..+0x94, handed to the CLUT-cell HSV cyclerFUN_80019D50. - VramScroll (
0x04). StoreImage a band → MoveImage the remainder → LoadImage the band back at the far edge, on the actor's+0xD0rect; the countdown at+0xC6drains by the frame scalar and reloads from+0xC4. +0xB4aliases two arms: the SFX emitter reads it as ani32release-pending flag, the keyframe arms as twoi16shake values. One actor never runs both, so the alias is benign.
One pointer, several meanings (actor+0x4C)
The field at +0x4C is a multi-purpose pointer whose meaning depends on the spawn path that created the actor. A morph actor spawned from a VDF record keeps its spawn bytes there (read once, at spawn, to size and fill the vertex pool); a keyframe-animated actor keeps its pose-output buffer there, read by the renderer's per-bone interpolator. Retail relies on the actor classes never colliding. The window VM above never reads +0x4C, and nothing ticks a VDF body's bytes as opcodes.
A VDF body is [u32 record_count] followed by 12-byte records, each beginning [u32 group_idx] - there is no 16-byte header, only the 4-byte count. The port keeps the bytes in a retention slot (Actor::spawn_record); what consumes them is the per-actor vertex-pool allocator. One detail of that allocator is open: its first pass advances a 12-byte cursor while the second advances vertex_count * 8.
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); walked at spawn to size the vertex pool and copy vertices from the indexed TMD groups |
FUN_80024CFC | writer | CLUT-walk spawner: binds an entry of the type-6 CLUT-walk table (_DAT_8007B7C8 + table[id]) with walker state actor[+0x56] = 0xB, and seeds the frame counter to 100 |
FUN_80021DF4 arm 0x06 | writer | Per-bone interpolated pose bytes, 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 | not this slot | Battle clip-morph blend: it runs on a battle draw node, whose +0x4C is the playing monster action entry, and reads that entry’s +0x04 / +0x08 lanes (clip morphs) |
FUN_8003A1E4, FUN_801DE840 | readers | Animation-period u16 at ptr + 2 |
Field-overlay actor families
Two field-overlay (PROT 0897) pool-actor families hang off a parent actor's +0x90 back-link on the shared actor list the field VM walks. Neither is an opcode of the window VM.
- Scripted arc (
FUN_801D25EC, reached only from field-VM op0x43; its standalone spawn half isFUN_801D5780). Given a source actor, a landing point, an arc height and a frame count, it spawns an actor that hops along a quadratic Bézier: the X and Z midpoints are plain, but the Y control point is2*apex - mid, which puts the curve throughapexat its halfway point. The arc height is how far above the higher endpoint the hop peaks - a lob, not a straight tween. The per-frame step is0x1000 / duration. - Attached light (tick
FUN_801E4470, field-VM op0x34sub-1). Each frame it adds the parent's world position to its own offset, screen-projects it, and draws an untextured semi-transparent light pool. Of the parent's state it reads only two bits of+0x10(0x801E4490..0x801E44B8):8retires the light and2, the visibility cullFUN_801D79E8, skips a frame. A zero render scale hides nothing, so a light can hang on an invisible marker: the light Noa follows in her first dream (dreamP2[1]) rides a marker whose prologue zeroes its scale. Ported inengine-vm::field_actor_billboardand drawn on both play hosts.
At scene load, the re-hydration pass FUN_801D7518 walks each of the seven actor lists and allocates the runtime side-buffers a freshly loaded actor record does not carry - retiring three handler classes, rebuilding object tables, a VRAM staging buffer for anim-tick actors on dispatch byte 3, and a two-key interpolation table for those on byte 6. The field overlay's MAN-load reset hook FUN_801D9C3C reseeds its globals and find-or-spawns one actor.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Window-script driver | FUN_801D6628 | 13-entry jump table at 0x801CED70; 4-byte stride; descriptor-table base 0x801E4738 | overlay_menu_801d6628.txt |
| Window helpers | FUN_80035334 / 800326AC / 800357FC / 800358C0 / 80035978 / 80035A4C / 800319A8 | Lookup, create off the park edge, start-slide, snap, begin-close (with the slide reversal), close-all, destroy | funcs/800357fc.txt and siblings |
| Anim / physics tick | FUN_80021DF4 | Dispatch-byte ladder (never tests 1); per-arm side-effect order; envelope caps | ghidra/scripts/funcs/80021df4.txt |
| CLUT-walk spawner | FUN_80024CFC | Binds a CLUT-walk table entry at +0x4C on the ambient palette walkers it allocates, seeds the frame counter | 80024cfc.txt |
| VDF spawn walker | FUN_801D77F4 | Record-count-first body layout; one-shot spawn consumption | field overlay dump |
| Live captures | actor pool 0x801C9594.. | Dispatch byte and record pointer flip together across idle vs art-strike saves | mednafen state diffs |
Full write-up: docs/subsystems/actor-vm.md; program byte-spec on window scripts.