Player battle files (data\battle\PLAYER1..4) Confirmed
Equip a new sword and, in the next battle, your character is holding it. These four files - one each for Vahn, Noa, Gala and Terra - carry everything a character needs in battle: mesh pieces keyed by item id, textures, palette and the battle animations. At battle load the game picks the piece matching each equipped item and splices the winners into one model.
At a glance
- Where
- PROT extraction entries 0863..0866 (Vahn / Noa / Gala / Terra); in-RAM TOC index
char_id + 0x360. Theedstati3filename 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_actor_formats::battle_data_pack(walker),::battle_char_palette(palette chain),::battle_char_assembly(splice, poses, uploads), editing inlegaia_patcher::equip_transplant.- 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.
Load chain and 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. The retail ISO9660 branch is a trap stub on this build, so the open always resolves through the PROT reader with the raw in-RAM TOC index char_id + 0x360 - extraction entry char_id + 0x35E (PROT.DAT). FUN_80052FA0 then assembles the character.
The footprint is the file. The sector gap to the next TOC entry is the true size: the slot region tiles each file’s footprint exactly. An extracted 0865 of about 16 MB is Gala’s file followed by its neighbours (Terra’s file and most of the monster archive), the read window of the superseded entry-size expression - not a “16 MB battle_data container”.
File layout
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. The header itself is four words:
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | desc_off | Descriptor-table offset. Also reads as a type-0 streaming chunk header, so stream walkers skip the head cleanly. |
+0x04 | 4 | clut_a_off | Image block A offset inside the decoded record[0]. |
+0x08 | 4 | clut_b_off | Image block B offset, same space. |
+0x0C | 4 | budget | Decoded size of record[0] (the LZS output budget). |
+0x10 | … | record[0] | LZS stream: action-offset table, action entries, art bank, two image blocks. |
+desc_off | 12 × n | descriptor table | [id, offset, size] entries, all-zero terminator, zero-padded to 0x8000. |
+0x8000 | to end | slot region | Per 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:
| 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 |
Descriptor table: one section per equipment slot
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | id | Item id (item table space). 0 ends a section and names its default slot. |
+0x04 | 4 | offset | Slot offset from 0x8000. |
+0x08 | 4 | size | Slot 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.
| Section | Vahn | Noa | Note |
|---|---|---|---|
| 0 | body | body | Armour re-sculpts the torso object. |
| 1 | head | head | Face rows are stamped by the facial animator. |
| 2 | weapon | Ra-Seru | Order differs per character; both fill the equipment table's Weapon slot. |
| 3 | Ra-Seru | weapon | A Ra-Seru is the character's weapon arm. |
| 4 | feet | feet | Carries 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
| class | records | shape |
|---|---|---|
own-object | 6 | Already its own 0xFE object - retail shipped the split. |
separate | 21 | Own connected component, zero shared vertices. Lossless. |
welded | 53 | Palette subset joined at the grip rim; 3-64 shared vertices; exported with an open grip. |
fused | 1 (+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 incrates/actor-formats/data/equip-isolation.toml): colour diff against the bare counterpart for head / held items, identity test for body / feet, plus per-record overrides. A keep by object, column orwholemode still drops flesh: those overrides say “this object is the item”, not “this skin is”. Only an explicit"tag:ordinal"keep is absolute. - Skin inside a kept primitive (
equip_skin_mask). Some quads paint the item and the flesh beside it - a clip band and the forehead around it, a collar and the neck inside it - with too small a skin share to drop. A primitive that is at least 4% flesh and 25% clearly not is mixed: its flesh texels (a narrow hue / saturation band, or within one 5-bit step of the character’s own face colours) are grouped into patches, grown into their shadowed edge, and written as the transparent word0x0000into a masked 15-bit copy of the footprint in an unused page of the loadout VRAM. The mixed primitives’ TSB points at the copy; UVs do not change. Only the item-alone mesh (export, preview, thumbnail) is re-pointed - the full character never samples the copy. - 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(tag200+) 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_objectstests 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, at 0x8000 + offset:
Decoded - the header's self-relative offsets locate everything else:
frame_off = 0x14 + 4 × attach_obj_count in every retail slot; the pool is uploaded only when upload_flag != 0.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | frame_off | Self-relative offset of the loader frame. |
+0x04 | 4 | swing_rec_a | Self-relative offset of the section's swing action record (sections 2..4; 0 in 0/1). |
+0x08 | 4 | swing_rec_b | Second swing record, consumed only for section 4. |
+0x0C | 4 | tmd_body_end | Where the TMD ends and the texture pool begins; the decode-buffer advance to the next section. |
+0x10 | 2 | attach_obj_count | Signed; 0 / 1 / 2 observed. |
+0x12 | 2 | upload_flag | Non-zero = the pool is uploaded to VRAM at battle init; zero = pool bytes are dead. |
+0x14 | 4 × n | attach_obj_off[] | Attach-object records; each record's +0x07 byte is its attach key. |
+frame_off | … | loader frame | u8 attach_count, u8 bone_ids[], u32 data_size at +0x08, TMD at +0x0C. |
+tmd_body_end | … | texture pool | One 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.
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.
| Block | x0 | y0 | w | h |
|---|---|---|---|---|
| record[0] A | 0x20 | 0x80 | 0x20 | 0x80 |
| record[0] B | 0x60 | 0x00 | 0x20 | 0x80 |
| section 0 body | 0x00 | 0x80 | 0x20 | 0x80 |
| section 1 head | 0x00 | 0x00 | 0x40 | 0x80 |
| section 2 | 0x40 | 0x00 | 0x20 | 0x80 |
| section 3 | 0x40 | 0x80 | 0x20 | 0x80 |
| section 4 feet | 0x60 | 0x80 | 0x20 | 0x80 |
Port: battle_char_assembly::{SECTION_TEXTURE_RECTS, RECORD0_TEXTURE_RECTS, character_texture_uploads}. It matches captured battle VRAM per member except for the facial animator's current frame. mednafen-state clut-trace confirms placement from the other side: sliding each decoded slot’s pool over captured VRAM finds every hit inside its section’s banded rect, consecutive pool offsets stepping one VRAM row per 0x40 bytes.
One unflagged record is a stripped pool, not an empty one. Noa’s Force Blade (0x25, section 3) is her Short Sword record up to the pool - same attach list, TMD and pool offset - with the upload flag cleared and the decode ending at the pool. Its blade samples a rect only that missing pool fills, so under retail’s loader it draws whatever the rect last held, in a fresh VRAM nothing at all. Retail never reaches the record: the equipment table makes the Force Blade Vahn’s alone. The port uploads the Short Sword’s pool for it (battle_char_assembly::stripped_pool_donor); it is the only such record in the four files.
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.
| Slot | Action | Source |
|---|---|---|
0 | idle (frame 0 = rest pose) | disc |
1 | walk | disc |
2..5 | hit reactions | disc |
7 / 8 / 9 | ready / recover / defeat | disc |
0x0B | block | disc |
0xC..0xF | four direction-command swings | spliced from equipment sections 2 / 3 / 4 at load |
0x10 / 0x11 | dynamic art clips | materialised per staged art from the art bank + readef.DAT |
- Persistence. Only the region below
clut_a_offsurvives 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+0x08for section 4) is installed at action slot0xC + 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]then0xD0-stride records - a0x24-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 ofreadef.DAT; win poses (0x11..=0x18) decode from the base archive, everything else from main. - Facial tracks (entry
+0x8Ceyes,+0x98mouth) 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 a0xFFvariant onto its attach-bone channel - a mesh swap, not a stamp. Live windows exist only in Noa's file. The port keeps retail’s per-channel model table (legaia_engine_battle::battle_attach_swap): the world steps a party seat’s table on every battle pose, and both play hosts draw only the objects it names for the playing stream’s channels, which also hides the0xFEextras outside surplus-channel streams. The after-image walk re-runs the swap per ghost, so on a ghosted frame the live body draws the table the last ghost left, as retail’s does.
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_patcher::equip_transplant):
- Cut the weapon's primitives out of the donor record (item-alone cut; CLUT columns remapped onto the target section).
- 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). - 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.
- Keep the target default's header, loader frame, attach list and swing records; set the swing record's
+0x74to 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_actor_formats::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.
Not the monster archive
The monster stat archive (extraction 0867, legaia_actor_formats::monster_archive) shares the [dec_size][LZS] → mesh + texture pool shape but nothing else:
| Player files | Monster archive | |
|---|---|---|
| Slots | Variable size via the descriptor table | Fixed 0x14000 stride keyed by monster id |
| Decoded head | 32-byte slot header + loader frame | Monster stat record (name, HP, action-offset array) |
| Keyframe stream | entry +0xAC | entry +0x8C |
How we know
Provenance: the loader, assembler and animation routines
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Streaming loader | FUN_80052770 | Dev-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 |
| Assembler | FUN_80052FA0 | Slot-header field roles, swing-record splice into slots 0xC..0xF, upload_flag gate, rect table at 0x800775B8, +0x58 / +0x5C rebase. | 80052fa0.txt |
| Loader-frame reader | FUN_800536BC | Bone-id bytes per object, then 0xFF / 0xFE surplus tags. | - |
| Texture upload | FUN_80053B9C | Two LoadImages per block: CLUT at row 0x1E1 + p with STP, pixels at the banded rect. | - |
| Keyframe splice | FUN_800557B8 | Raw inline stream size (parts×frames×9 + 5) >> 2 words. | - |
| Art clip commit | FUN_8004AD80 | Slots 0x10 / 0x11 install record +0x24; reads the +0x58 art bank. | - |
| ME decoder | FUN_8002A9CC | Delta-coded 12-bit channels behind the bit-15 flag. | - |
| Facial animator | FUN_8004C7B4 | Eyes / mouth tracks at entry +0x8C / +0x98; victory mouth table at 0x80077E80. | - |
| Variant swap | FUN_8004CCD4 | +0xA4 windows swap model pointers on the render node's channel table. | - |
| Archive staging | FUN_801DABA4, FUN_801E295C | Main 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. | - |
Closed: the record[0] +0x5C word is vestigial
Rebased at load together with the +0x58 art-bank pointer, and read by nothing. The sweep now covers the whole disc: SCUS_942.54 plus all 83 mapped overlay images, 518,656 instruction words. Eight word-width accesses sit at that displacement on a base other than the stack pointer, and every one is accounted for - the relocation itself, a render struct's z component, two scratchpad block copies, two module-local effect-handle stores, and one pointer walk that starts in a module context rather than at the record table.
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.
Parser modules (legaia_actor_formats)
| Module | Role |
|---|---|
battle_data_pack | The slot walker. Detection checks the chain invariant (entry 0 at offset 0, contiguous sector-aligned slots, zero terminator) and the header ordering; it accepts the four player files, Terra’s all-default table included, and rejects every other PROT entry. The table starts at desc_off itself - read four bytes later and Terra’s file looks empty. |
battle_char_palette | Header words, descriptor chain, record[0] palette assembly; byte-exact against live battle VRAM. |
battle_char_assembly | Section selection, mesh splice, TSB/CBA relocation, texture uploads. |
::equip_diff, ::equip_item, ::equip_isolate, ::equip_skin_mask, ::equip_repair | The presentation cuts: loadout diff, exact palette cut, opinionated item-alone cut, texel skin mask, grip bridging. |
battle_texture_catalog | The pool blocks as a texture tier (also the patcher’s --tier battle). |
me_archive, face_anim | The "ME" archive codec; facial tracks and stamp selection. |
Open questions
- Why Noa’s sections 2 and 3 are swapped relative to Vahn’s and Gala’s. The grouping is measured; the cause is not traced.
- The victory counter’s incrementer.
gp[+0x9EA]is reset when the win pose is staged; the routine that advances it is not in the dumped corpus. - A pre-biased
+0x5Creader is the one shape the no-reader sweep cannot exclude by scanning; a read watchpoint on the word would settle it.
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.