At a glance

Driver
FUN_801D6628, dispatch table at 0x801CED70 (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 opcode 0x00
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)

What it is not

Despite the name it inherited, this VM never moves characters. NPC pathing is the motion VM, combat choreography is the move VM, and per-frame body animation is the separate anim tick below. This VM only ever moves windows.

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.

1Callera menu screen (e.g. the shop's Buy/Sell picker) materialises a program pointermenu overlay 2VM runone call executes the whole program: 4-byte instructions until opcode 0x00FUN_801D6628 3Window helperslook up / create / close a window; install a slide targetSCUS helpers 4Per-frame walkeranimates each window toward its target over the next frameswindow system

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:

ByteMnemonicWhat it drives
0x01SnapPose-snap variant.
0x02KeyframeAltPer-bone keyframe-style; shares logic with 0x06.
0x03PathThree-axis velocity path with zoom envelope.
0x04VramScrollVRAM texture-rect wrap-scroll (scrolling water / conveyor textures).
0x05PathAlt / SFXPositional sound emitter: distance-based pan and volume.
0x06KeyframeThe dominant path: per-bone keyframe interpolation, fully ported in legaia_anm::AnimPlayer.
0x07SplineSpline / 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 +0x54 and the rotation accumulator at +0x22.
  • Keyframe accel (0x02/0x06). Folds +0xC0..+0xCA × scalars >> 6 into 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 +0xBC frames, 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..+0x9A into +0x90..+0x94; zoom envelope at +0x68 (cap 0x100); path state at +0x9C (cap 1000, skips default movement once non-zero).
  • Default movement (every byte except 0x05). Adds +0x80..+0x84 into +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; fires MoveVmKick (+0x56 set) and UnlinkRequest (flag 0x2000); per-arm render - line draws for 0x04, scene-graph triangle for 0x07, keyframe pose write for 0x06.

+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
RoutineRolePayload / access
FUN_801D77F4writer + one-shot readerVDF 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_80024CFCwriterPose-output buffer bound on animation transition; also seeds the frame counter to 100.
FUN_80021DF4 arm 0x06writerPer-bone interpolated pose bytes: count at +0, ones at +0x02/+0x06, 8-byte-stride bones from +0x0F.
FUN_8001BE80readerThe part's frame-0 entry at ptr + bone*8 + 8: the blend target on a looping clip's last frame.
FUN_800495C8readerPer-bone envelope curve walker at ptr + 4.
FUN_8003A1E4, FUN_801DE840readersAnimation-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

FunctionAddressWhat it provesDump
Window-script driverFUN_801D662813-entry jump table at 0x801CED70; 4-byte instruction stride; window-id lookup by slot byte.menu overlay dump
Anim / physics tickFUN_80021DF4Dispatch-byte ladder 0x01..0x07; per-arm side-effect order; envelope caps.ghidra/scripts/funcs/80021df4.txt
Keyframe registrarFUN_80024CFCBinds the pose buffer at +0x4C, seeds the frame counter.80024cfc.txt
VDF spawn walkerFUN_801D77F4Record-count-first body layout; one-shot spawn consumption into the vertex pool.field overlay dump
Pose interpolatorFUN_8001BE80Reads the case-0x06 per-bone layout back for the two-keyframe lerp.8001be80.txt
Live capturesactor 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.

See also