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.
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.
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.
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.
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.