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. - Per-frame tick
FUN_80046A20(SCUS) drives one fixed order every battle frame: scene loader, seat stager, party-file loader, menu dispatcher, action state machine, separation pass, actor presentation (below).- Monster data
- Monster archive, PROT entry 867: one LZS slot per species (stats, mesh, textures, spells, rewards).
- Confidence
- Loader, roster, seats, flow states, wipe path: Confirmed (disassembly + live captures). Some actor-struct fields: 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 - and the same byte marks the fight no-escape.
| 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). Battle setup also rolls for a back attack or a pre-emptive strike by comparing the two sides' mean SPD; the side caught out faces the wrong way and loses its first-round turns (formula).
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 walking up to it is what resumes it.
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:
- Stage packthe paired battle stage pack · PROT 0869 / 0875
- Texture pack
etim.dat, the effect textures · raw TOC 872 - Model + VDF packsone read:
etmd.datinto the effect window,vdfinto its pointer table · raw TOC 873 + 874 - Effect scripts
efect.dat, then the runtime effect 2-pack · raw TOC 875 - Stage overlayonly for a non-zero stage id: per-stage code, not geometry · PROT 967..969
- Side-band streams
summon.dat/readef.DATfor arts poses and summons · PROT 893 / 894 - Party + monstersparty meshes assembled from equipment; one archive slot per distinct species · PLAYER1..4, PROT 867
The befect_data rows 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.
- 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 1234 KiB best-fit heap, and the monster streamer copies through the returned pointer unchecked. About 141 KiB 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.
- Each species owns its VRAM band - a texture-page column and a palette row per slot - so distinct enemies never contend for video memory. The slot follows the monster's id, not its seat: the loader uploads one pool per distinct id in ascending id order, and a repeated species shares the first one’s slot. Slot 2 is the one every summoned Seru creature’s texture overwrites, so only a fight with three or more distinct species has a monster under it. One upload breaks the pattern: for the late boss formations the monster loader reads one more archive slot after the block, a bare TIM, and uploads it into the slot-1 column and the next palette row whatever its own header says. Cort’s two forms are the fights that sample it; the Mystic Shield’s sphere draws from it (cast modules). Both hosts upload it beside the monster mesh.
- A stage overlay takes the fight over. Only the Tetsu sparring tutorial and the Cort fight page one in. The evolved-Cort intro leaves the command-flow byte on a value the menu has no handler for, so the menu idles while the module runs its own camera move, banner and countdown, then writes the flow byte back with the intro timer already zeroed. No button press hurries it.
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. 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.
The formation recentres every round. Nothing walks a combatant home after an action. Instead, at every round start (and when the ring’s first member cancels back to the prompt) FUN_801DB318 boxes the party and the living monsters, squashes an axis wider than 0x800, and shifts everyone so the box is centred on the origin - that is what pulls a wandered formation back into frame, and the far framing re-derives its focus from it. Every action’s seed runs a sibling that boxes only the target of an Attack, so a melee opens with its target on the stage origin, and the action machine steps a melee’s bystanders out of the attacker’s lane (action helpers).
Range. FUN_8004E2F0 is the range check the action machine calls over and over: the distance between two actors against the sum of their size bytes, clamped per actor. It answers “out of range” outright once the battle-end byte has been raised, before it reads either slot.
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 pairs, 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 and the Spirit gauge) 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 reproduces.
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, Spirit, Run), 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 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.
- 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. A monster fills its turn by paying each move's cost out of its AGL gauge - the same budget the party's arts bar spends.
- 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 two gauges. The arts bar's per-turn pool is the character's AGL, spent by direction presses; a named art is paid from the Spirit gauge, which Spirit charges and which carries from fight to fight. A Spirit turn also lengthens the next arts bar and raises a guard stance. Both are on arts command 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.
One menu dispatcher, four modes. FUN_801D0748 is byte-identical in the battle, capture, level-up and Muscle Dome captures - “the capture dispatcher”, “the level-up tick” and “the dome match controller” are this one routine. Its most-called helper, FUN_801D8DE8, is the HUD element renderer: one case per on-screen element through an 80-entry jump table, shared by the battle HUD and the dome plate (battle HUD).
What runs every battle frame
That sweep also carries a few per-boss arms keyed on the formation's lead. Dohati's ties his body to his Spirit gauge: he opens at half size and reaches his authored size at a full gauge, and his framing depth, reach and the party's make-way grow with him (0x8004D43C..0x8004D530).
- Clip-tag arms key on the acting actor’s committed clip tag and its keyframe cursor and write to its target: an impact tint, a pose freeze, a ribbon at the target, or (one Noa art) the AI-delegation bits, re-rolled on every frame the cursor sits in its window.
- Boss hooks apply hand-written camera, pose and scale overrides to the first monster for four boss formations. The ramp they write to actor
+0x1BAis the shatter amount: non-zero draws the body as flying triangles (Jette’s arm adds spin and shrink); the cast modules write it too. - Status recolour rewrites the actor’s 240-entry palette row, not its texels - Stone averages to grey, the limb ailments tint blue - latched once per affliction rather than flashed.
Stone greys the whole row; Rot tints one limb. Stone averages the three colour channels of every entry into a grey. The other three status bits are Rot’s limb bits, each with its own latch: they build the same grey, lift its blue by half again and set the semi-transparency bit, but only inside that limb’s index window of the row, and only that span is uploaded. Both arms start from one pristine copy of the row, so a fresh affliction never compounds an earlier one. A cure puts the colour back: an item or a healing cast that finds any of the four bits calls FUN_8005133C, which uploads the pristine row and re-arms every latch, so a member cured of Stone is in colour again for the rest of the fight. The pass reads the actor’s whole status word, whichever path wrote it. The port runs both arms and the restore in one model on both hosts; a monster special that inflicts Rot reaches it in play.
0x80078630, one pair per Rot bit. Because a window spans whole 16-colour palettes, a rotted limb's texture turns blue and the rest of the body keeps its colours. The window drawn here is an example; each character has its own.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). The member who finished the fight strikes the win pose (a downed finisher hands it to a standing member; Songi's two fights fix the seat), then the spoils banner, then 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, one random draw each; Chicken Heart x1.5, Chicken King or a pre-emptive strike forces the tie; a scripted fight always catches you. Success ends the battle with no loot and downed members floored at 1 HP; failure spends the turn. Worked example. |
| 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.
The end sequence frames whoever acted last. On a win that is the member who finished the fight: they strike the win pose, the result window names their team ("Gala's team won the battle!"), and the pose's own voice line plays - each pose streams one of the heroes' clips out of monster.snd into the sound slot the formation's monster bank had, and the pose's animation keys it. Holding Start with a direction, L1, L2 or R2 as the fight ends picks the pose; Songi's fights always end on the same one. On a wipe the camera stays on the monster that landed the last blow, which plays its knockout taunt, and a few bosses get a voiced line over the loss.
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).
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. (Both reroll loops terminate with probability 1; an "unbounded reroll" is not the cause.) The disc-side fix, legaia_patcher::charm_fix, is a one-word detour at the keep-branch.
In the port
The engine keeps the battle simulation renderer-free - the action machine and formulas in engine-battle-vm, the World-free kernels (AI, catalogs, encounters, level-up, pickers) in engine-battle, the round loop and command flow on World in engine-core, the slot-B cast modules in engine-cast, the HUD’s reads of a running battle in engine-battle-hud - 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 on both hosts. Where to look:
- Encounters and entry:
engine-battle::encounter,World::begin_encounter_battle,battle_seats. - Menu half:
battle_input,battle_flow, with the submenus inbattle_arts,battle_magic,inventory_use,target_picker. - Action half:
battle_action(the state machine) andbattle_formulas(the arithmetic). - Monsters, spoils, wipe:
monster_ai,World::apply_battle_loot,LevelUpTracker,game_over::GameOverSession.
Retail's render and animation systems clear two gates the action machine waits on - the advance-done flag at attack recovery and the caster's spell iterator at magic sustain - so a renderer-free port has to retire them itself. Post-battle HP / MP is written into the character records before the field snapshot is restored. The battle entry seats each member's HP and MP from its character record, as retail's init does, never from a field actor a scene script may have blanked.
A killing blow's Seru absorb reads the monster record's own Seru id and catch chance, as retail does; the separate registry the capture spells feed teaches the pinned retail spell ids (SeruRegistry::retail), but its per-monster Seru attachment is an engine-side approximation, Inferred, not Confirmed.
Two slot numberings. Retail seats the party at slots 0..2 and monster k at 3 + k whatever the party size, so every slot < 3 test in the battle code means "a party member". The port seats the monsters straight after the party instead, so for a party of one or two its slot numbers and retail's disagree. Every place a retail slot test or a retail slot-indexed table meets an engine slot goes through one map (battle_slot::SeatMap), and the kernels whose test is on retail's numbering take a RetailSlot type that an unconverted slot cannot pass for.
The one crossing a real party reached before the map was Terra's: as the AI companion of the Noa + Terra pair, she aimed one seat past the monster she meant to hit, and against a lone monster she stood by every turn. Per-character tables are a second, related split - they key on which character sits in a seat, not on the seat number. The full list is in the reference.
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 |
| Formation advantage | FUN_80051D84 | Back-attack / pre-emptive roll on mean SPD; the first-round lockout | 80051d84.txt |
| 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_801DA780 / FUN_801DABA4 | Key seed from SPD; highest-key-acts selection | overlay_0897_801daba4.txt |
| Monster AI | FUN_801E9FD4 / FUN_801E7320 | Strike-or-spell roll, AGL budget, 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, the two passives, the pre-emptive tie | 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 |
Two save-state pairs that bracket a transition are pinned as constants with disc-gated tests: battle scene init (the battle-bundle residency window, the overlay scratch slice reset on entry, and the per-frame actor-tick pointer slot switching to FUN_80021DF4) and a mid-battle item use (the field-pack base pointer flipping and the script-VM context block rewritten). 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 - the loader, context struct, actor record, seats, monster record and archive, heap budget, character record and the per-frame actor passes.
The rest of the battle set is split across sibling pages, each mirrored here: the command flow (sparring tutorial, boss stage modules, commit log), the action state machine and its helpers and queue, the stage and camera (ground grid, backdrop, entry sweep, intro curtain), actor rendering (staged-anim channel, tint pixel path, weapon trails), the HUD, and the round loop (encounters, status, AI, capture, results, wipe).