Battle subsystem
The battle overlay carries the battle scene loader, the per-actor action state machine, and the effect VM cluster. Loaded at RAM 0x801CE818 - the same load slot as the town overlay, so battle and town never coexist in memory.
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 - an 11-case state machine, dev/retail split, etc.
- 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 ID byte at
+0x07of the battle context, and the inner dispatch keys on a per-actor "sub-state" byte at+0x1DE. - 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.
Battle scene loader
- Function
FUN_800520F0- Shape
- 11-case state machine
| Case | Loads |
|---|---|
| 6 | The befect_data bundle (PROT 0x369–0x36B) |
| 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 |
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. (The historical overlay capture filed under the0896label - a mislabeled slot-A window image - shows the same renderer; it is battle-overlay code.) - 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 dome is a front half (all 4 objectsZ ≥ 0) drawn once, world-fixed - not a full surround: retail mountains cover only 44–81% of the horizon columns depending on angle (the camera sweeps the front arc; the rest is open sky/grass). Its ground ring (inner radius2889) is the far grass behind the flat grid.
Correction
An earlier reading called the backdrop the world-map continent heightfield per a prim-trace "3715 hits in 0x80190000". That was a false positive (3 degenerate clut=0 POLY_FT4 prims stride-1 flooding that window). The ground is the flat procedural grid above - not a per-tile continent descriptor table, and not a 3D heightfield (cell Y ≈ 0).
Battle camera (exact)
The orbit camera (mode 0x15) is pinned exactly from the four saves + Ghidra (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 engine's retail_battle_mvp reproduces it to 0.0002 px and matches the savestate framebuffer's sky / mountain-ring / horizon. These values are 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, not a scale).
Engine render
legaia-engine play-window --scene map01 --live-loop 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). One caveat: the live camera uses a closer depth than the exact tr.z=7680 - the battle meshes are small (party 134–284 units), so at the true depth they are a few pixels (retail draws actors off the rotation-only matrix, not the backdrop's deep plane).
Battle action state machine
- Function
FUN_801E295C- Size
- 16 KB / 4099 instructions / 155 outgoing calls
- Outer key
switch((*_DAT_8007BD24)[7])- the active action ID byte at ctx+0x07- Inner key
switch(actor[+0x1DE])- the per-actor action sub-state
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).
Action IDs surfaced from save-state captures:
| ID | Action |
|---|---|
0x20 | Special move / capture (different sub-states) |
0x28 | Action-menu cursor active (player still selecting) |
0x35 | Magic - summon |
0x47 | Spirit |
0x50 | Martial-arts directional input mode |
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 | Previous-frame X / Z (for delta tracking). |
+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 | Monster size byte (read from a monster record at +0x1F and stored here at init). |
+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: +0x00 name string ptr (→ actor +0x1BC); +0x04 battle-model TMD offset (→ +0x230, walked as 0x1C-stride object-table records; not XP/drop - see monster mesh); +0x08 texture/CLUT pool ptr; +0x0C u16 HP; +0x0E AGL (agility / action gauge, → actor +0x154); +0x10 u16 MP; +0x12 ATK (→ actor +0x158); +0x14 DEF↑ (+0x15C); +0x16 DEF↓ (+0x160); +0x18 INT (magical damage / magic defense in the summon/arts kernel + accuracy/evasion seed, +0x168); +0x1A SPD (turn-order seed, +0x164); +0x21..+0x23 u8[3] magic-attack ids (see below); +0x44 u16 gold; +0x46 u16 EXP; +0x48 u8 drop item id; +0x49 u8 drop chance %; +0x4A magic-slot count; +0x4C 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. Names traced from the damage / accuracy formulas - see battle formulas and the spell list section.
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 (the EXTENDED footprint - the 15.9 MB archive lives in the entry's trailing-gap sectors, not its small indexed payload). 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 earlier “misleading monster_data stub at extraction 869” reading was the filename shift; extraction 869 is a sound_data VAB stream). 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 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 --live-loop / --player-battle 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.
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
Decides whether a queued action can proceed for the active actor. Sub-dispatches on actor[+0x9A8] (the queued-action byte) into 16+ handler arms; each arm consults a mix of per-actor state, the current target's record at 0x80084708 + tgt*0x414, the global ability bitmask via FUN_800431D0, and the 0x8007BD10 actor-type table to gate the action with a 16-bit return code (action-OK, blocked, requires-target-flag, etc.).
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).
| 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) |
| Sleep | 4 | 3 turns | Asleep; wakes when hit |
| Confuse | 5 | 3 turns | Acts uncontrollably / random target |
| 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.
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_max (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_max (AP / arts gauge, +8) |
| Level-up | +0x11C..+0x12C | six per-byte +1..+4 | per-stat increments at byte stride 2 |
| Level-up | +0x130 | 0x02 → 0x03 | rank counter (+1) |
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 (see level-up). The earlier writer-search came up empty because it scanned the magic_level_up display overlay, not the victory-path applier; the "Seru struct +0x74" hypothesis stays falsified (those reads are a 0x80808080 battle-state flag from FUN_800480D8). legaia_asset::level_up_tables::growth_tables_from_scus parses the curves + param block; turning their bytes into a per-character StatGrowthCurve::PerLevel vector is the remaining step (it needs a pre/post level-up capture to validate the byte->gain math).
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; see the Phase 3 section for the catalogue.
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); onPartyWipeit raisesWorld::game_over. Either way it ends the encounter session's battle (post-battle grace + suppression), restores thefield_returnsnapshot, flipsmodeback toField, and (when a swap was active) callsWorld::restore_field_bgm.
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 four commands - Attack, Arts, Magic, Item - 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):
- 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 exposes both as flags: --live-loop walks-and-fights through the round trip, and --player-battle (which implies --live-loop) makes battles player-driven and renders the party/monster HP plus the live command menu / target cursor / arts + spell + item submenus in the HUD. --battle-bgm <id> enables the Battle↔Field music swap. Without a flag, play-window keeps the legacy "explore but never fight" behaviour. 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 four 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.