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 static SCUS_942.54 code.
Battle state
One context struct reached through the global pointer 0x8007BD24 (resolves to 0x800EB654); 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 (World battle tick, battle_input, battle_seats, monster_ai, battle_flow), engine-vm::battle_action + battle_formulas, engine-ui HUD.
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

Encounterroll, region or script Load the fightoverlay + assets Seat the roster8-slot actor table Begin / Run promptround opens Command ringD-pad, per member Action SMplays each command End-of-action gateliving side check Battle endswin, flee or wipe Field resumestitle screen on wipe next round no side standing
One battle: a one-time load, then a round loop that only leaves when one side has nobody standing.

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> (any op0 < 100 runs the same body). The row's non-zero header byte is what gives a scripted fight its particle intro and second audio cue.
SceneOpFormation rowContents
garmel3E FF 099lone Zeto
garmel3E FF 088lone Songi
rikuroa3E FF 1117lone 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.

1Stage packthe paired battle stage packPROT 0869 / 0875 2Texture packetim.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.

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

RecordWhat it holdsWhere it comes fromTables
Actor structPosition, HP / MP / AGL triplets, status halfword, action queue, mesh pointerBuilt at setup from the records belowactor struct
Battle contextMenu cursor, action cursor, acting slot, action parameters, message buffer, intro timerAllocated per battlecontext struct
Character recordThe persistent 0x414-byte per-character block: stats, spells, equipment, nameThe save filecharacter record, save record
Monster recordStats, three castable spell ids, gold / EXP / drop, mesh + texture pool, action entriesMonster archive slot, one per speciesmonster 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 stateOn screenWhat the player does
0x0A / 0x0BParty plates + formation bannerWaits out the intro timer (a back attack skips the party's first round)
0x1EBegin / RunLeft begins the round, Right opens the flee confirm
0x28Command ringUp = Item, Left = Attack, Right = the Ra-Seru (magic), Down = Spirit; one member at a time
0x78Auto / CommandAttack is a door: Auto goes to the target cursor, Command to directional arts entry (an option byte can skip this prompt)
0x3C / 0x46 / 0x50 / 0x5AItem window / magic window / arts entry / target cursorPicks 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

OutcomeTriggerWhat happens
VictoryEnd-of-action gate finds no living monsterGold 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 captureA party member's killing blow on a monster with a Seru idChance = 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.
EscapeRun at the round prompt, or an escape item / spellParty (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 wipeEnd-of-action gate finds no living, non-petrified party memberThe 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.

PieceModuleRetail counterpart
Encounter bracket + region rollencounter, RegionEncounterTrackerRegion rate counter, one-step anti-repeat
Enter / seat / leaveWorld::begin_encounter_battle, enter_battle_from_formation, finish_battleLoader ladder + seats + MAIN INIT gate
Round prompt, ring, attack modebattle_input (CommandPhase), battle_flowMenu machine flow byte
Action resolutionengine-vm::battle_action, battle_formulasAction machine + arithmetic kernels
Submenusbattle_arts, battle_magic, inventory_use, target_pickerArts entry, magic window, item window, target cursor
Monster turnsWorld::pick_monster_action, monster_aiAI picker + per-id overrides
Spoils, captures, level-upsWorld::apply_battle_loot, resolve_captures, LevelUpTrackerVictory spoils + capture grant
Wipegame_over::GameOverSessionThe 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

FunctionAddressWhat it provesDump / probe
Battle scene loaderFUN_800520F0The async ladder, one PROT entry per state; the stage-overlay dispatch on a non-zero stage idghidra/scripts/funcs/800520f0.txt
Stage setupFUN_800513F0Seat tables, dome drawn as two actors, party mesh registration800513f0.txt; autorun_battle_party_mesh_install.lua
Monster streamer + initFUN_800542C8 / FUN_80054CB0Archive slot per species, record-to-actor field map800542c8.txt, 80054cb0.txt; autorun_monster_record_source.lua
Battle setup + species rebuildFUN_80055B6CFormation cells, 50% species-order shuffle, per-formation stage id override80055b6c.txt; autorun_formation_cell_writers.lua
HeapFUN_8002B3D4Best-fit heap, NULL on exhaustion, the ~145 KB monster budget8002b3d4.txt; autorun_delilas_battle_load.lua
Menu state machineFUN_801D0748Flow-byte comparison ladder, Begin / Run every round, D-pad chip masksoverlay_0898_801d0748.txt; autorun_battle_item_window_capture.lua
Action state machineFUN_801E295CPer-category state bands, end-of-action wipe scans, round hand-backoverlay_0898_801e295c.txt
InitiativeFUN_801DABA4Highest-key-acts selection; key seed from SPDoverlay_0897_801daba4.txt, overlay_0897_801e23ec.txt
Monster AIFUN_801E9FD4 / FUN_801E7320Strike-or-spell roll, per-id overrides; charmed-party target resolveroverlay_0898_801e9fd4.txt, overlay_battle_action_801e7320.txt
Hit resolve + capture rollFUN_801EC3E4Killing-blow gate, catch-rate d100, Magic Boost +30overlay_0898_801ec3e4.txt
Escape rollFUN_801E791CThe SPD / missing-HP comparison and the two passivesoverlay_battle_action_801e791c.txt
Victory spoilsFUN_8004E568Gold / EXP arithmetic, drop roll; reads the record-pointer side table8004e568.txt; gold write-watch
Battle exit + wipe gateFUN_80046A20 / FUN_8003AEB0Exit is always MAIN INIT; the CARD-screen fork and its latches80046a20.txt, 8003aeb0.txt; autorun_gameover_mode_writer.lua
Orbit camera + gridFUN_80026988 / func_0x801d02c0Exact projection constants; the ground is a GTE grid, not a mesh80026988.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.

See also