Legaia TMD (3D mesh) Confirmed
Every solid thing in the game - Vahn and his Ra-Seru arm, every monster, the houses of Rim Elm, the props on a shop counter - is a small 3D mesh: a list of points and a list of triangles and quads that connect them, each painted with a texture or a flat colour. The container is TMD, the PlayStation SDK's model format, but Legaia ships a custom variant that stock TMD tools reject or draw as garbage. This page is the variant, byte for byte.
At a glance
- In the game
- Every 3D model: party, monsters, NPCs, scenery and props, battle arenas, world-map landmarks.
- Magic
0x80000002at offset 0 (stock PSX TMD is0x00000041)- Where
- Inside LZS-compressed PROT.DAT entries: scene bundles, field-packs, the monster archive (PROT 867), character packs, player battle files. Type byte
2in 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; CLItmd- 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
scaleis0x00808080, 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).
Header and object table
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | id | Always 0x80000002. Bit 31 = FLIST (pointers are offsets from the header end); low byte = format version 2. |
+0x04 | 4 | flags | 0 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. |
+0x08 | 4 | nobj | Object 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.
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | vert_top | Offset of the vertex array |
+0x04 | 4 | n_vert | Vertex count |
+0x08 | 4 | normal_top | Offset of the normal array |
+0x0C | 4 | n_normal | Normal count |
+0x10 | 4 | prim_top | Offset of the primitive section |
+0x14 | 4 | n_primitive | Sum of every group's count in the section |
+0x18 | 4 | scale | Always 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).
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0 | 2 | count | Records in this group |
+2 | 2 | flags | Selects the shape-table row; bit 1 = quad |
+4 | 1 | olen | SDK output length (GPU packet words) |
+5 | 1 | ilen | Record stride in words; bytes = ilen × 4 |
+6 | 1 | flag | SDK flag byte |
+7 | 1 | mode | SDK mode byte (FT3 / FT4 / GT3 / GT4 ...) |
+8 | count × ilen × 4 | records | Uniform 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.
| Row | flags (tri / quad) | Raw 8 bytes | byte3 | byte4 | Family |
|---|---|---|---|---|---|
| 0 | 0x10,11 / 0x12,13 | 04 00 00 05 07 00 00 00 | 0x05 | 0x07 | FT, lit |
| 1 | 0x14,15 / 0x16,17 | 09 00 00 07 06 00 00 00 | 0x07 | 0x06 | GT, lit |
| 2 | 0x18,19 / 0x1A,1B | 04 00 00 00 02 00 00 00 | 0x00 | 0x02 | F |
| 3 | 0x1C,1D / 0x1E,1F | 06 00 00 02 06 00 00 00 | 0x02 | 0x06 | G |
| 4 | 0x20,21 / 0x22,23 | 07 03 00 01 07 00 00 00 | 0x01 | 0x07 | FT, baked |
| 5 | 0x24,25 / 0x26,27 | 09 03 00 03 0B 00 00 00 | 0x03 | 0x0B | GT, 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.
| Shape | flags | ilen | Record |
|---|---|---|---|
| F4 | 0x1B | 3 (12 B) | [0..4) one colour for all corners; [4..12) 4 × u16 vertex offsets |
| G3 | 0x1D | 5 (20 B) | [0..12) v0 / v1 / v2 colour; [12..18) 3 × u16 vertex offsets; [18..20) pad |
| G4 | 0x1F | 6 (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.
| Shape | flags | ilen | Record |
|---|---|---|---|
| FT3 lit | 0x10/11 | 5 (20 B) | [0..12) texture block; [12..14) 1 normal offset; [14..20) 3 vertex offsets |
| FT4 lit | 0x12/13 | 6 (24 B) | [0..12) texture block; [12..20) 4 vertex offsets; [20..22) 1 normal offset; pad |
| GT3 lit | 0x14/15 | 6 (24 B) | [0..12) texture block; [12..18) 3 vertex offsets; [18..24) 3 normal offsets |
| GT4 lit | 0x16/17 | 7 (28 B) | [0..12) texture block; [12..20) 4 vertex offsets; [20..28) 4 normal offsets |
| FT* baked | 0x20..23 | 5 / 6 | [0..4) one colour word; texture block from byte 4; vertex offsets at byte4 |
| GT* baked | 0x24..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:
| byte3 | rows | lit renderer | general renderer |
|---|---|---|---|
| 0 | 2 | 2 | byte4 (= 2) |
| 1 | 4 | 8 | 8 |
| 2 | 3 | 8 | byte4 + 2 (= 8) |
| 3 | 5 | 0xE | 0xE |
| 5, 7 | 0, 1 | byte4 (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
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Model register | FUN_80026B4C | Rejects any file whose first word is not 0x80000002; writes the model into the pointer table at 0x8007C018 | funcs/80026B4C.txt |
| Pointer fixup | FUN_800268DC | Offsets → RAM addresses; flags sentinel | funcs/800268DC.txt |
| General renderer | FUN_8002735C, table read 0x80027460..500 | Shape-table row math, byte3 / byte4, quad remap DAT_8007B410 = 00 01 03 02; no normal read; only GTE colour op is DPCS | funcs/8002735C.txt |
| Lit renderer | FUN_80029888 | Second reader of the shape table DAT_8007326C; per-byte3 quad chain; no NC* lighting op | funcs/80029888.txt |
| Actor object-table rebuild | FUN_80024D78 | Copies 28-byte objects from tmd + 12 + i×28; relies on the sentinel | funcs/80024D78.txt |
| Disc sweep | every field / town env pack | Vertex-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 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 / 28 / 48 / 68 | 20 bytes each | Prims 0..3 (FT3-style) |
| 88 | zeros | Padding |
| 108 | 01 00 22 00 09 06 01 2f | Group 2 header: count=1, flags=0x22, olen=9, ilen=6, mode=0x2F |
| 116 | 24 bytes | Prim 4 (FT4-style) |
| 140 | zeros | Trailing 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:
| Function | Role |
|---|---|
FUN_80021B04 | Actor spawn: builds the 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 models) |
FUN_8001E890 | Player 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.