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:

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:

Filedesc_offclut_aclut_bbudgetentriesfootprint
0863 Vahn0x55F40x5E000x7E040x9E48540xA9000
0864 Noa0x75C40x76A80x970C0xB750500x97800
0865 Gala0x6C680x74640x94880xB4AC430x6F000
0866 Terra0x6CAC0x83E00xA5C40xC7A850x17800

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):

PlayerRaw TOC indexPROT.DAT offsetFootprintExtraction entry
Vahn0x3610x36E8000338 sectors0863 (edstati3 label)
Noa0x3620x3791000303 sectors0864 (edstati3 label)
Gala0x3630x3828800222 sectors0865
Terra0x3640x389780047 sectors0866

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 0x14000 bytes 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, +0x0C HP, +0x4C action-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_palette implements 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 signatureVRAM 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.)

See also