Field VM op 0x4C: sixteen dispatchers in one opcode
0x4C is the field script's grab-bag opcode. It carries the name MENU_CTRL and is not a menu: the byte after it picks one of sixteen small sub-dispatchers, and between them they paint walls into the walkability grid, steer the camera, tint and clone actors, wobble the floor, take items from the bag, spend casino coins, start movies and schedule escape timers. This is the reference for all sixteen; the rest of the VM is on the field / event VM page.
At a glance
- Form
[4C, op0, operands…]; the high nibble ofop0selects the dispatcher- Outer table
- Sixteen entries at
0x801CEE60, inside the field VMFUN_801DE840(field overlay, PROT 0897) - Inner tables
- Most arms index a second table on the low nibble; every sub-op has its own byte width
- Disassembler
legaia_scene_formats::field_disasmcarries the widths for all sixteen nibbles- Confidence
- Confirmed - arm addresses and widths read from the disassembly; shipped usage checked by the disc-wide opcode census
The sixteen nibbles
Three exits recur across the arms:
- Advance - the arm adds its width to the script cursor and returns. The add is often in a branch delay slot, which the decompiled C hides.
- Halt at PC - the arm returns the cursor unchanged, so the op re-runs next frame. This is how a sub-op waits.
- Halt-acquire - the idiom for taking over another actor: the arm writes the target's payload pointer, sets its halt bit
0x400, then advances the caller; if the target is already held, the caller halts at PC instead.
Nibble 3: the ambient gate and the camera-zone arms
4C 30 and 4C 31 raise and clear one word, _DAT_8007B854. It is the ambient-particle master gate, not an input lock. Its six references disc-wide are two clears in the executable, the field render pass that stages the particle table, the ambient particle emitter, and these two stores - none of them is pad state. A field script decides, per scene, whether ambience emits at all.
Four arms are the script's only grip on the field camera's parameter block:
| Op | What it does |
|---|---|
4C 38 | camera-zone query and load at the player's tile |
4C 39 | the same query, a floor-height sample, then falls into the snap below |
4C 3A | the player's heading takes the arrival facing a door set; every scene's entry script issues it once |
4C 3D | walk-region attribute refresh at the player's tile - not a camera load |
4C 3E | camera snap and focus clamp; its table entry points inside 4C 39's arm |
Nibble 7: wall paint
[4C, 0x7s, col0, row0, col1, row1 (, mask)] edits the walkability grid the locomotion collision check reads - one byte per 128-unit tile, whose high nibble is four sub-cell wall bits.
| Sub | Effect | Width |
|---|---|---|
0 | clear walls (&= 0x0F) | 6 |
1 | block all (|= 0xF0) | 6 |
2 | clear the mask's wall bits | 7 |
3 | set the mask's wall bits | 7 |
The row bounds carry an extra +1 the column bounds do not. The painted rectangle is columns [col0, col1 + 1) and rows [row0 + 1, row1 + 2). These edits layer over the disc-streamed base grid and are commonly gated behind story-flag tests: story-conditional terrain, such as a gate that opens after a boss.
Nibble 8: the multi-purpose dispatcher
| Op | What it does |
|---|---|
4C 80 | child-actor allocator |
4C 81 | actor draw tint, with a blend target and a tick count |
4C 82 slot | full HP / MP restore of one party slot |
4C 83 | rectangular tile fill |
4C 84 | screen-shake amplitude |
4C 85 / 8E / 8F | halt-acquire on a target, which turns it to face someone |
4C 86 / 87 | install / retire the reflection controller |
4C 89 | the dialog pager's automatic press |
4C 8B / 8D | jumps on an actor search |
There is no inn opcode. 4C 82 takes a literal slot, not “every active member”, and it is the primitive every inn, rest and infirmary script is built on; the charge is a separate gold gate and debit, so the price is per-scene script data.
The halt-acquire is not a freeze. The bytes it hands the target are a face-target leg of the walk kernel: the target turns toward a named actor over a frame budget, and the leg's last frame releases it. Cutscene records issue over a thousand of these - “one character turns to another” is exactly this op. Aimed at the player, the arm also holds the caller, so a cutscene waits on the player's turn.
Nibble 9: the floor-height ladder, not a fade
The scene keeps a 16-entry ladder of floor elevations: the one ground height is interpolated from, and the one added to every placed object's height. Nibble 9 writes it.
4C 9Einstalls all sixteen rungs, and also the copy the follow camera samples - so an installed ladder moves the camera.4C 90sets one rung oscillating (live rungs only, so the camera does not follow).4C 9Fretires every oscillator.
The organic Seru interior's undulating floor is the clean example: its entry script installs a linear ramp, then issues 4C 90 over consecutive rungs with a rising phase - a travelling wave across the ladder. Two of the three oscillator sub-ops are dead code: 4C 91 and 4C 92 land on phases whose loop never terminates, and no shipped scene uses them.
What a moving ladder moves
Three readers see the live rungs, and placed objects are not among them:
- The ground pass takes each ground cell’s four corner tiers through the ladder every frame, so the ground deforms per vertex - the pulsing path and the flesh floors.
- The decoration sweep places each terrain or decoration cell at its floor term once a frame, so a whole mesh rides up and down. It skips placed-object records.
- The floor sampler, so the player walks the same shape and rides it while standing.
A placed object’s Y is written once, when it is spawned (at scene init or by the window sweep’s re-plan), as its rung plus its own offset; its actor template sets neither height-arm bit that would make the per-frame driver re-sample the floor. When a scene’s system script lifts rungs after spawn, the objects on those rungs stay where they were placed.
Nibbles 4 and 5: ramps, held heights and the seat
Nibble 4 is one pattern, [4C, 4s, value:u16, ticks:u16]: with ticks zero the slot s is written at once; otherwise a scheduled actor ramps it from its current value over ticks frames and the script never waits on it. The slots cover the actor’s speed and draw scale, its held height, its position halves, its drawn heading, the floor ladder, three globals and an audio duck (a fraction of the configured level, which the port runs as its BGM duck). Sub 5 is the one 11-byte form.
Held height. Sub 2 writes the actor’s height slot +0x8E. An actor with flag bit 0x20000000 takes Y = -(+0x8E) every frame in the per-actor tick’s height arm instead of the ground sample, and the arm sits ahead of both floor arms, so the held height follows the actor wherever it moves. A placed object is drawn at its actor, and the height is absolute rather than an offset from its map seat - this is how a script lifts or drops scenery (a collapsing dungeon’s boulders tween down to the stairs) and how a walking boss can stay at a fixed altitude while its motion stream walks it. The port runs the arm for its placement NPCs (World::tick_field_npc_heights), and both play hosts draw from it.
Sub 1 of nibble 5: the seat binds its move clip
[4C, 51, x, z, depth, move_id] is a seat, not a walk: no walk kernel is armed. On the actor the op resolves (the executing context, or a cross-context target), the arm writes four things in order:
0x8_, so its clip id resolves through the party locomotion bank; any other depth plays a scene-bank clip.Two shipped consequences: a monster seated beside its tree on its pre-fight arm loops its tree-eating clip instead of its header clip, and party members seated at a bridge get their walk clips before the compass legs walk them up it. The port writes the bit, the mask and the clip slot on every NPC arm (FieldHostImpl::run_to_tile) and raises the clip cue on the cross-context arm; one difference remains - the port’s cue skips a re-request of the clip already playing, where retail’s restarts it.
Nibbles C and D: camera, counters, timers
| Op | What it does |
|---|---|
4C C0 | seats the actor's clip at a random frame - not a cancel. The festival crowd in town0d issues it for every dancer, so each starts its loop at its own phase rather than in lockstep |
4C C1 | fog-region enable reset |
4C C3 | script-table teleport and spawn-section re-run |
4C C4 x z | camera-zone query at an explicit tile: frames a shot from a region the player is not standing in |
4C C5 / C6 | wait until a system flag is set / cleared. The flag is flipped by a move-VM part the record itself just spawned (ext sub-op 0x1C / 0x1D in the scene's stager record), so the wait lasts as long as that part takes to reach it. Those parts tick in the frame tail, not in the scene tick, so a headless driver that skips the tail leaves the wait to the cutscene timeline’s anti-hang cap, and the record’s tail never runs |
4C C7 char art | teach a Hyper Art: inserts art into roster slot char's learned list - the masters' lessons |
4C CA / CB / CC | script counter set / add / subtract |
4C CD | wait for the camera glide |
4C CE | player clip override: re-points walk, idle and run at other scene-bank records |
4C CF x z | script camera-focus override |
4C D0 x y | press-a-button prompt: a blinking cursor at (x, y) holds the script until confirm or cancel is pressed (Tetsu's lessons) |
4C D3 | timed-flag scheduler (14 bytes) |
4C D4 / D5 | set / clear the VRAM semi-transparency bit over a rectangle |
4C D8 | synchronous morph-actor spawn (9 bytes) |
The timed-flag scheduler 4C D3 expiry:u16 below:u16 duration:u32 threshold:u32 arms a countdown that lives in the save block. A per-tick consumer sets the expiry flag at zero and the below flag while under the threshold. Retail uses it for the two escape sequences: a collapsing dungeon and the Floating Castle. Stopping the clock does not clear the below flag, which is the root of a traced missing-encounters oddity.
4C D2, D6 and D7 all advance in retail. A VM that halts on them holds a cross-context record forever.
The rest, briefly
- Nibble 1 - global write, screen tint, the field clear colour, and an actor clone.
- Nibble 2 - one arm: the camera-octant setter that rotates what the D-pad means.
- Nibble 5 - besides the seat above: model select (which re-stages the actor), TAKE_ITEM (sub-2 removes an item from the bag; it is not a menu poll), and two dialog polls.
- Nibble 6 - VRAM image moves and a CLUT-cell write.
- Nibble A - conditional skip on a flag bit: a set bit steps over the bytes its halfword names, counted from the end of the instruction (the shared exit
LAB_801E360Cadds the halfword less two), not an absolute target.4C DC/4C DEskip the same way when the party holds or wears an item, and so do4C 8B..8D(spell known, a context word zero, art learned),4C D1(a camera glide running) and4C EB(an actor missing). - Nibble E - text balloon, movie trigger, a position-copy teleport, a box-test branch, camera animate and zoom, and the casino coin delta.
4C E0formats the reading box: a row capacity, or a latch that loads the next page whole (the help pages use twelve-row boxes loaded at once). - Nibbles B and F - the error printer. They form a message string and call the message routine, returning the PC unchanged. Only
4C FFpasses through. The disc-wide opcode census finds no coherent use of either in any scene.
In the port, and how it is known
The dispatcher lives in legaia_engine_vm::field (step/menu_ctrl/), with one FieldHost hook per sub-cluster implemented over the world by FieldHostImpl. Feature homes include the reflection controller, the actor clone, the morph-weight apply, the casino coin bank, the escape timer, the step-counter reroll and the camera-zone requests.
Every arm address and width on this page comes from the disassembly of the field VM, not the decompiled C - which rendered several arms wrongly: absolute jumps that are relative skips, halts that advance from a branch delay slot, and TAKE_ITEM as a menu poll. Disc usage comes from the field-op census, taken at decoded instruction boundaries. Live captures confirm the reflection controllers and the clear colour.
See also
Every sub-op's body, arm address and width is in docs/subsystems/script-vm-menuctrl.md.