Move-table opcode VM
A tiny per-actor animation engine. Every actor - a party member mid-art, a summon's body part, a swinging door, a looping torch flame, a spell's beam - owns a "move buffer" and a program counter into it, and every frame the actor tick runs opcodes out of that buffer until one of them stops. It is what turns a move record into motion, pose and animation-bank changes, tween setup and per-actor flag writes, and it lives in the always-resident main executable.
At a glance
- Driver
FUN_80023070inSCUS_942.54, jump table at0x80010778- Opcodes
- 71 (
0x00..0x46), plus 61 field-overlay sub-ops behind op0x2F - Program
- Move buffer at
actor+0x48, program counter atactor+0x70 - Operand width
- u16 stream - the PC and every size count in 16-bit units, not bytes
- Fed by
- The summon / cast stagers and the per-scene prescript stager table; both share one record format. Field-VM op
0x22 EXEC_MOVEis not a source: it drives an ANM clip, not this buffer - In the port
engine-vm::move_vm, host callbacks via theMoveHosttrait- Confidence
- Confirmed - per-case disassembly (how we know)
The runtime VM family
Legaia runs five bytecode / state VMs that share the actor model, and confusing them is the classic mistake in this corpus. Five is an orientation, not a census - the full list is the VM inventory.
| VM | What it drives in the game | Driver | Where | Opcodes | Operands |
|---|---|---|---|---|---|
| Field / event VM | Towns, NPC talk, doors, cutscenes, story flags | FUN_801DE840 | Field overlay (0897) | 43 + flag routes | byte stream |
| Motion VMs (two) | Scripted walks, NPC wandering, facing ramps | FUN_8003774C / FUN_80038158 | SCUS_942.54 | 6 / 26 | byte stream |
| Move VM (this page) | Per-actor animation programs: poses, tweens, loops, render setup | FUN_80023070 | SCUS_942.54 | 71 + 61 via 0x2F | u16 stream |
| Window-widget VM | Menu and shop window choreography | FUN_801D6628 | Menu overlay (0899) | 13 | 4-byte instructions |
| Effect VM | Battle sparks and spell sprites | FUN_801E0080 | Battle overlay (0898) | none - a slot state machine | spawn records |
How a frame runs
0x2F works only in the field and 0x17 only in battle.The u16 trap
Operands, sizes and the PC all count in 16-bit units. The other Legaia VMs use byte streams, so a disassembler that assumes bytes here decodes garbage from the second instruction on. Handlers that look like no-ops in decompiled C still advance the PC - the increment hides in a branch-delay slot.
Every record starts [i16 model_sel][u16 reserved] before its bytecode: −1 makes a transform node, 0x4000 / 0x4001 a render-mode node, ≥ 0 a library mesh. Bytecode ends at op 0x08.
An opcode past the table. A word of 0x47 or more (read signed, so 0x8000 and up too) does not end the frame. It branches to the dispatcher's epilogue, which adds the width the previous instruction left in its size register and loops on while the flag stands, so the stray word is stepped over by that width, the same way 0x0B is. Ahead of a pass's first instruction the width is whatever the caller left. The port's move_vm::FrameStep carries the width across the frame, and the retail-kernel differential holds it to retail.
A part seated mid-walk. The per-frame list walk reads each node's next before it runs the node, and a new part is linked at the list's tail. So a part one part seats from inside its own tick runs on the same frame only when something already stood behind the seating part: an emitter that is the newest node seats parts that wait a frame, one with older parts behind it seats parts that move on the frame they appear. Barra's cloud stream is the second kind. A part seated from a seater's own first run lands behind the nodes still to be walked as well: a new node runs its program once as it is seated, so an op 0x20 call it opens with seats parts the same walk reaches. Viguro's ring emitter (PROT 0909) calls its stager before its first wait, and retail's first ring shows one radius step on the frame it opens; an emitter that opens on WAIT 0 seats from the walk instead. The port models the rule on both hosts.
The instruction set
Grouped by what they touch. Sizes are in u16 words and include the opcode; v1..vN are the operand words.
| Group | Opcodes | What they do |
|---|---|---|
| Position | 01 03 07 2D 35 37 2B | Add to / rotate-add into / set world X Y Z; add or set the tween-source triple and its rates |
| Pose banks | 00 02 04 05 06 39 | Set the animation banks (value << 3) and the render-bank rotation slots, absolute or additive |
| Scale and colour | 0C..12 14 2E 28..2A 33 | Composite draw word, depth-cue and scale rates, per-axis scales, colour channels |
| Control | 08 09 18..1B 0B 22 | Halt; wait (timer at +0x54); two nestable counted loops (count bit 0x4000 = loop forever); continue |
| Flags | 31 32 3A 3B 43 15 | AND / OR the local flag bank at +0x62; set / clear actor flag bit 2; camera-relative node bits |
| Tweens and keys | 0A 2C 30 34 36 3C..3F 46 | VDF morph lanes; allocate / free a key buffer; tween setup and duration; seat or retarget a keyframe pose; blend cursor and rate; tween-delta seed |
| Render setup | 13 1C 1E 1F 23 42 44 45 | Draw-kind-4 nodes (default emitter, sprite arm, lightning-style ribbon) and the emitter-select word, a beam length, the VRAM wrap-scroll, a morph install, a framebuffer grab |
| Sprites | 24 26 27 38 41 | Sprite position and UV, absolute or additive |
| VRAM | 40 | MoveImage a literal rect: the animated-texture strip primitive (field scenes cycle 4-frame strips this way) |
| Spawn and escape | 25 20 17 2F 21 1D 16 | Spawn a child from the prescript table; call the slot-B module hook, the battle extension, or the field extension; a shatter-row write and a global write; a no-op stub |
Per-opcode stores and sizes are in docs/subsystems/move-vm.md; the format of a move record is on MDT.
Rates land a pass late. Ops 0x22 / 0x30 capture a VRAM rect for the CLUT-cell cycler, and the cycler's integrate runs in the actor tick ahead of the VM call. A rate an op stores therefore takes effect on the next pass: a program of +32 / -32 rate stores turns at the top and bottom of its triangle one pass after the op. The port integrates battle parts' cyclers in the same order.
Reading a program. asset move-disasm lists a move program without running it: every instruction's width, mnemonic, operands and the actor fields it stores, the 0x2F sub-ops included, for one record, every record of a cast / summon module (slot-b) or a scene's prescript table. Loops and the ten conditional branches are reported, not followed. The widths come from the disassembly, and the engine's tests hold its executing VM to the same widths opcode by opcode.
The loop pair, the child spawn and the render-mode-4 emitters
Loops. 0x18 latches its own PC into +0x88 and a count into +0x8C; 0x19 decrements and jumps back to the body start (past the 0x18) until the count underflows, so a count of N runs the body N + 1 times. A count with bit 0x4000 never decrements - the infinite loop ambient effects idle on. 0x1A / 0x1B are the same on a second register pair, so programs can nest two loops.
Child spawn. 0x25 seats a child part from the per-scene prescript stager table at the parent's world position and render slots - how one move program spawns another as a sub-actor.
Shatter row. 0x21 writes one row of the deformation table at 0x8007BE60 (a burst offset and a flag word) and points the part at it, so when the part's model breaks into triangles it breaks about that offset and under those flags. On Mt. Rikuroa two spawned meshes write rows with the shrink flag, so their triangles collapse in place as the shatter ramps rather than flying apart at full size; both play hosts draw them that way. A field-effect part’s model slot resolves against the scene’s environment pack on both hosts, not against the entry the scene bundle names: in Mt. Rikuroa and about twenty other scenes the pack streams from an entry the bundle does not name, and a bundle-keyed list left every field-effect part there undrawn.
Render-mode-7 is a framebuffer grab, not a billboard. 0x45 sets render mode 7. Each tick the part tick projects the part's position with one RTPS (FUN_8003D368) and links a GP0 0x80 VRAM-to-VRAM copy (FUN_800468A4) of the screen rectangle round that point into a fixed VRAM rect, where a textured mesh can sample the drawn frame. Seven field prescript records carry it (jou, kor5, korb3, noaru, uru, uru2), each grabbing 64×64 pixels into (832, 0); in kor5 it runs during the Genesis Tree beat. Both play hosts project the request through the camera of the frame they last drew and land the copy through the same queue as the field VM's 43 12 photo grab.
Render-mode-4. 0x42 sets render mode 4 with selector 0x2000 (the random-walk ribbon: lightning, beams, whips) and fills the ribbon's inputs; 0x23 is the 0x4000 sprite sibling; 0x13 the default emitter. On the disc, 0x42 appears only in slot-B cast / summon images (effect VM).
The field extension (opcode 0x2F)
Op 0x2F reads a 16-bit sub-opcode and dispatches through a 61-entry jump table in the field overlay (FUN_801D362C, table 0x801CE868). The sub-opcode is bounds-checked before the jump, so there is no out-of-bounds path. Only the field overlay carries this dispatcher, so battle-side move records cannot reach it. This is where the scene-specific tricks live:
| Cluster | Sub-ops | What it does |
|---|---|---|
| Shared scratch table | 25..28 31 32 34 35 0F..11 | A 16-slot table actors use to hand each other positions, tween sources, render banks and scales; a cycle counter can pick the slot |
| Player-relative moves | 24 2A 0E 12 23 3A 3B | Lerp toward the player or the camera focus, place the actor on a quadratic Bezier, lerp the anim bank, write the bearing to the player or a party member's position into the bytecode |
| Branches | 06 07 0A 0B 13 14 36..39 | Ten conditional branches: player inside / outside a box or radius, a shared predicate word, a story flag set or clear |
| Story flags | 13 14 1C 1D | Test, set and clear the field VM's story-flag bank; a backward branch onto a wait is the spin the ambient cyclers idle on |
| Self-modifying | 04 1B 1E 06 07 | Write the actor's position, a copy loop or an addition into the bytecode a few words ahead; the box branches canonicalise their own operands in place |
| Colour | 1F 20 3C | HSV ramps on a packed RGB (the ambient "pulsating flesh" and lightning effects); a screen fade |
| Object effects | 17..1A 2B..2D | The object-effect parameter table; the scanline strip emitter (2C, which no shipped record issues) |
Every in-range sub-op has its own width (2 to 16 words); there is no size-1 arm. The widths sit in branch-delay slots, so the decompiled C reports the bounds-check default for every arm - the reason an earlier reading of the HSV ramps as a "size-1 density trick" was wrong. Per-sub-op detail: move-vm-overlay-ext.md.
A sibling that is not the move VM: puzzle rooms run a discrete tile-board mode in the same overlay, a byte-cell grid where each d-pad press moves exactly one cell. It is not town locomotion (that is field locomotion).
Screen-effect widgets (PROT 0900)
The resident slot-B overlay PROT 0900 hosts four 2D screen widgets - the presentation layer of iris wipes, letterboxes and tweened panels. They are ordinary actors on the effect list with a per-frame handler, and all share one four-mode interpolator.
| Kind | Handler | Per-frame behaviour |
|---|---|---|
| sprite | FUN_801F7A9C | Widget-script-driven tweened 2D sprite |
| mask | FUN_801F811C | A tweened screen rectangle with four black border bands (the iris) |
| panel | FUN_801F849C | Five-channel tween, one or two textured quads |
| letterbox | FUN_801F8A34 | Two black bands plus two gradient feather strips |
- Sprite script waits. The handler tests a record's
0x40wait byte once, on entry. A wait that passes loops back to dispatch the next record's sub-op without testing its first byte again, so a passed wait followed by a zero byte runs sub-op0and kills the widget. - Panel tweens draw from their targets. There is no latch for a zero duration: a move with
dur = 0shows at once, while the current values stay where the last tween left them, so the next tween starts from there.
On the disc only the ten ending-sequence (ed*) scenes invoke the family, through field-VM op 0x43 sub-ops 0x10..0x15. The mask widget was once read as a summon-part renderer; a live capture of a Gimard cast showed the summon draws as an ordinary battle actor through the rigid-keyframe pipeline (battle action).
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Dispatcher | FUN_80023070 | u16 PC at +0x70, JT at 0x80010778, the < 0x47 bound, the loop-exit ops | ghidra/scripts/funcs/80023070.txt |
| Actor tick | FUN_80021DF4 | Per-frame entry, wait-timer countdown | 80021df4.txt |
| Clip driver | FUN_800204F8 | Field-VM op 0x22's callee stores only the ANM clip fields (+0x4C / +0x56 / +0x5E / +0x62 / +0x68), never +0x48 / +0x70: not a move-buffer source | 800204f8.txt |
| Prescript stager | FUN_800252EC → FUN_80021B04 | The second record source; spawn with an immediate first step | 800252ec.txt |
| Field extension | FUN_801D362C | 61-entry JT at 0x801CE868; bounds check; per-arm widths in delay slots; field overlay only | overlay_0897_801d362c.txt |
| HSV pair | FUN_8001A78C / FUN_8001A8DC | The RGB↔HSV algorithms the colour ramps call | 8001a78c.txt |
| Widget family | FUN_801F7A9C..FUN_801F8A34 | Four widget kinds, spawn APIs, shared interpolator; static decode of PROT 0900 at link base 0x801F69D8 | overlay_0900 dumps |