On-disc layout

PROT entry 874 is a parse_player_lzs(buf, 3)-shaped container with three LZS-compressed sections (LZS being the game's own dictionary compression). Section 0 decompresses to a canonical pack with five Legaia TMDs:

Slot Body offset nobj (disc) Body bytes (runtime) Role
00x00181213 220Vahn - active party slot 0
10x33BC1213 800Noa - active party slot 1
20x69A41211 656Gala - active party slot 2
30x972C36 488Savepoint (save crystal)
40xB08421 048Auxiliary actor (untriaged)

The “runtime body bytes” column is what the LZS-bounded decode produces. Slot 4's underlying compressed stream would expand to roughly 20 KB if decoded unbounded, but the descriptor's compressed-size hint caps the decode at the first ~46 KB so slot 4 receives only its 1 048-byte TMD prefix - byte-equality verified against the live DAT_8007C018[4] allocation (see world-map-overlay § Disc-side source of [0..4]). The character pack is shared across every field scene; only the trailing field-pack at [5..] changes per scene.

TMD shape

Each pack body is a Legaia TMD with the canonical 12-byte header followed by nobj × 0x1C group descriptors:

+0x00  u32  magic = 0x80000002
+0x04  u32  flags (= 1 post-fixup)
+0x08  u32  nobj                  ; 12 / 12 / 12 / 3 / 2 on disc
+0x0C  group descriptors          ; 0x1C bytes each

Inside the active-party slots (0..=2), groups 10 and 11 are templates for the equipment-conditional swap below; the engine caps live group_count to 10 at install time so the templates aren't drawn directly.

10-group cap + equipment-conditional swap

FUN_8001E890 overwrites DAT_8007C018[party_base + 0..2]'s entry[+0x08] (TMD group_count) to 10 after the install, capping each active-party TMD at 10 live groups. The last two disc groups (10 and 11) are the equipment-conditional templates the per-frame patch loop picks between.

FUN_8001EBEC runs that loop. For each of the three active-party slots:

  1. Read the equipment toggle byte at the character record's per-slot offset.
  2. If the byte is non-zero, source the group-10 template at TMD+0x124. If it's zero, source the group-11 template at TMD+0x140.
  3. Overwrite the visible group descriptor at the slot's patched group index with that 28-byte template.
Party slot Character Patched group Equip-byte record offset Template-zero (TMD+0x140) Template-nonzero (TMD+0x124)
0Vahn0+0x196group 11group 10
1Noa3+0x199group 11group 10
2Gala5+0x19Bgroup 11group 10

(The “patched group index” and the offset-within-the-equip-byte-window are the same three numbers {0, 3, 5} - retail's FUN_8001EBEC reuses one tiny stack table for both roles.)

The swap is binary: each character has exactly one visible mesh group that toggles between two pre-baked variants. Different equipped items don't each get their own mesh swap; the toggle is a single bit (“weapon-bearing group is on / off”) and item identity is conveyed by the character's texture atlas, not by mesh changes.

legaia_asset::character_pack::equipment_swap::apply is the clean-room equivalent: given a slot's disc-form TMD bytes, a PatchSlot, and the character's equipment toggle byte, it returns the patched TMD buffer.

Textures

The field-form character textures live in PROT 0874 section 2 - the third LZS descriptor of the same player.lzs container (parser legaia_asset::field_char_textures): an 8-entry pack of standard PSX TIMs uploaded via FUN_800198e0. Entries 1/2/3 tile horizontally to fill the 4bpp texpage (832, 256) every field-form character prim samples, with per-character CLUTs on row 478 (Vahn cols 0..63 / Noa 64..127 / Gala 128..191) - uploaded as flat horizontal strips, not the declared CLUT rects, which is why one character spans several CBA columns of a single row. They are not in extraction PROT 0876 (a VAB + empty TIM_LIST + SEQ trailer) - that entry was only searched because its filename label says player_data; the retail player_data define names extraction 0874 itself under the -2 numbering correction. Byte-exact against a live field VRAM dump; kept resident across transitions by FIELD_SHARED_BLOCKS.

The field-form atlas (above) serves the field form only; the battle form (below) carries its own seven TIM atlases.

Battle form - assembled from the player files

This is the part that defeats a naive “extract the model” approach. A real main-game battle does not render any disc TMD directly: at battle setup the engine assembles each active party member's higher-detail mesh from that character's player battle file (data\battle\PLAYER<n>, extraction 0863..0866 - see the player battle files), picking one section per equipment slot by the character's equipped item ids (char record +0x196..+0x19A), and installs the merged TMD into DAT_8007C018[0..=2].

Assembly chain (static SCUS)

FUN_80052770 case 4 (section select) → FUN_80052FA0 (assembler, blob at ctx+0x50) → FUN_800536BC ×5 (object splice: relocated object entries, nobj += section_nobj, bone-id byte per object, surplus objects tagged as the equipment's visual meshes) → FUN_80053898 (retag 200/201/100+, attach bones at blob+nobj, sort) → FUN_800513F0 registers blob+0x18. Runtime nobj = skeleton bones + equipment extras (Vahn 15+2=17) - the equipped weapon's visual mesh is one of those spliced extras.

Battle pose source + engine port

The clean-room engine renders the battle party the same way: play-window assembles each member from their player file (legaia_asset::battle_char_assembly), applies the registration texture-band rewrite engine-side (relocate_tsb_cba, the FUN_80053a28 port: CLUT row 481 + slot, runtime texpages at y=256), and poses + animates it from the character's own idle keyframe stream in record[0] of the same player file (action-offset table at the record head, monster-format [parts][frames][9-byte TRS] stream at entry +0xAC, parts = skeleton bones; channel i drives object i, equipment extras ride their attach bone - live-pinned: a mid-battle capture's anim context points at exactly this stream, and no PROT 1203 record is resident). PROT 1204 remains the default-gear fallback, posed from its PROT 1203 bank idle (bone i → 1204 object i - 1204's own object order, which differs from the assembled blob's sorted bone-tag order, so 1203 must never pose the assembled mesh).

Provenance (byte-verified, full-party save)

DAT_8007C018[0] = 0x80165E38 = the assembler's ctx+0x50 blob + 0x18 exactly; the assembled TMD reads nobj=17, bone-id bytes [0..14, 200, 201], attach array [5, 8]. With Vahn equipped Hunter Clothes / Survival Knife / Ra-Seru Meta, every one of the 17 object vertex pools byte-matches a PLAYER1 section, equipment-selectively (body only in the id 0x43 section, weapon objects only in id 0x22, the Ra-Seru extra in the Meta tiers, defaults elsewhere). The five equipped-variant objects appear nowhere in PROT 1204; the other 12 default-section objects are byte-shared with 1204.

Install sites + party gating

The Baka Fighter fist-fight minigame loads PROT 1204 (other5), whose five pre-assembled meshes are the same characters with default equipment - it lets you play as Vahn / Noa / Gala, so it borrows the battle models (overlay_baka_fighter loads data\field\other5.lzs + PROT 1205/1206; debug string "OTHER5 %d %d"). It is a shared battle/minigame pack, not a minigame-exclusive roster. The captured battle loader FUN_800520F0 tmd_registers PROT 0x36a into the effect window DAT_8007C018[3..] (etmd.dat), not the party [0..=2]. The party-mesh install into [0..=2] is static SCUS, pinned by a DAT_8007C018[0..2] write-watchpoint at battle entry: FUN_800513F0 (lead/active actors - tmd_register(*(actor+0x50)+0x18) in a while<3 loop, alongside the FUN_80052FA0 palette decode) and FUN_800542C8 (additional members - per-member loop tmd_register(*(*rec+4))). Both are dispatched indirectly as battle state-handlers, so a static 0x8007C018 cross-reference finds no writer. Installed pointers byte-match the battle form (Vahn → 0x80165F48).

FUN_800513F0's while<3 loop is gated per slot by DAT_8007bd10[i], the active-member ID (1=Vahn / 2=Noa / 3=Gala / 0=empty) - not a 0/1 flag. A Vahn-solo fight ([1,0,0,0]) installs only slot 0 there, with FUN_800542C8 preloading the rest; a full party ([1,2,3,0]) installs all three through the loop. Confirmed against the catalogued full-party battle save states (game_mode 0x15, party_count=3, DAT_8007C018[0..2] = 0x80165E38 / 0x8017A908 / 0x8018D550 - all three battle-form meshes).

Superseded readings
  • "Battle reuses the field pack (PROT 0874 §0)" - superseded; the field pack is field-form only.
  • "Battle renders PROT 1204 directly (12/17 partial match)" - the 12 matching objects were the default sections byte-shared between 1204 and the player files; the five equipped-variant objects appear nowhere in 1204.
  • The party-mesh install was long mis-assumed to live in an overlay because a static 0x8007C018 cross-reference finds no writer - the installers are indirectly-dispatched SCUS battle state-handlers.
  • FUN_8001EBEC is a pose toggle that never changes the object count (not an equipment mesh swap-in).

The pack is two entries. PROT 1204 is a flat streaming-format container (no LZS wrapper) with five chunks of asset type 0x09 (TMD2) plus a terminator, and ends there - 0x25800, its own 75 sectors exactly. PROT 1205 is the sibling container holding the eight character atlases as type-0x00 (TIM) chunks at 0x8224 stride:

RegionEntryOffsetTypeSizeRole
chunk 012040x000004TMD233 516Vahn battle (nobj=15)
chunk 112040x0082F4TMD233 636Noa battle (nobj=16)
chunk 212040x01065CTMD224 780Gala battle (nobj=15)
chunk 312040x01672CTMD227 036Extra fighter (nobj=20)
chunk 412040x01D0CCTMD233 340Extra fighter (nobj=15)
atlas 012050x000004TIM33 312256×256 4bpp + 256×1 CLUT @ (0, 490)
atlas 112050x008228TIM33 312CLUT @ (0, 491)
atlas 212050x01044CTIM33 312CLUT @ (0, 492)
atlas 312050x018670TIM33 312CLUT @ (0, 493)
atlas 412050x020894TIM33 312CLUT @ (0, 494)
atlas 512050x028AB8TIM33 312CLUT @ (0, 495)
atlas 612050x030CDCTIM33 312CLUT @ (0, 497)
atlas 712050x038F00TIM33 312CLUT @ (0, 496)

Correction: eight atlases, and none truncated. This table used to place seven atlases inside PROT 1204 at 0x25804 + k*0x8224, the last "truncated, last in pack", and called row 496 skipped. All of that was the pre-correction PROT entry size: 1204's declared span was 184 sectors instead of 75, so it ran 0x36800 into 1205 - 0x25804 is 1205's offset 0x4, the window stopped between atlas 6 and atlas 7, and the "~23 332 truncated" size was the window's end rather than the TIM's. 1205's own 131 sectors hold eight whole TIMs (4 + 8×0x8224 = 0x41124 of 0x41800). The live capture already agreed: a played battle uploads party CLUTs at rows 490..497 including 496.

Slots 0–2 are Vahn / Noa / Gala default-equipment battle models (their geometry byte-shared with the player files' default sections); slots 3 and 4 carry two extra battle/minigame fighters.

asset battle-char-pack extracted/PROT/1204_other5.BIN --atlas-entry extracted/PROT/1205_other5.BIN
asset battle-char-pack extracted/PROT/1204_other5.BIN --slot 0 --out-tmd vahn_battle.tmd
asset battle-char-pack extracted/PROT/1204_other5.BIN --atlas-entry extracted/PROT/1205_other5.BIN --atlas 0 --out-tim vahn_atlas.tim

Animation

The party's field clips are in PROT 0874: section 1 is the party locomotion ANM bundle (walk / run / idle; three 7-record character banks + savepoint + aux - see ANM § Disc sources). Bank slot 1 is the standing idle, and frame 0 of that clip is the field rest-pose assembly transform: the field TMDs' vertices are object-local (each group models one body part about its own joint origin), so a drawn character poses each group with the clip's flat R·v + T, bone i driving group i one-to-one (FUN_80024D78 fills the actor object table straight off the pool TMD; FUN_8001B964 requires the counts to match - the 10-bone clips animate exactly the 12 disc groups minus the 2 equipment templates).

Battle actions feed through a parallel consumer struct at actor[+0x234] - see ANM § Per-actor anim state offsets.

Readers (retail)

FunctionRole
FUN_80020224FUN_8001F05C case 2 → FUN_80026B4CSingle descriptor-walk that installs PROT 0874 §0's 5 TMDs into DAT_8007C018[0..=4]. The retail loader chain that calls FUN_8001F05C with PROT 0874 specifically is not yet pinned; the engine routes the disc bytes directly through this parser.
FUN_8001E890“DATA_FIELD player loader” - post-install, caps entry[+0x08] = 10 for the three active-party slots, then dispatches the per-character equipment-conditional patch to FUN_8001EBEC.
FUN_8001EBECPer-frame group-descriptor patch. Reads the equipment toggle byte and copies one of the two templates over the visible group descriptor.

CLI

asset character-pack extracted/PROT/0874_befect_data.BIN
asset character-pack extracted/PROT/0874_befect_data.BIN --slot 0 --equip 1 --out vahn_equipped.tmd

See also