Files
llamamod/notes/geometry.md
T

220 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 by `geom_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)
1. (STPC only) nBones × u16
2. **Vertex** 24 bytes: `f32 x,y,z ; f32 nx,ny,nz` (smoothed normals; lighting comes from MAP vertex colours)
3. **Face** 28 bytes (triangles only):
`u16 flags ; u16 v0,v1,v2 ; u16 tex ; u16 0 ; f32 nx,ny,nz,d` with n = normalize((v1−v0)×(v2−v0)), d = n·v0
(verified for all faces). On load `tex` is replaced by a pointer to the texture table entry
(`DAT_00581154 + tex*20`). The plane is used for backface culling (camera·n − d ≥ ε).
4. **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_00556510` from 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:
1. `u32 N (tiles), u32 W, u32 H` (grid)
2. **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).
3. u32 n + n×u32 (list, referenced by strats)
4. u32 n + n×90 bytes (lights/cameras? – objects.md)
5. u32 n + n×34 bytes (path nodes)
6. **Zone table** 256 bytes = 32 × `{u32 visibility mask, i32 height value}` → `DAT_006da350`
7. **Zone grid** W·H × u32 (zone number 0..31 per cell) → world+0x48
8. **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.
9. **Model index**: N × u32 (= A.model)
10. 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.
11. **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).
12. 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 flat` sets 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):
1. **TRAK**: u32 count; header with correct nVerts/nFaces/nColl; box+centre (for culling,
recomputed); face planes from vertices; vertex normals. No checksums.
2. **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).
3. **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.
4. **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).
5. 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).
6. Total WAD length (wadtool/geomlib rewrite it). No other checksums found.
7. 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-space` on
`geom_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).