Player battle files (data\battle\PLAYER1..4) Confirmed
One file per playable character - Vahn, Noa, Gala, Terra - holding everything that character needs in battle: mesh pieces, textures, and the battle animations. The elegant part is how the mesh pieces are keyed: the file is divided into sections, one per equipment slot (body, head, weapon, Ra-Seru, footwear), and each section's sub-entries are labelled with item ids. At battle load the engine matches your equipped items against those ids and splices the winning pieces into one mesh - which is why the battle model is assembled per character rather than stored whole.
These are the retail battle_data CDNAME block (defines 865..868, extraction entries 0863..0866; the extraction filename labels 0863/0864_edstati3 are the +2 label shift). Parsers: crates/asset/src/battle_char_palette.rs + crates/asset/src/battle_data_pack.rs.
Identity note
Several distinct battle-data containers live near each other on the disc and have been conflated in the past. In particular this format is not a “16 MB container at PROT 0865” - that figure is extraction 0865's TOC-indexed window (7811 sectors), which over-reads across 0866 into the monster archive's sectors; every structure documented here sits inside each player file's own footprint (0865 = Gala, 222 sectors). The monster archive is a different container at extraction 0867.
This format is distinct from:
- the monster stat archive (extraction 0867, retail
monster_data), - the standalone TIM-pack used by some other PROT entries,
- the DATA_FIELD streaming format used by scene bundles,
- the field-pack and effect-bundle containers.
File layout
Each file is a self-contained container: a header + LZS-compressed record[0] (LZS being the game's own dictionary compression; this record carries the battle-palette chain and the character's battle action-animation streams), a 12-byte descriptor table, and a region of per-slot LZS streams that decompress to [32-byte header + Legaia TMD + texture pool].
+0x00 u32 desc_off ; descriptor-table offset. Also reads as a type-0
; streaming chunk header ((0x00<<24)|size), which is
; how streaming-format walkers skip the head cleanly.
+0x04 u32 clut_a_off ; CLUT A offset within record[0]'s DECODED output
+0x08 u32 clut_b_off ; CLUT B offset within record[0]'s DECODED output
+0x0C u32 budget ; record[0] decoded size (LZS output-byte budget)
+0x10 record[0] LZS stream
+desc_off ; descriptor table (12-byte entries, see below)
+0x8000 (data_base) ; slot region: per-slot [u32 dec_size][LZS stream]
Measured per file:
| File | desc_off | clut_a | clut_b | budget | entries | footprint |
|---|---|---|---|---|---|---|
| 0863 Vahn | 0x55F4 | 0x5E00 | 0x7E04 | 0x9E48 | 54 | 0xA9000 |
| 0864 Noa | 0x75C4 | 0x76A8 | 0x970C | 0xB750 | 50 | 0x97800 |
| 0865 Gala | 0x6C68 | 0x7464 | 0x9488 | 0xB4AC | 43 | 0x6F000 |
| 0866 Terra | 0x6CAC | 0x83E0 | 0xA5C4 | 0xC7A8 | 5 | 0x17800 |
data_base = 0x8000 in all four retail files (the gap between the table end and 0x8000 is zero-padded). It is not header-derived: FUN_80052770 opens each file with a fixed 16-sector prologue read - FUN_800559EC(…, 0x8000) → FUN_8003E800(dst, 0x10, 1) - so 0x8000 is a hardcoded loader constant. legaia_asset::battle_data_pack reaches the same base by probing sector boundaries until every slot's dec_size prefix reads sane.
Descriptor table
At desc_off, a chained array of 12-byte entries:
u32 id ; slot id; 0 marks a section boundary / default-variant slot
u32 offset ; byte offset of the slot from data_base
u32 size ; slot allocation in bytes (sector-aligned)
The chain invariant offset[i+1] == offset[i] + size[i] holds across every entry; an all-zero entry terminates the table. Entries group into sections of descending ids separated by id = 0 entries - e.g. Gala (0865):
57 56 55 54 53 | 00 | 42 41 40 3f | 00 | 21 20 27 26 25 24 23 22
2b 2a 29 28 33 32 31 30 2f 2e | 00 | 19 18 17 16 15 14 13 | 00 |
69 68 67 66 | 00
Terra (0866) carries only five id = 0 entries - no variant slots. The slot ids are equippable item ids (the item-name table id space): the five sections are the character's five equipment slots, and FUN_80052770 case 4 walks the table matching each entry's id against the character record's equipped-item bytes (+0x196..+0x19A), taking the id = 0 default when nothing matches. Vahn's file carries body (0x43 Hunter Clothes …), head, weapon (0x22 Survival Knife …), Ra-Seru weapon (0x01..0x09 Meta tiers) and footwear sections. Live proof: the assembled battle mesh's vertex pools byte-match exactly the equipped sections - see character mesh packs.
Slot region
At data_base + entry.offset:
u32 decompressed_size ; LZS output-byte budget
LZS stream ; standard Legaia LZS (see lzs.html)
The decoder stops on the output count, not the input length - hand it a generous source slice rather than truncating to entry.size.
Decompressed slot layout
+0x00 u32 frame_off ; self-relative offset of the loader frame the
; assembler reads: 0x14 + 4*attach_obj_count
+0x04 u32 swing_rec_a ; self-relative offset of the section's SWING
; ACTION RECORD (sections 2..4; 0 in sections 0/1)
+0x08 u32 swing_rec_b ; second swing record - consumed only for
; section 4 (0 everywhere else)
+0x0C u32 tmd_body_end ; section footprint: where the embedded Legaia
; TMD ends = where the texture-pool block starts
+0x10 s16 attach_obj_count ; attach-object records (0 / 1 / 2 observed)
+0x12 u16 upload_flag ; non-zero = the post-TMD pool is uploaded to
; VRAM at battle init; zero = pool bytes are dead
+0x14 u32 attach_obj_off[] ; attach_obj_count self-relative offsets
+frame_off loader frame ; consumed by FUN_800536BC: attach_count +
; bone_ids[] + data_size + embedded Legaia TMD
; (magic 0x80000002, see tmd.html)
+tmd_body_end ; texture / CLUT pool
FUN_80052FA0's section loop rebases +0x04 (and, for section index 4 only, +0x08) and splices the swing records into the runtime action table, and walks +0x10/+0x14 as the attach-object list (decomp ghidra/scripts/funcs/80052fa0.txt). frame_off = 0x14 + 4 * attach_obj_count across the whole retail corpus (0x14/0x18/0x1C). The post-TMD pool has no PSX TIM image-block headers: it is one upload block [u16 clut_x][u16 clut_n][clut_n × u16 BGR555][4bpp pixels] - the same CLUT struct the palette chain STP-copies to VRAM rows 481..483 (decoded in character-mesh; ported as legaia_asset::battle_char_palette).
Battle animations (record[0])
The player files are also the party's in-battle pose source - not just their meshes. record[0] carries the battle animations: its head is a u32 action-offset table (22 slots; slot 0 = the idle loop; the first byte of each entry is its action tag - 1 walk, 2..5 hit-reaction family, 7/8/9 ready/recover/defeat, 0x0B block; slots 0xC..0xF are runtime-filled with per-equipment swing records and 0x10/0x11 are dynamic art-anim slots) whose entries hold a monster-format [u8 parts][u8 frames][9-byte TRS] keyframe stream at entry +0xAC (the monster archive's sibling entries keep theirs at +0x8C). parts = the character's skeleton bone count (15/16/15/17): channel i drives assembled object i, equipment extras ride their attach bone. Frame 0 of the idle is the combat-stance rest pose that sockets the assembled battle mesh; live-pinned byte-exact against a full-party capture's anim contexts (no PROT 1203 record is resident in battle RAM). Parsers: battle_char_assembly::{decode_record0, battle_animations, idle_battle_animation}.
Swing records (equipment sections → slots 0xC..0xF)
The swing records live in the equipment sections themselves (each section payload's +0x04 word; section 4 carries a second at +0x08 - see the slot layout), so the four direction-command swings are per-equipment animations. FUN_80052FA0 splices them into runtime action slots 0xC..0xF at battle load.
Art-animation bank (record[0] +0x58)
The art-anim bank (0xD0-stride records with the arts-combo matcher head and the inline art name) keeps its keyframe streams out-of-file. A materialized art clip installs through the bank record's embedded entry: FUN_8004AD80 installs record +0x24 as the action-table slot 0x10/0x11 pointer.
"ME" stream archives (readef.DAT)
The art keyframe streams resolve through compressed "ME" archives at the head of readef.DAT's side-band slots (see summon-readef; parser legaia_asset::me_archive; every retail art stream decodes length-exact). The main-vs-base archive pick is per battle phase: the initiative scheduler FUN_801DABA4 stages the acting character's group each turn and the applier stops after slot 3×char+1, leaving the main archive resident; the battle-end arms (FUN_801DABA4's no-living-enemy branch, FUN_801E295C's victory arm) directly request slot 3×char+2, so the win poses (ids 0x11..=0x18 = the eight rate_alt == 0xFF bank records 1..8, sources 0..=7) decode from the base archive.
Facial animation tracks (entry +0x8C / +0x98)
Two further action-entry header fields are per-clip facial keyframe tracks - eyes at entry +0x8C, mouth at +0x98 - consumed by the per-frame facial animator FUN_8004C7B4, which MoveImage-stamps the selected face frame over the head section's texture rows in step with the playing clip. An art clip's tracks sit at bank-record +0xB0/+0xBC (battle_char_assembly::ArtAnimRecord::face; nearly every Vahn/Noa/Gala bank record carries live records - the mid-battle art-strike faces - while Terra's are all empty). During the battle-end victory celebration (the DAT_8007BD71 == 0xFE signal with the FUN_8004E568 victory sequencer running) a member whose staged anim id sits in the dynamic-art-slot band 0x11..=0x18 - the staged win pose - takes its mouth records from a sixteen-record static SCUS table at 0x80077E80 instead (char*0x180 + staged_id*0x30, raw band byte), clocked - eyes too - by the global victory counter gp+0x9EA >> 1: the win-quote mouth flap.
Equipment-variant track (entry +0xA4)
The entry header's last 8 bytes are a third track: two 2-byte [start, end] frame windows per variant pair, consumed by FUN_8004CCD4 - called back to back with the facial animator under the same guards, but a mesh swap, not a stamp. Each equipment section's surplus objects are tagged 0xFF / 0xFE at splice: the 0xFE extras sort in as extra animated channels (driven only by their section's extra-channel swing streams), while the 0xFF variants sort past every drawn channel and only render when the pass swaps their pointer onto the attach-bone channel of the render node's per-channel model table (*(node+0x44)+4) - inside a +0xA4 window, or unconditionally during the extra-channel swings. The arts motion-trail renderer FUN_80049348 re-runs the pass per after-image ghost with the ghost's historical cursor. Live +0xA4 windows exist only in Noa's file (reactions, every named art, her swings); the other three carry all-zero windows, so the pass just re-asserts their defaults.
Load chain + index space
FUN_80052770 points each party character's asset-table entry at the dev path data\battle\PLAYER<n> and opens it through the dual-mode wrapper FUN_800558FC(path, …, char_id + 0x360). The retail ISO9660 branch is a trap stub on this build, so the load always resolves through FUN_8003E8A8 with the raw in-RAM TOC index char_id + 0x360 - extraction entry char_id + 0x360 − 2 (see PROT TOC § In-RAM TOC):
| Player | Raw TOC index | PROT.DAT offset | Footprint | Extraction entry |
|---|---|---|---|---|
| Vahn | 0x361 | 0x36E8000 | 338 sectors | 0863 (edstati3 label) |
| Noa | 0x362 | 0x3791000 | 303 sectors | 0864 (edstati3 label) |
| Gala | 0x363 | 0x3828800 | 222 sectors | 0865 |
| Terra | 0x364 | 0x3897800 | 47 sectors | 0866 |
The offsets are the live-traced FUN_800558FC reads and equal the TOC start_lba × 0x800 of extraction 863..866 exactly. The historical “Vahn = PROT 0861” attribution matched the same bytes through the 1-sector stub entries 0859..0862 that precede the true file - entry 0861's extended window reaches Vahn's file at window offset 0x1000.
FUN_80052FA0 then decodes record[0] + its sub-records into the battle party palette (rows 481..483); the TMD slots install through the battle loaders. Full palette chain: character-mesh § battle render.
TOC geometry (the 16 MB misreading)
The TOC declares extraction 0865 with indexed_size = 7811 sectors (0xF41800 ≈ 16.0 MB) against a 222-sector footprint. That extended window covers Gala's own file (0x0..0x6F000), all of Terra's (0x6F000..0x86800), and 7542 of the monster archive's 7760 sectors (0x86800..). The extractor's 0865_battle_data.BIN is therefore a 16 MB file whose first 222 sectors are the actual player file - the earlier “16 MB battle_data container” reading analyzed that window without noticing the boundary. The format structures documented above all live inside the footprint, and the slot region tiles each file's footprint exactly (data_base + last_offset + last_size = footprint in all four retail files), confirming the footprint is the true file size.
Not the monster archive
The monster stat archive (legaia_asset::monster_archive, retail-space monster_data = define 869 → extraction 0867) shares the [u32 dec_size][LZS] → mesh + texture pool general shape but is a different container with no shared structures:
- Archive slots are fixed-stride
0x14000bytes keyed by 1-based monster id (slot = (id−1) × 0x14000), with no descriptor table; player-file slots are variable-size, reached through the 12-byte descriptor table. - The archive's decoded head is the monster stat record (
+0x00 name_offset,+0x0CHP,+0x4Caction-offset array - see monster-animation); the player-file slot head is the 32-byte texture-layout header above, with the TMD at+0x20. - Within extraction 0865's extended window the archive begins at byte
0x86800; the player-file descriptor table (0x6C68) and slot region (0x8000..0x6F000) sit entirely before it.
The old conflation (“battle_data 0865 vs monster archive 0867”) came from the overlapping extraction windows; the CDNAME −2 correction resolves it - the dev names say exactly what each entry is.
Parser status
Two parsers read these files:
legaia_asset::battle_char_paletteimplements the runtime-pinned framing above (header words, descriptor chain,record[0]+ sub-record palette assembly; byte-exact vs live battle VRAM).legaia_asset::battle_data_pack(the TMD-slot walker) reads the descriptor table in the[id, offset, size]frame: detection validates the chain invariant (entry 0 at offset 0,offset[i+1] == offset[i] + size[i], sector-aligned sizes, all-zero terminator) plus the header-word ordering (clut_a < clut_b < budget), accepting all four retail player files - including Terra's all-default 0866 - and rejecting every other PROT entry. An earlier revision read the table through a 4-byte-shifted frame; its observations (“the table is sized to a maximum and zero-padded”, “0866 has a zero count in the canonical position”, “the last 0865 slot over-runs the footprint”) were artifacts of that frame - under the correct frame 0866 parses like its siblings and all four files tile their footprints exactly.
VRAM byte-match corpus
The principled tool for pinning the texture-pool descriptor is byte-matching: slide a 32-byte halfword-aligned window over each decoded slot's post-TMD bytes and search a mednafen-captured VRAM blob for exact matches; each hit yields (slot, slot_offset, fb_x, fb_y) (framebuffer coordinates in the console's 1024×512 video memory). Driver: mednafen-state clut-trace; analysis API battle_data_pack::find_clut_in_vram.
Findings from a four-save corpus over Gala's file (0865; saves: Rim Elm town, Izumi town, pre-battle, active battle):
| Slot (table entry) | Header signature | VRAM placement (fb_x, fb_y range) |
|---|---|---|
| id 0x66 | ..., 0x010000, 0x0b0a0906, 0x000e0d0c, ... | (864, 426..433) - town only |
| id 0x00 (last section default) | ..., 0x010000, 0x0b0a0906, 0x000e0d0c, ... | (864, 388..507) - town only |
| id 0x54 | ..., 0x010000, 0x010002, 0x000000, ... | (768, 441) - battle only |
| id 0x53 | ..., 0x010000, 0x010002, 0x000000, ... | (768, 393..441) - battle |
| id 0x00 (first section default) | ..., 0x010000, 0x010002, 0x000000, ... | (768, 385..496) - battle |
| ids 0x42..0x3f | ..., 0x010000, 0x000201, 0x000000, ... | (768, 272..310) - battle |
| id 0x00 (second section default) | ..., 0x010000, 0x000201, 0x000000, ... | (768, 272..331) - battle |
Consecutive slot offsets step by 0x40 per +1 in fb_y: the post-TMD pool uploads as a 32-halfword-wide (128 px @ 4bpp) contiguous block. The placement resolver is pinned: the rect comes from the static SCUS_942.54 table at 0x800775B8 (4 × u16 per equip section), offset into the member's band by the party ordinal (x0 + 0x200 + p*0x80, y0 + 0x100; upload FUN_80053B9C). The "texture format tags" this corpus read at u32[5..6] (e.g. 0x0b0a0906) are actually the loader frame's attach-count + bone-id bytes. Typed port: battle_char_assembly::SECTION_TEXTURE_RECTS.
What's not in these files: the NPC palettes at row 479
The town NPC CLUTs at row 479 byte-match no decoded slot of any player file (nor any raw PROT entry or SCUS_942.54 as an 8-byte prefix). They are plain PSX TIMs in each scene's own scene_tmd_stream entries, uploaded by FUN_8001FE70 at battle init - see npc-palette.html. The engine consequence (field scene-loads exclude these packs from VRAM entirely) is wired through SceneResources::SceneLoadKind.
CLI
# Inspect one player file's TMD-slot table.
asset battle-data-pack extracted/PROT/0865_battle_data.BIN
# Dump every decoded slot to a directory.
asset battle-data-pack extracted/PROT/0865_battle_data.BIN --out /tmp/0865_records
# Bulk-scan a directory of PROT entries for this shape.
asset battle-data-pack-scan extracted/PROT --cdname extracted/CDNAME.TXT
# Byte-match decoded slots against PSX VRAM in mednafen save states.
mednafen-state clut-trace \
--pack extracted/PROT/0865_battle_data.BIN \
--json /tmp/clut_corpus.json \
~/.mednafen/mcs/Legend\ of\ Legaia*.<TOWN_SLOT> \
~/.mednafen/mcs/Legend\ of\ Legaia*.<BATTLE_SLOT>
(The CLI names keep the historical “battle-data-pack” spelling; they operate on the player files.)
Vestigial field
record[0] +0x5C has no consumer. The word is rebased self-relative→absolute at load by FUN_80052FA0 (:561) alongside the +0x58 art-bank pointer (:558), but unlike +0x58 (read by FUN_8004AD80) it has no traced reader - an exhaustive corpus sweep finds none, and the word (slot 0x17) sits outside every action-table consumer's range. Its target is clut_a_off − 4 (zero on disc); the CLUT upload uses file+0x04/+0x08, not this field. A rebased-at-load paired-relocation field the runtime never reads. (The art "ME" archives it was once suspected of pointing at live in readef.DAT.)