At a glance

Where
Battle overlay (PROT 0898, resident at slot A during fights); driver FUN_801E295C, the overlay’s largest function
State
One byte, ctx[7] in the battle context at 0x800EB654, dispatched through a 256-entry jump table with no default arm
Band key
The actor’s category byte +0x1DE, read once at the seed state 0x0C
Damage
Not in the state bodies - one power byte per animation hit event, through the battle formulas
Turn end
Done band 0x50..0x52, then the end gate 0x5A, which also detects a party or monster wipe
Port
engine-vm::battle_action (ActionState, step, BattleActionHost); composed by engine-core::World
Confidence
Confirmed - every state body read from the disassembly; queue, summon loads and trigger tables capture-validated
Used by
battle loader, Arts browser, ROM patcher battle tuning

How it works

Battle action state bands: the seed state fans out into one band per command category, every band funnels into the Done band and the end gate, and the end gate hands the turn to the next combatant Seed 0x0A..0x0C Attack / Arts0x14..0x20, cat 3 / 0 Magic / Item0x28..0x2E, cat 2 / 1 Summon0x32..0x38, spell 0x81+ Spirit0x3C..0x40, 0x46..0x48 Run0x64..0x67, cat 5 Capture0x6E..0x71, class c Done 0x50..0x52 End gate 0x5A, wipe scans next combatant, or 0xFD / 0xFF at a round boundary
One byte walks a band per command category and always funnels through Done and the 0x5A end gate, which counts survivors on both sides before handing the turn on.

The driver runs every frame from the battle main dispatcher. It resolves the active combatant through the 8-slot actor pointer table and pumps the action through three keys:

KeyFieldValues
Action categoryactor[+0x1DE]0 Tactical Arts, 1 Item, 2 Magic, 3 Attack, 4 Spirit, 5 Run / Defend
Execution phasectx[7]the bands in the figure, plus terminal 0xFD / 0xFF; any unhandled byte falls to the shared epilogue as a no-op
Per-actor sub-stateactor[+0x1DC] + scratchflag bits; +0x1D9 current anim, +0x1DA queued anim, +0x1DF..+0x1F2 the action-parameter byte stream
  • Not a bytecode VM. No opcode table, no program counter. Each state body waits on a per-actor condition - animation matched, timer expired, range check passed - and writes the next state when ready.
  • Run band. 0x65 branches on the roll: a failed run returns to 0x50 (turn consumed, battle continues); a success enters 0x66, which stages a 64-frame black-to-white fade, sets the battle-end signal DAT_8007BD71 = 0xFE and parks in 0x67. Engine: ActionState::RunEscape → BattleEndCause::Escaped.
  • Wipe arm. The 0x5A victory arm stages the win pose off the acting actor’s party slot, re-picking a living member only when the actor is dead. Retail is safe because the wipe scan and the scheduler share one liveness predicate; the randomizer’s enemy-ally charm widens that mask and breaks it (charm softlock).
  • Effects are indirect. The machine calls the UI-element spawner with an element id (0x07, 0x0F, 0x34, …) and the effect VM resolves it to sprite spawns. That path draws the 2D layer; the 3D summon is a separate mechanism below.
  • Two known parks. The 0x51 HP-bar settle gate and the 0x19 attack-approach range poll can wait forever under the wrong seeding; both are dissected in the full doc and the Gaza write-up.
History: the 0x66 “run failed” reading

State 0x66 was once labelled “run failed, battle continues”. The battle-end signal it writes falsifies that: 0x66 is the successful-escape teardown, and the failed run is the 0x65 branch back to 0x50.

Seru-magic summons

When a character casts a Seru spell, the creature that appears is not in the battle overlay: the cast state pages a small per-summon code overlay off the disc into slot B (0x801F69D8, above the resident battle overlay) while the cast band waits. That is why every summon has its own PROT entry and why a mid-cast save state holds an extra chunk of code.

CastSpell idsLoader argumentExtraction entries
Player Seru magic0x81..0x8Bid − 0x79903..913 (Gimard 0x81 → 903; Nighto 0x85 → 907)
Evolved Seru0x8C..0x95same arithmetic914..923
Capture-class spellsclass crecord[+1] + 0x28the cast modules
Enemy specialsmove idid + 895Delilas 958 / 959 / 960, Zeto 946, Cort 938 / 940 / 944 / 961 / 962 / 966

The loader resolves its argument in the raw TOC (+ 0x381), which is extraction entry + 0x37F (PROT). The player and enemy arms of the same spell ship separate stagers. What a stager contains:

  • No mesh geometry. The meshes are the separately loaded 30-entry model library etmd.dat (extraction 0871) that the battle loader registers at battle init; the flame atlas etim.dat is 0870 (effect cluster).
  • Part records - [i16 model_sel][u16 reserved][move-VM bytecode] - handed to the executable’s part-stager, which spawns a part-actor per record: model_sel ≥ 0 seats a library mesh, -1 is a transform node, 0x4000 / 0x4001 are special render modes. Parser legaia_asset::summon_overlay.
  • Motion is geometric. Gimard’s flame is eight RNG-seeded part-actors moving; the palette is byte-identical across animation-distinct frames.
  • The player summon draws as an ordinary battle actor through the TRS-keyframe pipeline; the engine renders it that way (monster_archive::battle_render_mesh + MonsterAnimPlayer) and keeps the part records as the on-disc stager model.
Details: stager internals, enemy stagers, the record-table trim

Case 0x29 of the outer machine performs the load when the queued spell id is in the player block: it advances to 0x32, stores the per-summon effect-data pointer (&PTR_s_re_check_801f6734)[id - 0x81] into _DAT_8007BA2C, and calls overlay loader B with id - 0x79. Every player id 0x81..=0x8B and eight of ten evolved ids are capture-pinned mid-cast (0x90 / 0x91 predicted).

Stagers spawn through FUN_80021B04(world_pos, render_slots, record_ptr, 0x1000), directly or via the pool wrapper FUN_80050ED4 (stores the actor in the 0x60-slot pool at DAT_801C90F0). In the spell-0x83 slot (extraction 905), FUN_801F16A0 phase 0 loops eight spawns with rand()-seeded actor fields (+0xB4 = rng % 15 + 16, +0xB6 = rng % 255 + 512); the record pointers resolve to file 0x180C..0x1E00 under the slot-B base. The jal into the move VM lives in the executable’s stager, not the overlay.

A live Gimard cast shows the overlay’s own scene-graph entry at 0 calls while the battle per-actor draw runs in exact lockstep with the TRS-keyframe decoder, once per live actor per frame - the player summon is a battle actor. The active flame is library mesh DAT_8007C018[26], one of ten fire-textured meshes (CLUT row 478), drawn Gouraud-textured off the resident etim page at (832, 256). The enemy Fire Tail is a single move-VM part-actor over a record in the battle overlay’s effect-prototype table (0x801F6324, 61 pointers → 54 unique records); PROT 0900’s screen-widget family stays dormant outside the ending scenes.

Trim before counting. Extraction .BINs for stagers over-read into their neighbours; an entry’s own content is (next_start_lba − start_lba) × 0x800 bytes (unique_content_len; Cort mid-cast saves pin 0938 → 0x1800, 0940 / 0944 / 0961 → 0x2000, 0962 → 0x2800, 0966 → 0x4000). Trimmed, every record’s first word is -1, a small mesh index, or 0x4000. The 0x4000 render-mode records sit in five player stagers (Palma 0928, Mule 0929, Jedo 0931, Aluru 0916, Iota 0921); no live capture has seated one, so those render modes’ draw behaviour is open.

History: superseded readings
  • “Gimard → extraction 905” was the raw-vs-extraction off-by-2; the loader constant is raw-TOC space.
  • PROT 0907’s “Hell’s Music” string is Nighto’s attack name, not a dance song; the dance overlay has no slot-B loader call.
  • “Records beyond the file / no move VM in the stager” came from a wrong link base; under 0x801F69D8 every pointer resolves in-file.
  • The 0x1000 / 0x8000 “sentinel” population was over-read contamination from untrimmed files.
  • The flame flicker is not CLUT animation - see do-not-re-walk.

The action queue and Tactical Arts triggers

What the player types on the D-pad becomes a flat list of action bytes before the machine sees it: the per-actor byte stream at actor[+0x1DF..+0x1F2]. Miracle Arts and Super Arts are pattern matches on that list, which is why the right arrows in the right order “upgrade” a chain into a named combo. The queue builder runs both passes, in this order, when the seed state commits the turn:

  1. Miracle Art match. Two conditions, both required. The slot’s Miracle marker ctx[+0x25F + slot] has to be set — it is raised once per battle, when the party actor is seated, from the acting character’s Ra-Seru equipment byte, not by anything the player types. And the command string has to equal the character’s Miracle string (Vahn’s Craze: R D L U L U R D L). Then the whole queue becomes the replacement string: four directions, the SpecialStarter byte, then the arts.
  2. Super Art tail replace. For each chained art the builder walks the character’s five Super find patterns; a match on the queue tail is replaced by the replace tail ending in the finisher. The match needs the find’s last art to be the queue’s last action, and every participating art to have paid AP.
Resident table (battle overlay)AddressLayout
Miracle replacement strings0x801F64F4 + (char − 1) × 0x10three strings, 16 bytes each
Super find0x801F652415 entries, 13-byte stride: Vahn ×5, Noa ×5, Gala ×5
Super replace0x801F65E815 entries, 16-byte stride, zero-padded
Byte codes-0x0C / 0x0D / 0x0E / 0x0F = Left / Right / Down / Up; 0x19 art starter; 0x1A SpecialStarter

Every string is capture-validated byte for byte against legaia_art’s tables, and a Vahn Tri-Somersault capture’s queue tail 19 27 0F 19 1F 0E 1A 2B 2B 2B equals its modelled replace. Ports: MiracleMatcher / SuperMatcher in legaia_art supply the tables; the live path runs the byte-exact appliers in battle_action::queue_applier. The Super pass applies once and takes the first matching row in resident-table order — not to a fixpoint, and not the longest match.

In the live Arts submenu (engine-core::battle_arts) an art is a saved directional chain. A Miracle is recognised by exact string match; a Super by tokenising the chain into named arts and tail-matching the art ordering, because the connector direction after each art is per-combo data (Vahn’s 0x27 is followed by 0F in Tri-Somersault but 0E in Power Slash) that a saved chain does not carry. Both flag the menu row with the combo name and resolve the strike profile from the replacement queue.

Strikes. In the attack chain (state 0x1A) the host receives apply_art_strike(ArtStrikeInfo) - per-strike power byte, damage timing, hit cue, status effect - alongside apply_damage. engine-core::art_strike folds it into an HP delta, a status flag and scheduled SFX cues; World::fold_battle_event applies HP / status and queues the cues, which the host plays through the scene VAB at the cue’s frame delay.

History: the ctx[+0x274] queue hypothesis

The queue was once placed at ctx[+0x274]. A capture showed that word is the turn-order active-actor index; the queue is the per-actor stream at actor[+0x1DF..+0x1F2], confirmed by the Noa Miracle and Vahn Tri-Somersault captures (super-art queue capture).

Spirit and Run in the live command menu

The engine’s command menu carries all six commands. Spirit charges the caster’s AP gauge (+5) and raises a guard stance - the engine model of the pending-action byte +0x1DE == 4 that the damage finisher’s guard-halve reads - until that actor’s next turn. Run arms the run band; success tears the battle down as Escaped (no loot, downed members floored at 1 HP), failure consumes the turn.

Escape rollRule
party scoresum over slots (downed included) of (SPD × 3) >> 1 + missingHP >> 4
enemy scoresum of SPD + missingHP >> 5
outcometwo BIOS-rand draws modulo each score; caught iff roll_p < roll_e, or the scripted-fight flag ctx+0x287 is set (the same per-battle byte the audio ducks and the counter-attack gate read)
Chicken Heart (passive 0x34)party roll ×1.5, from living members only
Chicken King (passive 0x37)forces a tie - assured escape, still blocked by ctx+0x287, hence “non-boss”

Ported as battle_formulas::escape_roll, rolled by World::roll_battle_escape.

Engine port

The port keeps one enum value per retail state byte, so a transition trace from the engine lines up against a retail capture line by line.

ItemRole
ActionState / ActionCategoryevery named state byte and category; from_byte returns None for unmapped values, surfaced as StepOutcome::UnknownState
BattleActor / BattleActionCtxthe per-actor and context fields the machine touches; names mirror the +0xNNN offsets
BattleActionHostcallbacks for every cited helper (pose, ui_element, range_check, recompute_battle_order, camera_bounds, party_setup / monster_setup, rng, art_record); all default so a minimal host compiles
step(host, ctx)one frame of dispatch: Stay, Transition, BattleComplete or UnknownState
validate_actionthe 18-arm action validator from the executable: heal / revive / MP targets, status presence, stat caps, the per-slot validity byte discipline; its 224-slot inventory gate is item_count_gate
pool_opspure ports of the actor-pool leaf helpers (below)
Details: actor-pool leaf helpers
FunctionDoes
FUN_801DB9C4end-of-action flag scrub: ANDs slots 0..=6’s +0x8 word with 0x7CFFFFFF
FUN_801DB318formation span-normalise + recentre when the X/Z extent exceeds 0x800
FUN_801D8A88attack target ring: live-monster count into ctx[+0x244], current target as the wrap slot, three nearest others by bearing
FUN_801D8D00target-cycle accessor with wrap
FUN_801DB8B4first live monster slot (3..6 by the +0x14C liveness halfword), else 7
FUN_801DBA04 / FUN_801DB81Cselectable-participant scans: action byte != 4, alive, no +0x16E & 0xF84 ailment; from slot 0 / after the current actor
FUN_80019B2812-bit bearing (atan2) over the arctan LUT at 0x8006F4C8; ported as bearing_12bit with the LUT caller-supplied
FUN_801DB124dead-target redirect: re-rolls a living slot on the same side (Attack always; Magic / Item conditionally)

How we know

FunctionAddressWhat it provesDump
action driverFUN_801E295Cthe 256-entry jr table at 0x801CED44, every band body, the 0x66 end signaloverlay_battle_action_801e295c.txt
queue builderFUN_801EED1C (called at 0x801E2C7C)arrow chain → art constants, AP paid at +0x170, Miracle copy inline, Super applier FUN_801EF9E4-
queue locationactor[+0x1DF..+0x1F2]Noa Miracle and Vahn Tri-Somersault captures byte-exact; dequeue at 0x801D89D8super-art queue capture
summon loadcase 0x29 → FUN_8003EC70loader-B id at 0x8007BC4C; every player id observed mid-cast at its arithmetic slotmid-cast save corpus
stager recordsPROT 905 at base 0x801F0000spawn loops, record format, in-file pointers under 0x801F69D8dump_summon_overlay.py
flame identityDAT_8007C018[26]the only rendered model baking etim; CLUT band identical across framesbattle_gimard_tail_fire_a / _b
escape rollFUN_801E791Cscore formulas, strict compare, the two Chicken passives-
UI-element spawnerFUN_801D8DE8~30 call sites; fans out through the effect cluster-
action validatorFUN_8003FB10 / FUN_8004689818 arms over the validity byte at gp + 0x9A8; item cap-
victory arm0x801E6690the alive-skip that assumes the acting actor is a party member-

The full per-state table - every case body, what it runs and where it goes next - is in docs/subsystems/battle-action.md, along with the turn cursor ctx[+0x1A], the two park analyses, the Magic arm’s class discriminator and the battle voice cues.

See also