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 categoryactor[+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 phasectx[7]: 47 distinct case values, organised in 7 contiguous bands (Attack 0x14–0x20, Magic/Item 0x28–0x2E, Summon 0x32–0x38, Spirit 0x3C–0x40 + 0x46–0x48, Done 0x50–0x52/0x5A, Run/Capture 0x64–0x6B, Magic-capture 0x6E–0x71, terminal 0xFD/0xFF).
  • Per-actor sub-stateactor[+0x1DC] (flag bits) plus several scratch fields (+0x1DA queued anim, +0x1D9 current anim, +0x1DF..+0x1F2 action-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_byte returns None for unmapped values (so the dispatcher surfaces them as StepOutcome::UnknownState for engine logging).
  • ActionCategory — symbolic enum for the action-category byte at actor[+0x1DE].
  • BattleActor — the per-actor fields the state machine reads or writes. Field names mirror the +0xNNN byte 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 +0x6D8 countdown timer, etc.
  • BattleActionHost — engine callbacks for every cited helper (FUN_801D5854pose, FUN_801D8DE8ui_element, FUN_8004E2F0range_check, FUN_801DABA4recompute_battle_order, FUN_801EFE44camera_bounds, FUN_801EED1C / FUN_801E7320party_setup / monster_setup, func_0x80056798rng, ...). All methods have default impls so a minimal host compiles.
  • step(host, ctx) -> StepOutcome — runs one frame's worth of dispatch; returns Stay (still waiting on a precondition), Transition { from, to }, BattleComplete (terminal), or UnknownState { 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:

  1. Miracle Art match. If the input command sequence equals the character's Miracle Art command string (R D L U L U R D L for Vahn's Craze, etc.), the entire queue is replaced with the Miracle Art's replacement string (L/R/D/U × 4 → SpecialStarterart1, art2, …). The first 4 directional bytes carry the on-disc MSB-set quirk and are masked to 0x0C..=0x0F.
  2. Super Art find/replace at tail. For each chained art the runtime walks all the character's Super Art find patterns and replaces the matched tail with a replace tail ending in the Super Art's finisher action constant. Triggers require: the last art of find is 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.