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 +0x07 of 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
CaseLoads
6The befect_data bundle (PROT 0x369–0x36B)
0xEInitialises the runtime effect 2-pack wrapper via FUN_801DE914. Also fires for the field-VM op 0x3E warp/interact path on the system context.
0xFFDispatches 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-0x15 render FUN_80026f50 makes) - a GTE rasteriser, not a TMD walk: a _DAT_1f8003f8 × _DAT_1f8003fa cell grid (pitch 0x200) on a Y ≈ 0 flat plane centred on the actors. Pass 1 RTPS-culls each cell into the 0x1000-byte buffer _DAT_8007b814; pass 2 RTPTs each visible cell into one POLY_GT4. Those tiles are the 619 POLY_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 the 0896 label - 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_800513F0 does tmd_register (→ DAT_8007C018[]) + FUN_80020de0 (actor_alloc) + FUN_80020f88 (link), so the normal actor renderer FUN_80048A08 draws it - no special dome-draw fn (hence DAT_80076810 has no resolved reader). The dome is a front half (all 4 objects Z ≥ 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 radius 2889) 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:

IDAction
0x20Special move / capture (different sub-states)
0x28Action-menu cursor active (player still selecting)
0x35Magic - summon
0x47Spirit
0x50Martial-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.

OffsetTypeUse
+0x00u8 × 6Battle phase/state flags (01 01 01 00 00 00 while a turn resolves).
+0x06u8Monster-slot active action ID (0xFF if none queued).
+0x07u8Party-slot active action ID. The outer switch in FUN_801E295C keys on this.
+0x09u8Turn / phase counter.
+0x13u8Active-actor slot index - used to look up the actor pointer via (&DAT_801C9370)[ctx[0x13]].
+0x14..+0x1Bu8 × 8Per-action parameter bytes (target slot, sub-action, dir/elem, etc.).
+0x1Du8Action context flag - 0x03 for summon and capture; 0x00 otherwise.
+0x29..+0x2DstringActive spell/move icon glyph.
+0xA9..+0xECtextBattle dialog buffer.
+0x6D6..u8 × NAction state machine's PC offset / sub-state cursor.

Battle actor table

8-slot pointer table at 0x801C9370:

SlotRole
0..2Active party members (ordered by formation).
3..7Monster slots (up to 5 enemies per battle).

Selected combatant struct fields:

OffsetTypeUse
+0x07u8Per-actor state byte. Drives FUN_801E295C.
+0x1Fu8Hit-radius / size byte. Used by FUN_8004E2F0 (range).
+0x34 / +0x38i16Current world X / Z.
+0x3C / +0x40i16Previous-frame X / Z (for delta tracking).
+0x4A / +0x4Cu8 / int*Magic-slot count + list pointer.
+0x14C..+0x158u16HP / MP / AGL triplets (cur/max; AGL = the agility / action gauge).
+0x172..+0x174u16HP/MP secondary mirror.
+0x1DFu8Monster size byte (read from a monster record at +0x1F and stored here at init).
+0x1EF..+0x1F3u8Magic-resistance per element (5 elements).
+0x230u32Monster 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..0x158 and +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 >> 1 across dead enemies, optionally × 1.25 (a living party member with ability bit 0x10000), then halved. A lone enemy yields floor((gold >> 1) / 2) - Gimard 6015, confirmed by a runtime write-watchpoint on party gold (0x8008459C).
  • EXP (+0x46): summed × 3/4, then split evenly among living party members.
  • drop (+0x48 item id, +0x49 chance %): per dead enemy, rand() % 100 < chance grants the item (added to the win banner at actor +0xA9 and to inventory via FUN_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 from FUN_801DABA4 (recompute_battle_order). Its generic decision core counts the live global magic ids in the record's +0x21..+0x23 array, rolls rand % (1 + live_count); a 0 selects a physical strike (target rand % party_count), otherwise it picks magic id magic[roll-1], gates on affordability (actor[+0x150] MP < cost), and resolves the target by the spell's shape byte spell_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 large switch on DAT_8007BD0C[slot] can override the choice with bespoke scripted casts (hard-coded ids 0x50/0x51/0x52/0x53/0x6f/0x40, cooldowns in DAT_801C8FE0). DAT_8007BD0C[slot] is the per-slot monster id - FUN_801DA51C fills 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 at ActionSeed as the monster_setup hook, but only for monster actors with actor[+0x16e] & 0x380 != 0. It reads the targeting class the picker left in actor[+0x1DD] and expands it: class 0..2 → a living monster slot; class 3..6 → a living party slot; class 8/other → a rand % 3 gate 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's case 0xFF (_DAT_8007BD24[0x28A] += 1), a scripted phase-transition action a boss issues at an HP/script boundary - ported as World::advance_battle_mode. 0 until then.
  • actor+0x16e & 0x380 is not a monster flag. FUN_80047430 sets it only on party slots (slot < 3) whose status word +0x00 has bit 0x2000 (Confuse/Charm), delegating that party member to the AI target resolver; the resolver runs only when it's set. A normal monster keeps 0x380 clear, so its !ai380 scripted-cast cases fire and monster_setup stays dormant - exactly what the engine does (monster actors carry field_flags == 0). The set-0x380 path (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:

  1. Caps each character's stats at 0x3E7 (999, the in-game stat ceiling).
  2. ORs the character's "active abilities" 16-byte block at +0xF4..0x100 into a global 4×u32 bitmask at 0x80074358..0x80074368. This is the "currently-active accessory effects" register read by every other game system.
  3. For each character, calls FUN_800432BC / FUN_80042DBC to 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):

OffsetUse
+0x13Cu8 spell-list count
+0x13D..+0x160u8 spell IDs (variable-length; up to 36)
+0x161..+0x184u8 parallel spell-level / experience array
+0x185..+0x195Displayed-skills list (count-prefixed; head-insert on item-use)
+0x196..+0x19Du8 equipment slot bytes (8 slots; weapon, armour, accessories)
+0x2A7..+0x2B0NUL-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..+0x37FActive spell-slot array (stride 0x14)
+0xF4..0x100"Active abilities" 16-byte block - OR'd into global ability bitmask
+0x104..0x110HP / MP / AP triplets (live stats; AP = the arts / action-point gauge, max sized by AGL)
+0x11AStat-cap field (clamped to 0x3E7)
+0x11C..+0x122Six adjacent stat bytes (RecordStats; incremented on level-up)

Other notable helpers

FunctionRole
FUN_801D0748Battle 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_801D8DE8Hottest battle utility (3 KB / 77 incoming refs). Likely a per-actor utility that every state arm bottoms out into.
FUN_80048310 + FUN_800485BCWeapon / 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_80035EA8HP / 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).

StatusSource byteDefault duration (clean-room)Retail effect (wiki)
Toxic14 turns"Deadly Poison": HP drains faster than Venom and attack/defense drop (min(max_hp/16, 256) tick, never kills; rolls ×7/10)
Numb23 turnsParalysis: cannot act; clears on being hit or after some turns (a full block, not a chance roll)
Venom3 (Other)6 turns"Poison": HP drains, lesser than Toxic (min(max_hp/32, 128) tick, never kills; rolls ×9/10)
Sleep43 turnsAsleep; wakes when hit
Confuse53 turnsActs uncontrollably / random target
Curse64 turnsBlocks Magic
Stone7whole battlePetrification: cannot act, cannot be damaged, counts as defeated; lasts the whole battle (no in-battle cure; escape restores)
Faint8until curedKO 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 rangeAP costNotes
0x00 Nothing0placeholder
0x01..=0x050system actions (Item / Magic / Attack / Spirit / Escape)
0x0C..=0x0F0direction bytes (free)
0x19 Regular Art Starter1
0x1A Special Art Starter1
0x1B..=0x321per-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:

RangeBytes changedWhat it is
0x801CE808..0x801F3818~133 KBBattle overlay loaded into RAM (single contiguous region)
0x801C9370..0x801C9900~200–500 B8-slot battle actor pointer table; stride 0x60 per slot
0x80083000..0x80084000~600 BScene-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.

RangeBytes changedWhat it is
0x80124690..0x801503C4~168 KBBattle-bundle residency window. Pre-battle holds field-scene payload; post-battle holds battle-bundle data.
0x801CE808..0x801D3018~16 KBBattle-overlay scratch slice. Wholesale reset on entry.
0x800836C84 BPer-frame actor-tick fn-pointer slot. Pre-battle = 0x80024C50; post-battle = FUN_80021DF4.
0x801FFCA0..0x801FFFFE~600 BCD 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:

AddressPre → PostNotes
_DAT_8007B8D00x8014BD30 → 0x800ABA4CField-pack base pointer flips. The item-use sub-mode reseats the active scene asset buffer.
0x801BA7DC..0x801BADEC~660 B shiftScript-VM context block. The menu / item / target / commit pipeline rewrites the entire ctx region.
Actor pool slots 0..4per-frame motion deltas3 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):

EventOffsetBefore → AfterInterpretation
Magic-rank up (pre → post)+0x080x30 → 0x3Cflag word low byte (+12)
Magic-rank up+0x9C0x09 → 0x0Amagic-rank counter (+1)
Magic-rank up+0x10A0x1B → 0x11low byte of mp_max (cast cost spent)
Magic-rank up+0x1610x02 → 0x03spell-level array (spell_levels[0] +1)
Level-up, 4-level jump (pre → post)+0x000x4F → 0x73unconfirmed (jump +0x24 doesn't match single-level granularity)
Level-up+0x04..+0x060x016D → 0x02DAu16 LE XP delta (+365)
Level-up+0x10E0x3A → 0x42low byte of ap_max (AP / arts gauge, +8)
Level-up+0x11C..+0x12Csix per-byte +1..+4per-stat increments at byte stride 2
Level-up+0x1300x02 → 0x03rank 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::exp and distributes the total via World::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 against LevelUpTracker::xp_table.
  • Sums each MonsterDef::gold and adds it to World::money (saturating).
  • For each defeated monster with a non-None drop_item and drop_rate_q8 > 0, pulls one byte from World::next_rng and compares against drop_rate_q8 / 256. On hit, the item id is appended to BattleRewards::drops and incremented in World::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:

TargetKindAllowed targets
SingleEnemyOne alive monster slot.
SingleAllyOne alive party slot, excluding the actor.
SingleAllyOrSelfAny alive party slot, including the actor.
DeadAllyOne fallen party slot (Revive / Resurrection).
AnyAllyAny party slot, alive or dead.
AllEnemies / AllAlliesSweep 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 one World::on_field_step encounter roll; World::tick_encounter advances the session's Transition / Grace countdowns every frame. When the EncounterSession reaches Triggered, World::begin_encounter_battle resolves the rolled formation_id, snapshots the field actor table into World::field_return, seeds the battle actor table from the formation + MonsterCatalog (enter_battle_from_formation), and flips mode to Battle. If a battle track is configured, it also calls World::swap_to_battle_bgm (see audio → BGM dispatch).
  • Battle tick (World::live_battle_tick): wraps step_battle with the host-side glue the retail engine performs through its render + animation systems, so the battle resolves from tick alone. It folds this frame's BattleEvent::ApplyArtStrike damage into target HP; applies a generic physical strike (apply_basic_attack) on the AttackChain → AttackRecovery edge when no art strike did; marks zero-HP combatants dead; clears ADVANCE_DONE at AttackRecovery; and re-arms the next party attacker at EndOfAction. On StepOutcome::BattleComplete it calls World::finish_battle.
  • Return (World::finish_battle): on BattleEndCause::MonsterWipe it credits loot via World::apply_battle_loot (recorded in World::last_battle_rewards); on PartyWipe it raises World::game_over. Either way it ends the encounter session's battle (post-battle grace + suppression), restores the field_return snapshot, flips mode back to Field, and (when a swap was active) calls World::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::InventoryUseSession on World::battle_item_menu with one ally row per party slot plus one enemy row per live monster slot (tagged TargetRow::is_enemy). Heals / cures / revives validate against ally rows, offensive items (Bomb / capture / escape) against enemy rows. World::use_item folds offensive outcomes too: DamageDealt subtracts enemy HP, CaptureRolled reuses World::resolve_capture, and EscapeRequested sets World::battle_escaped so the tick returns to the field via finish_battle (no loot). The permanent stat-up consumables (Power Tonic, Vital Tonic) fold through the same kernel: StatRaised applies via apply_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 +0x110 live-stat block that seed_party_battle_stats re-derives from. Combat stats cap at the per-stat cap constant, HP/MP max at 9999.
  • Magic opens a battle_magic::BattleSpellSession on World::battle_spell_menu (the caster's learned spells off their roster record + live MP, MP-gated). On confirm World::apply_battle_spell deducts MP once, resolves each affected slot through spells::cast_spell, and folds the outcome into the live actor table. All SpellOutcome shapes apply: damage / heal / cure / revive; buffs (delta written into the per-slot scalar with a per-turn timer reverted on expiry); capture (World::resolve_capture rolls 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::BattleArtsSession on World::battle_arts_menu (built from World::saved_chains filtered 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_art drives each power byte through art_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 staged ArtRecord (populated from disc PROT entry 0x05C4) 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 one apply_art_strike kernel.

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:

  1. Boot - load an LGSF SaveFile (party + story flags + money + inventory) into a fresh World via load_full. load_full hydrates the LevelUpTracker per-slot level from each record's +0x100 byte so reloads don't roll the tracker back to L1.
  2. Field walk - switch to SceneMode::Field, install an EncounterSession keyed to vanilla_formation_table at saturated trigger rate, step until EncounterPhase::Triggered.
  3. Encounter - drain the formation roll, populate monster slots 3..N from the MonsterCatalog, flip mode to SceneMode::Battle.
  4. Battle SM - drive World::tick while applying clean-room formula damage on every AttackChain → AttackRecovery transition until the action SM resolves to BattleEndCause::MonsterWipe.
  5. Rewards - call World::apply_battle_loot to 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.
  6. Save round-trip - world.save_full().write() → SaveFile::parse() → load_full() into a fresh World; assert HP/MP, level, money, story flags, and inventory survived intact.

The crate ships four test variants:

TestPurpose
synthetic_party_completes_full_gameplay_loopDefault CI cycle; hand-spins the action SM.
battle_session_phase_transitions_during_loopSmoke around the BattleSession side; verifies the session reaches CommandInput.
battle_session_drives_action_sm_to_monster_wipeDrives the same loop through BattleSession::tick - push_commandSessionInput { start: true } → Resolve → BattlePhase::Victory.
real_battle_data_encounter_drives_loopDisc-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_loopDisc-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.

See also