Player-character mesh packs Confirmed
Vahn, Noa and Gala have two bodies. Walking around a town you see the small, low-detail field form; the moment a battle starts you see the tall, high-detail battle form, wearing exactly the armour and weapon you have equipped. The field form is five whole models in one archive entry. The battle form exists on disc only as parts: at battle setup the engine assembles each character from per-equipment-slot pieces, so the model you fight with may exist nowhere on the disc as a single file. Both forms are live on the characters page.
At a glance
- In the game
- Field form: every town and dungeon. Battle form: every battle, plus the Baka Fighter minigame's default-gear copies.
- Magic / marker
0x80000002- every piece is a Legaia TMD; the battle pack's chunks carry asset type0x09(TMD2), the same body shape.- Lives in
- Field form: PROT 0874 §0 (retail
player.lzs, CDNAME define 876). Battle form: assembled fromdata\battle\PLAYER1..4= extraction 0863..0866. Default-gear sibling pack: PROT 1204 (atlases 1205, rig 1203). - Resident at
- Global TMD pointer table
DAT_8007C018: field slots[0..=4], battle party[0..=2]. - Parser
legaia_asset::character_pack,::field_char_textures,::battle_char_assembly,::battle_char_pack(1204).- Confidence
- Confirmed - loader disassembly plus byte-exact runtime captures (how we know).
- Used by
- Renderer, Battle scene, Characters page, Equipment editor
Two forms, two sources
The field mesh and the battle mesh are different data from different places, and only the field one is a file you can extract whole.
| Field form | Battle form | |
|---|---|---|
| Where you see it | Towns, dungeons, the world map | Battles; Baka Fighter (default gear) |
| Disc source | PROT 0874 §0, five whole TMDs | Equipment sections of PLAYER1..4 (extraction 0863..0866) |
| Objects | 12 groups on disc, 10 live (2 equipment templates) | Skeleton bones + equipment extras (Vahn 15 + 2 = 17) |
| Equipment | One binary pose toggle; item identity via texture only | Whole bone objects re-sculpted per item id |
| Rest pose | Frame 0 of the locomotion idle (PROT 0874 §1) | Frame 0 of the idle in the same player file's record[0] |
| Textures | PROT 0874 §2, row-478 CLUTs | The player file's own pools, relocated to rows 481..483 |
| Pool slots | [0..=4] | [0..=2] |
Field form - PROT 0874
PROT 0874 is a three-section LZS container (parse_player_lzs(buf, 3) shape): §0 the mesh pack, §1 the party locomotion ANM bundle, §2 the textures. Section 0 decompresses to a canonical pack with five Legaia TMDs, shared by every field scene:
| Slot | Body offset | nobj (disc) | Body bytes (runtime) | Role |
|---|---|---|---|---|
| 0 | 0x0018 | 12 | 13 220 | Vahn - party slot 0 |
| 1 | 0x33BC | 12 | 13 800 | Noa - party slot 1 |
| 2 | 0x69A4 | 12 | 11 656 | Gala - party slot 2 |
| 3 | 0x972C | 3 | 6 488 | Savepoint (save crystal) |
| 4 | 0xB084 | 2 | 1 048 | Auxiliary actor |
The runtime byte count is what the size-bounded LZS decode produces (~46 KB total), so slot 4 receives only its TMD prefix. Each body is a standard Legaia TMD: 12-byte header, then nobj × 0x1C group descriptors.
10-group cap + equipment swap
In the field, equipping a weapon changes one thing: a single group toggles between two pre-baked variants. The field player loader overwrites the three party TMDs' group count to 10 after install; disc groups 10 and 11 are never drawn directly - they are the templates the per-frame patch loop copies over the visible group:
| Party slot | Character | Patched group | Equip-byte record offset | Template when zero | Template when non-zero |
|---|---|---|---|---|---|
| 0 | Vahn | 0 | +0x196 | group 11 (TMD+0x140) | group 10 (TMD+0x124) |
| 1 | Noa | 3 | +0x199 | group 11 | group 10 |
| 2 | Gala | 5 | +0x19B | group 11 | group 10 |
The swap is binary and never changes the object count; item identity in the field comes from the texture atlas, not the mesh. From-scratch port: character_pack::equipment_swap::apply.
Rest pose (§1)
The field TMDs' vertices are object-local - each group models one body part about its own joint - so drawing a character needs a per-object rigid transform. That comes from the locomotion ANM bundle in section 1 (23 records: three 7-record character banks + savepoint + aux). Bank slot 1 is the standing idle; frame 0 of that clip is the field rest pose. Bone i drives group i one-to-one, which is why the 10-group cap matters: 12 disc groups minus 2 templates leaves exactly the 10 objects the 10-bone clips animate. Each 8-byte (bone, frame) entry decodes to a flat R·v + T.
Textures (§2)
Section 2 is a pack of eight standard PSX TIMs (parser field_char_textures), byte-exact against a live field-scene VRAM dump. Three per-character atlases (Vahn / Noa / Gala) tile horizontally to fill the 4bpp texpage at (832, 256); their CLUTs land on row 478 as flat horizontal strips, 64 columns per character. Three shared 256-colour pages and two atlas extensions complete the set (entry table).
- Only about a third of a field character's primitives are textured; hair, vest and boots carry per-vertex RGB in the TMD. A texture-only renderer leaves holes - details.
- The textures are not in extraction PROT 0876, whose filename label reads
player_dataonly because of the CDNAME −2 shift.
Battle form - assembled from the player files
A main-game battle renders no disc TMD directly. At battle setup the engine builds each party member's mesh from that character's player battle file, picking one section per equipment slot by the character's equipped item ids (character record +0x196..+0x19A), and installs the merged TMD into pool slots [0..=2]. A section is not an accessory bolted on: it carries whole re-sculpted bone objects (a fist with the sword modelled into it), so equipping something swaps limbs, not attachments.
Assembly chain
PLAYER<n>; select five sections by matching descriptor ids against the equipped-item bytes (id = 0 = default).battle_char_assembly
2DecodeLZS-decode record[0] (palette + battle animations) and the five sections; open an empty merged TMD.assemble
3Splice ×5Append each section's object entries with relocated offsets and one bone-id byte each; objects past the attach list are the equipment extras. This step grows nobj.splice
4Retag + sortExtras get tags 200/201 and 100+ with their attach bone recorded; the table is sorted by tag so object index == bone tag.retag + sort
5InstallRegister the blob in the pool slot; rewrite texpage / CLUT into the slot's band.relocate_tsb_cba
Runtime nobj = skeleton bones + equipment extras: Vahn's 15 bones + weapon + Ra-Seru = 17.
Where each rest pose comes from
Every battle TMD is a set of object-local pieces posed with a flat per-object transform v_world = R·v_local + T - no skeleton hierarchy, no pivot subtraction. The transforms come from the character's own action-animation streams in record[0] of the same player file: a u32 action-offset table at the record head, slot 0 the idle loop, with a [parts][frames][9-byte TRS] stream at entry +0xAC (the monster archive keeps its at +0x8C). parts = the bone count (15 Vahn / 16 Noa / 15 Gala / 17 Terra); channel i drives sorted object i, and the extras ride their attach bone's channel. Frame 0 of the idle is the combat-stance rest pose.
The two battle sources are not interchangeable: 1204's object order differs from the assembled blob's sorted order (Vahn and Gala permute head / torso / limb triples; Noa happens to coincide), so 1203 must never pose the assembled mesh and a player-file stream must never pose 1204. Posed at frame 0, a correct assembly stands with feet within a few units of y = 0 and faces +Z toward the monster seats (per-source table + measured extents).
Load-time texpage / CLUT relocation
The texpage and CLUT words on disc are an authoring layout (the one Baka Fighter renders directly). A normal battle rewrites every textured primitive into a per-party-slot band: CLUT row 481 + slot, texpages (512 + 128*slot, 256) and the page beside it - on the player-file meshes a uniform +3 texpage / +0x40 CLUT-id rewrite. Textures come from the player file itself, loaded into the band at static per-section rects (placement); the palette is rebuilt at battle load from the file's CLUT structs, one 16-colour sub-CLUT per object, with the STP bit set on every non-zero colour (the disc source is bit-15-clear). Port legaia_asset::battle_char_palette.
PROT 1204 + 1205 - the default-equipment sibling pack
The Baka Fighter minigame lets you play as Vahn / Noa / Gala, so it loads pre-assembled copies of the same characters with default equipment. PROT 1204 is a flat streaming container with five TMD2 chunks (Vahn / Noa / Gala + two extra fighters); PROT 1205 holds eight character atlases with CLUTs at rows 490..497 (the authoring palette a normal battle never uses); PROT 1203 is the matching ANM rig. Its default-section geometry is byte-shared with the player files, which is what makes it look like the battle source. The engine keeps it as the per-member fallback; play-window assembles the real thing. Chunk table in the details; parser battle_char_pack.
How we know
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Field player loader | FUN_8001E890 | Decodes the three sections; caps the party group count to 10. Install chain FUN_80020224 → FUN_8001F05C → FUN_80026B4C fills pool [0..=4]. | funcs/8001e890.txt |
| Per-frame group patch | FUN_8001EBEC | Copies one 28-byte template over the visible group; the {0, 3, 5} table is both group index and equip-byte offset. | funcs/8001ebec.txt |
| Actor object table fill | FUN_80024D78 / FUN_8001B964 | Bone i drives group i, counts must match; FUN_8001BE80 decodes flat R·v + T. | funcs/80024d78.txt |
| Texture upload | FUN_800198e0 | Uploads (clut_x, clut_y, w*h, 1) - the flat-strip CLUT rows; STP only when _DAT_8007b998 != 0. | funcs/800198e0.txt |
| Player-file streaming SM | FUN_80052770 | Case 1 opens PLAYER<n> by TOC index member_id + 0x360; case 4 selects sections by item id; slot ctx 0x801C92F0 + slot*0x1C. | funcs/80052770.txt |
| Assembler / splice / post-pass | FUN_80052FA0 / FUN_800536BC / FUN_80053898 | Merged TMD at ctx+0x50 (magic at blob+0x18); splice grows nobj; retag, attach bones at blob + nobj, sort by tag. | funcs/80052fa0.txt, 800536bc.txt, 80053898.txt |
| Battle-form install | FUN_800513F0 / FUN_800542C8 | Register blob + 0x18 into DAT_8007C018[slot]; write-watchpoint across a live field→battle transition (indirectly dispatched, so no static cross-reference). | funcs/800513f0.txt, 800542c8.txt; probe scripts/pcsx-redux/autorun_battle_party_mesh_install.lua |
| Texpage / CLUT relocation | FUN_80053A28 | CBA row 0x1E1 + slot, texpage 0x18/0x19 + 2*slot. | funcs/80053a28.txt |
| Palette build | FUN_80053B9C | [u16 base][u16 count][BGR555] structs into a 0x1E0-stride block, STP set, row 481 + slot. | funcs/80053b9c.txt |
| Battle anim contexts | FUN_80047430 → FUN_8004AD80 | node +0x4C points at record0 + action_table[0] + 0xAC; byte-matches the disc decode; no 1203 record resident. | full-party battle saves |
| Byte-equality oracles | - | All 17 Vahn object pools byte-match PLAYER1 sections (details); pose extents in battle_pose_orientation_real.rs. | disc-gated tests |
Details
Editing trap: the header words are a size sum, and the battle VDF pack is a different entry
meta[1] (0x2CBA0) is the sum of the three descriptors' decompressed sizes (0xB49C + 0x41E0 + 0x1D524) - the ordinary scene-bundle +0x04 word, which nothing reads. It was read as "the byte offset of a VDF data tail past the LZS payload inside this entry", and the entry's own size refutes that: the raw-TOC gap makes it exactly 0x19800 bytes, so 0x2CBA0 is 78 KB past the end.
The loop that produced the misreading is real, but walks another entry. FUN_800520F0 hands each base + offset of a flat [count][offsets] pack to FUN_8001FBCC, the append for the VDF pointer table at 0x80083E58. Its four load constants 0x368 / 0x369 / 0x36a / 0x36b are raw TOC indices 872..875 = extraction entries 870..873, the befect_data block's etim / etmd / vdf / efect. The pack walked is vdf (extraction 872), whose header is a genuine flat pack: count 0x20, then 32 ascending offsets. The character pack is extraction 874 = raw 876 - the same number in the other index space, which is how the two entries were fused.
The editing contract is unchanged: legaia_asset::party_swap::fieldize keeps the first four words byte-exact and pins §0's decoded size at 46 236 bytes. A rebuild that changed that size was observed to hang the next battle load on a wild read at 0x808425F8. With the VDF-pointer mechanism falsified, the shape of that address is now narrowed rather than unexplained: it is not a materialised constant, because no lui anywhere on the disc carries an immediate of 0x8084 or 0x8085 (all 84 images, 2.1 MB), so it is computed - a base plus a word read out of the container. The battle loader's two pack walks were the standing candidates and both are now out: a mid-battle save state shows one walking the vdf pack and the other the etmd pack, neither of which is this container. The walk that does read it is the one inside the boot-time player_data loader, over §0's own decoded pack, and the observed address sits a whole word offset above that buffer's measured base. The container's buffer is also sized from its own header at boot rather than from a constant, so a rebuild that keeps the header byte-exact while changing what the stream decodes to gets a truncated pack - which is how an offset word past the table becomes mesh payload read as an offset.
Field texture pack - the eight TIM entries
The upload writes (clut_x, clut_y, w*h, 1), which is why a character spans several CBA columns of one row instead of the declared CLUT rect. The row-479 NPC CLUTs are a separate, STP-set upload. Kept resident across transitions by FIELD_SHARED_BLOCKS.
| Entry | Image (x, y, w_words, h) | CLUT (x, y, colours) | Role |
|---|---|---|---|
| 0 | (448, 0, 64, 256) | (0, 473, 256) | shared 256-colour page |
| 1 | (832, 256, 20, 128) | (0, 478, 64) | Vahn atlas + palettes cols 0..63 |
| 2 | (852, 256, 20, 128) | (64, 478, 64) | Noa atlas + palettes cols 64..127 |
| 3 | (872, 256, 20, 128) | (128, 478, 64) | Gala atlas + palettes cols 128..191 |
| 4 | (320, 256, 64, 256) | (0, 475, 256) | shared 256-colour page |
| 5 | (384, 256, 64, 256) | (0, 475, 256) | shared 256-colour page |
| 6 | (880, 384, 16, 64) | (192, 478, 32) | atlas extension (lower) |
| 7 | (880, 448, 16, 64) | (224, 478, 32) | atlas extension (lower) |
Hybrid render: a third of the field prims are textured, the rest carry vertex colour
Only face, eyes, skin and parts of the clothing sample the atlas. Hair, vest and boots are untextured flat / gouraud prims whose (cba, tsb) is (0, 0), so a texture-only renderer samples empty VRAM and leaves holes. The colour block sits at the start of the prim, before the vertex indices, with length vertex_offset: flat prims carry one RGB, gouraud one per corner at a 4-byte stride. legaia_tmd::mesh::tmd_to_vram_mesh_field_hybrid returns the per-vertex shading alongside the mesh; the characters page's field form draws it that way.
Why a live VRAM dump can differ from the TIM
Each strip carries authored alternate-face frames (blink / mouth variants) below the live texel rows. Cutscene scripts stamp them over the live cell with the field-VM op 4C 60, a literal-operand MoveImage whose six u16 operands sit at arbitrary byte parity (read through FUN_8003CE9C; handler at 0x801E1B28..0x801E1B90 inside FUN_801DE840). A script that stamps once and never stamps back leaves the cell parked on the alternate frame until the band is re-uploaded. The engine queues the same stamps (World::queue_script_vram_move / apply_script_vram_moves). The actor wrap-scroll (FUN_80021DF4, installed by move-VM opcode 0x1E) scrolls water and ambient cells but never the character atlas.
Per-character animation is otherwise not in the pack: the actor tick consumes a record pointer at actor[+0x4C] installed by FUN_80024CFC, and battle actions feed a parallel struct at actor[+0x234] - see ANM.
Battle form: byte-verified provenance and party gating
On a 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 it. The full party installs 0x80165E38 / 0x8017A908 / 0x8018D550 into slots 0..2 (game_mode 0x15).
The install loop in FUN_800513F0 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 and FUN_800542C8 fills the rest (per-member loop bounded by *(rec+0x4a)). The loader FUN_800520F0 separately registers PROT 0x36a (etmd.dat) into the effect window [3..], never the party slots.
Rest-pose sources, measured extents and the two pose encodings
| Mesh | Rest pose comes from | Object ↔ bone mapping |
|---|---|---|
| Field form (PROT 0874 §0) | Locomotion ANM bundle, PROT 0874 §1, bank slot 1 frame 0 | bone i → group i, 10 live groups |
| Assembled battle form | Same player file, record[0] action slot 0 frame 0 | channel i → sorted object i; extras on their attach bone |
| PROT 1204 default-gear pack | PROT 1203 ANM bank, first record of the character's bank, frame 0 | bone i → 1204 object i - 1204's own order |
Posed at frame 0 (Y down, battle-world units): Vahn 202 × 425 × 339 with the head at −423 and feet at +3; Noa 217 × 390 × 296; Gala 208 × 503 × 340. Terra's file (0866) is a quadruped - long in Z by design - and has no runtime texture band, so it is not drawn as a party member.
1203 stores u8 << 4 angles where the player streams are full 12-bit, so the two idles agree only up to quantisation. The inline record[0] streams are raw (2 + parts*frames*9 bytes); the character's art clips carry the same decoded bytes as channel-delta-coded "ME" bodies in readef.DAT - two container encodings, one pose format.
Palette port accuracy against the reference capture: Vahn byte-exact, Gala 100 %, Noa ~98 % (late-game equipment patches in the capture). Texture placement reproduces live battle VRAM at 99.7-100 % per member. The 1204 atlases match the live band only at 73-98 % (default-equipment texels).
PROT 1204 + 1205 chunk layout
PROT 1204 is a flat streaming-format container (no LZS wrapper) of exactly 75 sectors (0x25800): five chunks of asset type 0x09 plus a terminator. PROT 1205 holds the eight atlases as type-0x00 TIM chunks at 0x8224 stride. The minigame overlay loads data\field\other5.lzs + PROT 1205/1206.
| Region | Entry | Offset | Type | Size | Role |
|---|---|---|---|---|---|
| chunk 0 | 1204 | 0x000004 | TMD2 | 33 516 | Vahn battle (nobj=15) |
| chunk 1 | 1204 | 0x0082F4 | TMD2 | 33 636 | Noa battle (nobj=16) |
| chunk 2 | 1204 | 0x01065C | TMD2 | 24 780 | Gala battle (nobj=15) |
| chunk 3 | 1204 | 0x01672C | TMD2 | 27 036 | Extra fighter (nobj=20) |
| chunk 4 | 1204 | 0x01D0CC | TMD2 | 33 340 | Extra fighter (nobj=15) |
| atlas 0 | 1205 | 0x000004 | TIM | 33 312 | 256×256 4bpp + 256×1 CLUT @ (0, 490) |
| atlas 1 | 1205 | 0x008228 | TIM | 33 312 | CLUT @ (0, 491) |
| atlas 2 | 1205 | 0x01044C | TIM | 33 312 | CLUT @ (0, 492) |
| atlas 3 | 1205 | 0x018670 | TIM | 33 312 | CLUT @ (0, 493) |
| atlas 4 | 1205 | 0x020894 | TIM | 33 312 | CLUT @ (0, 494) |
| atlas 5 | 1205 | 0x028AB8 | TIM | 33 312 | CLUT @ (0, 495) |
| atlas 6 | 1205 | 0x030CDC | TIM | 33 312 | CLUT @ (0, 497) |
| atlas 7 | 1205 | 0x038F00 | TIM | 33 312 | CLUT @ (0, 496) |
History: readings that were wrong
- “Battle reuses the field pack” and “battle renders PROT 1204 directly, posed from 1203” - the partial byte match was the default-section geometry both sources share; the equipped-variant objects are in no 1204 chunk, and no 1203 record is resident in battle RAM.
- “Seven atlases inside 1204, the last truncated” - an over-reading entry size ran 1204 into 1205; there are eight whole atlases in 1205.
- “Nominal CBA, no relocation, the palette is scene residue at rows 490..497” - those rows hold the scene environment palette; the party palette is uploaded to 481..483 by the battle loader.
- “The per-frame patch loop adds the equipment groups” - it is a pose toggle and never changes the object count. The “+2-row scroll phase” reading of a Noa strip variant was a cutscene blink stamp.
Full entries: do-not-re-walk § battle, § containers, § field.
CLI
asset character-pack extracted/PROT/0874_befect_data.BINasset character-pack extracted/PROT/0874_befect_data.BIN --slot 0 --equip 1 --out vahn_equipped.tmdasset field-char-tex extracted/PROT/0874_befect_data.BINasset battle-char-pack extracted/PROT/1204_other5.BIN --atlas-entry extracted/PROT/1205_other5.BINasset battle-char-pack extracted/PROT/1204_other5.BIN --slot 0 --out-tmd vahn_battle.tmdasset battle-char-pack extracted/PROT/1204_other5.BIN --atlas-entry extracted/PROT/1205_other5.BIN --atlas 0 --out-tim vahn_atlas.timThe assembled battle form has no single-file export because it is not a single file - the characters page splices one live from a chosen loadout and offers the posed .glb; the CLI equivalent is the player-file commands. Full reference: docs/formats/character-mesh.md.