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

The state machine

Action begin + seed 0x00 → 0x0A → 0x0C, reads +0x1DE Attack / Artscategory 30x14–0x20 Magiccategory 20x28–0x2E Itemcategory 10x3C–0x40 Spiritcategory 40x46–0x48 Monster fleecategory 50x68–0x6B Party runcategory 50x64–0x66 Summon0x32–0x38 Cast module0x6E–0x71 Seru spell capture Done 0x50–0x52 auto-revive, Spirit pay-in, poses, HUD teardown fails End-of-action gate 0x5A count the living on both sides Next actorturn cursor +1, back to 0x0A Round end 0xFFmenu machine back to 0x14 Battle-end signalDAT_8007BD71 = 0xFE a side is wiped escaped
One byte walks a band per command category and always funnels through the 0x5A gate, which counts survivors before handing the turn on. Battle end is a separate signal byte, never a state value: 0xFF only means the round is over. Category 0 (an arts chain already recorded by the input flow) jumps straight to Done.

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 0xFD (idle hold) and 0xFF (round end); any unhandled byte falls to the shared epilogue as a no-op
Per-actor sub-stateactor[+0x1DC] + scratchflag bits (0x01 windup done, 0x02 advance done, 0x04 exit); +0x1D9 current anim, +0x1DA queued anim, +0x1DF..+0x1F2 the action queue
  • 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.
  • Magic and Item cross over. Magic seeds 0x28 but a low-class, low-id spell (class byte < 0x14 and id < 0x65) takes 0x3C; Item seeds 0x3C but items 0x98 / 0x99 take 0x28. Player Seru magic (0x81+) always takes 0x28. The choice picks animation and timing, not cost.
  • The seed frames on the raw roll. The seed rolls a camera style (rand() % 4) and frames on it before the category arm narrows the byte (Magic stores 0), so a cast’s seed pass and its 0x28 pass can aim the camera at different yaws; a retail recording of Nova’s Scythe Wind shows the seed pass heading for one and 0x28 re-arming toward the other.
  • Run. 0x64 rolls the escape. A failed run returns to 0x50 (turn consumed, battle continues); a success enters 0x66, which stages a 64-frame fade, raises the battle-end signal and parks.
  • 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.
  • A button press cuts two waits short. State 0x52 - entered from 0x51 only when the killing blow absorbed a Seru (ctx[+0x269]), countdown re-seeded to 0xB4 - holds the “acquired the power of” banner; a newly-pressed button (_DAT_8007B874, the pad’s press edge, not a screen-shake word) clamps its countdown to 0x13. The failed-run wait 0x65 zeroes its countdown on a new press or a button held through the pad pump’s 32-vsync ring (_DAT_8007B874 | _DAT_8007B938, 0x801E5978..0x801E59A0), and the level-up banner’s skip reads the same pair; the Seru-absorb skip takes the press alone. Because the frame driver draws rand() once a pass, these skips also move every later roll.

One attack, state by state

The attack band is the one most turns take. A party member's committed Attack plays out like this:

  1. 0x14 Faceturn toward the target, range check; already in reach skips to the strike loop
  2. 0x19 Close inhold until the range check passes (a monster walks via 0x15–0x18, its walk clip’s root motion carrying it)
  3. 0x1E Strike loopone queue byte per swing clip: direction, art starter or art; a counterattack can redirect here
  4. 0x1F Recoverrecover pose; wait for the advance-done flag to clear
  5. 0x20 Returnhold for the last swing’s clip, then the target’s reaction; the pass the last art clip ends, every slot’s animation rate returns to normal (FUN_801E93C8)
  6. 0x50–0x52 Doneauto-revive, Spirit +8, HUD teardown, Seru-absorb banner hold
  7. 0x5A Gatewipe check, then the next actor or the round end

Every reaction turns the defender. When a melee strike commits its reaction (0x801EEC34..0x801EECBC), the defender’s heading is set to the bearing of the attacker, block included, and nothing turns it back: a struck monster keeps that heading until its own action recomputes it, and its knockback steps along it. The port does the same in World::commit_melee_reaction.

A struck body slides by what it played. The same commit flags the defender for an immediate re-commit, and its next animation tick cuts the clip it is leaving (FUN_80047430, 0x80047940): the body steps along its new heading by that clip’s end displacement (entry +0x0E), scaled by how far through the clip it got. A guarding member hit three times mid-block slides back a share of the block each time, and the attacker walks after it: in the Theeder strike film Vahn steps 140 units per hit, and Theeder follows him down the stage.

The slide starts on the defender’s own next tick. A clip’s root motion is stepped by the entry the body holds once its own tick has made its commits (the speed read at 0x80047D34), and the party’s bodies tick ahead of the monsters’. A monster’s blow therefore lands on a party member that has already stepped this frame on whatever it was playing: the block’s or flinch’s backstep begins a frame later, behind the cut. A monster struck by a party member ticks after its attacker and backs off on the blow’s own frame. In the Gilium Lv3 strike film Vahn’s block ends on the frame the fourth swing lands, so he stands on idle for that frame and slides from the next; the port (BattleState::anim_slice_roots) steps the entry each body ticked on.

A locked clip refuses the reaction. The cut skips an entry whose lock byte +0x76 is set (0x80047940), and that clip’s natural end then commits idle over the staged reaction (0x80047B44), so the reaction never plays. No flinch, block, knockdown or get-up entry is locked; the locked entries are some monsters’ walks and attack clips, so this is a body struck inside its own swing.

A hit only stages its reaction. Neither the strike kernel nor the damage primitive starts the struck body’s clip: each writes the reaction into the body’s staged-clip byte and raises its commit flags (0x801EEC6C..0x801EEC84, 0x80042118..0x80042170), and the body’s own next animation tick commits it, after that tick has advanced and walked the clip it is leaving. A party member struck by a monster has already ticked when the blow lands, so its flinch starts a frame later; a monster struck by a party member ticks after its attacker and flinches on the blow’s frame. The port holds the stage on the body (Actor::battle_reaction_stage) and commits it in that body’s slice of the animation walk.

A clip’s last tick still fires its late records. The cue, hit and effect walks read the animation cursor as that tick’s advance left it, before the natural-end test, so on the tick a clip ends they see the frame count, one past the last keyframe. A record gated there fires as the clip ends: a get-up’s dust, some monsters’ walk sparks once a cycle, and the effect a party member’s get-up and flinches spawn.

When the monster’s action table has no walk entry it falls back to the party’s 0x19 path, which has no movement code and no timeout: if nothing else closes the distance it waits forever - the root of the 0x19 park. On arrival a walking monster shoves its target back to the range boundary, not itself forward. A counterattack fires on the attacker’s first frame when the target chose Attack, carries the Counterattack passive and won the picker’s coin flip: the target becomes the active actor and swings first.

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_overlay_images::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, like any monster; the part records drive the stager, not the draw. The port seats each summon’s own body and clips the same way on both hosts (summon::summon_spawn_asset), and streams the cast’s own summon.dat texture pages into VRAM when the stager arms.
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. The Super pass applies once and takes the first matching row in resident-table order — not to a fixpoint, and not the longest match.

Why a saved chain is not enough. 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 - so recognising a Super from a list of named arts means tokenising the arrows into arts first and tail-matching the art ordering, not comparing raw strings.

Strikes. The attack chain walks the queue one swing per animation clip. Each swing stages its own power byte, hit timing, hit cue and status effect; damage lands on the clip’s authored hit events through the melee kernel, never in the state body.

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

Spirit (category 4, states 0x46–0x48) is a gauge move, not an attack. 0x46 stages two targets off the actor’s own AGL and Spirit gauge: next turn’s arts bar becomes min(base AGL × 7/5 + 8, 288), and the Spirit gauge climbs by 32 (35 or 40 with the AP Boost passives), capped at 100. 0x47 grows both bars on screen; 0x48 finishes the ramp. The 0x300 hold 0x47 arms for the flush never holds it: 0x48’s compare is the other way round from a drain, so a hold at or above 8 × step is zeroed on the first pass. The pending category byte +0x1DE == 4 is also what the damage kernels read as the guard stance until the round’s sweep clears it.

Run (category 5) rolls the escape formula in 0x64. On success every downed party member is floored at 1 HP before the teardown - which is why fleeing cures Stone - and the battle ends with no loot. Chicken Heart (passive 0x34) scales the party roll ×1.5 and Chicken King (0x37) forces a tie, both only from living wearers; the scripted-fight flag ctx+0x287 blocks both.

In the port

engine-battle-vm::battle_action keeps one enum value per retail state byte (ActionState), so an engine transition trace lines up against a retail capture line by line. step(host, ctx) runs one frame of dispatch; BattleActionHost carries a callback per helper the state bodies call; validate_action ports the executable’s 18-arm action validator. engine-core::World composes it into the battle tick.

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