Battle subsystem
How Legaia runs a fight, from encounter trigger to victory screen. Nearly all of it lives in the battle overlay - an overlay being a chunk of code the game streams into RAM on demand - which carries the battle scene loader, the per-actor action state machine, and the effect VM cluster. It loads at RAM 0x801CE818, the same load slot the town overlay uses, so battle and town code never coexist in memory: entering a fight literally pages the town logic out. (Function names like FUN_800520F0 below are Ghidra's labels for traced routines at those RAM addresses.)
This page is the RE-side reference: scene loader, action SM keys, ctx struct, actor table, and the per-frame helpers. For the clean-room engine port (BattleSession, HUD, runner, SFX, equipment), see engine reimplementation.
How it works
Battles are a separate top-level game mode, and Legaia handles them by loading a dedicated battle overlay over the same RAM region the town overlay was using. The whole field state goes away; the battle context takes over. When the battle ends, the town overlay loads back in.
Inside the battle overlay there are three big systems running in parallel:
- The scene loader brings in character meshes, monster meshes, the active sound bank, and the visual-effect script archive. Same shape as other scene loaders - a multi-step async state machine with a dev/retail split.
- The action state machine takes the player's selected action (attack, magic, summon, special, item) and runs it to completion across multiple frames. It's a state machine, not a bytecode VM: the outer dispatch keys on the action-state byte at
+0x07of the battle context, and the inner dispatch (at the action-seed state) keys on the per-actor action category byte at+0x1DE(Arts / Item / Magic / Attack / Spirit / Run); per-actor progress flags live at+0x1DC. - The effect VM cluster handles per-effect spawn / render but doesn't drive actor decisions.
The move VM still runs in battle - it's how Tactical Arts directional inputs play out per-frame - but the action validator decides whether a queued action can fire in the first place, and the action state machine sequences "windup → execute → recover" stages around it. So battle-time animation actually involves all three layers cooperating.
Superseded readings
- The overlay capture historically filed under the “PROT 0896” label is a mislabeled slot-A window image (a RAM dump of the shared overlay load slot at
0x801CE818, not a disc entry); its renderer code is battle-overlay code. Extraction 0896 is unreachable from any static loader call. - “The overworld battle backdrop is the world-map continent heightfield” - a
prim-tracefalse positive (3 degenerateclut=0POLY_FT4prims stride-1 flooding the0x80190000window). The ground is the flat procedural grid; the sky is the PROT 88 dome. - “
monster_datais a misleading stub at extraction 869” - the CDNAME +2 filename shift; the definemonster_data 869names extraction 867, where the monster archive lives (extraction 869 is asound_dataVAB stream). - The “Seru struct
+0x74stat-growth grant” hypothesis - falsified;+0x74is the actor’s colour word, whichFUN_800480D8stamps with the 24-bit mid-grey0x00808080(mask0x00FFFFFF), not a0x80808080flag. Growth comes from the static SCUS tables (see level-up). - An early capture-derived action-ID table (“
0x28= menu cursor active,0x35= magic-summon,0x50= arts directional input”) mis-attributed the state bands; the authoritative band map is on battle action FSM (0x28= Magic/Item band,0x32..0x38= Summon band,0x50= Done/cleanup).
Battle scene loader
- Function
FUN_800520F0- Shape
- Multi-step async state machine (sub-state byte at
gp+0xa59); dev/retail dual-mode loader keyed on_DAT_8007B8C2
Each loader state pulls one entry out of PROT.DAT (the disc's single big archive; nearly everything lives in its numbered entries). The states are raw-TOC-indexed loads (raw index = extraction entry + 2; see PROT):
| State | Loads |
|---|---|
| 2 / 4 / 6 | The paired battle stage pack: raw TOC 0x367/0x36D (= extraction entries 0869 / 0875). |
0x8 | The battle texture pack: raw 0x368 (872) / etim.dat. |
0xB | The battle model pack: raw 0x36A (874) / etmd.dat, with raw 0x369 (873) as its index. |
0xC | Walks the loaded model pack and calls tmd_register (FUN_80026B4C) on every entry, then loads efect.dat (raw 0x36B / 875). This registration fills the effect/model window DAT_8007C018[3..] only - the party battle meshes are not from this pack; they are assembled per character from the player battle files' equipment-id sections and installed into DAT_8007C018[0..=2] by the SCUS battle state-handlers FUN_800513F0 / FUN_800542C8 (see character-mesh). |
0xE | Initialises the runtime effect 2-pack wrapper via FUN_801DE914. Also fires for the field-VM op 0x3E warp/interact path on the system context. |
0xFF | Dispatches the side-band streaming-effect handler 0x801F17F8 for summon.dat / readef.DAT (extraction 893 / 894; format in summon-readef). |
The asset-viewer's --bundle battle mode overlays the extraction 865–890 tim_scan window - an empirically-tuned VRAM set spanning the player battle files, the befect cluster, and the sound_data2 streams (the directory labels carry the +2 filename shift) - so character meshes have the right CLUT bindings.
Battle background
A battle is fought on the environment where the encounter triggered, kept resident and drawn as a full 3D backdrop - there is no separate flat arena. The battle SM only swaps the camera (field/world walk camera → a slow orbit around the party↔enemy midpoint) and overlays the actors + HUD; the surrounding terrain keeps rendering normally.
For an overworld encounter the backdrop is two layers - a flat tiled ground grid + the map's scene_tmd_stream dome (sky + distant mountains) - pinned from a 4-angle capture set (overworld_battle_bg_angle_a..d, the same Vahn-vs-Gobu-Gobu fight paused on the Begin/Run menu while the camera idly orbits):
- Ground = a procedural flat grid. The grass underfoot is emitted by
func_0x801d02c0(the sole draw the mode-0x15renderFUN_80026f50makes) - a GTE rasteriser, not a TMD walk: a_DAT_1f8003f8 × _DAT_1f8003facell grid (pitch0x200) on aY ≈ 0flat plane centred on the actors. Pass 1 RTPS-culls each cell into the0x1000-byte buffer_DAT_8007b814; pass 2 RTPTs each visible cell into onePOLY_GT4. Those tiles are the 619POLY_GT4- a full flat plane, so the ground fills the foreground at every orbit angle with no half-dome gap. - Sky + mountains = the PROT 88 dome (the 116
POLY_GT3), drawn as a background actor:FUN_800513F0doestmd_register(→DAT_8007C018[]) +FUN_80020de0(actor_alloc) +FUN_80020f88(link), so the normal actor rendererFUN_80048A08draws it - no special dome-draw fn (henceDAT_80076810has no resolved reader). The mesh on disc is a half shell, and it is complete as authored - what closes the circle is thatFUN_800513F0allocates two actors from the one registered mesh (ctx+0x106C/+0x1070) and draws both. Its ground ring (inner radius2889) is the far grass behind the flat grid. Object 1 of the mesh is dropped from both draw lists.
The second copy is either a half turn about Y (the default, actor+0x26 = 0x800) or an X mirror (actor+0x5A = 2, an X scale of −1) for stages named in a table at DAT_80078B50. Only one geometric rule constrains it - a −Z-open shell is already symmetric about X = 0, so mirroring would fill nothing, and none is on the list. Beyond that the choice is authored per stage, not implied by the mesh: 39 backdrop meshes are shared byte-for-byte by more than one PROT entry and the table splits 12 of those groups. 0730 concend and 0736 conc3 are the same file and are placed differently; one pair inside balden2 is split on identical bytes, which is harmless there only because that shell's cut section is symmetric in Z and both transforms land it on the same points. So a stage that looks rotated against its twin is showing the table, not a bug.
Superseded. This dome was previously described as drawn once, covering only 44–81% of the horizon. Both halves of that came from measuring the wrong thing - a band-thickness threshold standing in for presence, over a capture set with one diagnostic angle in it. Two live actor pointers, non-null and distinct with identical object lists, are confirmed across 15 battle save states.
Which stage stream a scene fights in
A scene bundle is a fixed slot array - .MAP, v12 table, event scripts, asset table, texture pack, then one scene_tmd_stream per sub-area. The backdrop is whichever stream the type-0x01 chunk walker FUN_8001FE70 last recorded in _DAT_8007B864 (its sole writer, at 0x8001FEC0), so the choice is scene data rather than a code table - and it is not uniformly the block's first stream. map01 uses bundle slot 5 (extraction 88, 4 objects / 340 verts); Rim Elm town01, whose bundle carries four sub-area backdrops at entries 6–9, uses slot 6 (extraction 7, 2 objects / 341 verts) - the entry the three Tetsu tutorial anchors hold resident. Each row is pinned by reading _DAT_8007B864 in a battle save state, taking object 0's live vertex pool, and byte-matching it back to a PROT entry.
Over-read trap. PROT extraction over-reads into the following entries, so the Rim Elm dome's bytes also appear inside entry 6's file - at offset 0x16038, past entry 6's own (next_lba − lba) × 0x800 = 0x14000. Any "scan the block for the resident dome" sweep must reject hits beyond an entry's unique length or it attributes the backdrop one entry too low. Entries 7 and 8 additionally share a vertex count, so shape alone cannot separate them either - only the bytes can.
Stage-overlay dispatch (the +0x47 loader band)
The battle scene loader also reads a stage id byte _DAT_8007B64A and, only when it is non-zero (beq v1, zero at 0x80052688), pages a per-stage code overlay into slot B: FUN_8003EC70(stage_id + 0x47) at 0x800526A0 → extraction entry stage_id + 966. This is the only call site that can reach entries 967 / 968. Across the catalogued battle save library every fight reads stage id 0 - no stage overlay, the battle simply draws over the resident backdrop - except the Tetsu sparring tutorial, which reads 1 with loader-B tracker 0x8007BC4C = 0x48 = extraction 967, the battle tutorial overlay. SCUS's only non-zero writer is the per-formation override *_DAT_8007BD0C == 0xB5 → 2 (entry 968); _DAT_8007BD0C is the formation monster id, reading 0x4F (Tetsu) in those states. The overlay is battle code, not stage geometry.
Battle camera (exact)
The orbit camera (mode 0x15) is pinned exactly (FUN_80026988/FUN_80026f50). For a PSX (Y-down) world vertex v: screen = H·(R·v + TR)/Ze with R = Rx(pitch)·Ry(yaw), pitch = 32 (12-bit, 4096 = 360°), yaw = _DAT_8007b792 (the orbit azimuth, idle −4 units/frame via FUN_801D0748), TR = (0, 1280, 7680), H = 256 (GTE projection register), target = world origin. The battle base matrix DAT_8007BF10 holds 16384 · I (GTE 4096 = 1.0 → a 4.0× uniform world scale; a base, not the composed rotation): the actor renderer FUN_80048A08 composes it under the shared camera per actor, which is what makes the small actor meshes project at retail size on the same 7680-deep translation the backdrop uses at 1×.
Camera pin provenance
Pinned from a 4-angle save set (overworld_battle_bg_angle_a..d) + Ghidra; the engine's retail_battle_mvp reproduces the projection to 0.0002 px against the savestate framebuffer's sky / mountain-ring / horizon. Live-confirmed byte-exact by a PCSX-Redux probe (autorun_battle_render_capture.lua) reading at the func_0x801d02c0 grid-render breakpoint on a real map01 battle: mode=0x15 pitch=32 roll=0 TR=(0,1280,7680) H=256, grid 28×28 cells, actors at scale 1.0 (the on-screen size comes from the mesh + the DAT_8007BF10 base, not a per-actor scale). The base matrix is pure diagonal at all four orbit yaws across every catalogued battle savestate.
Stage seats
Where everyone stands is authored data, not arithmetic: two static SCUS_942.54 tables of [i16 x, y, z, pad] seat entries, selected by combatant count and stamped into every actor at battle setup (FUN_800513F0 → spawn node +0x14..+0x19 → actor +0x3C/+0x40 → live position +0x34/+0x38). The party table (0x800775C8) seats 1 member at (0,−800), 2 side-by-side at (±300,−800), 3 as a wedge - lead (0,−825), flankers (±600,−775). The monster table (0x80077608) mirrors it on the positive-Z side (1 enemy at (0,+800), up to 4 in an arc), with an alternate family four rows up - selected by the same flag that sets the no-escape byte - for the pincer / scripted seatings. Pinned byte-exact against seven battle savestates; engine mirror engine-core::battle_seats.
Engine render
legaia-engine play-window --scene map01 renders the overworld battle as a faithful scene: the exact orbit camera, the PROT 88 dome (front + a Ry(180°) mirror so the mountain ring + sky read as a full circle), a flat tiled grass grid under the actors (the func_0x801d02c0 grid, 0x200 pitch), a sky-blue clear, the real assembled battle party (each member's mesh assembled from their player battle file's equipment-id sections and relocated into its runtime texture band, posed + idle-animated by the character's own keyframe stream from record[0] of the same player file (frame 0 = the combat-stance rest pose; equipment extras ride their attach bone's channel; PROT 1203 is not this source - its banks follow PROT 1204's own object order and only pose the fallback) with their decoded per-character palettes; PROT 1204 is the default-gear fallback), and animated monsters. A stage battle draws only active actors (the inactive scene-init actors are parked at the origin). The actors + battle FX draw through the exact tr.z=7680 camera with the retail 4× actor world scale composed under the rotation, so the small battle meshes (party 134–284 units) read at retail size against the deep translation.
Battle action state machine
- Function
FUN_801E295C- Size
- 16 KB / 4099 instructions / 155 outgoing calls
- Outer key
switch((*_DAT_8007BD24)[7])- the active action-state byte at ctx+0x07 (47 explicit states in 7 bands)- Inner key
switch(actor[+0x1DE])- the per-actor action category (0 = Arts, 1 = Item, 2 = Magic, 3 = Attack, 4 = Spirit, 5 = Run), read once at the action-seed state to select the band
The action-execution dispatcher: takes the player's selected action and runs it to completion across multiple frames. _DAT_8007BD24 is a pointer to the active battle context struct; the pointer itself is resolved at battle entry (*_DAT_8007BD24 = 0x800EB654 for the captured battle). The state bands: Attack 0x14..0x20, Magic / Item 0x28..0x2E, Summon 0x32..0x38, Spirit 0x3C..0x48, Done / cleanup 0x50..0x5A, Run / Capture 0x64..0x71 - the full per-state table is on battle action FSM.
The function reads battle actor pointers via (&DAT_801C9370)[ctx[0x13]] - resolves the active actor via ctx[0x13] (slot index), then indexes the 8-slot pointer table. It guards on _DAT_800846C0 != 2 (game-state check). The global pointer _DAT_8007BD24 plays the same role as the field-VM context pointer - this is a state machine, not a bytecode VM, but it shares the field VM's "context-pointer-as-VM-state" idiom.
This is distinct from:
- The field/event script VM (which doesn't run in battle).
- The effect VM cluster (which handles per-effect spawn/render but doesn't drive actor decisions).
- The move-table VM (which drives Tactical Arts inputs and per-action keyframe scheduling - a layer below this one).
The full state-band breakdown lives on battle action FSM.
Battle context struct
The active battle context lives at 0x800EB654 (resolved at battle entry; the global pointer at 0x8007BD24 is set to this address). 32-byte fixed prefix followed by a per-battle dialog/text buffer.
| Offset | Type | Use |
|---|---|---|
+0x00 | u8 × 6 | Battle phase/state flags (01 01 01 00 00 00 while a turn resolves). |
+0x06 | u8 | Monster-slot active action ID (0xFF if none queued). |
+0x07 | u8 | Party-slot active action ID. The outer switch in FUN_801E295C keys on this. |
+0x09 | u8 | Turn / phase counter. |
+0x13 | u8 | Active-actor slot index - used to look up the actor pointer via (&DAT_801C9370)[ctx[0x13]]. |
+0x14..+0x1B | u8 × 8 | Per-action parameter bytes (target slot, sub-action, dir/elem, etc.). |
+0x1D | u8 | Action context flag - 0x03 for summon and capture; 0x00 otherwise. |
+0x29..+0x2D | string | Active spell/move icon glyph. |
+0xA9..+0xEC | text | Battle dialog buffer. |
+0x6D6.. | u8 × N | Action state machine's PC offset / sub-state cursor. |
Battle actor table
8-slot pointer table at 0x801C9370:
| Slot | Role |
|---|---|
0..2 | Active party members (ordered by formation). |
3..7 | Monster slots (up to 5 enemies per battle). |
Selected combatant struct fields:
| Offset | Type | Use |
|---|---|---|
+0x07 | u8 | Per-actor state byte. Drives FUN_801E295C. |
+0x1F | u8 | Hit-radius / size byte. Used by FUN_8004E2F0 (range). |
+0x34 / +0x38 | i16 | Current world X / Z. |
+0x3C / +0x40 | i16 | Stamped with the authored stage seat at setup (then copied into +0x34/+0x38); read as the b-actor position by the range check. Its mid-battle role is not fully pinned. |
+0x4A / +0x4C | u8 / int* | Magic-slot count + list pointer. |
+0x14C..+0x158 | u16 | HP / MP / AGL triplets (cur/max; AGL = the agility / action gauge). |
+0x172..+0x174 | u16 | HP/MP secondary mirror. |
+0x1DF | u8 | Chosen action id - head of the per-action parameter stream +0x1DF..+0x1F2 (item / spell / strike-anim ids). The AI picker FUN_801E9FD4 writes the chosen magic-attack id here; it is also the index into the move-power table. |
+0x1EF..+0x1F3 | u8 | Magic-resistance per element (5 elements). |
+0x230 | u32 | Monster attack-effect / animation data ptr (not XP/drop). |
Range / line-of-sight
- Function
FUN_8004E2F0(actor_a_id, actor_b_id) -> i16 distance- Called from
- Per-actor state machine (5+ sites)
The canonical battle range check. Reads [DAT_801C9370 + id*4] for both actors, computes a euclidean distance from +0x34/+0x38 (or +0x3C/+0x40 for the b-actor), then sums the two +0x1F size bytes (party-member size table at 0x80078878, monster size byte read from the live actor) to get the hit radius.
Monster init
- Function
FUN_80054CB0- Called from
FUN_800542C8(secondary battle archive loader)
Populates a battle-actor at [DAT_801C9370 + (slot+3)*4] from a monster record:
- HP / MP / AGL triplets at
+0x14C..0x158and+0x172..0x174(AGL = the agility / action gauge at+0x154/+0x156). - Magic-resistance bytes at
+0x1EF..+0x1F3(5 elements; one nibble per element). - Walks the spell list at
+0x4C(count at+0x4A): for the elemental ids (2,3,4,5,0xB) it records the matching spell's slot index into the per-element table at+0x1EF..0x1F3. - Attack-effect / animation data ptr into
+0x230.
Monster-record source layout
param_1 is the in-RAM monster record (after offset→pointer fixups). Field map from FUN_80054CB0; stat names traced from the damage / accuracy formulas (see battle formulas and the spell list):
| Offset | Field | Destination / notes |
|---|---|---|
+0x00 | Name string ptr | → actor +0x1BC |
+0x04 | Battle-model TMD offset | → actor +0x230, walked as 0x1C-stride object-table records; not XP/drop - see monster mesh |
+0x08 | Texture / CLUT pool ptr | See monster mesh |
+0x0C | u16 HP | → actor +0x14C |
+0x0E | AGL | Agility / action gauge → actor +0x154 |
+0x10 | u16 MP | → actor +0x150 |
+0x12 | ATK | → actor +0x158 |
+0x14 / +0x16 | DEF↑ / DEF↓ | → actor +0x15C / +0x160 |
+0x18 | INT | Magical damage / magic defense in the summon/arts kernel + accuracy/evasion seed → actor +0x168 |
+0x1A | SPD | Turn-order seed → actor +0x164 |
+0x21..+0x23 | u8[3] magic-attack ids | Global spell ids the enemy casts (see below) |
+0x44 / +0x46 | u16 gold / u16 EXP | Victory rewards (see below) |
+0x48 / +0x49 | u8 drop item id / u8 drop chance % | Per-enemy drop roll |
+0x4A / +0x4C | Magic-slot count / spell-entry offset array | Block-relative, fixed at load. Each entry's first byte is a spell/action id (ids 2/3/4/5/0xB = elemental affinity → magic-resist slot at +0x1EF..0x1F3; 0x0C..0x1F = offensive castable; 0x23 special); entry +0x74 = AGL (action) cost. |
Magic-attack ids (+0x21..+0x23): up to three global spell ids the enemy casts. A slot is live when its value is > 1. The AI spell picker FUN_801E9FD4 reads record[0x21 + slot], writes it into the live actor at +0x1DF, and the action SM names it via &DAT_800754D0 + id*0xC (0x27 → "Tail Fire"). These global ids are distinct from the local +0x4C entry ids (which only gate SP); they are the names that appear on screen. Parser: MonsterRecord::magic_attacks + legaia_asset::spell_names.
Rewards (EXP / gold / drop) are inline in the record head at +0x44..+0x49 (not at +0x04). The victory-spoils function FUN_8004E568 reads them from the per-enemy record-pointer table at 0x801C9348 (the loader populates it, so the actor retains its record there - that's why monster-init never copies the reward fields):
- gold (
+0x44): summed>> 1across dead enemies, optionally× 1.25(a living party member with ability bit0x10000), then halved. A lone enemy yieldsfloor((gold >> 1) / 2)- Gimard60→15, confirmed by a runtime write-watchpoint on party gold (0x8008459C). - EXP (
+0x46): summed× 3/4, then split evenly among living party members. - drop (
+0x48item id,+0x49chance %): per dead enemy,rand() % 100 < chancegrants the item (added to the win banner at actor+0xA9and to inventory viaFUN_800421D4).
The reward commit side: FUN_80026018 adds an accumulator (0x80084440 = SC + 0x300) into the party XP bank (0x800845A4); it's generic (the minigames share it). Drop item names cross-check against legaia-gamedata (Gimard +0x48=119 @ 10% - drops Healing Leaf). The reward formula detail lives in battle formulas.
Monster archive (PROT entry 867)
FUN_800542C8 streams the records as per-monster 0x14000-byte LZS slots at (id-1)*0x14000 (monster id = global monster-table index, ~194 fixed slots). Each slot is [u32 dec_size][LZS] decoding to a block whose head is the stat record above.
The archive is extraction PROT entry 0867_battle_data, read through its extended footprint: the PROT TOC's per-entry size formula describes only an "indexed" subset of each entry's disc range, and the contiguous trailing-gap sectors up to the next entry's LBA belong to the same file - the 15.9 MB archive lives in those trailing sectors, not the small indexed payload (see PROT TOC and the static-overlay pipeline for the footprint rule). Retail-semantically it is the monster_data block: the define monster_data 869 names extraction 867 under the raw-TOC −2 correction, and the loader index 0x365 = define-space 869 resolves there directly. The shipped retail build takes the debug FUN_8003E8A8(0x365) PROT-index path; the alternate data\battle\<name> open via the break 0x103 host trap (FUN_800608F0) is a build-time dev-host artifact with no matching disc file.
Pinned by a PCSX-Redux watchpoint during the Rim Elm scripted battles (autorun_monster_record_source.lua): the relative seek (id-1)*40 sectors + the disc_read CdlLOC resolve to PROT.DAT 0x38AF000 = entry 867, and three records match live actor stats byte-for-byte (Gimard id 10 = 99/20, Killer Bee id 62 = 288/288, Queen Bee id 63 = 888/888). town01's encounter formations resolve to the Rim Elm Mist-attack set (Gobu Gobu 4, Green Slime 7, Gimard 10, Hornet 61, Killer Bee 62, Queen Bee 63, Tetsu 79 - Tetsu the 999/999 tutorial sparring partner). Parser legaia_asset::monster_archive (CLI asset monster-archive); bridge catalog_from_monster_archive merged into the catalog by SceneHost::enter_field_scene.
Monster mesh (record +0x04)
Each decoded monster block carries the monster's battle model: a Legaia TMD embedded at the block-relative offset held in the stat record's +0x04 field (immediately after the name string). This is the same pointer the loader installs at battle-actor +0x230 and that FUN_80049858 / FUN_800495C8 walk as 0x1C-stride records - a TMD object-table entry is exactly 0x1C bytes, so that walk is iterating the mesh's per-object table. Verified across the archive: 186 of the 194 slots carry a Legaia TMD at +0x04 that the parser walks cleanly (the other 8 are empty / filler ids); e.g. Gimard (id 10) = 200 vertices / 269 textured prims at block +0x7c.
Decoded-block layout (after the stat-record head at +0x00):
+0x00 stat record head (name_offset, +0x04 mesh offset, +0x08 pool offset, stats, rewards, spells)
name NUL-terminated name string (at name_offset, typically just before the mesh)
+0x04→ Legaia TMD ; the monster's battle model (magic 0x80000002)
spells spell-entry blobs ; each carries its own attack-effect geometry
+0x08→ texture / CLUT pool ; per-monster palettes + 4bpp texture pages
The name string carries a two-byte element-icon escape: a ^ + letter prefix (^A Gimard, ^F Aluru) the battle UI renders as the element badge, in the fixed order ^A=Fire, ^B=Thunder, ^C=Wind, ^D=Water, ^E=Earth, ^F=Light, ^G=Dark, ^H=Evil (the icon-glyph row 0x1D..0x24 in the same order - not the element-id order of the +0x1D element byte). Across the roster every carrying monster's caret letter agrees with its element byte, with one deliberate exception: ^H Cort (the final boss) wears the Evil icon over element byte 7 - the no-affinity id whose matrix row and column are all-100. Boss-tier $2/$3 name suffixes are literal ASCII, not markup.
The mesh's primitives are textured: they reference a CLUT + a 4bpp texture page via per-prim CBA/TSB. The matching palette + pixel bytes live in the texture pool at record +0x08, whose layout is pinned from the battle loader FUN_80055468:
+0x000 15 x [16 BGR555 colours] ; CLUT region (0x1E0 bytes; zero-padded for
; monsters that use fewer than 15)
+0x1E0 4bpp indices ; texture page, width x 256 texels, row-major
The loader uploads the CLUT region to VRAM (0, 484 + slot) (STP bit set on non-zero entries) and the page to (slot*64 + 320, 256). The page is always 256 rows tall; its width is 128 texels for most monsters or 256 texels when the per-monster wide flag is set - so width_texels = (pool_len - 0x1E0) / 256 * 2. A primitive selects its palette by cba & 0x3F and samples the page at its per-vertex (u, v); PSX index 0 is transparent. The byte arithmetic is exact: Gimard 0x1E0 + 128*256/2 = 0x41E0, Tetsu 0x1E0 + 256*256/2 = 0x81E0, both equal to their pool sizes. (The on-disc CBA/TSB are nominal defaults the loader relocates per slot, so the raw pool bytes don't appear verbatim in a battle VRAM dump - the FUN_80055468 layout is ground truth.)
Parser: legaia_asset::monster_archive::mesh(entry, id) (returns the decoded block + TMD/pool offsets); MonsterMesh::texture() decodes the pool into MonsterTexture { palettes, indices, width, height }. CLI asset monster-archive --id N --obj <out> exports the mesh as Wavefront OBJ and --texture-png <out> bakes the texture page. The WASM accessors feed the in-browser WebGL viewer on the enemy-table site page, which textures the model with the same index→palette lookup the PSX GPU does in VRAM.
Native renderer bridge (clean-room engine)
The clean-room engine renders the decoded monster directly through its standard PSX-VRAM texture path rather than the site's index→palette shortcut. MonsterMesh::battle_render_mesh(slot, &mut vram) reproduces the loader's per-slot relocation: it writes the CLUT region to VRAM row 484 + slot and the 4bpp page to ((5 + slot) * 64, 256), then rewrites every prim's CBA/TSB to point at those regions (relocate_cba / relocate_tsb), keeping the page-local UVs untouched. The CLUT region (x < 240) and the texture pages (x ≥ 320) never overlap, so up to five monster slots coexist in one VRAM.
World::battle_monster_slots() reports the active enemies as (actor_index, monster_id, battle_slot); the engine itself never loads the archive, so the host resolves each id to a MonsterMesh, injects it, and binds the relocated mesh to the actor. play-window does this on each Field → Battle transition (against a throwaway clone of the field VRAM, restored on the way back) so the enemy is drawn, not a stand-in.
Monster AI (FUN_801E9FD4 action picker + FUN_801E7320 target resolver)
Retail monster AI is two routines in the battle overlay:
FUN_801E9FD4- action picker. Called per monster fromFUN_801DABA4(recompute_battle_order). Its generic decision core counts the live global magic ids in the record's+0x21..+0x23array, rollsrand % (1 + live_count); a0selects a physical strike (targetrand % party_count), otherwise it picks magic idmagic[roll-1], gates on affordability (actor[+0x150]MP < cost), and resolves the target by the spell's shape bytespell_table[id*0xC + 2] & 0x60(0x40= one enemy;0x60= all enemies, class 8;0x20= all allies, class 9;0x00= most-weakened ally). After the core, a largeswitchonDAT_8007BD0C[slot]can override the choice with bespoke scripted casts (hard-coded ids0x50/0x51/0x52/0x53/0x6f/0x40, cooldowns inDAT_801C8FE0).DAT_8007BD0C[slot]is the per-slot monster id -FUN_801DA51Cfills it from the encounter record's[+4 + slot]ids - so each case is bespoke AI for a specific monster id, not an abstract AI-type.FUN_801E7320- target resolver. Called from the action SM atActionSeedas themonster_setuphook, but only for monster actors withactor[+0x16e] & 0x380 != 0. It reads the targeting class the picker left inactor[+0x1DD]and expands it: class 0..2 → a living monster slot; class 3..6 → a living party slot; class 8/other → arand % 3gate selecting all-target codes 8/9 or self.
The clean-room engine ports it across engine-core: World::pick_monster_action is the picker's generic core (real RNG, real magic_attacks, spell-shape targeting through the catalog's SpellTarget); monster_ai::decide is the per-monster-id switch (low-HP self-heal, MP-gated nukes, multi-phase boss scripts, reading/writing the battle-scoped MonsterAiState); monster_ai::apply_recent_target_ring is the post-switch anti-repeat ring; World::resolve_monster_target is the exact FUN_801E7320 port, wired as the monster_setup hook; and World::advance_battle_mode is the ctx+0x28a writer (the SM's case 0xFF, a boss phase-transition pseudo-action).
The picker drives the live loop's monster turns, folding a chosen cast through cast_spell_on_slots and parking the SM at EndOfAction. Scripted casts emit retail spell ids; they fold when the active catalog knows the id and otherwise degrade to a physical strike.
The two AI gates. The ctx+0x28a battle-mode counter and the actor+0x16e & 0x380 flag are distinct, and only the first is a monster behaviour the AI flips:
ctx+0x28a(battle mode) gates the multi-phase boss cases. Its writer is the SM'scase 0xFF(_DAT_8007BD24[0x28A] += 1), a scripted phase-transition action a boss issues at an HP/script boundary - ported asWorld::advance_battle_mode.0until then.actor+0x16e & 0x380is not a monster flag.FUN_80047430sets it only on party slots (slot < 3) whose status word+0x00has bit0x2000(Confuse/Charm), delegating that party member to the AI target resolver; the resolver runs only when it's set. A normal monster keeps0x380clear, so its!ai380scripted-cast cases fire andmonster_setupstays dormant - exactly what the engine does (monster actors carryfield_flags == 0). The set-0x380path (AI-driven party members) is a separate status-effect feature.
Remaining gaps (documented in monster_ai): a few cases touch actor fields the engine doesn't model yet - monster 0x8A's actor+0x170 charge counter, the 'O' (0x4F) boss that rewrites another actor slot, and the capture-archive preload for spell ids 0x2E/0x2F.
Enemy-ally charm at the end-of-action gate (the charm battle softlock)
The randomizer's enemy-ally ("charm") feature rides the stock 0x380 delegation flag plus one overlay word: the monster-wipe scan's down-mask at 0x801E6638 widens from andi 0x4 to andi 0x384, so a living charmed monster counts as "down" and the player never has to kill their own ally to win (legaia_patcher::enemy_ally). That widen interacts with a retail invariant inside the end-of-action gate (state 0x5A of FUN_801E295C) - the pinned cause of the charm battle hard-freeze.
- The retail invariant. The state-
0x5Awipe scans and the initiative schedulerFUN_801DABA4gate on the same standing predicate (+0x14C != 0 && (+0x16E & 0x4) == 0;0x4retail-marks a captured, staged-out monster), so under the retail mask an alive acting actor at monster-wipe victory is always a party member. - The victory arm leans on it. After the monster-wipe branch sets the end signal (
DAT_8007BD71 = 0xFE), the win-pose staging keeps a living acting actor's slot unconditionally (bneat0x801E6690) and re-rolls only for a dead one; a formation override forces the pose slot for the Songi fights (first monster id0xB3/0xB4). It then reads the pose slot's character id from the 3-byte party rosterDAT_8007BD10[slot]and arms the win-pose "ME" archive side-band requestFUN_80055B4C(char_id*3 - 1)(see summon-readef). - What the widen breaks. With
0x384the two predicates disagree: the scheduler still picks the living charmed ally, but the wipe scan no longer counts it - so when the ally's own action kills the last real enemy, victory fires with a living monster (slot3..6) as the acting actor. The roster read indexes pastDAT_8007BD10[0..2]into the adjacent globals, and the stream request arm receives a garbage slot: char byte0arms request0(no transfer ever starts), any other byte seeks far pastreadef.DAT/summon.dat. Either way the battle wedges at the victory hand-off - a randomizer-interaction defect, unreachable in retail. - What the softlock is not. The long-standing "unbounded reroll in
FUN_801E7320" theory is falsified as the cause: both reroll loops are structurally unbounded but terminate with probability 1 in every reachable state (the acting charmed monster is itself an in-band exit - a self-pick turns the action into a no-op - and the party band always holds a living member).
Fixes, both sides. The engine port (engine-vm::battle_action end_of_action) carries the full gate: BattleActionCtx::charm_widen models the 0x384 widen, and victory_pose_fixup ports the victory arm with the corrected invariant - the re-pick triggers whenever the acting slot is not a living party slot (dead or a monster slot) and picks uniformly among eligible slots instead of rejection-sampling, so it cannot spin; the staging surfaces as BattleActionHost::victory_stage(party_slot) with the slot guaranteed valid. The randomizer's disc-side fix (legaia_patcher::charm_fix) is a single-word detour at the 0x801E6690 keep-branch, widening the keep-condition to "a living party slot".
Stat aggregator
- Function
FUN_80042558- Called
- Per frame
Walks the 3 active party members (stride 0x414) and:
- Caps each character's stats at
0x3E7(999, the in-game stat ceiling). - ORs the character's "active abilities" 16-byte block at
+0xF4..0x100into a global 4×u32 bitmask at0x80074358..0x80074368. This is the "currently-active accessory effects" register read by every other game system. - For each character, calls
FUN_800432BC/FUN_80042DBCto add/remove temporary spells per the active spell-slot layout at+0x2B0.
The 4-u32 global ability bitmask is what tells the renderer to draw "auto-counter" / "regen" / "magic up" indicators and what tells the battle dispatcher to apply post-hit effects. The read-side primitive is FUN_800431D0(bit_id) -> bool.
Action validator
- Function
FUN_8003FB10
The per-slot target-validity walker: decides which slots a queued action may target. Dispatches the arm byte through an 18-arm jump table (bound 0x84); each arm tests per-slot HP/MP quads (battle-actor table DAT_801C9370 in battle, char records 0x80084708 + n*0x414 in field), record stats, party-slot indirection, system flags (FUN_8003CE64), or the inventory-count leaf FUN_80046898, writing per-slot validity bits. It does not consult the ability bitmask (FUN_800431D0). Ported as engine-vm::battle_action::validate_action; the full arm map lives on the battle-action page.
Character record layout
Stride 0x414 bytes per character, base 0x80084708 (so character n lives at 0x80084708 + n*0x414):
| Offset | Use |
|---|---|
+0x13C | u8 spell-list count |
+0x13D..+0x160 | u8 spell IDs (variable-length; up to 36) |
+0x161..+0x184 | u8 parallel spell-level / experience array |
+0x185..+0x195 | Displayed-skills list (count-prefixed; head-insert on item-use) |
+0x196..+0x19D | u8 equipment slot bytes (8 slots; weapon, armour, accessories) |
+0x2A7..+0x2B0 | NUL-padded ASCII display name (Vahn/Noa/Gala/Terra/player-entered lead), 9 bytes bounded by the active-spell table at +0x2B0. In the retail SC save block this lands at game+0x66F + n*0x414 (SC +0x86F for slot 0). Accessor legaia_save::CharacterRecord::name (NAME_OFFSET). |
+0x2B0..+0x37F | Active spell-slot array (stride 0x14) |
+0xF4..0x100 | "Active abilities" 16-byte block - OR'd into global ability bitmask |
+0x104..0x110 | HP / MP / AP triplets (live stats; AP = the arts / action-point gauge, max sized by AGL) |
+0x11A | Stat-cap field (clamped to 0x3E7) |
+0x11C..+0x122 | Six adjacent stat bytes (RecordStats; incremented on level-up) |
Other notable helpers
| Function | Role |
|---|---|
FUN_801D0748 | Battle main dispatcher (11 KB / 182 calls). The top of the per-frame battle loop. Routes through every active battle subsystem (rendering, AI, animation, hit detection). |
FUN_801D8DE8 | Hottest battle utility (3 KB / 77 incoming refs). Likely a per-actor utility that every state arm bottoms out into. |
FUN_80048310 + FUN_800485BC | Weapon / effect trail builder - visual-only quad-strip emitters for sword trails, dash plumes, particle ribbons. Iterates the 16-slot per-actor frame buffer at actor[+0x68], runs each vertex through GTE projection, drops textured-quad packets into the OT. |
FUN_800349EC / FUN_80035EA8 | HP / MP threshold UI classifiers. Return one of 2 (dead), 6 (low), 7 (warn), 9 (healthy); the dialog renderer keys text colour on the result. |
Inventory
Battle reads inventory through the same page-banked structure the field VM's op 0x3B SET_ITEM_COUNT writes: 16 entries × 16-bit per page × 0x414-byte stride. The page index is the high nibble of the slot byte; the entry index is the low nibble.
The page-banked inventory state lives in the 512-byte region at [0x80085718 .. 0x80085918) - adjacent to the fourth-flag-bank bitfield at DAT_80086D70 (see field VM → "fourth flag bank"). The field VM's op 0x4C sub-3 sub-2 zeros the entire region.
Status effects
Per-actor status conditions inflicted by enemy attacks or art enemy_effect bytes. The retail engine stores per-status timers and tick-damage values in the battle-actor struct around +0x130; the layout is per-flag and not captured in any single overlay dump. The poison tick formulas are pinned from the per-round DoT ticker FUN_801E752C: Toxic drains min(max_hp/16, 256) per round, Venom min(max_hp/32, 128), Toxic suppresses Venom's tick while both are set, and a tick never kills (it bottoms out at 1 HP). The matching combat-roll penalties are the FUN_801DD864 scales (Venom ×9/10, Toxic ×7/10).
The per-actor status halfword actor[+0x16E] has a fully-enumerated writer set in the static images (a sweep of every store, pointer precompute, and ori/sllv bit-set across SCUS_942.54 plus every static overlay). The infliction appliers live in two overlay legs sharing one kind→bit map (hit leg in FUN_801EC3E4, kind byte at descriptor +0x7A; cast leg in FUN_801E09F8, spell descriptor byte +0x0A): kinds 3/4 set bits 0x1/0x2, kind 5 one random bit of 0x38, kind 6 0x1000, kinds 1-2 defer through the +0x21F latch to the 0x380 delegation writer FUN_80047430. Bit 0x400 has no retail setter - every 0x400-touching write is a clear (accessory cure andi 0xFBFF in FUN_8004CE2C, a dedicated per-round RNG waker in FUN_801F45A4, item cures, the on-hit strip andi 0xF07F, battle-exit) - a latent status with a complete consumer lifecycle and no infliction path. Full writer inventory: battle.md.
| Status | Source byte | Default duration (clean-room) | Retail effect (wiki) |
|---|---|---|---|
| Toxic | 1 | 4 turns | "Deadly Poison": HP drains faster than Venom and attack/defense drop (min(max_hp/16, 256) tick, never kills; rolls ×7/10) |
| Numb | 2 | 3 turns | Paralysis: cannot act; clears on being hit or after some turns (a full block, not a chance roll) |
| Venom | 3 (Other) | 6 turns | "Poison": HP drains, lesser than Toxic (min(max_hp/32, 128) tick, never kills; rolls ×9/10) |
| Toxic (strong DoT) | 4 | 4 turns | The pinned appliers install byte 4 as the strong-DoT bit (+0x16E |= 2, 1/8 chance) - the same Toxic ailment |
| Rot | 5 | 6 turns | A random body part becomes unusable: +0x16E |= 1 << (rand%3 + 3); the rolled limb's attack command grays out (Rot Guard / Master Guard passives nullify) |
| Sleep | - | 3 turns | Asleep; wakes when hit (host-driven kind; no on-disc byte maps here since the 4/5 remap) |
| Confuse | - | 3 turns | Acts uncontrollably / random target (host-driven kind) |
| Curse | 6 | 4 turns | Blocks Magic |
| Stone | 7 | whole battle | Petrification: cannot act, cannot be damaged, counts as defeated; lasts the whole battle (no in-battle cure; escape restores) |
| Faint | 8 | until cured | KO at 0 HP: collapse, no actions; revived only by Phoenix / revive Magic |
AP / Spirit gauge
Each character has a per-turn AP budget that limits how many art commands they can chain. The retail engine reads this from the character record's +0xC9 (current_ap) and +0xCA (bonus_ap) bytes. Pressing the Spirit button during command input adds +5 AP exactly once per turn.
The base AP grows by 1 each 10-level milestone (level 1..9 → 4 AP, 10..19 → 5 AP, …, 60+ → 10 AP capped).
| Action constant range | AP cost | Notes |
|---|---|---|
0x00 Nothing | 0 | placeholder |
0x01..=0x05 | 0 | system actions (Item / Magic / Attack / Spirit / Escape) |
0x0C..=0x0F | 0 | direction bytes (free) |
0x19 Regular Art Starter | 1 | |
0x1A Special Art Starter | 1 | |
0x1B..=0x32 | 1 | per-character art body |
Encounter trigger - runtime memory layout
A pre/post encounter save pair (one frame walking the map01 field scene; the next frame with battle just initiated, same map01 scene) pins the runtime memory layout of an encounter trigger. The mednafen-state diff over 0x801C0000..0x80200000 surfaces:
| Range | Bytes changed | What it is |
|---|---|---|
0x801CE808..0x801F3818 | ~133 KB | Battle overlay loaded into RAM (single contiguous region) |
0x801C9370..0x801C9900 | ~200–500 B | 8-slot battle actor pointer table; stride 0x60 per slot |
0x80083000..0x80084000 | ~600 B | Scene-bundle / sound-pool: encounter formation + BGM resolution |
The active scene-name table at 0x80084540 is identical between the pre-encounter and post-encounter saves - the battle is layered on top of the field scene rather than swapping it out.
Scripted-battle entry (3E FF <row>)
The scripted boss fights enter through the field-VM interact op 0x3E with op0 = 0xFF: the case-0x3E interact arm (FUN_801DE840) sets the SYSTEM entity's 5-state SM to Activating (sys_ctx[+0x8A] = 1), points its encounter-record slot at the per-scene MAN formation-table row op1 (sys_ctx[+0x94] = *(ctrl+0x20) + op1 * stride + 1), and requests the battle mode switch; the entity tick FUN_801DA51C's confirm state then copies the row into the battle formation cell 0x8007BD0C. The boss rows sit outside every region's rollable slice, so they can only enter through this op.
| Scene | Beat record | Op | Formation row | Contents |
|---|---|---|---|---|
garmel | P2[12] (C1 gate [0x198], self-latching) | 3E FF 09 | 9 | lone Zeto (0x4B) |
garmel | P2[11] (C1 gate [0x195]) | 3E FF 08 | 8 | lone Songi (0x4C) |
rikuroa | P1[3] (the Caruban stager, after its 52 89 marker SET) | 3E FF 11 | 17 | lone Caruban (0x49) |
This dissolves the "boss battle-id global" hypothesis for these fights: the formation is the scene's own MAN encounter-section row, selected by index from script bytes (live-capture pinned for Zeto - the formation writer ra sits in FUN_801DA51C's record-copy body while 0x8007B7FC stays silent). The carrier differs per boss: the garmel fights ride partition-2 beat records (spawned by the gated record dispatch), while the Caruban op lives in a partition-1 boss-stager placement - rikuroa's P1[3] is a parked special-model placement (the Noa actor) whose own record opens on a SysFlag.Test 0x142 park gate, stations its actor at the nest tile via its own 0x4C 0x51 leg, self-suspends on a 4C 85 halt-acquire, and carries the beat body (52 89 staged-marker SET → 3E FF 11). No script-side un-halt poke to the stager channel exists anywhere in the MAN, so the resume is the engine-side approach dispatch (touch FUN_801d5b5c / probe FUN_801cf9f4). Engine port: World::trigger_scripted_battle(row), reached from the field-VM host's field_interact arm when op0 == 0xFF; the formation resolves against the MAN rows registered at scene entry (with the PROT 867 archive stats merged; the v12 dungeons resolve their encounter section from the streaming variant MAN). Boss-stager placements are derived from the MAN at scene entry (man_field_scripts::boss_stager_placements → World::install_boss_stagers_from_man) and run on approach/interact via World::run_boss_stager_record - the whole rikuroa chain, staged marker included, lands from script bytes. Disc-gated oracles: organic_zeto_encounter_disc.rs, organic_beat_records_disc.rs.
Battle scene-init residency window
A separate map01 save pair (one frame with the encounter armed but battle not yet entered, the next frame with battle just initiated) pins the post-load residency window of the battle scene-init pipeline. Distinct from the encounter-trigger overlay swap above; this pair brackets the loader function with concrete RAM-resident artefacts the loader writes into.
| Range | Bytes changed | What it is |
|---|---|---|
0x80124690..0x801503C4 | ~168 KB | Battle-bundle residency window. Pre-battle holds field-scene payload; post-battle holds battle-bundle data. |
0x801CE808..0x801D3018 | ~16 KB | Battle-overlay scratch slice. Wholesale reset on entry. |
0x800836C8 | 4 B | Per-frame actor-tick fn-pointer slot. Pre-battle = 0x80024C50; post-battle = FUN_80021DF4. |
0x801FFCA0..0x801FFFFE | ~600 B | CD I/O state slice; rewires while the battle bundle is paged in. |
The pair is post-load by design - both save frames resolve to a state where the loader function has already returned. The loader function (which reads PROT entry 0x05C4 + sibling Seru blobs and populates the battle bundle) lives in an overlay slice that is not directly visible in either snapshot. Pinning it requires a mid-execution capture between the field→battle game-mode flip and this residency state.
Codified as engine_core::capture_observations::battle_init_overlay; disc-gated test battle_init_overlay_pair_pins_battle_bundle_window_and_actor_tick_wiring.
Item-use battle-event residency
A mid-battle save pair (battle just initiated; party member about to use a Healing Leaf) pins the item-use sub-mode residency:
| Address | Pre → Post | Notes |
|---|---|---|
_DAT_8007B8D0 | 0x8014BD30 → 0x800ABA4C | Field-pack base pointer flips. The item-use sub-mode reseats the active scene asset buffer. |
0x801BA7DC..0x801BADEC | ~660 B shift | Script-VM context block. The menu / item / target / commit pipeline rewrites the entire ctx region. |
| Actor pool slots 0..4 | per-frame motion deltas | 3 party + 2 monsters (count-2 formation). Slots 5..7 stay zero across the pair. |
The captured pair uses a Healing Leaf (consumable HP-restore) - not Fire Book I (a spell-learn item). The pair therefore pins the residency window of the item-use battle-event handler without lifting the Fire Book-specific writer to the displayed-skills array at +0x185. A second save pair specifically capturing Fire Book I use is required to lift that writer.
Codified as engine_core::capture_observations::item_use_battle_event; disc-gated test item_use_pair_pins_field_pack_base_flip_and_script_vm_ctx_shift.
Captured stat-growth observations
The mednafen-state diff toolkit over a magic-rank-up + character-level-up save triplet pins the per-byte footprint for Vahn (party slot 0). The deltas inside Vahn's character record at 0x80084708 (stride 0x414):
| Event | Offset | Before → After | Interpretation |
|---|---|---|---|
| Magic-rank up (pre → post) | +0x08 | 0x30 → 0x3C | flag word low byte (+12) |
| Magic-rank up | +0x9C | 0x09 → 0x0A | magic-rank counter (+1) |
| Magic-rank up | +0x10A | 0x1B → 0x11 | low byte of mp_cur (cast cost spent) |
| Magic-rank up | +0x161 | 0x02 → 0x03 | spell-level array (spell_levels[0] +1) |
| Level-up, 4-level jump (pre → post) | +0x00 | 0x4F → 0x73 | unconfirmed (jump +0x24 doesn't match single-level granularity) |
| Level-up | +0x04..+0x06 | 0x016D → 0x02DA | u16 LE XP delta (+365) |
| Level-up | +0x10E | 0x3A → 0x42 | low byte of ap_cur (AP / arts gauge refill, +8) |
| Level-up | +0x11C..+0x12C | six per-byte +1..+4 | per-stat increments at byte stride 2 |
| Level-up | +0x130 | 0x02 → 0x03 | displayed character level (+1 per level-up event; see level-up) |
The retail per-level growth source is in SCUS_942.54: the per-stat 98-entry curves at DAT_800769CC (stride 0x62) + the parameter block at DAT_80076918 that selects each stat's curve row, read and applied by the overlay level-up function FUN_801E9504. The engine applies them: legaia_asset::level_up_tables::growth_tables_from_scus parses the tables and LevelUpTracker::with_growth_tables installs per-character StatGrowthCurve::PerLevel (all 8 stats) at boot, byte-validated against the captured Noa L2→L3 single-level deltas - see level-up.
The engine port
The clean-room engine ports the battle stack as a layered set of pure-data sessions and orchestrators (BattleSession / BattleRunner / BattleRound / BattleHud, the compute_battle_stats aggregator, the item / spell / equipment / monster / formation catalogs, and the StatusEffectTracker / ApGauge models). The full crate-by-crate breakdown lives on the engine reimplementation page, under Gameplay systems.
Battle rewards composite
World::apply_battle_loot(formation, catalog) -> BattleRewards is the post-victory composite that turns a defeated formation into the runtime side-effects:
- Sums each
MonsterDef::expand distributes the total viaWorld::apply_battle_xp, which splits the pool equally among the surviving party members (integer divide, remainder dropped; dead members get zero) and runs per-character level-up checks againstLevelUpTracker::xp_table. - Sums each
MonsterDef::goldand adds it toWorld::money(saturating). - For each defeated monster with a non-
Nonedrop_itemanddrop_rate_q8 > 0, pulls one byte fromWorld::next_rngand compares againstdrop_rate_q8 / 256. On hit, the item id is appended toBattleRewards::dropsand incremented inWorld::inventory. - Returns
BattleRewards { xp, gold, level_ups, drops }for the engine to surface as the post-battle banner ("got N XP, M gold, level up, found Healing Leaf!").
Monster ids missing from the catalog contribute zero (silently skipped) so a partially-populated catalog still drives a battle-end transition. Implementation: crates/engine-core::world::World::apply_battle_loot.
Battle target picker
Drives the post-action target cursor. Parameterised on a TargetKind enum constraining valid targets:
| TargetKind | Allowed targets |
|---|---|
SingleEnemy | One alive monster slot. |
SingleAlly | One alive party slot, excluding the actor. |
SingleAllyOrSelf | Any alive party slot, including the actor. |
DeadAlly | One fallen party slot (Revive / Resurrection). |
AnyAlly | Any party slot, alive or dead. |
AllEnemies / AllAllies | Sweep target - auto-confirm. |
Self_ | The actor itself - auto-confirm. |
Sweep kinds resolve in init_cursor; single-target picks walk valid candidates with cursor-wrap and auto-skip-dead. BattleSession::push_command_with_target is the wiring API engines drive when a command needs a target: it charges AP up-front, opens the picker, and stashes the command in pending_target_command. When the picker resolves, maybe_close_picker_with_world writes the resolved slot to BattleActor::active_target (the field the action SM reads at strike time) and admits the buffered command without re-charging AP. Sweep targets write a 0xFF sentinel; cancellation drops the command. Implementation: crates/engine-core::target_picker.
Live gameplay loop - Field ↔ Battle in tick
World::tick drives the full Field → Battle → Field round trip itself when World::live_gameplay_loop is set. The flag is opt-in: with it clear (the default), the Field branch runs the field VM + locomotion but never rolls encounters, and the Battle branch runs a single step_battle without applying damage or re-arming - preserving every existing caller and test that drives those externally.
With the flag set, the per-frame flow is:
- Field tick (
World::live_field_tick): a step is the player actor crossing into a new 128-unit collision tile (pos >> 7). Each step drives oneWorld::on_field_stepencounter roll;World::tick_encounteradvances the session'sTransition/Gracecountdowns every frame. When theEncounterSessionreachesTriggered,World::begin_encounter_battleresolves the rolledformation_id, snapshots the field actor table intoWorld::field_return, seeds the battle actor table from the formation +MonsterCatalog(enter_battle_from_formation), and flipsmodetoBattle. If a battle track is configured, it also callsWorld::swap_to_battle_bgm(see audio → BGM dispatch). - Battle tick (
World::live_battle_tick): wrapsstep_battlewith the host-side glue the retail engine performs through its render + animation systems, so the battle resolves fromtickalone. It folds this frame'sBattleEvent::ApplyArtStrikedamage into target HP; applies a generic physical strike (apply_basic_attack) on theAttackChain → AttackRecoveryedge when no art strike did; marks zero-HP combatants dead; clearsADVANCE_DONEatAttackRecovery; and re-arms the next party attacker atEndOfAction. OnStepOutcome::BattleCompleteit callsWorld::finish_battle. - Return (
World::finish_battle): onBattleEndCause::MonsterWipeit credits loot viaWorld::apply_battle_loot(recorded inWorld::last_battle_rewards, and armed as an on-screen spoils panel); onPartyWipeit raisesWorld::game_over. Either way it writes the party's post-battle HP / MP into the character records (World::save_party) before restoring thefield_returnsnapshot and pushing those values back onto the restored actors - without that step the fight's damage was thrown away and losing looked like winning. Then it ends the encounter session's battle (post-battle grace + suppression), flipsmodeback toField, and (when a swap was active) callsWorld::restore_field_bgm. - Post-battle script re-entry (
SceneHost::tick): retail reloads the field scene after every battle, re-running the scene-entry system scriptP1[0](FUN_8003ab2c). The host mirrors that on theBattle → Fieldmode edge by reloading the entry script (Scene::field_man_entry_script→World::load_field_script_at). This re-run is what dispatches post-battle beat records: rikuroa'sP1[0]tests the transient staged marker0x289(SET by the stagerP1[3]'s own52 89script bytes when the approach dispatch ran the record pre-battle) and issues the op-0x44spawn of the post-victory recordP2[50]through the C1-gated dispatch - whose own script bytes SET the progression gate0x142. No engine code writes the gate flag or the marker (there is no victory latch and no battle-entry stamp); both land from record execution. Disc-gated oracle:organic_beat_records_disc.rs.
Auto-resolve vs player-driven
The battle tick has two modes. By default it auto-resolves: every turn commits a generic physical strike against the first living combatant on the opposing side, with no player choice. The whole actor table takes turns, so monsters take turns too - a monster turn strikes a living party member, and a party wipe ends the battle (game_over) the same way a monster wipe does. When World::battle_player_driven is set (requires the live loop), each party turn instead pauses the action SM and opens a battle_input::BattleCommandSession (monster turns still auto-resolve) - the player picks a command and a target before the strike commits. On confirm World::tick_battle_command arms battle_ctx + the actor's active_target and resumes the SM. An abort falls back to a default strike so the loop can't deadlock. Target selection reuses the battle target picker.
Turn order. Who acts next is chosen by World::next_combatant_by_initiative, the port of recompute_battle_order (FUN_801daba4). Each living actor carries a per-turn initiative key (+0x16c) seeded from its SPD (+0x164): init_key = speed + rand()%(speed/2 + 1) + 1 (overlay_0897_801e23ec). The selector picks the living actor with the highest key (random tiebreak via rand % tie_count), consumes that key, and seeds a fresh round once everyone has acted; dead actors' keys are zeroed first so they can't be picked. Party SPD is seeded from each character record's live stats; monster SPD from the archive record's stats[5] at battle setup. When no living actor carries SPD (the disc-free / synthetic case) the selector falls back to round-robin slot order, keeping the synthetic loop deterministic.
All six commands - Attack, Arts, Magic, Item, Spirit, Run - are wired into the live loop. Attack opens a target cursor and commits a physical strike through the action SM. Arts / Magic / Item hand off to a host-owned submenu (they need the caster's saved chains / learned spells / live MP / inventory). Spirit and Run resolve immediately with no target: Spirit charges the caster's AP gauge (ApGauge::charge_spirit, the retail Square-press +5) and raises a per-slot guard stance (World::battle_guarding, the engine model of the retail pending-action byte +0x1DE == 4) that halves incoming damage through the finisher's guard stage until the actor's next turn; Run rolls the escape and arms the ported run band (category 5 → RunBegin/RunWait/RunEscape) - success ends the battle Escaped (no loot, downed members floored alive at 1 HP), failure consumes the turn. The roll is the decoded FUN_801E791C formula: party (SPD*3)>>1 + missingHP>>4 vs enemy SPD + missingHP>>5, two rand draws, with the Chicken Heart (x1.5) and Chicken King (forced tie) passives honoured for living wearers. The submenu hand-offs:
- Item opens a battle-context
inventory_use::InventoryUseSessiononWorld::battle_item_menuwith one ally row per party slot plus one enemy row per live monster slot (taggedTargetRow::is_enemy). Heals / cures / revives validate against ally rows, offensive items (Bomb / capture / escape) against enemy rows.World::use_itemfolds offensive outcomes too:DamageDealtsubtracts enemy HP,CaptureRolledreusesWorld::resolve_capture, andEscapeRequestedsetsWorld::battle_escapedso the tick returns to the field viafinish_battle(no loot). The permanent stat-up consumables (Power Tonic, Vital Tonic) fold through the same kernel:StatRaisedapplies viaapply_stat_raise, which bumps the persistent character record - an HP/MP-max raise also bumps the live actor caps and refills the gain; a combat-stat raise lands in the record’s+0x110live-stat block thatseed_party_battle_statsre-derives from. Combat stats cap at the per-stat cap constant, HP/MP max at 9999. - Magic opens a
battle_magic::BattleSpellSessiononWorld::battle_spell_menu(the caster's learned spells off their roster record + live MP, MP-gated). On confirmWorld::apply_battle_spelldeducts MP once, resolves each affected slot throughspells::cast_spell, and folds the outcome into the live actor table. AllSpellOutcomeshapes apply: damage / heal / cure / revive; buffs (delta written into the per-slot scalar with a per-turn timer reverted on expiry); capture (World::resolve_capturerolls vs the monster's missing-HP fraction); and escape. Accuracy / Evasion / Speed buffs are tracked but have no live-loop scalar to move yet. - Arts opens a
battle_arts::BattleArtsSessiononWorld::battle_arts_menu(built fromWorld::saved_chainsfiltered to the caster). Each menu row carries a per-strike power profile (Vec<PowerByte>+EnemyEffect) and runs through the real art-power path:World::apply_battle_artdrives each power byte throughart_strike::apply_art_strike, so the byte's multiplier tier + UDF/LDF target decode and the art's status effect lands on a hit. The profile comes from a stagedArtRecord(populated from disc PROT entry0x05C4) when the saved chain matches a record's command string; with no match it falls back to a synthetic per-direction profile. Both paths share the oneapply_art_strikekernel.
While any submenu is open both the SM and the command session are parked; World::tick_battle_{arts,spell,item}_menu drives it from World::input. On a completed action the result is applied, the relevant popup is surfaced (battle_hit_fx), and the action SM is parked at EndOfAction so the re-arm block cycles to the next combatant - a cast / art / item use is the actor's whole turn. Implementation: crates/engine-core::battle_input + battle_arts / battle_magic; coverage crates/engine-core/tests/battle_player_driven.rs.
Post-battle Seru learning
Capturing a monster (magic capture roll or a capture item) downs it and logs its monster id into World::battle_captures. World::finish_battle resolves these through World::resolve_captures: each captured monster id maps to a Seru id via MonsterCatalog's MonsterDef::seru_id, and seru_learning::record_capture banks that Seru's capture points against World::seru_log for every active party slot eligible by the Seru's learnable_mask. When a slot's accumulated points cross the Seru's learn_threshold the taught spell id joins that character's learned list, and World::build_battle_spell_session unions the roster's saved spells with seru_log.learned_spells(slot) so a freshly-learned spell is immediately castable - no save/load round-trip needed. resolve_captures builds the first accepted capture into World::current_capture_banner (a seru_learning::SeruCaptureSession); World::tick advances the banner one frame per call so it plays out over the field after the battle ends. Capture-point progress persists through World::save_full / load_full as (seru_id, points) pairs in each CharSaveExt::seru_captures. The MonsterDef::seru_id mapping + thresholds are clean-room approximations (SeruRegistry::vanilla); pinning the real per-monster Seru attachments is gated on the still-uncaptured stat-grant table loader.
The legaia-engine play-window host ships the loop on, matching the browser play page: walking rolls encounters, battles are player-driven and the HUD renders party/monster HP plus the command menu / target cursor / arts + spell + item submenus. --no-live-loop turns the encounter roll off (field VM + locomotion only, for scene inspection) and --no-player-battle auto-attacks each party turn. --battle-bgm <id> enables the Battle↔Field music swap; the browser twin is LegaiaRuntime::set_battle_bgm.
The loop flag gates the encounter roll, not the battle. Once the world is in SceneMode::Battle — however it got there, including a scripted 3E FF fight or a world-map region encounter, neither of which was ever gated — World::tick always drives the full battle to resolution. Retail has no "loop enabled" concept either (FUN_801E295C drives the battle it is in), and gating the driving side while leaving entry ungated is what let a default session strand itself in an unresolvable battle. Regression: crates/engine-core/tests/battle_always_resolves.rs.
Two animation gates are host-simulated. Retail's render / animation systems clear them; the port has no such driver, so World::live_battle_tick retires each on the frame its state is reached: ADVANCE_DONE at AttackRecovery, and the caster's spell_iter (actor+0x1FA) at MagicSustain. The action SM only ever sets the latter, so without the edge every battle in which a monster or party member cast a spell stopped dead - which is most real encounters, and a large part of what "battles don't work" looked like from the outside.
Both hosts arm the loop through one shared kernel, World::arm_live_loop, and a resolved battle now reaches the player: post-battle HP/MP is written into the character records before the field snapshot is restored, a wipe raises World::game_over and both hosts route it to the title screen - retail's own destination, the game_mode = 0x16 / _DAT_8007BB00 = 1 store pair in FUN_8003AEB0's back-from-battle arm. Nothing is drawn on the way and no button is read, because retail draws and asks nothing here; the port's former three-row Continue/Retry/Quit panel was an engine invention from the period when that destination was unpinned, and it is deleted. A victory arms a spoils panel showing the XP / gold / drops that were previously applied invisibly.
Implementation: crates/engine-core::world; integration test crates/engine-core/tests/live_loop_tick.rs drives boot → walk → encounter → victory → return-to-field through tick alone with no test-side battle glue.
BattleSession Resolve driver
BattleSession owns the action SM during the Resolve phase. After commit_turn succeeds, the session builds a per-slot ResolveDriver queue keyed on whether the slot's resolved action queue carries a RegularStarter (→ TacticalArts category) or only directional commands (→ Attack category).
Per frame the session: drains world events into HUD popups, arms world.battle_ctx for the head-of-queue attacker if not already armed, calls world.tick() exactly once, clears the render-side ADVANCE_DONE flag on AttackRecovery, applies clean-room formula damage on AttackChain → AttackRecovery transitions, and advances to the next attacker on EndOfAction. When the queue drains, the session transitions to RoundOutro; StepOutcome::BattleComplete routes through the existing BattleEnd event into the terminal Victory / Defeat phase.
End-to-end coverage in crates/engine-core/tests/end_to_end_gameplay_loop.rs::battle_session_drives_action_sm_to_monster_wipe exercises the full pipeline - encounter trigger → BattleSession setup → push_command per slot → commit via SessionInput { start: true, .. } → Resolve → BattlePhase::Victory.
End-to-end gameplay loop integration test
crates/engine-core/tests/end_to_end_gameplay_loop.rs stitches every gameplay-side subsystem into one cycle:
- Boot - load an
LGSFSaveFile(party + story flags + money + inventory) into a freshWorldviaload_full.load_fullhydrates theLevelUpTrackerper-slot level from each record's+0x100byte so reloads don't roll the tracker back to L1. - Field walk - switch to
SceneMode::Field, install anEncounterSessionkeyed tovanilla_formation_tableat saturated trigger rate, step untilEncounterPhase::Triggered. - Encounter - drain the formation roll, populate monster slots 3..N from the
MonsterCatalog, flip mode toSceneMode::Battle. - Battle SM - drive
World::tickwhile applying clean-room formula damage on everyAttackChain → AttackRecoverytransition until the action SM resolves toBattleEndCause::MonsterWipe. - Rewards - call
World::apply_battle_lootto credit the per-character XP / gold split, fire drop rolls, and trigger per-character level-ups; assert at least one party slot crossed a threshold. - Save round-trip -
world.save_full().write() → SaveFile::parse() → load_full()into a freshWorld; assert HP/MP, level, money, story flags, and inventory survived intact.
The crate ships several test variants:
| Test | Purpose |
|---|---|
synthetic_party_completes_full_gameplay_loop | Default CI cycle; hand-spins the action SM. |
battle_session_phase_transitions_during_loop | Smoke around the BattleSession side; verifies the session reaches CommandInput. |
battle_session_drives_action_sm_to_monster_wipe | Drives the same loop through BattleSession::tick - push_command → SessionInput { start: true } → Resolve → BattlePhase::Victory. |
real_battle_data_encounter_drives_loop | Disc-gated: scans an early PROT.DAT entry for a valid EncounterRecord pattern, installs it via install_encounter_from_record, and resolves the battle. |
real_psx_memory_card_save_drives_full_loop | Disc-gated: boots the loop from a real Legaia memory-card save block. |
Disc-gated variants skip silently when extracted/PROT.DAT / the mednafen card is missing.