Battle action state machine
A two-level finite state machine that drives the per-actor execution of a chosen battle action - the layer between “the player picked Attack” and “the actor's body has finished swinging the sword and HP has been deducted.” Lives in the battle overlay (0898, RAM-resident at 0x801C0000+); driver is FUN_801E295C (~16 KB / 4099 instructions, the largest function in the battle overlay).
How it works
FUN_801E295C runs every frame from the battle main dispatcher. It picks the global battle context (_DAT_8007BD24, a pointer to the live struct at 0x800EB654), resolves the active actor via the 8-slot actor pointer table at 0x801C9370, and pumps the action through three nested keys:
- Action category -
actor[+0x1DE]:0=Tactical Arts,1=Item,2=Magic,3=Attack,4=Spirit,5=Run/Defend. Read at the action-start case (ctx[7] == 0xC) and used to seed the next state. - Execution phase -
ctx[7]: 47 distinct case values, organised in 7 contiguous bands (Attack0x14–0x20, Magic/Item0x28–0x2E, Summon0x32–0x38, Spirit0x3C–0x40+0x46–0x48, Done0x50–0x52/0x5A, Run/Capture0x64–0x6B, Magic-capture0x6E–0x71, terminal0xFD/0xFF). - Per-actor sub-state -
actor[+0x1DC](flag bits) plus several scratch fields (+0x1DAqueued anim,+0x1D9current anim,+0x1DF..+0x1F2action-parameter byte stream).
It is not a bytecode VM. There is no opcode table, no PC stride. Each case ctx[7] body waits on a per-actor condition (animation matched, timer expired, distance check passed) and writes the next ctx[7] value when ready. Actions that need multiple frames do nothing on the frames where their condition isn't met yet.
Run band (0x64–0x67): state 0x66 is the successful-escape teardown, not a failed run. The 0x65 wait branches on the run outcome: a failed run routes back to 0x50 (Done band - the action is consumed, the battle continues), a successful escape routes to 0x66, which stages a 0x40-frame black→white screen fade through the fade-primitive spawner (FUN_80024E80 + the FUN_80020B00 fade-state loader, 10.6 fixed-point ramp), sets the battle-end signal DAT_8007BD71 = 0xFE (the same byte the 0x5A wipe gate sets), and parks in the 0x67 terminal hold. An earlier reading labelled 0x66 “run failed, battle continues” - falsified by that signal byte. Engine: ActionState::RunEscape → BattleEndCause::Escaped (no loot, no defeat) + the engine_core::fade kernel.
Effect spawning is indirect: the state machine doesn't call the effect VM directly - it calls FUN_801D8DE8(ui_element_id, mode) (the hottest battle helper, ~30 sites), which fans out through the effect cluster. The state machine knows UI element IDs (0x07/0x0F/0x34/0x43/0x4C/0x52/0x66); the effect VM resolves them to actual sprite-anim spawns. That path drives the 2D UI/sprite layer (FUN_801DFDF8 emits POLY_FT4 billboard quads); the 3D summon model is a separate mechanism (below).
Seru-magic summon-overlay dispatch
The 3D visual of a player Seru-magic cast - the summoned Seru and its attack mesh, e.g. Gimard's Burning Attack flame (the player summon; the enemy boss move Fire Tail is distinct) - is not spawned by an opcode and does not live in befect_data. It is a per-summon code overlay paged in on demand. In outer state case 0x29, when the queued action's spell id actor[+0x1df] falls in the player Seru-magic block 0x81..0x8b:
_DAT_8007bd24[7] = 0x32; // advance to the cast band
_DAT_8007ba2c = (&PTR_s_re_check_801f6734)[id - 0x81]; // per-summon effect-data pointer
FUN_8003ec70(id - 0x79, 0); // overlay loader B: PROT (id - 0x79 + 0x381)
FUN_8003EC70(param) (overlay loader B) loads raw TOC index param + 0x381 into *DAT_80010390 (= 0x801F69D8, above the resident battle overlay) - extraction entry param + 0x37F, two below the raw index (see PROT § In-RAM TOC). So the summons map to extraction PROT 903..913 (Gimard Burning Attack 0x81 → param 8 → PROT 903; the earlier "905..915 / Gimard → 905" reading was the + 0x381 off-by-2). The capture-class ('c') spell branch loads from a different base: FUN_8003EC70(spell_record[+1] + 0x28). The whole block is capture-pinned: every spell id 0x81..=0x8B was observed mid-cast loading its arithmetic slot, zero exceptions. The evolved-Seru block 0x8C..=0x95 continues the same run to extraction 914..923 (stager-shaped; eight of ten legs capture-pinned by mid-cast states - 0x8C..=0x8F/0x92..=0x95, only 0x90/0x91 still predicted). Enemy boss specials ride their own stagers through the same loader: the catalogued final-boss Cort mid-cast corpus lands every leg on the same arithmetic, byte-resident at slot B - Mystic Circle 0x2B → 938, Mystic Shield 0x2D → 940, Guilty Cross 0x31 → 944, evolved-form Final Crisis / Ultra Charge 0x42/0x43 → 961/962, and Cort's Evil Seru Magic 0x47 → 966, distinct from the player-side Juggernaut stager 0927: the player and enemy arms of the same spell ship separate stagers. PROT 0907 on the 0x85 slot is Nighto's stager - its head title "Hell's Music" is the attack's display name (the SCUS spell table carries the same string), not a Disco King dance song (refuted: the dance overlay 0980 has no slot-B loader callsite). See static overlay pipeline.
Inside a summon overlay (extraction PROT 905, decoded)
The summon overlay carries no embedded TMD geometry (no 0x80000002 magic). The summon's meshes are the separately-loaded DAT_8007C018 model library: extraction entry 0871 (etmd.dat), a 30-entry asset::pack of Legaia TMDs that the battle scene loader FUN_800520F0 pulls at battle init (case 0xb, raw TOC index 0x369, retail dev path h:\prot\battle\etmd.dat) and registers via FUN_80026B4C, populating DAT_8007C018[3..32] ([0..2] are the party battle meshes). Its extraction filename label says sound_data (the +2 label shift; the retail define block is befect_data); its texture sibling etim.dat = extraction 0870 (the flame atlas) loads via loader case 0x8 - see effect § battle effect cluster. The overlay spawns and animates part-actors over those meshes. Decompiled (PROT 905 imported raw at base 0x801F0000, ghidra/scripts/dump_summon_overlay.py):
- The overlay spawns part-actors via the SCUS part-stager
FUN_80021B04(world_pos, render_slots, record_ptr, 0x1000)(param_1= world position →actor[+0x14..0x18],param_3= a part record, allocated from the effect poolDAT_8007062c) - either directly, or through the thin pool wrapperFUN_80050ED4(stores the spawned actor pointer in the 0x60-slot pool atDAT_801C90F0, then forwards the same arguments; the dominant call form in the high-summon and enemy boss stagers).record[+0](model_sel) drives the spawn-time render seat:≥ 0→ library meshDAT_8007C018[model_sel + gp[0x754]](actor[+0x5A] = 1), any negative value (-1canonical) = no-mesh transform/pivot node (actor[+0x56] = 0,+0x5A = 0),0x4000/0x4001= special render-mode nodes (actor[+0x5A] = 3/5). - Three staging functions drive the spawn:
FUN_801F16A0(phase 0 = ado { FUN_80021B04(...) } while(< 8)loop spawning 8 flame parts, each withrand()-seeded actor paramsactor[+0x84]/+0xb4 = rng%15+16/+0xb6 = rng%255+512/+0x28; phase 1 = 1 more),FUN_801F36A0,FUN_801F4DD0. The per-frame motion is the standard actor-tick consuming those RNG-seeded fields. - Part records ARE in-file move-VM bytecode (corrected link base): under the correct link base
0x801F69D8eachFUN_80021B04record pointer resolves to PROT 905 file0x180C..0x1E00-[i16 model_sel][u16 flags][move-VM bytecode @+4], recovered bylegaia_asset::summon_overlay(disc-gated). This supersedes the wrong-link-base "records beyond the0x5800file / no move VM" readings; thejal 0x80023070lives in the SCUS stagerFUN_80021B04, not the overlay. - But the scene-graph is NOT the player render path (live trace): a player Gimard Burning Attack cast shows
FUN_801F7088= 0×, move VM = 2-3× (noise), and the battle per-actor drawFUN_80048A08= 35-64×/frame → TRS-keyframe decoderFUN_8004998C→ cluster-AFUN_80043390. The player summon is drawn as an ordinary battle actor; the faithful path isengine-vm/anim_vm.rs. The engine drives the move-VM records as a stand-in only. SCOPE (now resolved): the enemy Gimard Fire Tail mid-cast holds PROT 0900 resident but its screen-widget family is dormant (zero live widgets - the widget path stays exclusive to the 8 ending scenes); the live effect is a single move-VM part-actor ticked by the SCUS render-tailFUN_80021DF4over a[model_sel][flags][bytecode]record in the battle overlay (0898) data (below the 0900 link base), not a 0900 record - so Fire Tail does not drive the widget path.
The flame renders as Gouraud-textured (POLY_GT3/POLY_GT4) prims sampling the resident etim page (832,256) 4bpp; cba/tsb are applied at render time. In a live mid-cast Gimard capture (battle_gimard_tail_fire_a) the summon library occupies DAT_8007C018[3..32]; ten of those ([23..32]) are fire-textured meshes (cba row 478 0x778B baked), and the active Gimard flame is DAT_8007C018[26] - the only rendered model baking etim, with both rendering actors carrying actor[+0x64]=26 and actor[+0x56]=5 (full-TMD mode → FUN_8002735C).
Enemy boss stagers + the record-table trim
The six final-boss Cort special-attack stagers (extraction PROT 0938 Mystic Circle / 0940 Mystic Shield / 0944 Guilty Cross / 0961 Final Crisis / 0962 Ultra Charge / 0966 Evil Seru Magic) parse as summon stagers under the same 0x801F69D8 link base and record format as the player block (summon_overlay::ENEMY_BOSS_STAGER_PROT; disc-gated enemy_stager_real), spawning dominantly through the FUN_80050ED4 pool wrapper. Stager extraction entries are over-read windows: each .BIN runs past the next entry's start LBA, so only the first (next_start_lba - start_lba) * 0x800 bytes are the entry's own content (unique_content_len) - a boundary the Cort mid-cast saves pin byte-exactly against the slot-B resident image (0938 → 0x1800, 0940/0944/0961 → 0x2000, 0962 → 0x2800, 0966 → 0x4000).
The enemy-cast stager path is not Cort-specific. Mid-cast captures of ordinary bosses pin the same mechanism on the universal extraction = id + 895 arithmetic (loader-B id at 0x8007BC4C, byte-resident at slot B; disc+library-gated enemy_stager_binding): the Delilas brothers - Gi / Blazing Slash 0x3F → 0958, Che / Megaton Press 0x40 → 0959, Lu / Plasma Strike 0x41 → 0960 - and Zeto, whose Call Wave and Big Wave are one logical attack over two turns and so share a single stager (0x33 → 0946). None of these four carries a 0x4000 render-mode record, and at the captured instants the part pool DAT_801C90F0 is empty, so the render-mode draw still has no live exerciser.
That trim resolves the record-first-word "sentinel" question: across every trimmed stager (player 0903..0913, the evolved-Seru block 0914..0923, high 0927..0934, the six Cort entries) the first word is only ever -1 (transform node, dominant), a small library-mesh index, or 0x4000 - matching FUN_80021B04's own dispatch. The previously-reported 0x1000/0x8000-class sentinel population was over-read contamination. The 0x4000 render-mode records live in five stagers: the Sim-Seru trio Palma 0928 (4) / Mule 0929 / Jedo 0931, plus two evolved-Seru casts - 0x8E → 0916 (4, Aluru) and 0x93 → 0921 (6, Iota), the first found outside the Sim-Seru trio. All five are player casts (both evolved carriers are now capture-pinned mid-cast), so none seats a live render-mode part. Live correlation: every live pooled part-actor in the Cort states carries actor[+0x48] pointing at a -1 record inside the trimmed table, with the spawn-time +0x56/+0x5A zeros rebound post-spawn by the move-VM ops (+0x56 = 4 / +0x5A = 2 dominate mid-cast). No 0x4000/0x4001 part-actor was live in these captures, so those render modes' draw behaviour stays open.
Engine port
crates/engine-vm/src/battle_action.rs ports the state graph as a per-frame edge-triggered state machine. Surface:
ActionState- symbolic enum for every named state byte;from_bytereturnsNonefor unmapped values (so the dispatcher surfaces them asStepOutcome::UnknownStatefor engine logging).ActionCategory- symbolic enum for the action-category byte atactor[+0x1DE].BattleActor- the per-actor fields the state machine reads or writes. Field names mirror the+0xNNNbyte offsets so the link to the decompile stays explicit.BattleActionCtx- the subset of the live ctx struct (_DAT_8007BD24-pointed) the state machine touches:action_state,active_actor, the+0x6D8countdown timer, etc.BattleActionHost- engine callbacks for every cited helper (FUN_801D5854→pose,FUN_801D8DE8→ui_element,FUN_8004E2F0→range_check,FUN_801DABA4→recompute_battle_order,FUN_801EFE44→camera_bounds,FUN_801EED1C/FUN_801E7320→party_setup/monster_setup,func_0x80056798→rng, ...). All methods have default impls so a minimal host compiles.step(host, ctx) -> StepOutcome- runs one frame's worth of dispatch; returnsStay(still waiting on a precondition),Transition { from, to },BattleComplete(terminal), orUnknownState { state }.
crates/engine-core/src/world.rs composes this with the actor VM, move VM, and effect VM into a single World struct that engines drive via World::tick.
Action queue and Tactical Arts trigger ordering
Before FUN_801E295C reaches the inner-state machinery, the battle code resolves the player's command-input sequence into a flat action queue of ActionConstant bytes. The queue is built incrementally from directional inputs and accumulated arts; once the player commits, the runtime applies two trigger passes in order:
- Miracle Art match. If the input command sequence equals the character's Miracle Art command string (
R D L U L U R D Lfor Vahn's Craze, etc.), the entire queue is replaced with the Miracle Art's replacement string (L/R/D/U× 4 →SpecialStarter→art1, art2, …). The first 4 directional bytes carry the on-disc MSB-set quirk and are masked to0x0C..=0x0F. - Super Art find/replace at tail. For each chained art the runtime walks all the character's Super Art
findpatterns and replaces the matched tail with areplacetail ending in the Super Art's finisher action constant. Triggers require: the last art offindis the last action in the queue, and all participating arts paid AP.
Both passes are clean-room ports in legaia_art::MiracleMatcher / legaia_art::SuperMatcher. The engine-vm BattleActionHost exposes an art_record(char_id, art_id) callback so the SM can fetch the art record for power-byte resolution, hit timing, and status-effect application during the 0x14..0x20 Attack chain.
The reusable helper is legaia_engine_vm::battle_action::resolve_action_queue(character, command_input, chained_arts) - runs Miracle then Super to fixpoint. Returns an ActionQueue ready to feed into ctx.queued_action.
Miracle / Super in the live player-driven Arts submenu
The player-driven battle Arts submenu (legaia_engine_core::battle_arts) models an art as a saved directional chain (SavedChainRecord, raw command bytes), not an in-gauge buffered input. The two trigger paths interact with that model differently:
- Miracle Arts are wired. A Miracle Art's trigger is an exact directional-string match, so
battle_arts::miracle_for_chainrecognises a saved chain whose command string equals the caster's Miracle Art and flags the menu row (ArtRow::miracle = Some(name)).World::build_battle_arts_rowsresolves the row's per-strike profile from the Miracle's finisher-replacement queue viaresolve_action_queue: each art constant contributes its stagedArtRecordpower, or one tier-0 (x12) synthetic strike when that art's record isn't loaded. The nativeplay-windowHUD shows the Miracle name on the row. - Super Arts are wired, with the queue connectors abstracted. A Super fires when the player chains several named arts ending on a known combination.
legaia_art::recognize_art_sequencetokenizes a saved chain's flat directional string into its ordered named arts (each identified by its ownArtRecord::commands, greedy longest-match), andSuperMatcher::trigger_by_art_sequencetail-matches that ordering against each Super'sSuperArt::art_sequence()- thefindpattern projected to its art constants only ([0x27, 0x1F, 0x27]for Tri-Somersault), with the0x19starters and the interleaved connector directions stripped.battle_arts::super_for_chain/World::build_battle_arts_rowsflag the row (ArtRow::super_art = Some(name)) and resolve theSuperArt::replace-queue strike profile through the same helper the Miracle path uses; theplay-windowHUD shows the Super name. Super is checked after Miracle (retail order). The match is connector-abstracted: the connector direction after each art is combo-specific (Vahn's0x27is followed by0Fin Tri-Somersault but0Ein Power Slash), so it can't be derived from each art's commands. The live path is faithful to which combination triggers which Super but does not yet reproduce the byte-exact queue. The queue itself is now pinned by a live capture: it is the per-actor action-parameter byte stream atactor[+0x1DF..+0x1F2](notctx[+0x274], which a capture showed is the turn-order active-actor index written byrecompute_battle_order). Direction/connector bytes encode as0x0C/0x0D/0x0E/0x0F= Left/Right/Down/Up and0x1A=SpecialStarter; a Noa Miracle Art capture read that stream and it matches the engine's modeled replacement string byte-exact. A Vahn Tri-Somersault capture confirmed the Super path too: its queue tail19 27 0F 19 1F 0E 1A 2B 2B 2Bis byte-identical tosuper_art.rs'sTri-Somersaultreplace, validating the combo-specific connectors (0x27 → 0F,0x1F → 0E) and the finisher tail (dequeue at pc0x801D89D8). The byte-exact matcher (SuperMatcher::try_trigger_at_tail) is also ported and exercised byresolve_action_queue's tail pass.
When the active actor's chosen_art is set and art_record returns a record, attack_chain (state 0x1A) calls a second host hook apply_art_strike(ArtStrikeInfo) alongside the existing apply_damage. ArtStrikeInfo carries the strike-indexed power byte, dmg_timing, hit cue, and the art's flat status effect. Engines drive HP deduction, status application, sound-effect scheduling, and visual hit-cue dispatch off this struct.
The engine-side translator at crates/engine-core/src/art_strike.rs (apply_art_strike(attack, defense, info) -> ArtStrikeOutcome) folds an ArtStrikeInfo into a concrete HP delta + status flag + scheduled SFX cues using art_strike_damage in legaia_engine_vm::battle_formulas. The world's BattleActionHost::apply_art_strike impl resolves per-slot weapon attack from World::battle_attack and the right defense (UDF or LDF, picked from World::battle_defense_split) before calling the translator, then emits a BattleEvent::ApplyArtStrike with the resolved outcome. Engines apply each strike's damage / enemy_effect / cues through their runtime path for HP / status / SFX dispatch.
World::fold_battle_event folds the ApplyArtStrike outcome: HP / status into the target, and the outcome's sound cues (cue.is_sound(), the HitCue::kind SfxBank ids - distinct from the move-power +0x0d FUN_8004fcc8 namespace) into a per-frame BattleSfxCue queue the host drains via World::drain_battle_sfx_cues (the audio sibling of drain_battle_hit_fx). The host plays each through SfxBank::play_one_shot at the cue's timing_frames delay. The live battle loop wires this end to end: the SFX bank is decoded from the user's executable at boot (SfxTable::from_scus → SfxBank::from_descriptors) and the cues key on through the per-scene VAB, driven once per simulation tick by the BGM director's SFX scheduler.
Full reference
The full per-state table (every ctx[7] case body, what it runs, and which state it transitions to next) lives at docs/subsystems/battle-action.md in the repo. That doc cites ghidra/scripts/funcs/overlay_battle_action_801e295c.txt at the line level.