At a glance

Driver
FUN_801D6628, dispatch table at 0x801CED70
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]; opcode 0x00 ends 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 (run over a Host trait), hosted by engine-menus::menu_widget::MenuWidgetState; live on both play hosts
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, animation programs are 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 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.

menu screen e.g. shop picker entry FUN_801D6628 read [op][window][u16] home = table[window] call one window helper until op == 0, one call window list create / destroy snap position slide target begin close walker next frames no branches, no subroutines, no cross-context targeting
The VM is a one-shot command list: everything it does is install a window state. Motion is target-based, so a "slide" instruction finishes immediately and the window arrives over the next frames.
OpcodeWhat it does to the named window
00End of program
01Open (create if missing) and slide toward its home position; motion cleared if it is already there
02Open and slide toward the packed position in the operand
03Write the operand's low byte as the window's style byte
04Begin an animated close
05Close every window
06Clear the window's motion flag
07No-op
08Destroy immediately (free its buffers, unlink it)
09Open and snap to the packed position, or to home when the operand is zero
0ARe-create in place: remember its position, destroy, re-create, snap back there
0B..0DNo-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.

ByteNameWhat it drives
0x01PlainCommon stages only: plain kinematics, no extra arm (the ladder never tests 1)
0x02KeyframeAltPer-bone keyframe-style; shares logic with 0x06
0x03PathIntegrates a velocity triple and hands it to the CLUT-cell HSV cycler - the scripted colour pulses; skips the default motion
0x04VramScrollWrap-scrolls a VRAM texture rect (water, conveyors); installed by move-VM op 0x1E
0x05PathAlt / SFXPositional sound emitter: distance-based pan and volume; skips the default motion
0x06KeyframeThe dominant path: per-bone keyframe interpolation
0x07SplineSpline / 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.

  1. Pre-updateEvery byte: drain the per-frame timer at +0x54 and the rotation accumulator at +0x22.
  2. Arm0x02 / 0x06 fold acceleration into the shake envelopes; 0x05 runs the SFX emitter; 0x03 integrates the path velocity.
  3. Default movementEvery byte except 0x03 and 0x05: add the angular rates, the trig-LUT world update, and step render scale and depth-cue by their rates.
  4. Late-updateCap the envelopes; kick the move VM; cull; render the arm (line draws for 0x04, a triangle for 0x07, the keyframe pose for 0x06).
The arms field by field
  • Keyframe accel (0x02 / 0x06). Folds +0xC0..+0xCA × scalars >> 6 into the shake envelopes at +0xB4..+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.
  • Path (0x03). Velocity from +0x96..+0x9A into +0x90..+0x94, handed to the CLUT-cell HSV cycler FUN_80019D50.
  • VramScroll (0x04). StoreImage a band → MoveImage the remainder → LoadImage the band back at the far edge, on the actor's +0xD0 rect; the countdown at +0xC6 drains by the frame scalar and reloads from +0xC4.
  • +0xB4 aliases two arms: the SFX emitter reads it as an i32 release-pending flag, the keyframe arms as two i16 shake 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
RoutineRolePayload / access
FUN_801D77F4writer + one-shot readerVDF 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_80024CFCwriterCLUT-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 0x06writerPer-bone interpolated pose bytes, 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_800495C8not this slotBattle 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_801DE840readersAnimation-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 op 0x43; its standalone spawn half is FUN_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 is 2*apex - mid, which puts the curve through apex at 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 is 0x1000 / duration.
  • Attached light (tick FUN_801E4470, field-VM op 0x34 sub-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): 8 retires the light and 2, the visibility cull FUN_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 (dream P2[1]) rides a marker whose prologue zeroes its scale. Ported in engine-vm::field_actor_billboard and 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

FunctionAddressWhat it provesDump
Window-script driverFUN_801D662813-entry jump table at 0x801CED70; 4-byte stride; descriptor-table base 0x801E4738overlay_menu_801d6628.txt
Window helpersFUN_80035334 / 800326AC / 800357FC / 800358C0 / 80035978 / 80035A4C / 800319A8Lookup, create off the park edge, start-slide, snap, begin-close (with the slide reversal), close-all, destroyfuncs/800357fc.txt and siblings
Anim / physics tickFUN_80021DF4Dispatch-byte ladder (never tests 1); per-arm side-effect order; envelope capsghidra/scripts/funcs/80021df4.txt
CLUT-walk spawnerFUN_80024CFCBinds a CLUT-walk table entry at +0x4C on the ambient palette walkers it allocates, seeds the frame counter80024cfc.txt
VDF spawn walkerFUN_801D77F4Record-count-first body layout; one-shot spawn consumptionfield overlay dump
Live capturesactor pool 0x801C9594..Dispatch byte and record pointer flip together across idle vs art-strike savesmednafen state diffs

Full write-up: docs/subsystems/actor-vm.md; program byte-spec on window scripts.

See also