At a glance

Where
PROT extraction entries 0863..0866 (Vahn / Noa / Gala / Terra); in-RAM TOC index char_id + 0x360. The edstati3 filename labels on 0863/0864 are the CDNAME +2 label shift.
Size
338 / 303 / 222 / 47 sectors. Header 16 bytes, descriptor entries 12 bytes, slot region from file offset 0x8000.
Magic
None - detected by shape (header words ordered clut_a < clut_b < budget, descriptor chain contiguous).
Parser
legaia_asset::battle_data_pack (walker), ::battle_char_palette (palette chain), ::battle_char_assembly (splice, poses, uploads), ::equip_transplant (editing).
Confidence
Confirmed - loader disassembly plus byte-exact captures of the assembled mesh, VRAM bands and palette.
Used by
Character meshes, characters page, patcher equipment editor, battle scene.

Not to be confused with the monster archive (entry 0867), the TIM-pack, the DATA_FIELD stream or the field-pack - all nearby, all different containers.

File layout

header 4 words record[0] (LZS) anims + art bank + 2 palettes descriptor table 12-byte [id, off, size] zero pad slot region [dec_size][LZS] per slot +0x00 +0x10 +desc_off +0x8000 footprint body head weapon Ra-Seru feet one section per slot: a default + one record per item id
The 16-sector prologue (header, record[0], descriptor table) is read in one go; slots are then streamed by seeking from one selected slot to the next.
OffsetSizeFieldMeaning
+0x004desc_offDescriptor-table offset. Also reads as a type-0 streaming chunk header, so stream walkers skip the head cleanly.
+0x044clut_a_offImage block A offset inside the decoded record[0].
+0x084clut_b_offImage block B offset, same space.
+0x0C4budgetDecoded size of record[0] (the LZS output budget).
+0x10record[0]LZS stream: action-offset table, action entries, art bank, two image blocks.
+desc_off12 × ndescriptor table[id, offset, size] entries, all-zero terminator, zero-padded to 0x8000.
+0x8000to endslot regionPer slot [u32 dec_size][LZS], sector-aligned, tiling the file exactly.

0x8000 is a loader constant (the fixed 16-sector prologue read), not a header field. Measured header values:

Filedesc_offclut_aclut_bbudgetentriesfootprint
0863 Vahn0x55F40x5E000x7E040x9E48540xA9000
0864 Noa0x75C40x76A80x970C0xB750500x97800
0865 Gala0x6C680x74640x94880xB4AC430x6F000
0866 Terra0x6CAC0x83E00xA5C40xC7A850x17800

Descriptor table: one section per equipment slot

OffsetSizeFieldMeaning
+0x004idItem id (item table space). 0 ends a section and names its default slot.
+0x044offsetSlot offset from 0x8000.
+0x084sizeSlot allocation, sector-aligned. offset[i+1] = offset[i] + size[i] always.

Entries run in sections of descending ids, each closed by an id = 0 default. The five sections are the character's five equipment slots, in the order of the equipped-item bytes of the character record (+0x196..+0x19A). Terra's file has only the five defaults.

SectionVahnNoaNote
0bodybodyArmour re-sculpts the torso object.
1headheadFace rows are stamped by the facial animator.
2weaponRa-SeruOrder differs per character; both fill the equipment table's Weapon slot.
3Ra-SeruweaponA Ra-Seru is the character's weapon arm.
4feetfeetCarries two swing records instead of one.

A section is bone geometry, not an attached object

A slot carries whole skeleton bone objects; selecting it replaces the bare object with a re-authored one that includes the gear. Vahn's right hand is 40 vertices bare, 71 with the Survival Knife, 109 with the Astral Sword; Hunter Clothes takes his torso from 79 vertices to 64. A full endgame set re-authors ten of his fifteen bones.

The item is still separable - by palette, not geometry. A weapon draws from its own CLUT column and no primitive across all 81 held-item records mixes columns; the flesh half always reaches the bone origin, the item never does. This is what powers the characters page's equipment diff and the item-alone .glb export.

Cut classes, item-alone policy and grip repair
classrecordsshape
own-object6Already its own 0xFE object - retail shipped the split.
separate21Own connected component, zero shared vertices. Lossless.
welded53Palette subset joined at the grip rim; 3-64 shared vertices; exported with an open grip.
fused1 (+51 armour)No material boundary: the whole section contribution. Body / head / feet always export this way.
  • A positional set-difference does not find added geometry (the armed hand shares as few as one vertex with the bare hand). The diff highlight uses a radius envelope around the bare object (battle_char_assembly::equip_diff).
  • Item-alone export (equip_isolate, rules in crates/asset/data/equip-isolation.toml): colour diff against the bare counterpart for head / held items, identity test for body / feet, plus per-record overrides.
  • Grip repair (equip_repair) lofts a tube between the two rims of a straight shaft on one object; nine of the 81 held items take a bridge, all Vahn's.
  • Most 0xFF (tag 200+) surplus objects are byte-copies of their attach bone, drawn only through the variant track. Sixteen are not (all of Noa's section-2 records, four of her section-3, three of Gala's section-3); AssembledCharacter::duplicate_objects tests by byte comparison.

Inside a slot

Each slot is [u32 dec_size][LZS stream]; the decoder stops on the output count, so give it a generous input slice. Decoded, every slot is a 32-byte header, a loader frame wrapping a Legaia TMD, and a texture pool.

on disc dec_size LZS stream pad to 0x800 0x8000 + offset + size decode (budget = dec_size) decoded header 32 B loader frame Legaia TMD texture pool +0x00 +frame_off +tmd_body_end pool = [clut_x][clut_n][clut_n x BGR555][4bpp pixels], uploaded only if upload_flag != 0
The header's self-relative offsets locate everything else; frame_off = 0x14 + 4 × attach_obj_count in every retail slot.
OffsetSizeFieldMeaning
+0x004frame_offSelf-relative offset of the loader frame.
+0x044swing_rec_aSelf-relative offset of the section's swing action record (sections 2..4; 0 in 0/1).
+0x084swing_rec_bSecond swing record, consumed only for section 4.
+0x0C4tmd_body_endWhere the TMD ends and the texture pool begins; the decode-buffer advance to the next section.
+0x102attach_obj_countSigned; 0 / 1 / 2 observed.
+0x122upload_flagNon-zero = the pool is uploaded to VRAM at battle init; zero = pool bytes are dead.
+0x144 × nattach_obj_off[]Attach-object records; each record's +0x07 byte is its attach key.
+frame_offloader frameu8 attach_count, u8 bone_ids[], u32 data_size at +0x08, TMD at +0x0C.
+tmd_body_endtexture poolOne headerless upload block - not a PSX TIM.

The assembler reads one bone-id byte per object while obj_index < attach_count, then tags surplus objects 0xFF (per-clip swap variant) or 0xFE (extra animated part). Assembled objects are also effect-read: the Spirit charge samples authored vertex indices of specific objects, so a rebuild must keep a real vertex pool in every slot even when it draws nothing.

History: earlier readings of the slot header

+0x04 / +0x08 were first read as nested-section end offsets and +0x10 as half of a flag word; the byte-match corpus read the loader frame's attach bytes as texture-format tags. The assembler's disassembly supersedes all of these - see settled threads.

Where the textures land in VRAM

The assembler runs once per present party member, with p the member's 0-based ordinal (not the character id). It uploads up to seven blocks: the two record[0] image blocks at fixed rects, plus one block per flagged section at the rect from a static SCUS table. Pixels go to (x0 + 0x200 + p×0x80, y0 + 0x100), the CLUT to row 0x1E1 + p with the STP bit set on every non-zero colour. The seven rects tile the member's 128×256 band exactly - texpages 0x18 + 2p / 0x19 + 2p, the pages the mesh relocation retargets.

Blockx0y0wh
record[0] A0x200x800x200x80
record[0] B0x600x000x200x80
section 0 body0x000x800x200x80
section 1 head0x000x000x400x80
section 20x400x000x200x80
section 30x400x800x200x80
section 4 feet0x600x800x200x80

Port: battle_char_assembly::{SECTION_TEXTURE_RECTS, RECORD0_TEXTURE_RECTS, character_texture_uploads}; validated against captured battle VRAM at 99.7-100 % per member (the residual is the facial animator's current frame).

Battle animations (record[0])

The player files are also the party's in-battle pose source. record[0] opens with a u32 action-offset table (12 disc slots) of action entries in the same family as the monster archive's: the first byte is the action tag, the rate byte sits at +0x78, and the packed [u8 parts][u8 frames][9-byte TRS] keyframe stream at +0xAC (monsters keep theirs at +0x8C). Frame 0 of the idle is the combat-stance rest pose the assembled mesh is socketed into.

SlotActionSource
0idle (frame 0 = rest pose)disc
1walkdisc
2..5hit reactionsdisc
7 / 8 / 9ready / recover / defeatdisc
0x0Bblockdisc
0xC..0xFfour direction-command swingsspliced from equipment sections 2 / 3 / 4 at load
0x10 / 0x11dynamic art clipsmaterialised per staged art from the art bank + readef.DAT
  • Persistence. Only the region below clut_a_off survives the battle: the five sections decode over the rest of the same buffer. An edit that adds per-frame data must grow that region.
  • Swing records are per-equipment animations: each selected section's record at slot-header +0x04 (and +0x08 for section 4) is installed at action slot 0xC + section − 2. Its first byte is the per-strike power run head, not a runtime slot (art-data).
  • Art bank (record[0] +0x58): [u32 count] then 0xD0-stride records - a 0x24-byte matcher head (command combo, stream index, inline art name) followed by a standard action entry. Keyframes live outside the file, in the "ME" archives of readef.DAT; win poses (0x11..=0x18) decode from the base archive, everything else from main.
  • Facial tracks (entry +0x8C eyes, +0x98 mouth) are stamped over the head section's texture rows per frame; during the victory quote the mouth comes from a static SCUS table instead.
  • Equipment-variant track (entry +0xA4): two [start, end] frame windows that swap a 0xFF variant onto its attach-bone channel - a mesh swap, not a stamp. Live windows exist only in Noa's file.
Two container encodings, one pose format

Inline in record[0] a stream is raw: exactly 2 + parts × frames × 9 bytes, no size field, no codec. An art clip's stream is a "ME" archive body whose size word carries a bit-15 compression flag (set on every retail player art), delta-coded per 12-bit channel. Both decode to the same bytes. A raw inline stream can be rewritten in place if parts and frames hold; a "ME" body must be re-encoded and its length depends on the content. The action table in RAM (0x801C9360 + slot×4) holds rebased copies of all of the above.

Attach-object records match their +0x07 key against action-entry +0x77 bytes (then art-bank +0x9B) and are linked into the entry's +0x04/+0x08 pair by a word-granular copy whose extent two nested signed counts define.

Editing: carrying a weapon into another character's file

A file only holds records for the items its character can equip, so a weapon handed to another character through the owner mask alone lands on that character's bare-hand default. The patcher's --equip-owner builds the missing record (legaia_asset::equip_transplant):

  1. Cut the weapon's primitives out of the donor record (item-alone cut; CLUT columns remapped onto the target section).
  2. Merge them channel by channel into the target's weapon-section default - the held sections are the same three arm bones on every skeleton, only numbered differently - and re-seat each channel through a rigid transform calibrated from the weapons both files carry (equip_hand_frame, per weapon class).
  3. Carry the donor's texture tile as the pool under the weapon's palettes; sections 2 and 3 share a texpage and differ only by tile row.
  4. Keep the target default's header, loader frame, attach list and swing records; set the swing record's +0x74 to the donor weapon's cost.

Not unlocked this way: Ra-Seru forms, body, head and footwear - those records are a whole re-sculpted limb on the donor's skeleton (above), and Terra's file has no equipment sections.

Parking the records in DMY.DAT

The loader never bounds a file by its TOC span - after the prologue read it streams slots by seeking a 32-bit gap forward from the previous slot. A rebuilt file that outgrows the free sectors between entries 863..865 therefore keeps its first 0x8000 bytes in place and puts its whole slot region, in chain order, into DMY.DAT, the unused developer-fixture file at the end of the disc, with every table offset displaced accordingly. The image keeps its size, so a PPF carries it. Two rules follow: slots must stay in chain order (the gap is unsigned), and old record sectors are simply left unread.

Sizes and the annex allocator

Vahn's Astral Sword record for Noa comes out at 7 sectors. Retail files tile their footprints exactly; the optimal LZS parse frees 4 / 2 / 5 sectors in Vahn's / Noa's / Gala's files and none in Terra's, and the patcher moves the boundaries between entries 863..865 before falling back to the annex. legaia_asset::player_file_annex recognises an annexed table (chain starts at a non-zero sector-aligned offset) and materialises the retail-shaped file for every parser; DiscPatcher::{annex_player_file, read_player_file, patch_player_file} are the disc side, with a bump allocator whose LGAX marker lives in DMY.DAT's last sector. Verified in PCSX-Redux from a pre-battle save on the patched disc.

Not the monster archive

The monster stat archive (extraction 0867, legaia_asset::monster_archive) shares the [dec_size][LZS] → mesh + texture pool shape but nothing else:

Player filesMonster archive
SlotsVariable size via the descriptor tableFixed 0x14000 stride keyed by monster id
Decoded head32-byte slot header + loader frameMonster stat record (name, HP, action-offset array)
Keyframe streamentry +0xACentry +0x8C

How we know

FunctionAddressWhat it provesDump
Streaming loaderFUN_80052770Dev-path open via TOC index char_id + 0x360; 16-sector prologue; per-section id match against record +0x196..+0x19A; gap-and-seek slot streaming (the loader never bounds by TOC span).ghidra/scripts/funcs/80052770.txt
AssemblerFUN_80052FA0Slot-header field roles, swing-record splice into slots 0xC..0xF, upload_flag gate, rect table at 0x800775B8, +0x58 / +0x5C rebase.80052fa0.txt
Loader-frame readerFUN_800536BCBone-id bytes per object, then 0xFF / 0xFE surplus tags.-
Texture uploadFUN_80053B9CTwo LoadImages per block: CLUT at row 0x1E1 + p with STP, pixels at the banded rect.-
Keyframe spliceFUN_800557B8Raw inline stream size (parts×frames×9 + 5) >> 2 words.-
Art clip commitFUN_8004AD80Slots 0x10 / 0x11 install record +0x24; reads the +0x58 art bank.-
ME decoderFUN_8002A9CCDelta-coded 12-bit channels behind the bit-15 flag.-
Facial animatorFUN_8004C7B4Eyes / mouth tracks at entry +0x8C / +0x98; victory mouth table at 0x80077E80.-
Variant swapFUN_8004CCD4+0xA4 windows swap model pointers on the render node's channel table.-
Archive stagingFUN_801DABA4, FUN_801E295CMain archive per turn; base archive (slot 3×char+2) at battle end.-
Live captures-Assembled mesh vertex pools byte-match the selected sections; VRAM bands match the rect table; mednafen-state clut-trace byte-matches decoded pools to captured VRAM.-
Open: the record[0] +0x5C word

Rebased at load together with the +0x58 art-bank pointer, but with no reader in SCUS_942.54 or any extracted overlay image (11 overlay images remain dump-only). Its target is clut_a_off − 4, zero on disc. The earlier "points at the ME archive" hypothesis is refuted by the disc: no "ME" archive exists in any player file.

History: the "16 MB container at PROT 0865" reading

That figure was extraction 0865's window under the superseded entry-size expression: it spanned Gala's file, Terra's, and most of the monster archive. The same over-read produced "Vahn = PROT 0861" and the battle_data / monster archive conflation. Each file's slot region tiles its own footprint exactly, which fixes the true size. Catalogued in do-not-re-walk.

Not here: the NPC palettes at row 479

The town NPC CLUTs byte-match no player-file slot. They are plain TIMs in each scene's own bundle, uploaded at battle init - see npc-palette.

CLI

# Inspect one player file's 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

Full reference, including the texture-block catalog and replacement commands: docs/formats/battle-data-pack.md.

See also