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. Tactical Arts are not a separate band: they run through the ordinary attack states and only differ in which power byte each swing stages.
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 - Port
engine-vm::battle_action(ActionState,step,BattleActionHost); composed byengine-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
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:
| 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 terminal 0xFD / 0xFF; any unhandled byte falls to the shared epilogue as a no-op |
| Per-actor sub-state | actor[+0x1DC] + scratch | flag 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.
0x65branches on the roll: a failed run returns to0x50(turn consumed, battle continues); a success enters0x66, which stages a 64-frame black-to-white fade, sets the battle-end signalDAT_8007BD71 = 0xFEand parks in0x67. Engine:ActionState::RunEscape→BattleEndCause::Escaped. - 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.
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.
| 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_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
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. 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 roll | Rule |
|---|---|
| party score | sum over slots (downed included) of (SPD × 3) >> 1 + missingHP >> 4 |
| enemy score | sum of SPD + missingHP >> 5 |
| outcome | two 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.
| Item | Role |
|---|---|
ActionState / ActionCategory | every named state byte and category; from_byte returns None for unmapped values, surfaced as StepOutcome::UnknownState |
BattleActor / BattleActionCtx | the per-actor and context fields the machine touches; names mirror the +0xNNN offsets |
BattleActionHost | callbacks 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_action | the 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_ops | pure ports of the actor-pool leaf helpers (below) |
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.