Legaia TMD (3D mesh) Confirmed
TMD is the PlayStation SDK's standard 3D model format - vertices, normals, and drawing primitives. Every 3D mesh in Legend of Legaia (characters, monsters, houses, props) is a TMD, but it is a custom Legaia variant, not the stock Sony format: if you point a standard PSX TMD tool at these files it will reject them or produce garbage. This page documents the variant byte-for-byte.
Not stock PSX TMD
The tell is the very first word: Legaia's magic is 0x80000002 instead of the standard 0x00000041. Beyond the magic, the differences run deeper:
- Custom primitive grouping - primitives are batched into groups behind an 8-byte group header instead of the stock per-primitive packet stream.
- Offset pointers - the object table's pointer fields are byte-relative offsets that the runtime patches to absolute RAM addresses on load.
- A fixed scale word - the per-object
scalefield is always0x00808080, a Legaia-custom value where standard PSX TMD stores a signed log2 scale.
Implementation: crates/tmd/src/lib.rs + crates/tmd/src/legaia_prims.rs. Reverser context (decompiled-function dumps from Ghidra, the disassembler this project uses - FUN_80xxxxxx names are addresses of traced functions in SCUS_942.54, the game's main executable): ghidra/scripts/funcs/{80026B4C, 800268DC, 8001F05C, 8002735C}.txt.
Header (12 bytes)
u32 id // ALWAYS 0x80000002 in Legaia. Bit 31 = FLIST_BIT
// (pointers are byte offsets relative to header end);
// low byte 0x02 = "Legaia TMD format version 2"
u32 flags // 0 on disc; runtime sets to 1 after pointer fixup
u32 nobj // number of objects
The 0x80000002 magic was confirmed via the dev string "Model Version Err: %x" in FUN_80026B4C - the registration function bails when *tmd != 0x80000002.
Object table (28 bytes per object × nobj)
A TMD holds one or more objects - independent sub-meshes (a character's limbs, a scene's props). Each gets a 28-byte descriptor:
u32 vert_top // byte offset from end of header (0x0C)
u32 n_vert
u32 normal_top // byte offset from end of header
u32 n_normal
u32 prim_top // byte offset from end of header
u32 n_primitive // SUM of all primitives across primitive-section groups
i32 scale // ALWAYS 0x00808080 in Legaia (Legaia-custom; standard
// PSX uses signed log2 scale)
After FUN_800268DC runs at load time, vert_top / normal_top / prim_top are patched in-place to absolute RAM addresses. Static tools should NOT do this patch - they should use the offsets as (ptr_base + offset) where ptr_base = 12 (HEADER_SIZE).
Vertex / normal data
Both are arrays of SVECTOR { i16 x, y, z, pad } = 8 bytes each - 16-bit integer coordinates, the native input format of the PSX's GTE (the console's geometry co-processor).
pub struct Vector { pub x: i16, pub y: i16, pub z: i16, pub _pad: i16 }
Primitive section
This is where Legaia departs furthest from stock TMD. The primitive section is a sequence of groups: each group has an 8-byte header followed by a fixed-stride array of primitives (a “prim” being one triangle or quad to draw).
group header (8 bytes):
+0 u16 count // how many primitives in this group
+2 u16 flags // selects entry in per-mode table (see below)
+4 u8 olen // PSX SDK "output length" (packet word count)
+5 u8 ilen // PSX SDK "input length" -- per-prim WORD stride
// per-prim byte stride = ilen * 4
+6 u8 flag // PSX SDK flag byte (lighting / shading / etc)
+7 u8 mode // PSX SDK mode byte (FT3/FT4/GT3/GT4/etc)
prim data (count × ilen*4 bytes):
count × [ilen u32 words]
n_primitive in the OBJECT header is the sum of count across all groups in the object's primitive section.
The per-prim layout depends on the prim type, and the runtime resolves it through a lookup table rather than the stock TMD mode byte. The renderer (FUN_8002735C) treats the 8-byte-stride table at 0x8007326C as a packed {u32 first; u32 second} per row, selects row = ((flags >> 1) - 8) >> 1, and reads exactly two fields: byte3 = first >> 24 (the shape selector, & 3 = F/FT/G/GT) and byte4 = second & 0xFF (the base vertex-index offset in u16 units):
| flags (tri / quad) | row | raw 8 bytes | byte3 (shape) | byte4 (vtx off) |
|---|---|---|---|---|
| 0x10,11 / 0x12,13 | 0 | 04 00 00 05 07 00 00 00 | 0x05 | 0x07 |
| 0x14,15 / 0x16,17 | 1 | 09 00 00 07 06 00 00 00 | 0x07 | 0x06 |
| 0x18,19 / 0x1A,1B | 2 | 04 00 00 00 02 00 00 00 | 0x00 | 0x02 |
| 0x1C,1D / 0x1E,1F | 3 | 06 00 00 02 06 00 00 00 | 0x02 | 0x06 |
| 0x20,21 / 0x22,23 | 4 | 07 03 00 01 07 00 00 00 | 0x01 | 0x07 |
| 0x24,25 / 0x26,27 | 5 | 09 03 00 03 0B 00 00 00 | 0x03 | 0x0B |
byte3 & 3 is the shading / texture family (0 = F flat untextured, 1 = FT flat textured, 2 = G gouraud untextured, 3 = GT gouraud textured); the quad bit (flags >> 1) & 1 picks tri vs quad. The vertex read offset is byte4 with a quad adjustment (+2, overridden to 8 when byte3==1 and 0xE when byte3==3, the +2 cancelled when byte3==0; × 2 for the byte offset). The crate's legaia_tmd::descriptor::TABLE is authoritative.
Per-prim colour / texture block
The prim's leading bytes hold either a texture block (FT*/GT*: [u0, v0, cba][u1, v1, tsb][u2, v2 (, u3, v3)] - cba is the CLUT-base address naming which palette row in video memory to use, tsb the texture-page selector naming which VRAM region the UVs index into) or a per-vertex colour block (F*/G*), selected by the byte3 shape. For textured prims the block's start comes from the table entry's byte 1 (Descriptor::texture_block_offset):
- byte1 = 0 (rows 0/1, flags
0x10..0x17): the light-source-lit variant - the texture block sits at offset 0, ahead of the vertices, with per-vertex data trailing the vertex indices. An earlier walker assumedblock_start = vertex_offset - block_len(true only for the baked-colour rows), so these prims read(cba, tsb)from geometry bytes and rendered as rainbow garbage - the Rim Elm decorative-plant props. - byte1 = 3 (rows 4/5, flags
0x20..0x27): baked per-vertex colours precede the block - one colour word for flatFT*,n_verticeswords for gouraudGT*- and the block ends at the vertex-index offset.
The colour word is PSX [R, G, B, code] (the 4th byte is the SDK GP0 code, dropped). Pinned byte-exact from the renderer + real town01 props:
- F4 (flags 0x1B, 12-byte record): one colour word at offset 0 broadcast to all 4 verts, then 4×u16 vertex byte-offsets; quad winding
[0,1,3,2]. - G3 (flags 0x1D, 20-byte record): 3 colour words at a 4-byte stride, then 3×u16 vertex offsets.
- G4 (flags 0x1F, 24-byte record): 4 colour words read in winding order
[0,1,3,2](tableDAT_8007b410 = 00 01 03 02, applied to both colours and vertices), then 4×u16 vertex offsets.
A surprise for anyone expecting PSX-style light sources: neither renderer lights from the normal array. FUN_8002735c reads only the prim pointer, the vertex base and the loop count, never the object's normal table, and the sibling FUN_80029888 that handles the lit rows issues no NC* op either. Between them the two run exactly one GTE colour op, DPCS (the depth cue), so the normals are authored but never used - shading is the stored colour word modulating the texel on the GPU (texel * colour / 128).
legaia_tmd::legaia_prims decodes the colours into Prim::colors for every prim, and they reach the engine as a per-vertex attribute on the VRAM-mesh pipeline (see renderer § Lighting).
Worked example
Small TMD 0001.tmd (5 prims, 168-byte section):
| Section offset | Bytes | Meaning |
|---|---|---|
| 0 | 04 00 20 00 07 05 01 27 |
Group 1 header: count=4, flags=0x20, olen=7, ilen=5, flag=0x01, mode=0x27 |
| 8 | (20 bytes) | Prim 0 (5 words = 20 bytes; FT3-style) |
| 28 | (20 bytes) | Prim 1 |
| 48 | (20 bytes) | Prim 2 |
| 68 | (20 bytes) | Prim 3 |
| 88 | (zeros) | Padding |
| 108 | 01 00 22 00 09 06 01 2f |
Group 2 header: count=1, flags=0x22, olen=9, ilen=6, flag=0x01, mode=0x2F |
| 116 | (24 bytes) | Prim 4 (6 words = 24 bytes; FT4-style) |
| 140 | (zeros) | Trailing padding |
Big TMD 0000.tmd (760 prims, 15232-byte section):
| Section offset | Bytes | Meaning |
|---|---|---|
| 0 | f8 02 10 00 07 05 00 26 |
Group header: count=0x02F8=760, flags=0x0010, olen=7, ilen=5, flag=0x00, mode=0x26 |
| 8 | (20 bytes per prim) | Prim 0 - start of uniform 20-byte stride |
| 8 + 760×20 = 15208 | Trailing 24 bytes of padding |
The per-prim data IS uniform 20 bytes (= ilen*4) - there are no per-prim sub-headers. Walker: legaia_tmd::legaia_prims::iter_groups.
TMD pointer table
At runtime the game keeps a global registry of loaded models. FUN_80026B4C writes registered TMDs to *(int **)(idx * 4 + 0x8007C018). Confirmed readers in retail (4 functions, all setup-not-render):
| Function | Role |
|---|---|
FUN_80021B04 |
Actor-spawn helper; builds per-actor OBJECT pointer table at actor[0x44]+4 |
FUN_80024D78 |
Per-actor OBJECT-table rebuild |
FUN_8001EBEC |
Per-frame OBJECT[10/11] swap (pose select for player TMDs) |
FUN_8001E890 |
“DATA_FIELD player loader” - calls FUN_8003eb98(0x36C, …) (PROT 876 = player_data, entry 876 of PROT.DAT, the disc's single big archive) and the dev paths data\field\player.lzs / h:\prot\all\data\field\player.lz. The retail bytes the loader reads (PROT 876 = streaming-format VAB+TIM_LIST+SEQ; the dev data\field\player.lzs file is absent from the ISO9660 walk) do not carry the [0..4] character TMDs. Those come from PROT 0874 (befect_data) section 0 - see world-map-overlay.html § Disc-side source of [0..4]. What this function does do that's still consumed at DAT_8007C018[0..2] is the post-install group-count cap (entry[+0x08] = 10) and the equipment-conditional patch dispatch into FUN_8001EBEC. |
The per-actor OBJECT[i] is a 28-byte struct copied into actor[0x44][i+1] from tmd + 12 + i*28 - sizeof(OBJECT) = 28.
The renderer itself is documented separately under renderer subsystem.