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 scale field is always 0x00808080, 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,13004 00 00 05 07 00 00 000x050x07
0x14,15 / 0x16,17109 00 00 07 06 00 00 000x070x06
0x18,19 / 0x1A,1B204 00 00 00 02 00 00 000x000x02
0x1C,1D / 0x1E,1F306 00 00 02 06 00 00 000x020x06
0x20,21 / 0x22,23407 03 00 01 07 00 00 000x010x07
0x24,25 / 0x26,27509 03 00 03 0B 00 00 000x030x0B

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 assumed block_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 flat FT*, n_vertices words for gouraud GT* - 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] (table DAT_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.

See also