Battle action state machine
The layer between “the player picked Attack” and “the sword has landed and HP has been deducted”. An action takes many frames - turn, run in, swing, damage pop, walk back - so the game tracks it as one state byte advanced once per frame, with a band of states per command category. A Tactical Art is not a band of its own: its arrows and art bytes ride in the same action queue as a plain Attack and play out through the attack states, each swing staging its own power byte.
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 at0x800EB654, dispatched through a 256-entry jump table with no default arm - Band key
- The actor’s category byte
+0x1DE, read once at the seed state0x0C - 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 gate0x5A, 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
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:
| Key | Field | Values |
|---|---|---|
| Action category | actor[+0x1DE] | 0 Tactical Arts, 1 Item, 2 Magic, 3 Attack, 4 Spirit, 5 Run / Defend |
| Execution phase | ctx[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-state | actor[+0x1DC] + scratch | flag 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
0x28but a low-class, low-id spell (class byte< 0x14and id< 0x65) takes0x3C; Item seeds0x3Cbut items0x98/0x99take0x28. Player Seru magic (0x81+) always takes0x28. 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 stores0), so a cast’s seed pass and its0x28pass can aim the camera at different yaws; a retail recording of Nova’s Scythe Wind shows the seed pass heading for one and0x28re-arming toward the other. - Run.
0x64rolls the escape. A failed run returns to0x50(turn consumed, battle continues); a success enters0x66, which stages a 64-frame fade, raises the battle-end signal and parks. - Wipe arm. The
0x5Avictory 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
0x51HP-bar settle gate and the0x19attack-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 from0x51only when the killing blow absorbed a Seru (ctx[+0x269]), countdown re-seeded to0xB4- 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 to0x13. The failed-run wait0x65zeroes 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 drawsrand()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:
- 0x14 Faceturn toward the target, range check; already in reach skips to the strike loop
- 0x19 Close inhold until the range check passes (a monster walks via
0x15–0x18, its walk clip’s root motion carrying it) - 0x1E Strike loopone queue byte per swing clip: direction, art starter or art; a counterattack can redirect here
- 0x1F Recoverrecover pose; wait for the advance-done flag to clear
- 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) - 0x50–0x52 Doneauto-revive, Spirit +8, HUD teardown, Seru-absorb banner hold
- 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.
| Cast | Spell ids | Loader argument | Extraction entries |
|---|---|---|---|
| Player Seru magic | 0x81..0x8B | id − 0x79 | 903..913 (Gimard 0x81 → 903; Nighto 0x85 → 907) |
| Evolved Seru | 0x8C..0x95 | same arithmetic | 914..923 |
| Capture-class spells | class c | record[+1] + 0x28 | the cast modules |
| Enemy specials | move id | id + 895 | Delilas 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 atlasetim.datis 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 ≥ 0seats a library mesh,-1is a transform node,0x4000/0x4001are special render modes. Parserlegaia_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 ownsummon.dattexture 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
0x801F69D8every 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:
- 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, theSpecialStarterbyte, then the arts. - Super Art tail replace. For each chained art the builder walks the character’s five Super
findpatterns; a match on the queue tail is replaced by thereplacetail 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) | Address | Layout |
|---|---|---|
| Miracle replacement strings | 0x801F64F4 + (char − 1) × 0x10 | three strings, 16 bytes each |
Super find | 0x801F6524 | 15 entries, 13-byte stride: Vahn ×5, Noa ×5, Gala ×5 |
Super replace | 0x801F65E8 | 15 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
| Function | Does |
|---|---|
FUN_801DB9C4 | end-of-action flag scrub: ANDs slots 0..=6’s +0x8 word with 0x7CFFFFFF |
FUN_801DB318 | formation span-normalise + recentre when the X/Z extent exceeds 0x800 |
FUN_801D8A88 | attack target ring: live-monster count into ctx[+0x244], current target as the wrap slot, three nearest others by bearing |
FUN_801D8D00 | target-cycle accessor with wrap |
FUN_801DB8B4 | first live monster slot (3..6 by the +0x14C liveness halfword), else 7 |
FUN_801DBA04 / FUN_801DB81C | selectable-participant scans: action byte != 4, alive, no +0x16E & 0xF84 ailment; from slot 0 / after the current actor |
FUN_80019B28 | 12-bit bearing (atan2) over the arctan LUT at 0x8006F4C8; ported as bearing_12bit with the LUT caller-supplied |
FUN_801DB124 | dead-target redirect: re-rolls a living slot on the same side (Attack always; Magic / Item conditionally) |
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| action driver | FUN_801E295C | the 256-entry jr table at 0x801CED44, every band body, the 0x66 end signal | overlay_battle_action_801e295c.txt |
| queue builder | FUN_801EED1C (called at 0x801E2C7C) | arrow chain → art constants, AP paid at +0x170, Miracle copy inline, Super applier FUN_801EF9E4 | - |
| queue location | actor[+0x1DF..+0x1F2] | Noa Miracle and Vahn Tri-Somersault captures byte-exact; dequeue at 0x801D89D8 | super-art queue capture |
| summon load | case 0x29 → FUN_8003EC70 | loader-B id at 0x8007BC4C; every player id observed mid-cast at its arithmetic slot | mid-cast save corpus |
| stager records | PROT 905 at base 0x801F0000 | spawn loops, record format, in-file pointers under 0x801F69D8 | dump_summon_overlay.py |
| flame identity | DAT_8007C018[26] | the only rendered model baking etim; CLUT band identical across frames | battle_gimard_tail_fire_a / _b |
| escape roll | FUN_801E791C | score formulas, strict compare, the two Chicken passives | - |
| UI-element spawner | FUN_801D8DE8 | ~30 call sites; fans out through the effect cluster | - |
| action validator | FUN_8003FB10 / FUN_80046898 | 18 arms over the validity byte at gp + 0x9A8; item cap | - |
| victory arm | 0x801E6690 | the 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.