16 KiB
Level geometry (TRAK / MAP / STPC / TEXT) – Ein Königreich für ein Lama (PC)
As of: 2026-11-06. Source: groove.exe (Ghidra decompilation re/groove_decomp.c, Capstone) +
data analysis of all 34 level WADs. Tools: tools/geomlib.py, tools/geom_export.py,
tools/geom_import.py, tools/geom_demo.py, tools/disx86.py.
Confidence: [certain] = verified from code + data (byte-identical round trip / rendering),
[likely] = plausible from data, [?] = unknown.
1. Overview: which chunk is what
Chunk dispatcher: FUN_00558xxx at VA 0x55899e (compares against tag constants, tag read as u32 LE,
e.g. 'CPTS' in the file = 0x53545043).
| Chunk | Loader (VA) | Contents |
|---|---|---|
| WFPC | inline → DAT_006da330 |
u32 feature flags of the level (control MAP/STPC layout) [certain] |
| TEXT | 0x4067b0 / 0x406d30 | texture pages (256x256, 16 bit) + texture table (UV rectangles) [certain] |
| SMPC / AMPC / SRPC | 0x558d30 / 0x558d70 / 0x545350 | sound (SMPC: 'cvg ' VAG samples; AMPC: 'ambi'/'VABp'; SRPC sound references) – no geometry |
| TRAK | 0x42aac0 → 0x5563f0 | world geometry: tile models (render triangles + collision polygons) → global array DAT_005846ec [certain] |
| STPC | 0x42ab50 | object ("strat") models (same model format), skeletal animations, then strat script data (names like "SinkingStatuePad") [certain for models] |
| MAP | 0x42ac50 | tile grid: placement of the TRAK models + vertex colours + zone PVS (+ strats/paths/etc., see objects.md) [certain] |
| LGHT / LNFO / LGPC | 0x42c180 / 0x42ab10 / 0x558db0 | lights / level info / ? – not geometry |
The world is a tile system (inherited from Croc): TRAK contains N "tile" models in local coordinates (roughly −0.5..0.5 in x/z, but they can stick out up to ±2.4). MAP places them on a W×H grid (1 unit = 1 cell) with height y and rotation around y. There is no BSP; the spatial partition is the grid itself (cell lists) + a 32-zone visibility table.
2. Coordinate spaces [certain]
- Export space (= MAP floats "A", = vertex data of TRAK/STPC): y up, grid cells lie at x ∈ [0,W), z ∈ (−H, 0]. The engine draws it with a Direct3D camera, i.e. left-handed: a right-handed tool shows export-space data mirrored (see editor-render.md).
- Game space (MAP fixed point "B", collision, strats): (x, y, −z), 20.12 fixed point (4096 = 1). As the game shows it, game space is right-handed (y up).
- File space (OBJ/glTF of all tools, since 2026-11-07) = game space: right-handed, y up,
z > 0 in the grid, so Blender shows a level the same way round as the game and a model from
Blender appears in game as modelled. Converting export ↔ file negates z and reverses the corner
order (first corner kept: export (0,1,2) ↔ file (0,2,1);
geomlib.to_file/file_order). Used bygeom_export/geom_import/geom_demo(--export-space= old raw export space, z < 0), the editor's Import mesh, OBJ export and glTF export. Verified in game and Blender with an asymmetric "F" (img/editor/handed_*.jpg, editor-tools-geometry.md). The renderer converts B back: pos = (Bx, By, −Bz)/4096, angle = rot·2π/4096. - Tile transform (local vertex v → world, export space), a = rot/4096·2π:
X = x + vx·cos a − vz·sin a ; Y = y + vy ; Z = z + vx·sin a + vz·cos a(verified: 93 % of all tile edge vertices hit a neighbour vertex exactly; the three other sign/mirror variants give 65–70 %). - Collision data is in game space (z mirrored relative to the render vertices of the same model). Negate nz when decoding (92 % of the reconstructed collision corners lie < 0.02 from a render vertex).
- rx/rz of the tiles are 0 in all levels; only rotation around y (in practice 0/1024/2048/3072).
3. Model format (TRAK and STPC) [certain]
Fixup functions: TRAK FUN_005563f0(&ptr, n, &out) (header 0x84),
STPC FUN_0041f770(&ptr, n, &out) (header 0x8c). File layout: all headers of one call
in a row, then the data of the models in order (TRAK: one call with n=count;
STPC: n calls with 1 model each → header/data alternating). Pointer fields are 0 in the file
and are set on load.
Header
| Off | Type | Meaning |
|---|---|---|
| 0x00 | 3×f32 | centre of the bounding box |
| 0x0c | 8×3 f32 | box corners, order (−x−y+z)(+x−y+z)(−x+y+z)(+x+y+z)(−x−y−z)(+x−y−z)(−x+y−z)(+x+y−z). Slightly larger than the vertices (margin not constant, ~0.004–0.014; probably from the original mesh). Used for frustum culling (FUN_00402840). |
| 0x6c | u16 | nVerts |
| 0x6e | u16 | nFaces |
| 0x70 | u32 | → verts (file: 0) |
| 0x74 | u32 | → faces (file: 0) |
| 0x78 / 0x7a / 0x7c | 3×u16 | number of collision polygons in 3 groups (code always sums all three; group meaning [?]) |
| 0x7e | u16 | 0 |
| 0x80 | u32 | → collision (file: 0) |
| STPC only 0x84 | u16 | = nVerts for rigid models, 0 for skinned models |
| STPC only 0x86 | u16 | nBones (skinned models) – preceded in the data by nBones×u16 = vertex count per bone (sum = nVerts, verified) |
| STPC only 0x88 | u32 | → bone table (file: 0) |
Data (in this order)
- (STPC only) nBones × u16
- Vertex 24 bytes:
f32 x,y,z ; f32 nx,ny,nz(smoothed normals; lighting comes from MAP vertex colours) - Face 28 bytes (triangles only):
u16 flags ; u16 v0,v1,v2 ; u16 tex ; u16 0 ; f32 nx,ny,nz,dwith n = normalize((v1−v0)×(v2−v0)), d = n·v0 (verified for all faces). On loadtexis replaced by a pointer to the texture table entry (DAT_00581154 + tex*20). The plane is used for backface culling (camera·n − d ≥ ε). - Collision polygon 32 bytes, nGroupA+B+C of them:
u8 type ; u8 material ; 5 × { i8 nx, i8 ny, i8 nz, u8 0, i16 d }- plane 0 = surface, planes 1..4 = edges (normals pointing inwards, point inside ⇔ n·p − d ≥ −tolerance).
- normals ×127 (code multiplies by 32 → 4096 scale), d ×4096 (fixed point) → local coordinates only ±8 units!
- type bit0 = quad (4 edges), otherwise triangle (3 edges, 5th plane = 0). Bit2 ≈ normal points down (ny<0; ceilings/overhangs) [likely].
- material: ground type (0 = normal; 0x11/0x12 are ignored depending on WFPC 0x8000000, 0x1e = water/death plane [likely]; the value ends up in the player's ground state).
- Collision queries:
FUN_004036d0(floor/ceiling),FUN_00403ad0(walls),FUN_004042f0,FUN_004046e0. Only the tiles of the grid cell the player stands in are tested (cell list from MAP), point relative to the tile's B position, rotated by −rot.
Face flags (u16)
| Bit | Meaning | Confidence |
|---|---|---|
| 0x0008 | new render batch: reload render state (page, blend mode) from this face, draw the previous batch. Sorted by page/state + bit at each change. | certain (code) |
| 0x0010, 0x0800, 0x0020 | UV corner selection (see below) | certain |
| 0x0400 | no backface culling (double-sided) | certain |
| 0x4000 | ignore vertex colours (white) | certain |
| 0x0001, 0x0002 | special render modes (sorting/transparency; together with global flags 0x5f6ee8) | likely |
| 0x8000, 0x1000, 0x2000, 0x0200, 0x0100, 0x0004 | present in the data, the renderer (0x556510) does not evaluate them | ? (probably PS1 leftovers) |
UVs – the engine has NO free UVs [certain]
Every triangle shows one half of a texture rectangle (entry of the TEXT table). UV rectangle: U0=u0/256, U1=(u1+1)/256, V0=v0/256, V1=(v1+1)/256. With m = flag 0x20 (mirrors U), a = m?U0:U1, b = m?U1:U0 the corners (v0,v1,v2) get:
- without 0x800: (a,V1) (b,V1) (a,V0)
- 0x800, without 0x10: (a,V1) (b,V1) (b,V0) ← first half of a quad
- 0x800 + 0x10: (b,V0) (a,V0) (a,V1) ← second half
(renderer
FUN_00556510from the line "if ((uVar6 & 0x800) == 0)"). Textures with flag bit0 are flat colours (u0,u1,v0 = R,G,B). 96 % of world faces have 0x800.
4. TRAK chunk [certain]
u32 n ; n × header(0x84) ; data of model 0..n−1. All 34 level WADs parse exactly up to the
end of the chunk; geomlib.build_trak(parse_trak(x)) == x byte-identical for all levels.
Typically 100–650 models, max. 422 verts / 798 faces per model. Model 0 is, in many levels,
an invisible plane (4 verts, 0 faces, 1 collision polygon, material 0x1e) at y=−2.75 –
the water/fall plane below the islands.
5. MAP chunk – geometry parts (loader FUN_0042ac50) [certain]
(Strat/path/zone blocks: see notes/objects.md; only what concerns geometry here.) Order in the file:
u32 N (tiles), u32 W, u32 H(grid)- A: N ×
{f32 x, f32 y, f32 z, u32 rot, u32 model, u32 animFlag}(export space; x,z = cell centre .5). Only used to sort into the cell lists: cell = ftol(x − 0.5) − ftol(z + 0.5)·W (ftol truncates; 0x42ad4a, constant 0x56e158 = 0.5) = floor(x) + floor(−z)·W for cell-centred tiles (all originals). The player/strat cell of the collision tests is x>>12, z>>12 (FUN_004036d0). - u32 n + n×u32 (list, referenced by strats)
- u32 n + n×90 bytes (lights/cameras? – objects.md)
- u32 n + n×34 bytes (path nodes)
- Zone table 256 bytes = 32 ×
{u32 visibility mask, i32 height value}→DAT_006da350 - Zone grid W·H × u32 (zone number 0..31 per cell) → world+0x48
- B: N ×
{i32 rx, i32 ry, i32 rz, i32 x, i32 y, i32 z}20.12 fixed point, game space (z = −A.z), ry = A.rot. Used for rendering and collision. A and B are consistent in all levels. - Model index: N × u32 (= A.model)
- if WFPC & 0x10000:
u32 n ; n × {u32 cellX, u32 y, u32 cellZ, u32 modelA, u32 modelB}– tile swaps (doors/bridges etc.), entry k belongs to the k-th tile with animFlag≠0. - Vertex colours per tile i: nVerts(model) × u32 RGBA (0x80 = 1.0, game ×2 with clamp);
byte3 of the first entry = number of colour sets (0/1 = one; ≥2 → another nVerts×u32 follow,
the second set is used when
DAT_005865d4==1). For animFlag tiles the same structure follows for modelB. Pointer table world+0x58 (twice as large with WFPC 0x10000000). - Rest (u32 n2,n3 + 58-byte strat entries, optional paths (0x200000), view distance (0x10), u16) – objects.md.
geomlib.build_map_tiles(..., zones='keep', anim=orig) reproduces all MAP chunks byte-identically.
Grid / visibility (no BSP) [certain]
- Cell lists: world+0x40 (W·H pointers, linked lists of tile indices), built from A.
- Rendering (
FUN_0042bf40): rectangle around the camera (view distance) over the grid; for every cell all tiles in the list, but only if bit zone[cell] is set in the mask of the camera zone (DAT_0058642c= zonetable[zone[camera cell]].mask,FUN_0041e020). Zone 0 has mask 0x1 in the village level. → New geometry must get matching zones, otherwise it is invisible.geom_import --zones flatsets grid=0 and mask(zone 0)=0xffffffff (everything visible). - Collision: only tiles of the player's cell (see above).
6. STPC chunk (objects) [certain up to animations]
u32 n1 ; n1 × (header 0x8c + data) static/skinned object models (33 in the village) ;
u32 n2 ; n2 × animation (FUN_0041f8b0, header 32 bytes: +0 nA (nA×4 bytes), +8 nFrames,
+0xc (0 → nFrames×8 bytes), +0x10 nDelta (frame 0: nDelta×8, otherwise nDelta×2 (+2 if odd)
bytes of compressed vertex deltas), +0x18 nBones (per frame nBones×64 bytes = 4×4 matrix)) ;
if WFPC & 0x100: u32 n3 ; n3 × {16-byte head (+4 count, +8 → n×8 bytes), models} ;
then strat data (scripts, names) – not parsed by the loader, referenced from MAP via offsets
(DAT_006d9dbc+off). geom_export --objects exports the n1 models side by side.
7. TEXT – as far as needed for geometry [certain]
u32 nPal ; nPal×{u32, u32 n, n×u16, n×u8} ; u32 nPages ; nPages×{u32 flags, u32 w, u32 h, (flags&0x80: u32 len + RLE16) | (flags≠0: w·h·2) | w·h} ; u32 nTex ; nTex × 8 bytes ; rest (40 bytes in the village) [?].
Texture entry: u16 flags, i8 page, i8 page2(-1), u8 u0, u8 u1, u8 v0, u8 v1 (u1/v1 inclusive).
Flags: bit0 flat colour, bit2 1-pixel border, bit3/bit4 width/height ×2, bits 12–14 blend mode, bits 8–10 another mode.
Pixels: ABGR1555 (R = bits 0–4), 0 = transparent. Details: notes/textures.md (other agent).
8. Writing new geometry – what must be consistent
Implemented in tools/geom_import.py (OBJ → TRAK + MAP tile part, rest of the WAD unchanged):
- TRAK: u32 count; header with correct nVerts/nFaces/nColl; box+centre (for culling, recomputed); face planes from vertices; vertex normals. No checksums.
- Face texture index < number of texture table entries; UVs only via rectangle corners (+flags). Bit 0x8 at the start of a batch (page/state change, at the latest every 256 faces).
- Limits: ≤ 8000 vertices per model (transform buffer 0x58b0f0..0x5d15f8, 36 bytes/vertex), batch output buffer 0x5d15f8 ≈ 4800 vertices; collision d as i16/4096 → collision polygons only within ±8 units of the tile origin. u16 indices.
- MAP: keep N in the header, A (floats) and B (fixed point, z negated) identical, model indices < TRAK count, tile cells within W×H, vertex colour block exactly nVerts(model) per tile (otherwise the rest of MAP = strats shifts!), animation block (WFPC 0x10000) matching the flags (import sets it to 0 entries), zones (see above).
- Collision belongs to the cell: a polygon only takes effect if it is in the tile list of the cell the player stands in → import enters every polygon into every cell it covers (also as a pure collision tile without faces, like original model 0).
- Total WAD length (wadtool/geomlib rewrite it). No other checksums found.
- Strats (start point, enemies, camera paths) remain at their old positions (objects.md).
Tests (without starting the game)
- Round trip TRAK and MAP of all 34 WADs byte-identical.
- Export → import (
--coll-obj) → export t1l1m001: rendering identical (img/), 26435/26442 triangles (7 degenerate ones dropped), floor height at 2800 random points: 99 % < 5 mm deviation, 10 points differ (polygons now also entered in neighbouring cells). - Independent cross-check:
tools/mapchunk.py(other agent) parses the generated MAPs completely. - NOT yet tested in game: demo WADs in $CLAUDE_JOB_DIR/tmp:
demo_add.wad(original + 2 blocks + ramp, original zones),demo.wad(everything replaced by a floor plate y=0 + blocks).
9. Open / unknown
- Meaning of the three collision groups (the sum is always used together) and the complete material list.
- Face flags 0x8000/0x1000/0x2000/0x200/0x100/0x4; exact effect of 0x1/0x2.
- Exact origin of the box margin in the header (recomputing with a 1/128 margin is probably enough).
- Zone table 2nd value (height,
DAT_00586424) – possibly water/fog height. - STPC animation: meaning of the nA block and the delta compression in detail (
FUN_0041fa30). - Whether the player start point/camera is coupled to geometry (objects.md).
10. Tools
- OBJ files of all three tools are file space = game space (section 2);
--export-spaceongeom_export/geom_import= the old raw export space (z < 0). geom_export.py WAD OUT [--collision] [--objects] [--export-space]→ level.obj/mtl (UV, vertex colours, one object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png (textured software renderer, right-handed camera, always in game space – the same way round as in the game, checked against an in-game shot of t1l1m001:img/geom_preview_handedness.jpg; before 2026-11-07 the previews rendered export space and were mirrored).tools/vorschau/render_level.py(Blender) reads file space and is not mirrored either (checked on the test world, 2026-11-07).geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]geom_demo.py BASE.wad demo.obj [--plaza]– example geometry made of texture-tile quads.geomlib.py– parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).