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 (legaia_prims walker, descriptor shape table, mesh builders); 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.
  • Mostly baked shading - most rows carry a colour word that modulates the texel; only the “lit” rows carry normals, and only the field's per-primitive dispatcher shades them through the GTE light (below).

File layout

+0x00header12 B
+0x0Cobjects28 B each
12 + vert_topvertices8 B each
12 + normal_topnormals8 B each
12 + prim_topprimitive sectiongroups

The primitive section is a chain of groups, each an 8-byte header followed by count uniform records, with zero padding between groups:

groupheader8 B
+8count × (ilen × 4) bytesrecords
next groupheader8 B
+8records…
 zero padding 

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

+0x00id = 0x80000002u32
+0x04flags (0 on disc)u32
+0x08nobju32

Bit 31 of id is FLIST (pointers are offsets from the header end); the low byte is format version 2. flags is 0 on disc; the loader sets it to 1 after pointer fixup and skips a second fixup when it reads 1, so a tool must never write this word.

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.

+0x00vert_topu32
+0x04n_vertu32
+0x08normal_topu32
+0x0Cn_normalu32
+0x10prim_topu32
+0x14n_primitiveu32
+0x18scale = 0x00808080u32

n_primitive is the sum of every group's count in the object's primitive section.

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. A parser that reads typed offsets instead of patching in place (as crates/tmd does) never needs 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).

+0countu16
+2flagsu16
+4olenu8
+5ilenu8
+6flagu8
+7modeu8
+8count × ilen × 4 bytes of recordsrecords

flags selects the shape-table row (bit 1 = quad); ilen is the record stride in words. olen, flag and mode are the SDK's output length, flag and mode bytes (FT3 / FT4 / GT3 / GT4 ...). Records have a uniform stride and 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].

A primitive section is a chain of groups, each an 8-byte header and a run of same-sized records. One record, a lit textured quad, expanded: a 12-byte texture block naming a palette cell and a texture page, four vertex offsets into the vertex array, one normal offset, and padding. primitive section of one object group header count, flags, ilen record record record ... zero pad group header another shape records ... count records, each ilen x 4 bytes one record: lit textured quad (flags 0x12, ilen 6 = 24 bytes) u0 v0 cba u1 v1 tsb u2 v2 u3 v3 v0 v1 v2 v3 n pad +0 +12 +20 palette cellin VRAM texture pagein VRAM vertex arrayoffset / 8 = index normal arrayoffset / 8
Records in a group share one stride and one layout, so the group header is the only place shape is stated. A record never stores coordinates: it points at shared vertices and normals by byte offset, and at a palette and texture page in video memory by the cba / tsb words.

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

The one exception is the FT3 lit tri, which puts its single normal before the vertices - which is what row 0's byte4 (7, byte 14) encodes. 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).

Where the light comes from

Neither of the two table-walking renderers (FUN_8002735C, FUN_80029888) reads a normal or issues a GTE lighting op: their only colour op is the depth cue (DPCS), and shading is the stored colour word modulating the texel on the GPU (texel × colour / 128). The lit rows' normals are read by a different path - the field's per-primitive dispatcher FUN_80043390, which every measured field frame's polygons come from. Flags 0x10..=0x17 select its NCCS / NCCT handlers, which shade each corner through the GTE light from the normal index in the packet. Every other row is unlit. See shading and 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.

How we know

Routines and disc sweep
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)-

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