Battle internals
The field-level companion to the battle subsystem: the roster pointer table and actor struct, the battle context, the character and monster records the loader reads, the exact camera, the heap ledger that bounds a formation, the status bits and the party-wipe hand-off. Read the main page first; this one assumes its vocabulary and is mostly tables.
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 pointer0x8007BD24 - Formation
- Four monster-id cells at
0x8007BD0C, a 3-byte party roster at0x8007BD10, per-battle flags byte0x8007BD60 - Character record
0x414bytes per member at0x80084708 + 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/+0x40mid-battle role, status timers): Inferred
Roster and actor struct
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:
| Offset | Type | Field |
|---|---|---|
| +0x07 | u8 | Per-actor state byte; drives the action state machine |
| +0x1F | u8 | Hit-radius / size byte read by the range check (party sizes from a static table, monster size from the record) |
| +0x34 / +0x38 | i16 | Live world X / Z (Y in the adjacent halfwords, 0 on the flat stage) |
| +0x3C / +0x40 | i16 | Authored 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 / +0x4C | u8 / ptr | Action-entry count and entry pointer array (each entry: id byte first, AGL cost at +0x74) |
| +0x58 | u8 | Monster size << 5 |
| +0x14C / +0x150 / +0x154 | u16 pairs | HP, MP, AGL (cur, max); mirrored at +0x172..+0x174 |
| +0x158 / +0x15C / +0x160 | u16 | ATK, DEF-high, DEF-low |
| +0x164 / +0x168 / +0x16C | u16 | SPD, INT, initiative key for the current round |
| +0x16E | u16 | Status halfword (below) |
| +0x1BC | ptr | Name string |
| +0x1DA / +0x1DC | u8 | Staged animation channel and its restage counter - every clip goes through this one channel |
| +0x1DD / +0x1DE | u8 | Target slot; action category (0 Arts, 1 Item, 2 Magic, 3 Attack, 4 Spirit, 5 Run) |
| +0x1DF..+0x1F2 | u8[] | Action queue: chosen action id first (also the move-power index), then per-action parameters |
| +0x1EF..+0x1F3 | u8[5] | Hit-reaction clip slots (flinch, knockdown, get-up, block), filled from the record's tagged entries |
| +0x1FA | u8 | Caster's spell iterator, cleared by the renderer at magic sustain |
| +0x230 | ptr | Battle-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.
| Offset | Type | Field |
|---|---|---|
| +0x00 / +0x01 | u8 | Party count / monster count (the seat-table row selectors) |
| +0x06 | u8 | Command-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 |
| +0x07 | u8 | Action-state byte the action machine switches on (0xFF idle); bands on battle action |
| +0x09 | u8 | Round counter |
| +0x0D | u8 | Per-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 |
| +0x13 | u8 | Acting slot index into the roster table |
| +0x14..+0x1B | u8[8] | Per-action parameters (target slot, sub-action, direction / element, second target) |
| +0x1D | u8 | Action context: 3 for summon and capture, else 0 |
| +0x26 | u8 | Level-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..+0x2D | bytes | Active spell / move icon glyph escape |
| +0x269 | u8 | Captured Seru id, set by the capture roll |
| +0x279 | u8 | Cast-module phase byte (below) |
| +0x287 | u8 | Scripted-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 |
| +0x288 | u8 | The counter-attack byte proper - the second term of the attack-return gate |
| +0x28A | u8 | Battle mode - the boss multi-phase gate the AI reads |
| +0xA9..+0xEC | text | Battle message buffer |
| +0x6D6 / +0x6D8 / +0x6DA | u16 | Intro 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:
| Address | Size | Field |
|---|---|---|
| 0x8007BD0C | u8[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 |
| 0x8007BD10 | u8[3] | Party roster (character ids per party slot) - the win-pose staging indexes it by acting slot |
| 0x8007BD60 | u8 | Per-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) |
| 0x8007B64A | u8 | Stage 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 |
| 0x8007B7FC | u8 | Forced-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.
| Allocation | Bytes | Note |
|---|---|---|
| Scene asset buffer | 0x62C00 | The resident field scene |
| GPU packet double buffer | 0x64000 | 0x32000 × 2 |
| Enemy stager working buffer | 0x2E390 | Fixed size, independent of party count |
| Battle context | 0x7A34 | |
| Party-mesh decode temp | 0x19000 | Transient, freed in-loop |
| One block per distinct monster | block[+0x08] each | Stats + name + TMD + action entries + animation streams; the texture pool decodes into GPU staging, not the heap |
| Post-monster tail | ~19.5 KB | 0x1800 + 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.
| Constant | Value | Note |
|---|---|---|
| pitch | 32 | 12-bit angle, 4096 = 360° |
| yaw | 0x8007B792 | Orbit azimuth; idles at −4 units per frame |
| TR | (0, 1280, 7680) | Fixed translation; target = world origin |
| H | 256 | GTE projection register |
| base matrix | 0x8007BF10 = 16384 · I | 4x world scale (GTE 4096 = 1.0) |
| ground grid | 28 × 28 cells, pitch 0x200 | GTE-rasterised flat plane, not a mesh |
| dome | two actors from one half-shell mesh | Half 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:
| Offset | Field |
|---|---|
| +0xC9 / +0xCA | Current AP / bonus AP |
| +0xF4..+0x100 | Active-abilities block, OR'd each frame into the global ability bitmask at 0x80074358 |
| +0x104..+0x110 | HP / MP / AP pairs (live) |
| +0x11A | Stat cap field (clamped to 999) |
| +0x11C..+0x122 | Six stat bytes, incremented on level-up |
| +0x13C / +0x13D..+0x160 | Spell count / spell ids (up to 36) |
| +0x161..+0x184 | Parallel spell-level array |
| +0x185..+0x195 | Displayed-skills list (count-prefixed; item use does an ordered insert, sorted ascending by id) |
| +0x196..+0x19D | Eight equipment slot bytes |
| +0x2A7..+0x2B0 | NUL-padded display name (9 bytes) |
| +0x2B0..+0x37F | Active spell-slot array, stride 0x14 |
| +0x6F6 | Persistent 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.
| Offset | Field | Destination |
|---|---|---|
| +0x00 | Name 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 |
| +0x04 | Battle-model TMD offset | actor +0x230 |
| +0x08 | Texture / CLUT pool offset (also the block's heap cost) | VRAM via the loader |
| +0x0C / +0x0E / +0x10 | HP / AGL / MP | actor +0x14C / +0x154 / +0x150 |
| +0x12 / +0x14 / +0x16 | ATK / DEF-high / DEF-low | actor +0x158 / +0x15C / +0x160 |
| +0x18 / +0x1A | INT / SPD | actor +0x168 / +0x164 |
| +0x1D | Element byte | Affinity matrix row |
| +0x21..+0x23 | Three global spell ids the AI may cast (live when > 1) | AI picker |
| +0x3E / +0x3F | Seru id / catch-rate percent | Capture roll |
| +0x44 / +0x46 | Gold / EXP | Victory spoils |
| +0x48 / +0x49 | Drop item id / drop chance percent | Victory drop roll - every enemy rolls, but at most one item drops per battle |
| +0x4A / +0x4C | Action-entry count / entry offset array | actor +0x4A / +0x4C |
- Action entries. Each entry's first byte is a tag:
2/3/4/5/0xBare the hit-reaction clips (their slot indices land in actor+0x1EF..+0x1F3),0x0C..0x1Fare castable attacks,0x23is special; the AGL cost sits at entry+0x74. The three global ids at+0x21are what appear on screen; the local entry ids only gate the pose. - Texture pool. Uploaded to CLUT row
484 + seatand 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_meshreproduce 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.
| Status | Kind byte | Retail effect | Engine note |
|---|---|---|---|
| Toxic | 1 | Drains min(max_hp/16, 256) per round, never kills; rolls ×7/10; suppresses Venom's tick | 4 turns (from-scratch duration) |
| Numb | 2 | Cannot act; clears when hit | 3 turns |
| Venom | 3 | Drains min(max_hp/32, 128) per round, never kills; rolls ×9/10 | 6 turns |
| Sleep | 4 | Cannot act; wakes when hit | 3 turns |
| Confuse | 5 | Random target on the opposite side; a confused party member is delegated to the AI resolver | Party-side auto-strike is an engine stand-in |
| Curse | 6 | Blocks Magic | 4 turns |
| Stone | 7 | Cannot act or be damaged, counts as defeated; a successful escape restores it (the run band floors 0-HP members at 1) | Whole battle |
| Faint | 8 | KO at 0 HP; revive only | Until 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.
| Aspect | Mechanism |
|---|---|
| Paging | Action 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 machine | The tick switches on context +0x279 (0..~0x11, 0xFF = done) - a second phase space under action phase 0x70 |
| Tick ABI | Caster = table[ctx+0x13], victim = table[caster+0x1DD]; clips staged through actor +0x1DA / +0x1DC |
| Damage | One 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 |
| Exemplars | PROT 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:
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
| Latch | Set by | Cleared by | Role |
|---|---|---|---|
0x8007BD60 bit 0x80 | Battle load; victory reward path; successful escape | The end-of-action wipe scans | Party-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, unconditionally | Scripted-loss latch: the wipe returns to the field like a win |
Story flag 1 (bit 0x40) | MAIN INIT on the survived path | MAIN INIT on the wipe path | Script-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
| Function | Address | What it proves | Dump / probe |
|---|---|---|---|
| Stage setup + seats | FUN_800513F0 | Seat tables, seat-to-actor copy, size byte, dome as two actors | ghidra/scripts/funcs/800513f0.txt |
| Monster init | FUN_80054CB0 | Record-to-actor field map, reaction-clip slots | 80054cb0.txt |
| Monster streamer | FUN_800542C8 | Archive slot arithmetic, unchecked copy through the heap pointer | 800542c8.txt; autorun_monster_record_source.lua |
| Texture pool upload | FUN_80055468 | Pool layout, CLUT row and page column per seat | 80055468.txt |
| Battle setup | FUN_80055B6C | Formation cells, species rebuild, stage-id override, forced-battle-id reader | 80055b6c.txt; autorun_formation_cell_writers.lua |
| Heap | FUN_8002B3D4 / FUN_80017888 | Best-fit heap, NULL on exhaustion, the measured ledger | 8002b3d4.txt; autorun_delilas_battle_load.lua |
| Orbit camera | FUN_80026988 / FUN_80026F50 | Projection constants and base matrix | 80026988.txt; autorun_battle_render_capture.lua |
| Range check | FUN_8004E2F0 | Distance + size-byte hit radius | 8004e2f0.txt |
| Stat aggregator | FUN_80042558 | 999 cap, ability bitmask OR | 80042558.txt |
| Status ticker + appliers | FUN_801E752C / FUN_801EC3E4 / FUN_801E09F8 | Poison tick formulas; the kind-to-bit map on both legs | overlay_0898_801e752c.txt, overlay_0898_801ec3e4.txt, overlay_0898_801e09f8.txt |
| Cast-module tick | PROT 958..960 at 0x801F69D8 | Phase byte, tick ABI, seat-0 victim | 8004ad80.txt (materialiser); autorun_delilas_enemy_cast_watch.lua; cast-module.md |
| Exit selector + wipe gate | FUN_80046A20 / FUN_8003AEB0 | Always MAIN INIT; the CARD fork and its three latches | 80046a20.txt, 8003aeb0.txt; autorun_gameover_mode_writer.lua |
| Forced-battle id | 0x8007B7FC | No store or materialised address in SCUS or any static overlay | capstone store sweep; autorun boss-entry captures |
The save-state diffs behind the roster window and residency claims are on capture notes.