At a glance

Roster
8 × u32 actor pointers at 0x801C9370; slots 0..2 party, 3..7 monsters
Context
One struct at 0x800EB654, reached through the global pointer 0x8007BD24
Formation
Four monster-id cells at 0x8007BD0C, a 3-byte party roster at 0x8007BD10, per-battle flags byte 0x8007BD60
Character record
0x414 bytes per member at 0x80084708 + n*0x414 (the save-file block)
Monster record
Decoded archive slot (PROT 867), stat head at +0x00, mesh at +0x04, texture pool at +0x08
Heap
One ~1.23 MB best-fit heap; ~145 KB workable for distinct monster blocks
Engine
engine-core (BattleActor, battle_seats, MonsterMesh), engine-vm::battle_action, legaia_asset::monster_archive
Confidence
Roster, context cursors, seats, camera, heap ledger, wipe path: Confirmed. A few actor-struct fields (+0x3C/+0x40 mid-battle role, status timers): Inferred

Roster and actor struct

0x801C9370 - 8 x u32 pointers 012 34567 party (formation order) monsters (up to 5) table[ctx+0x13] = the acting actor actor struct +0x07 state byte +0x34 / +0x38 world X / Z +0x14C.. HP / MP / AGL pairs +0x16E status halfword +0x1DD target slot +0x1DE action category +0x1DF..+0x1F2 action queue +0x230 mesh pointer
The pointer table is the battle's roster; a slot number is a combatant's identity for the whole fight.

Every combatant is one actor struct. Slots are filled at setup in formation order; empty seats hold a null pointer. The fields other routines read:

OffsetTypeField
+0x07u8Per-actor state byte; drives the action state machine
+0x1Fu8Hit-radius / size byte read by the range check (party sizes from a static table, monster size from the record)
+0x34 / +0x38i16Live world X / Z (Y in the adjacent halfwords, 0 on the flat stage)
+0x3C / +0x40i16Authored seat, stamped at setup and copied into the live position; the b-actor position for the range check. Its mid-battle role is Inferred
+0x4A / +0x4Cu8 / ptrAction-entry count and entry pointer array (each entry: id byte first, AGL cost at +0x74)
+0x58u8Monster size << 5
+0x14C / +0x150 / +0x154u16 pairsHP, MP, AGL (cur, max); mirrored at +0x172..+0x174
+0x158 / +0x15C / +0x160u16ATK, DEF-high, DEF-low
+0x164 / +0x168 / +0x16Cu16SPD, INT, initiative key for the current round
+0x16Eu16Status halfword (below)
+0x1BCptrName string
+0x1DA / +0x1DCu8Staged animation channel and its restage counter - every clip goes through this one channel
+0x1DD / +0x1DEu8Target slot; action category (0 Arts, 1 Item, 2 Magic, 3 Attack, 4 Spirit, 5 Run)
+0x1DF..+0x1F2u8[]Action queue: chosen action id first (also the move-power index), then per-action parameters
+0x1EF..+0x1F3u8[5]Hit-reaction clip slots (flinch, knockdown, get-up, block), filled from the record's tagged entries
+0x1FAu8Caster's spell iterator, cleared by the renderer at magic sustain
+0x230ptrBattle-model TMD (from record +0x04), walked as a 0x1C-stride object table

The range check between two slots is a euclidean distance from the live positions plus the sum of both size bytes, clamped to a per-actor cap. Slot pointers are the only way any battle routine names a combatant.

Battle context struct

One struct describes the fight in progress. Two byte cursors inside it hand the round back and forth: the menu machine walks +0x06, the action machine walks +0x07. Only the first 32 bytes vary between captures; beyond +0x40 is text-rendering scratch.

OffsetTypeField
+0x00 / +0x01u8Party count / monster count (the seat-table row selectors)
+0x06u8Command-flow byte: 0x0A intro, 0x1E Begin / Run, 0x28 command ring, 0x3C item, 0x46 magic, 0x50 arts, 0x5A target, 0x78 Auto / Command, 0x6E then 0xFE committed
+0x07u8Action-state byte the action machine switches on (0xFF idle); bands on battle action
+0x09u8Round counter
+0x0Du8Per-action camera-angle variant, 0..3 - two independent bits: 1/3 add a half-turn of yaw, 2/3 raise the pitch and drop the camera height. Rolled rand() % 4 at action seed, then narrowed per command category
+0x13u8Acting slot index into the roster table
+0x14..+0x1Bu8[8]Per-action parameters (target slot, sub-action, direction / element, second target)
+0x1Du8Action context: 3 for summon and capture, else 0
+0x26u8Level-up banner UI element id (0x65), or 0. Its non-zero state holds the action teardown open for 0x96 frames instead of 0x3C; see battle action
+0x29..+0x2DbytesActive spell / move icon glyph escape
+0x269u8Captured Seru id, set by the capture roll
+0x279u8Cast-module phase byte (below)
+0x287u8Scripted-fight flag, derived once at battle init from the per-battle flags byte ((flags >> 5) & 4). It gates the escape roll, both magic-capture audio ducks and the attack-return arm's counter-attack test - a per-battle property, not a per-action one
+0x288u8The counter-attack byte proper - the second term of the attack-return gate
+0x28Au8Battle mode - the boss multi-phase gate the AI reads
+0xA9..+0xECtextBattle message buffer
+0x6D6 / +0x6D8 / +0x6DAu16Intro timer, Done-band countdown, drifting camera yaw

Formation cells

Three small globals beside the context carry the formation from the trigger to every later stage:

AddressSizeField
0x8007BD0Cu8[4]Monster ids per seat, copied from the encounter row by the entity confirm state. Only the first occurrence of an id streams a record; the setup routine shuffles species order on a 50% coin flip
0x8007BD10u8[3]Party roster (character ids per party slot) - the win-pose staging indexes it by acting slot
0x8007BD60u8Per-battle flags. Bit 0x80 does double duty: set from the row's header byte it selects the alternate seat family, the particle intro and the no-escape flag; during the fight it is the party-survived latch (below)
0x8007B64Au8Stage id: 0 for every retail fight except Tetsu (1) and Cort (2, then 3 once the first form dies). Non-zero pages a code overlay at extraction 966 + id
0x8007B7FCu8Forced-battle id. Has no writer anywhere on the disc: dev-harness residue that would seed the cells from the id and exit to the debug menu
Details: the species-order rebuild and why three species is unsafe

Before any monster streams, setup classifies the cells into "the first species" and "the other species", holding the other in a single register, then on a coin flip rebuilds the array as [other × n, first × m]. With two species this is an exact swap. With three, the register is overwritten by each later species, so [a, b, c] rebuilds as [c, c, a]: the middle species vanishes and the last is duplicated - on the other half of the flip all three stream verbatim, which turns an over-budget trio into a probabilistic load hang. The randomizer caps every formation at two distinct species for this reason.

The heap ledger

Everything the loader places lives in one best-fit heap of ~1.23 MB (arena 0x80091800..0x801C6000). The malloc wrapper returns NULL on exhaustion and the monster streamer copies through that pointer unchecked, so an over-budget formation writes a decoded block over kernel RAM and the machine locks. Neither VRAM (each seat owns its own texture column) nor the AI bounds a formation - the heap does.

AllocationBytesNote
Scene asset buffer0x62C00The resident field scene
GPU packet double buffer0x640000x32000 × 2
Enemy stager working buffer0x2E390Fixed size, independent of party count
Battle context0x7A34
Party-mesh decode temp0x19000Transient, freed in-loop
One block per distinct monsterblock[+0x08] eachStats + name + TMD + action entries + animation streams; the texture pool decodes into GPU staging, not the heap
Post-monster tail~19.5 KB0x1800 + 0x3100 + small nodes

About 164 KB remains at the first monster allocation and the tail needs ~19.5 KB, so the workable budget for distinct monster blocks is ~145 KB. Retail's largest authored formation costs 124.3 KB; the three Delilas blocks cost 81-84 KB each, so any two together overshoot - which is why the Delilas Challenge fields them one per round. Instruments: autorun_delilas_battle_load.lua (allocator breakpoints + free-ring walk), battle-heap-walk.py (offline from a save-state).

Camera and seats

The orbit camera (game mode 0x15) projects a world vertex as screen = H · (R · v + TR) / Z, with the constants below. The base matrix holds 16384 · I - a 4x uniform world scale composed under each actor, which is what makes the small battle meshes project at retail size against the same deep translation the backdrop uses at 1x.

ConstantValueNote
pitch3212-bit angle, 4096 = 360°
yaw0x8007B792Orbit azimuth; idles at −4 units per frame
TR(0, 1280, 7680)Fixed translation; target = world origin
H256GTE projection register
base matrix0x8007BF10 = 16384 · I4x world scale (GTE 4096 = 1.0)
ground grid28 × 28 cells, pitch 0x200GTE-rasterised flat plane, not a mesh
dometwo actors from one half-shell meshHalf turn about Y by default; X mirror for stages listed at 0x80078B50

Seats are two static tables of [i16 x, y, z, pad] entries selected by combatant count (party table 0x800775C8, stride 0x18; monster table 0x80077608, stride 0x20, alternate family four rows up). The values are on the main page; engine mirror engine-core::battle_seats.

Character record (battle fields)

The party side of the roster is the persistent per-character record - the same 0x414-byte block the save file stores. Battle reads stats, equipment and learned spells from it and writes HP / MP back when the fight ends. The full layout is on save record; the battle-relevant fields:

OffsetField
+0xC9 / +0xCACurrent AP / bonus AP
+0xF4..+0x100Active-abilities block, OR'd each frame into the global ability bitmask at 0x80074358
+0x104..+0x110HP / MP / AP pairs (live)
+0x11AStat cap field (clamped to 999)
+0x11C..+0x122Six stat bytes, incremented on level-up
+0x13C / +0x13D..+0x160Spell count / spell ids (up to 36)
+0x161..+0x184Parallel spell-level array
+0x185..+0x195Displayed-skills list (count-prefixed; item use does an ordered insert, sorted ascending by id)
+0x196..+0x19DEight equipment slot bytes
+0x2A7..+0x2B0NUL-padded display name (9 bytes)
+0x2B0..+0x37FActive spell-slot array, stride 0x14
+0x6F6Persistent status word, seeded into actor +0x16E at battle start and mirrored back per frame

A per-frame aggregator caps each member's stats at 999 and ORs the abilities block into the global bitmask that every other system reads through one bit-test helper - accessories work by flipping bits, not by editing stats.

Monster record

Each species is one LZS slot of 0x14000 bytes in the monster archive at (id − 1) × 0x14000. The decoded block opens on a stat head; the loader copies stats into the actor and keeps the block pointer in a side table at 0x801C9348 so the spoils routine reads rewards without copying them.

stat head+0x00 nameNUL string Legaia TMDhead +0x04 action entrieshead +0x4C list texture poolhead +0x08 pool: 15 x 16 BGR555 CLUTs (0x1E0 bytes), then a 4bpp page 128 or 256 texels wide x 256 rows
One decoded archive slot. The mesh and pool offsets are block-relative and fixed up at load.
OffsetFieldDestination
+0x00Name offset (a ^+letter prefix is the element badge: A Fire, B Thunder, C Wind, D Water, E Earth, F Light, G Dark, H Evil)actor +0x1BC
+0x04Battle-model TMD offsetactor +0x230
+0x08Texture / CLUT pool offset (also the block's heap cost)VRAM via the loader
+0x0C / +0x0E / +0x10HP / AGL / MPactor +0x14C / +0x154 / +0x150
+0x12 / +0x14 / +0x16ATK / DEF-high / DEF-lowactor +0x158 / +0x15C / +0x160
+0x18 / +0x1AINT / SPDactor +0x168 / +0x164
+0x1DElement byteAffinity matrix row
+0x21..+0x23Three global spell ids the AI may cast (live when > 1)AI picker
+0x3E / +0x3FSeru id / catch-rate percentCapture roll
+0x44 / +0x46Gold / EXPVictory spoils
+0x48 / +0x49Drop item id / drop chance percentVictory drop roll - every enemy rolls, but at most one item drops per battle
+0x4A / +0x4CAction-entry count / entry offset arrayactor +0x4A / +0x4C
  • Action entries. Each entry's first byte is a tag: 2/3/4/5/0xB are the hit-reaction clips (their slot indices land in actor +0x1EF..+0x1F3), 0x0C..0x1F are castable attacks, 0x23 is special; the AGL cost sits at entry +0x74. The three global ids at +0x21 are what appear on screen; the local entry ids only gate the pose.
  • Texture pool. Uploaded to CLUT row 484 + seat and page column (320 + seat × 64, 256); the on-disc CBA / TSB words are nominal and relocated per seat. Width is 128 texels unless the wide flag is set: width = (pool_len − 0x1E0) / 256 × 2.
  • Steal data is not here - it is a static SCUS table (steal table). The monster viewer and the engine's MonsterMesh::battle_render_mesh reproduce the same relocation.

Status bits

Every ailment is a bit in the actor's status halfword. Two overlay legs set them - the hit leg (kind byte at art descriptor +0x7A) and the cast leg (spell descriptor byte +0x0A) - sharing one kind-to-bit map. Per-status timers live in the actor around +0x130; their layout is Inferred.

StatusKind byteRetail effectEngine note
Toxic1Drains min(max_hp/16, 256) per round, never kills; rolls ×7/10; suppresses Venom's tick4 turns (from-scratch duration)
Numb2Cannot act; clears when hit3 turns
Venom3Drains min(max_hp/32, 128) per round, never kills; rolls ×9/106 turns
Sleep4Cannot act; wakes when hit3 turns
Confuse5Random target on the opposite side; a confused party member is delegated to the AI resolverParty-side auto-strike is an engine stand-in
Curse6Blocks Magic4 turns
Stone7Cannot act or be damaged, counts as defeated; a successful escape restores it (the run band floors 0-HP members at 1)Whole battle
Faint8KO at 0 HP; revive onlyUntil cured

Bit 0x400 has no setter on the disc - every write that touches it is a clear (accessory cure, the per-round waker, item cures, the on-hit strip, battle exit). It is a latent status with a full consumer lifecycle and no infliction path. Rot (kind 5 in the art-record space) sets one random bit of 0x38, greying out the rolled limb's attack command. Full writer inventory: battle.md.

Capture-class cast modules

A spell whose class byte is 'c' is too elaborate for the battle overlay to choreograph itself. The action machine pages a small dedicated module - one PROT entry from 0935..0966, chosen by the record's +1 sub-id - into the side overlay window and re-enters it every frame until it reports done.

AspectMechanism
PagingAction phase 0x28 routes the action to 0x6E..0x71; the module loads at slot-B base 0x801F69D8. Phase 0x70 ticks it and advances only on a zero return - no timer, so an exit gate that cannot pass is a softlock
Phase machineThe tick switches on context +0x279 (0..~0x11, 0xFF = done) - a second phase space under action phase 0x70
Tick ABICaster = table[ctx+0x13], victim = table[caster+0x1DD]; clips staged through actor +0x1DA / +0x1DC
DamageOne call per hit into a roll wrapper with a power constant baked into the module image, so a capture cast never reads the move-power table for its magnitude. Three apply shapes over the band's 79 wrapper call sites: an unsigned clamp to HP (floor 0, can kill) at 70 of them, a signed clamp to HP−1 (floor 1, never kills, a negative roll heals) at the two whole-row sweep stagers, and an unsigned clamp to HP−1 (never kills and never heals) at seven tick-body sites. PROT 0910's applier picks between the last two per hit, from its own slash counter. In the three decoded exemplars the victim load is hardcoded to seat 0 at every apply site; the sweeps index by their own loop counter instead
ExemplarsPROT 958 Blazing Slash (Gi), 959 Megaton Press (Che), 960 Plasma Strike (Lu)

Module anatomy and the patcher mirror are on cast-module.md.

The party-wipe path

The battle always exits the same way - into MAIN INIT - and the wipe fork lives one mode later. Three latches decide where MAIN INIT sends the game:

1DetectThe end-of-action gate counts living, non-petrified party members; none sets the end signal 0xFE and wipe cause 5, and clears the survived latchFUN_801E295C 0x5A 2ExitThe exit selector writes game_mode = 2 (MAIN INIT) wipe or no wipe, with the back-from-battle marker set; it never reads the wipe causeFUN_80046A20 3ForkWith the survived latch clear and story-flag 0 clear, MAIN INIT writes game_mode = 0x16 (CARD) with entry context 1: the title screen with the cursor on ContinueFUN_8003AEB0
LatchSet byCleared byRole
0x8007BD60 bit 0x80Battle load; victory reward path; successful escapeThe end-of-action wipe scansParty-survived - a wipe is the only battle end that leaves it clear
Story flag 0 (0x80085758 bit 0x80)The scene script, before a scripted-loss battle (the Rim Elm ambush)MAIN INIT on every back-from-battle pass, unconditionallyScripted-loss latch: the wipe returns to the field like a win
Story flag 1 (bit 0x40)MAIN INIT on the survived pathMAIN INIT on the wipe pathScript-readable battle outcome, tested by post-battle scene scripts

Scripts reach the same hand-off directly through a helper twin of the gate body, called by the field-VM op 4C EA. The mode-18/19 game-over artwork overlay (PROT 0902, 29 TIMs) has no static writer and exits to the debug menu: a dev harness around dev art. Nothing on the reachable path draws "GAME OVER".

Details: the port's hand-off and the unseeded-party trap

The port carries the scan faithfully, and it can represent a state retail cannot: a party that was never seated. BattleActor::liveness defaults to 0, and 0 means dead, so a world built without the roster projection would wipe on its first gate. BattleActionHost::slot_seated gates the wipe arm on party_seated > 0; monster wipe still resolves so a port-only state can terminate (unseeded_battle_wipe_guard.rs). A real wipe raises World::game_over, which both hosts route to the title screen through GameOverSession - drawing nothing and reading no button, as retail does.

How we know

FunctionAddressWhat it provesDump / probe
Stage setup + seatsFUN_800513F0Seat tables, seat-to-actor copy, size byte, dome as two actorsghidra/scripts/funcs/800513f0.txt
Monster initFUN_80054CB0Record-to-actor field map, reaction-clip slots80054cb0.txt
Monster streamerFUN_800542C8Archive slot arithmetic, unchecked copy through the heap pointer800542c8.txt; autorun_monster_record_source.lua
Texture pool uploadFUN_80055468Pool layout, CLUT row and page column per seat80055468.txt
Battle setupFUN_80055B6CFormation cells, species rebuild, stage-id override, forced-battle-id reader80055b6c.txt; autorun_formation_cell_writers.lua
HeapFUN_8002B3D4 / FUN_80017888Best-fit heap, NULL on exhaustion, the measured ledger8002b3d4.txt; autorun_delilas_battle_load.lua
Orbit cameraFUN_80026988 / FUN_80026F50Projection constants and base matrix80026988.txt; autorun_battle_render_capture.lua
Range checkFUN_8004E2F0Distance + size-byte hit radius8004e2f0.txt
Stat aggregatorFUN_80042558999 cap, ability bitmask OR80042558.txt
Status ticker + appliersFUN_801E752C / FUN_801EC3E4 / FUN_801E09F8Poison tick formulas; the kind-to-bit map on both legsoverlay_0898_801e752c.txt, overlay_0898_801ec3e4.txt, overlay_0898_801e09f8.txt
Cast-module tickPROT 958..960 at 0x801F69D8Phase byte, tick ABI, seat-0 victim8004ad80.txt (materialiser); autorun_delilas_enemy_cast_watch.lua; cast-module.md
Exit selector + wipe gateFUN_80046A20 / FUN_8003AEB0Always MAIN INIT; the CARD fork and its three latches80046a20.txt, 8003aeb0.txt; autorun_gameover_mode_writer.lua
Forced-battle id0x8007B7FCNo store or materialised address in SCUS or any static overlaycapstone store sweep; autorun boss-entry captures

The save-state diffs behind the roster window and residency claims are on capture notes.

See also