Battle subsystem
How Legaia runs a fight, from the flash of an encounter to the spoils screen. The town logic is paged out of RAM, the battle overlay (a chunk of code streamed in on demand) is paged in, and the field you were standing on stays loaded underneath as the 3D backdrop. This page is the guided tour; the per-field tables live on battle internals, the measurements on capture notes.
At a glance
- Where
- Battle overlay = PROT entry 0898, loaded at
0x801CE818(the slot the town overlay also uses, so the two never coexist); scene loader, seats and archive streamers are staticSCUS_942.54code. - Battle state
- One context struct reached through the global pointer
0x8007BD24(resolves to0x800EB654); two byte cursors inside it drive the menu half (+0x06) and the action half (+0x07). - Roster
- 8-slot actor pointer table at
0x801C9370- slots 0..2 party, 3..7 monsters. - Monster data
- Monster archive, PROT entry 867: one LZS slot per species (stats, mesh, textures, spells, rewards).
- Engine modules
engine-core(Worldbattle tick,battle_input,battle_seats,monster_ai,battle_flow),engine-vm::battle_action+battle_formulas,engine-uiHUD.- Confidence
- Loader, roster, seats, flow states, wipe path: Confirmed (disassembly + live captures). Some actor-struct fields and the Seru attachment table: Inferred.
- Used by
- Action state machine · Battle formulas · Arts gauge · Encounter record · Monster viewer
How a battle works
Three systems share the battle overlay. The scene loader pulls the stage, effect and monster assets off the disc. The menu state machine walks the player through Begin / Run, the command ring and the target cursor. The action state machine plays each committed command out over many frames - wind-up, strike, damage popup, recovery - and hands the turn on. The effect VM draws sparks and spell visuals but never decides anything; the move VM turns a Tactical Arts input into keyframes one layer below the action machine.
Getting into a fight
Three doors lead into the same loader:
- Walking. Every step onto a new 128-unit collision tile rolls against the region's rate; a hit picks a formation row from the scene's own table (see encounter record).
- World map. Each region rolls the same way against its own slice of rows (world map).
- Script. Boss rows sit outside every rollable slice and are reached only by the field-VM scripted-battle op
3E FF <row>(anyop0 < 100runs the same body). The row's non-zero header byte is what gives a scripted fight its particle intro and second audio cue.
| Scene | Op | Formation row | Contents |
|---|---|---|---|
garmel | 3E FF 09 | 9 | lone Zeto |
garmel | 3E FF 08 | 8 | lone Songi |
rikuroa | 3E FF 11 | 17 | lone Caruban |
Whichever door opens, the field scene is not unloaded: the scene-name table is identical before and after the switch, and the battle draws on top of the resident terrain. The formation ends up in one four-cell array of monster ids that every later stage reads (internals).
Details: who carries the boss op, and the forced-battle id nobody writes
The garmel fights ride partition-2 beat records spawned by the gated record dispatch. The Caruban op lives in a partition-1 boss-stager placement: a parked special-model actor whose record opens on a park-gate flag test, stations itself at the nest tile, self-suspends on a halt-acquire, and carries the beat body (staged-marker SET, then 3E FF 11). No script-side un-halt exists, so the approach dispatch resumes it. The engine derives those placements from the MAN at scene entry (World::install_boss_stagers_from_man) and runs them on approach (World::run_boss_stager_record); scripted entry is World::trigger_scripted_battle(row). Oracles: organic_zeto_encounter_disc.rs, organic_beat_records_disc.rs.
A "forced battle id" global exists but has no writer anywhere on the disc: set, it would seed the formation cells from the id itself and exit to the debug menu. It is dev-harness residue, like the mode-18/19 game-over rows. Full trace in battle.md.
Loading the fight
Before the first prompt the loader ticks through an asynchronous ladder, one PROT.DAT entry per state. The stage overlay step is skipped for every retail fight except the Tetsu sparring tutorial and the Cort fight.
The befect_data rows below are numbered in the loader's own raw TOC space, the space its li constants live in; the extraction entry is two lower, so raw 872..875 are extraction 870..873 - etim / etmd / vdf / efect. Reading a raw index as an extraction one is what once fused the vdf pack with the character pack that carries extraction label 874.
When a stage overlay is paged in it takes the fight over. The evolved-Cort intro leaves the command-flow byte on a value the battle menu has no handler for, and the menu simply idles: the stage module runs its own camera move, its own banner and its own multi-frame countdown, then writes the flow byte back to a value the menu understands with the intro timer already zeroed - so the fight opens the moment the module is finished, and no button press hurries it. A live capture holding all ten pad buttons in turn moves the byte not at all.
etim.dat, the effect texturesraw TOC 872
3Model + VDF packsone read covers both: etmd.dat, whose every entry is registered into the effect window, and vdf, whose offsets fill the VDF pointer tableraw TOC 873 + 874
4Effect scriptsefect.dat, then the runtime effect 2-pack wrapperraw TOC 875
5Stage overlayonly when the stage id is non-zero: a per-stage code overlay, not geometryPROT 967..969
6Side-band streamssummon.dat / readef.DAT handler for arts poses and summonsPROT 893 / 894
7Party + monstersparty meshes assembled from each member's equipment; one archive slot streamed per distinct speciesPLAYER1..4, PROT 867
- Party meshes are assembled, not loaded whole. The player battle files (
data\battle\PLAYER1..4) carry per-equipment sections; the loader splices the equipped pieces into one mesh per member and relocates its textures into the seat's VRAM band. Details: battle data pack, character mesh. - The heap is the real formation limit. Everything the loader places comes from one ~1.23 MB best-fit heap, and the monster streamer copies through the returned pointer unchecked. About 145 KB is left for distinct monster blocks; retail never authors more than two distinct species per formation and the randomizer enforces the same cap. Ledger on internals.
- Duplicates are free. Only the first occurrence of a species id streams; repeated seats share the record and mesh.
The arena: backdrop, camera and seats
There is no arena. The fight happens on the ground you were walking on; the camera swaps from the walk camera to a slow orbit around the midpoint between the rows, and the actors and HUD are layered on top. An overworld fight adds two layers so the horizon reads complete from every angle: a flat procedural grass grid under the actors and the map's sky dome, drawn twice (a half turn or an X mirror, chosen per stage by an authored table).
The orbit camera is pinned exactly: pitch 32 (of 4096), a fixed translation 7680 deep, a 4x world scale composed under each actor - the engine's retail_battle_mvp reproduces it to 0.0002 px against a savestate framebuffer. Constants on internals; the per-phase framing (far shot for the menu, close-up for arts input) is on battle action.
Seats are authored data. Two static tables of (x, y, z) entries, picked by combatant count, are stamped into every actor at setup. The party faces +Z, the monsters -Z.
| Count | Party seats (x, z) | Monster seats (x, z) |
|---|---|---|
| 1 | (0, -800) | (0, 800) |
| 2 | (300, -800) (-300, -800) | (-300, 800) (300, 800) |
| 3 | (0, -825) (600, -775) (-600, -775) | (-600, 825) (0, 750) (600, 825) |
| 4 | - | (-900, 900) (-300, 800) (300, 800) (900, 900) |
An alternate monster family (used by the pincer and scripted seatings) is selected by the same flag bit that marks a fight no-escape. Engine mirror: engine-core::battle_seats.
Who is fighting
Every combatant is one actor struct reached through an 8-entry pointer table, and the slot number is identity for the rest of the fight: the action machine, the range check, the AI and the HUD all say "slot 4" rather than "the Gimard". Four records feed those structs:
| Record | What it holds | Where it comes from | Tables |
|---|---|---|---|
| Actor struct | Position, HP / MP / AGL triplets, status halfword, action queue, mesh pointer | Built at setup from the records below | actor struct |
| Battle context | Menu cursor, action cursor, acting slot, action parameters, message buffer, intro timer | Allocated per battle | context struct |
| Character record | The persistent 0x414-byte per-character block: stats, spells, equipment, name | The save file | character record, save record |
| Monster record | Stats, three castable spell ids, gold / EXP / drop, mesh + texture pool, action entries | Monster archive slot, one per species | monster record |
The party side is read straight out of the character record and written back (HP / MP) when the fight ends. The monster side is decoded once per species: the loader keeps the record pointer in a side table so the spoils routine can read gold, EXP and the drop roll without copying them into the actor. Each monster's mesh is a Legaia TMD inside the same block, textured from a 15-palette pool that the loader relocates into the seat's own VRAM column - which is what the monster viewer and the engine's MonsterMesh both reproduce.
A round, turn by turn
The command UI is not one menu. The menu machine walks a byte cursor through three small D-pad surfaces - each a cluster of plate chips around a D-pad glyph - and every round opens on the same prompt:
| Flow state | On screen | What the player does |
|---|---|---|
0x0A / 0x0B | Party plates + formation banner | Waits out the intro timer (a back attack skips the party's first round) |
0x1E | Begin / Run | Left begins the round, Right opens the flee confirm |
0x28 | Command ring | Up = Item, Left = Attack, Right = the Ra-Seru (magic), Down = Spirit; one member at a time |
0x78 | Auto / Command | Attack is a door: Auto goes to the target cursor, Command to directional arts entry (an option byte can skip this prompt) |
0x3C / 0x46 / 0x50 / 0x5A | Item window / magic window / arts entry / target cursor | Picks the payload and target |
0x6E then 0xFE | - | All members committed; the frame is handed to the action machine |
The action machine then resolves the round. It keys on its own byte, walks a band of states per command category (Attack, Magic / Item, Summon, Spirit, Run / Capture, Done), and ends each action at a gate that counts who is still standing. When every living actor has acted it writes the menu machine back to turn setup and bumps the round counter - the two bytes hand the round back and forth. State bands and the validator are on battle action; the arithmetic is on battle formulas.
- Turn order is an initiative key per living actor, seeded from SPD plus a random spread and re-rolled each round; the highest key acts, ties break at random. Port:
World::next_combatant_by_initiative. - Monster AI is two routines: a picker that rolls between a physical strike and the record's castable spells (MP-gated, targeted by the spell's shape byte) and can be overridden by a per-monster-id script, and a target resolver that only runs for a charmed party member. Port:
World::pick_monster_action,monster_ai::decide,World::resolve_monster_target. - Status effects are bits in one halfword on the actor, set only by the battle overlay's two infliction legs. Poison ticks never kill (they bottom out at 1 HP); Stone counts as defeated. Per-status table on internals.
- AP is the arts budget for the turn; Spirit adds +5 once per turn and raises a guard stance that halves incoming damage. Costs and the weapon-specialty penalty are on arts command gauge; model
engine-core::ap_gauge. - Items come from the same 256-slot bag the field menu uses - there is no battle inventory. The bag and its "split bag" window are on inventory.
- Capture-class casts (Seru capture, the Amulet, the Delilas signature attacks) page a small dedicated module into the side overlay window and tick it every frame until it reports done. See internals and cast modules.
Ending the fight
| Outcome | Trigger | What happens |
|---|---|---|
| Victory | End-of-action gate finds no living monster | Gold is summed halved and halved again (x1.25 with the gold passive); EXP is summed x3/4 and split among the living; every enemy rolls its drop chance, the last winner decides, and at most one item drops per battle (three times in four nothing does, unless a chance is 100% or Items Up applies). Win pose, spoils banner, back to the field. Formula: victory spoils. |
| Seru capture | A party member's killing blow on a monster with a Seru id | Chance = the record's catch-rate percent, +30 flat with Magic Boost, one d100. No HP-fraction term - "weakened Seru" is only the killing-blow condition. An unknown Seru is learned in the same action: the Done band teaches its spell and raises the learn banner (a Seru the character already knows, or a character without their Ra-Seru, absorbs nothing). The catch-rate byte is the ROM patcher's --seru-catch-rate target. |
| Escape | Run at the round prompt, or an escape item / spell | Party (SPD*3)/2 + missingHP/16 vs enemy SPD + missingHP/32, two random draws; Chicken Heart x1.5, Chicken King forces the tie. Success ends the battle with no loot and downed members floored at 1 HP; failure spends the round. |
| Party wipe | End-of-action gate finds no living, non-petrified party member | The battle exits like any other, into MAIN INIT; there a gate reads a party-survived latch and sends the game to the CARD screen - the title screen with the cursor on Continue. No "GAME OVER" art is drawn on the reachable path. |
Two things about the wipe catch people out. A scripted-loss battle (the Rim Elm ambush) raises a story-flag latch first, so its wipe returns to the field like a win; the same gate sets a second flag on the survived path, which is how a scene script can score a scripted battle. And the game-over artwork overlay that exists on the disc (mode 18/19) has no writer anywhere - it is a dev harness. Latches and stores on internals.
After every battle retail reloads the field scene and re-runs its entry script, which is what dispatches post-battle beat records (the rikuroa progression gate, for one). The port mirrors that reload on the Battle-to-Field edge.
The from-scratch port
The engine keeps the battle simulation renderer-free in engine-core and drives it from one per-frame World::tick, so walking, being ambushed, fighting and returning to the field all happen from a single call. Both hosts (native play-window and the browser play page) arm it through the same kernel, World::arm_live_loop.
| Piece | Module | Retail counterpart |
|---|---|---|
| Encounter bracket + region roll | encounter, RegionEncounterTracker | Region rate counter, one-step anti-repeat |
| Enter / seat / leave | World::begin_encounter_battle, enter_battle_from_formation, finish_battle | Loader ladder + seats + MAIN INIT gate |
| Round prompt, ring, attack mode | battle_input (CommandPhase), battle_flow | Menu machine flow byte |
| Action resolution | engine-vm::battle_action, battle_formulas | Action machine + arithmetic kernels |
| Submenus | battle_arts, battle_magic, inventory_use, target_picker | Arts entry, magic window, item window, target cursor |
| Monster turns | World::pick_monster_action, monster_ai | AI picker + per-id overrides |
| Spoils, captures, level-ups | World::apply_battle_loot, resolve_captures, LevelUpTracker | Victory spoils + capture grant |
| Wipe | game_over::GameOverSession | The CARD-screen store pair; draws nothing, reads no button |
Per frame the battle tick folds art-strike damage into HP, marks the dead, retires two animation gates retail's renderer would clear (advance-done at recovery, the caster's spell iterator at sustain), and re-arms the next attacker at the end-of-action gate. On completion it writes post-battle HP / MP into the character records before restoring the field snapshot - otherwise the fight's damage is thrown away. The loop flag gates only the encounter roll: once the world is in battle, however it got there, the tick always drives it to a result.
Player-driven battles are the default on both hosts; --no-live-loop turns the encounter roll off and --no-player-battle auto-attacks each party turn. Post-battle Seru learning banks capture points per Seru and party slot; the per-monster Seru attachment table is an engine-side approximation (SeruRegistry::vanilla), Inferred, not Confirmed.
Details: the two host-simulated gates, and the loop-flag regression
Retail's render and animation systems clear the advance-done flag at attack recovery and the caster's spell iterator at magic sustain; the port has no such driver, so World::live_battle_tick retires each on the frame its state is reached. Without the second edge every battle in which anyone cast a spell stopped dead. Gating the driving side while leaving entry ungated once let a default session strand itself in an unresolvable battle; battle_always_resolves.rs pins that the tick always finishes a battle. Integration: live_loop_tick.rs, battle_player_driven.rs, end_to_end_gameplay_loop.rs.
Details: the charm battle softlock (randomizer interaction)
The randomizer's enemy-ally feature widens the monster-wipe scan's down-mask so a living charmed monster counts as "down". That breaks a retail invariant: the wipe scan and the initiative scheduler gate on the same predicate, so in retail a living acting actor at victory is always a party member. With the widen, a charmed ally's own killing blow makes victory fire with a monster as the acting actor; the win-pose staging reads its character id past the 3-byte party roster and arms a garbage side-band stream request - the battle wedges at the hand-off. The old "unbounded reroll" theory is falsified: both reroll loops terminate with probability 1. Fixes: the engine's victory_pose_fixup re-picks whenever the acting slot is not a living party slot; the disc-side legaia_patcher::charm_fix is a one-word detour at the keep-branch.
How we know
| Function | Address | What it proves | Dump / probe |
|---|---|---|---|
| Battle scene loader | FUN_800520F0 | The async ladder, one PROT entry per state; the stage-overlay dispatch on a non-zero stage id | ghidra/scripts/funcs/800520f0.txt |
| Stage setup | FUN_800513F0 | Seat tables, dome drawn as two actors, party mesh registration | 800513f0.txt; autorun_battle_party_mesh_install.lua |
| Monster streamer + init | FUN_800542C8 / FUN_80054CB0 | Archive slot per species, record-to-actor field map | 800542c8.txt, 80054cb0.txt; autorun_monster_record_source.lua |
| Battle setup + species rebuild | FUN_80055B6C | Formation cells, 50% species-order shuffle, per-formation stage id override | 80055b6c.txt; autorun_formation_cell_writers.lua |
| Heap | FUN_8002B3D4 | Best-fit heap, NULL on exhaustion, the ~145 KB monster budget | 8002b3d4.txt; autorun_delilas_battle_load.lua |
| Menu state machine | FUN_801D0748 | Flow-byte comparison ladder, Begin / Run every round, D-pad chip masks | overlay_0898_801d0748.txt; autorun_battle_item_window_capture.lua |
| Action state machine | FUN_801E295C | Per-category state bands, end-of-action wipe scans, round hand-back | overlay_0898_801e295c.txt |
| Initiative | FUN_801DABA4 | Highest-key-acts selection; key seed from SPD | overlay_0897_801daba4.txt, overlay_0897_801e23ec.txt |
| Monster AI | FUN_801E9FD4 / FUN_801E7320 | Strike-or-spell roll, per-id overrides; charmed-party target resolver | overlay_0898_801e9fd4.txt, overlay_battle_action_801e7320.txt |
| Hit resolve + capture roll | FUN_801EC3E4 | Killing-blow gate, catch-rate d100, Magic Boost +30 | overlay_0898_801ec3e4.txt |
| Escape roll | FUN_801E791C | The SPD / missing-HP comparison and the two passives | overlay_battle_action_801e791c.txt |
| Victory spoils | FUN_8004E568 | Gold / EXP arithmetic, drop roll; reads the record-pointer side table | 8004e568.txt; gold write-watch |
| Battle exit + wipe gate | FUN_80046A20 / FUN_8003AEB0 | Exit is always MAIN INIT; the CARD-screen fork and its latches | 80046a20.txt, 8003aeb0.txt; autorun_gameover_mode_writer.lua |
| Orbit camera + grid | FUN_80026988 / func_0x801d02c0 | Exact projection constants; the ground is a GTE grid, not a mesh | 80026988.txt, overlay_0897_801d02c0.txt; autorun_battle_render_capture.lua |
Save-state diffs (overlay residency, actor-table window, stat-growth deltas) are on capture notes. Falsified readings are on do-not-re-walk.
Full reference
Source of record: docs/subsystems/battle.md. It also covers the sparring-tutorial prompt machine, the post-strike two-shot camera, the staged-anim channel, the packet-pinned HUD chrome and widget-class table, the weapon-trail builder, per-frame actor maintenance, the SFX scheduler and the field-to-battle intro curtain.