At a glance

In the game
Every 3D model: party, monsters, NPCs, scenery and props, battle arenas, world-map landmarks.
Magic
0x80000002 at offset 0 (stock PSX TMD is 0x00000041)
Where
Inside LZS-compressed PROT.DAT entries: scene bundles, field-packs, the monster archive (PROT 867), character packs, player battle files. Type byte 2 in the asset-type dispatcher.
Size
12-byte header, 28 bytes per object, then per-object vertex / normal / primitive blocks
Parser
crates/tmd (lib.rs, legaia_prims.rs, descriptor.rs); OBJ export; CLI tmd
Confidence
Confirmed - traced from the two retail renderers and pinned against every field / town mesh pack on the disc (how we know)
Used by
renderer, character mesh assembly, monster animation, asset viewer

How it differs from stock TMD

  • Custom primitive grouping - primitives are batched behind an 8-byte group header with a uniform stride, not the stock per-primitive packet stream.
  • Offset pointers - the object table stores byte offsets that the game patches to RAM addresses on load; a file on disc always has flags = 0.
  • Fixed scale word - every object's scale is 0x00808080, where stock TMD stores a signed log2 scale.
  • Shape by lookup table - the renderer resolves a group's record layout through a 6-row table, not the stock mode byte.
  • No light sources - normals are authored but never transformed; shading is a baked colour word (below).
Layout of a Legaia TMD: 12-byte header, 28-byte object table, then per-object vertex, normal and primitive blocks; the primitive section is a chain of groups file header 12 B objects, 28 B each vertices, 8 B each normals, 8 B each primitive section +0x00 +0x0C 12 + vert_top 12 + normal_top 12 + prim_top primitive section group header 8 B count × (ilen × 4) bytes group header count × (ilen × 4) zero padding group header = count, flags (picks the shape row), olen, ilen, flag, mode object n_primitive = sum of count over its groups; no per-primitive sub-headers
Pointer fields in the object table are offsets from the end of the 12-byte header; every block after the object table belongs to one object.

Header and object table

OffsetSizeFieldMeaning
+0x004idAlways 0x80000002. Bit 31 = FLIST (pointers are offsets from the header end); low byte = format version 2.
+0x044flags0 on disc. The loader sets 1 after pointer fixup and skips a second fixup when it reads 1 - a tool must never write this word.
+0x084nobjObject count.

A TMD holds one or more objects - independent sub-meshes (a character's limbs, a scene's props). Each has a 28-byte descriptor; every *_top is a byte offset from the end of the header, so a static tool reads 12 + offset.

OffsetSizeFieldMeaning
+0x004vert_topOffset of the vertex array
+0x044n_vertVertex count
+0x084normal_topOffset of the normal array
+0x0C4n_normalNormal count
+0x104prim_topOffset of the primitive section
+0x144n_primitiveSum of every group's count in the section
+0x184scaleAlways 0x00808080

Vertices and normals are both SVECTOR { i16 x, y, z, pad } - 8 bytes, 16-bit integer coordinates, the native input of the PlayStation's geometry co-processor (GTE).

The flags word as relocation sentinel

The pointer-fixup routine early-returns when flags == 1 and sets flags = 1 before walking the object table, so re-registering an already-relocated TMD is a no-op. Retail depends on this: an actor's per-object render table can be rebuilt from a TMD that may or may not have been relocated yet. A 1 on disc would make the game skip a relocation the mesh still needs. The from-scratch port parses to typed offsets instead of patching in place, so it does not reimplement the fixup.

Primitive groups and the shape table

The primitive section is a sequence of groups: an 8-byte header, then count records of ilen × 4 bytes each (a record is one triangle or quad).

OffsetSizeFieldMeaning
+02countRecords in this group
+22flagsSelects the shape-table row; bit 1 = quad
+41olenSDK output length (GPU packet words)
+51ilenRecord stride in words; bytes = ilen × 4
+61flagSDK flag byte
+71modeSDK mode byte (FT3 / FT4 / GT3 / GT4 ...)
+8count × ilen × 4recordsUniform stride, no per-record header

The shape table

The renderer picks row = ((flags >> 1) - 8) >> 1 in a 6-row, 8-byte-stride table and reads two bytes from it: byte3, the shape selector, and byte4, the vertex-index offset in u16 units. byte3 & 3 is the family: 0 = F (flat colour), 1 = FT (flat, textured), 2 = G (gouraud colour), 3 = GT (gouraud, textured). The crate's legaia_tmd::descriptor::TABLE is the authoritative copy.

Rowflags (tri / quad)Raw 8 bytesbyte3byte4Family
00x10,11 / 0x12,1304 00 00 05 07 00 00 000x050x07FT, lit
10x14,15 / 0x16,1709 00 00 07 06 00 00 000x070x06GT, lit
20x18,19 / 0x1A,1B04 00 00 00 02 00 00 000x000x02F
30x1C,1D / 0x1E,1F06 00 00 02 06 00 00 000x020x06G
40x20,21 / 0x22,2307 03 00 01 07 00 00 000x010x07FT, baked
50x24,25 / 0x26,2709 03 00 03 0B 00 00 000x030x0BGT, baked

"Lit" rows (0/1) carry normal indices after the vertex indices; "baked" rows (4/5) carry per-vertex colour words before the texture block. Examples: flags 0x1B is row 2, an F4; 0x1D is row 3, a G3.

Record layouts

A record is [colour or texture block][vertex indices][normal indices or pad]. Vertex "indices" are u16 byte offsets into the vertex array (divide by 8 for the index). Quads list corners in winding order [0,1,3,2] through the remap table so colour[i] pairs with vertex[i].

Untextured records (rows 2/3)

Each colour is the PSX [R, G, B, code] word; the fourth byte is the GPU command code, not colour.

ShapeflagsilenRecord
F40x1B3 (12 B)[0..4) one colour for all corners; [4..12) 4 × u16 vertex offsets
G30x1D5 (20 B)[0..12) v0 / v1 / v2 colour; [12..18) 3 × u16 vertex offsets; [18..20) pad
G40x1F6 (24 B)[0..16) 4 colour words in winding order; [16..24) 4 × u16 vertex offsets

Textured records

The texture block is 12 bytes: [u0 v0][cba][u1 v1][tsb][u2 v2][u3 v3 or pad], where cba names the palette row in video memory and tsb the texture page the UVs index. Baked rows put one (FT) or one-per-corner (GT) colour words before it; lit rows put it at offset 0 and trail normal indices after the vertices.

ShapeflagsilenRecord
FT3 lit0x10/115 (20 B)[0..12) texture block; [12..14) 1 normal offset; [14..20) 3 vertex offsets
FT4 lit0x12/136 (24 B)[0..12) texture block; [12..20) 4 vertex offsets; [20..22) 1 normal offset; pad
GT3 lit0x14/156 (24 B)[0..12) texture block; [12..18) 3 vertex offsets; [18..24) 3 normal offsets
GT4 lit0x16/177 (28 B)[0..12) texture block; [12..20) 4 vertex offsets; [20..28) 4 normal offsets
FT* baked0x20..235 / 6[0..4) one colour word; texture block from byte 4; vertex offsets at byte4
GT* baked0x24..27-3 or 4 colour words; texture block from byte 12 (GT3) or 16 (GT4); vertex offsets at byte4

Check: an object's n_normal equals 1 × FT4 + 3 × GT3 + 4 × GT4 summed over its lit groups (Rim Elm env pack slot 36 object 0: 8 + 44×3 + 100×4 = 540).

No light sources

Neither retail renderer transforms a normal or issues a GTE lighting op. The only colour op is the depth cue; shading is the stored colour word modulating the texel on the GPU (texel × colour / 128). The lit rows' normals are authored and ignored. The port decodes normal indices only to know where the record ends, and re-derives smooth normals from geometry for its optional dynamic lighting - see renderer § Lighting.

Vertex-index offset: where the two retail renderers disagree

Triangles read byte4 (u16 units) directly. Quads take a per-byte3 chain, and the two renderers that walk the table differ on the lit textured rows:

byte3rowslit renderergeneral renderer
022byte4 (= 2)
1488
238byte4 + 2 (= 8)
350xE0xE
5, 70, 1byte4 (7 / 6)byte4 + 2 (9 / 8)

The general renderer's fallback on rows 0/1 reads past the vertex indices into the normal block. The on-disc packets put a lit textured quad's four vertex indices at byte 12 for both rows, so the parser resolves 6 u16 units for both. Across every field / town env pack, offset 12 is the only candidate with zero out-of-range indices and it leaves the quads planar (0.78 units mean out-of-plane, vs 65-166 for the alternatives). Reading at the wrong offset makes indices exceed the vertex count and mesh builders drop the prims - a town house renders as a shredded pile. Guarded by crates/engine-core/tests/env_mesh_prims_disc.rs.

History: two texture-block mis-reads

A walker that placed the texture block at vertex_offset - 12 was right only for the baked rows; on lit rows it read (cba, tsb) from geometry and drew rainbow garbage. Mis-reading an untextured colour block as a texture block samples a random VRAM page - the "flat green tint / transparent hole". Both are catalogued on do not re-walk.

How we know

FunctionAddressWhat it provesDump
Model registerFUN_80026B4CRejects any file whose first word is not 0x80000002; writes the model into the pointer table at 0x8007C018funcs/80026B4C.txt
Pointer fixupFUN_800268DCOffsets → RAM addresses; flags sentinelfuncs/800268DC.txt
General rendererFUN_8002735C, table read 0x80027460..500Shape-table row math, byte3 / byte4, quad remap DAT_8007B410 = 00 01 03 02; no normal read; only GTE colour op is DPCSfuncs/8002735C.txt
Lit rendererFUN_80029888Second reader of the shape table DAT_8007326C; per-byte3 quad chain; no NC* lighting opfuncs/80029888.txt
Actor object-table rebuildFUN_80024D78Copies 28-byte objects from tmd + 12 + i×28; relies on the sentinelfuncs/80024D78.txt
Disc sweepevery field / town env packVertex-offset 12 for lit quads; record layouts byte-exact on town01 props (pack 31 obj 315, pack 109 obj 114)env_mesh_prims_disc.rs

Deep dive

Worked example: two primitive sections

Small TMD 0001.tmd (5 prims, 168-byte section):

Section offsetBytesMeaning
004 00 20 00 07 05 01 27Group 1 header: count=4, flags=0x20, olen=7, ilen=5, flag=0x01, mode=0x27
8 / 28 / 48 / 6820 bytes eachPrims 0..3 (FT3-style)
88zerosPadding
10801 00 22 00 09 06 01 2fGroup 2 header: count=1, flags=0x22, olen=9, ilen=6, mode=0x2F
11624 bytesPrim 4 (FT4-style)
140zerosTrailing padding

Big TMD 0000.tmd (760 prims, 15232-byte section): one header f8 02 10 00 07 05 00 26 (count=760, flags=0x10, ilen=5), then 760 uniform 20-byte records and 24 bytes of padding. Walker: legaia_tmd::legaia_prims::iter_groups.

The runtime TMD pointer table

Registered models live in a global table at 0x8007C018 + idx×4. Its readers are all setup, not render:

FunctionRole
FUN_80021B04Actor spawn: builds the per-actor object pointer table at actor[0x44]+4
FUN_80024D78Per-actor object-table rebuild
FUN_8001EBECPer-frame object 10/11 swap (pose select for player models)
FUN_8001E890Player loader: caps the group count of slots 0..2 and dispatches the equipment-conditional patch. The character meshes themselves come from PROT 0874 section 0 (character mesh), not from the PROT 876 stream this function names.

Source of record: docs/formats/tmd.md.

See also