Files
llamamod/notes/geometry.md
T

313 lines
24 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 against the map tiles), `FUN_00403ad0` (the same floor test against strat objects with a collision model, not walls – see the floor test section below), `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.
### Floor test: inclusion rule and the floating-next-to-the-log case
![top and side view of the needle triangle next to the crate](img/floor_explained_t1l4m002.png)
The crate has two such needles, one per long side: poly 15 (z 43.68–43.73, measured live) and poly 11 (z 43.29–43.38, same shape, where a user got stuck). Drawn with `tools/floorre/explain_t1l4m002.py` (builds on the floor-test agent's plot).
(2026-11-08, from the decompile + objdump of 0x4036d0–0x403ad0, a bit-exact Python re-implementation
`tools/floortest.py`, and live reads in game. [certain] unless marked.)
**Who calls what.** Per strat and frame `FUN_00403020(strat, models=DAT_005846ec, world=DAT_00584648)`:
`FUN_004036d0` = floor test against the **map tiles of the strat's cell**; `FUN_00403ad0` = the same test
against **strat objects with a collision model** (strat+0xec bit 0x20, within the box radius strat+0x13c;
result index = (strat − DAT_006d9dc0)/0x154), skipped when strat+0xe8 bit 0x400. Neither is a wall test.
The map result wins unless the object floor is higher. Result → floor record *(strat+0xc0):
+0 height, +4 material (record byte 1), +6 flags (bit0 map floor, bit1 object floor), +0xc tile, +0xe polygon.
Material 0x1e → treated as "no floor" (height 0xffff0000).
**FUN_004036d0, per tile of the cell, per collision record** (all ints, 20.12):
1. Probe point, tile-local: `lx,lz` = (strat x/z − tile B x/z) rotated by −ry with the static sine table
`DAT_00574318` (4096 entries, cos = sin[i+0x400]), `ly = strat y − tile y + DAT_0057dd98`;
DAT_0057dd98 = **0x400 (0.25)**, or floor-record+0x18 / 2 for strats with flag 0x200000.
2. Skip material 0x11 (0x12 instead when strat+0xe8 bit 0x8000000). Skip ny = 0; skip ny < 0 unless flag 0x200000.
3. Height of the probe above the surface plane, measured **vertically**:
`i6 = (d0·4096 − nz0·32·lz − nx0·32·lx − ny0·32·ly) / (ny0·32)` (C division), `dist = −i6`.
The plane point under/over the probe is `Y = ly + i6` (plane equation solved for y at (lx,lz)).
4. `dist < −0x80` (plane more than 1/32 above the probe) → ceiling/"surface above" branch (below).
Else, if `dist < best` (strictly; initial 1000000) and ny > 0 → edge test.
5. **Edge test** (3 edges, 4 if type bit0), on the point (lx, Y, lz) *on the plane*:
`e_k = ((nyk·32·Y + nzk·32·lz + nxk·32·lx) >> 12) − dk`; **fail if e_k < tol** with
`tol = −0x40 − ((0x1000 − ny0·32) >> 6)` — i.e. 64 + (4096−4064·ny)/64 units of 1/4064:
**≈ 0.016 for flat floors, 0.023 at ny 0.5** (perpendicular distance outside the edge plane).
No radius, no vertex test, no other use of the record (bytes 5, 11, … are padding; for triangles the
5th plane is never read). The point must be inside (within tol) **every** edge plane.
6. All edges pass → `best = dist`, height = tile y + Y, tile, polygon index (counted over groups A+B+C).
**Selection rule: the passing plane with the smallest dist, i.e. the highest surface not more than
0.25 + 1/32 above the feet.** Consequence: a passing surface up to 0.28 above the feet wins over the
ground under the llama, and the llama is put onto it (verified, step 3 below).
7. Ceiling branch (dist < −0x80): passing up-facing planes are kept as "lowest surface above"
(local_70/local_5c). `FUN_00403020` uses that as the floor when no floor below was found
(unless strat+0xe8 & 0x40200) – from the decompile only, not observed.
**Why polygon edges reach out: the tolerance turns sharp corners into long spikes.**
Every edge is moved outwards by `tol`. At a corner with interior angle θ the accepted area therefore
sticks out by ≈ tol / sin(θ/2): 0.023 at a right angle (every normal log/crate edge, all quads of the
path), but ~0.19 at a 13.5° corner.
**The case (t1l4m002, world 4 chapter 2).** Cell 57,43 contains only tile 443 = model 129 (the
wooden crate/log with its piece of path) at B (57.5, 2.0, 43.5), ry 3072. The transform was checked
three ways: render faces (with z mirrored, see §2) and collision of model 129 line up (crate box on the
crate box); the bit-exact engine re-implementation gives exactly the live floor record; the debug-wireframe
screenshot shows the crate's cut end right next to the llama. Collision polygon 15 (type 0 = triangle, mat 0,
plane (108,65,10)/127 → ny 0.51, a steep bevel strip along the crate's side) is a **needle triangle**:
corners (world x/y/z) (57.725, 2.553, 43.734), (57.750, 2.651, 43.677), (57.315, 2.497, 43.729); the corner at
57.315 has **13.5°** and points away from the crate, exactly along the crate's side face (x ≈ 57.31).
Its tolerance zone is a 0.01–0.02 wide sliver that continues ~0.19 beyond the tip, over the path
(pink in `img/floor_needle_t1l4m002.png`). Kuzco's position 57.204 / 43.733:
* local (0.2329, ·, 0.2959); poly 15 edges e = (−37, 2005, −72) ≥ tol −95 → **passes**; Y = 2.4744 (the
steep plane extended 0.11 beyond its tip), dist 0.2498.
* poly 2 (path, ny 0.99) also passes, e = (525, 304, 850, 1101), height 2.270, dist 0.454 → loses
because 2.474 is higher and still within 0.28 of the feet.
* The sliver at x = 57.204 is only z 43.7300–43.7402 wide; x reaches down to ≈57.12.
* Why exactly x = 57.204 [verified, mechanism not decoded]: putting the llama into the crate
(x 57.25, z 43.60 / 43.70 / 43.733) the game pushes it back to x = 57.202–57.204 (wall push-out from the
crate side at ~0.11 distance). So walking along/against the crate's side face puts the llama exactly on
the line the needle's tolerance sliver extends to; only the ~1 cm band at the corner's z catches it.
**In game** (apptest :25, boot-to-level start "57.204, 2.474, 43.733, 0 @ 4/2", winedbg --gdb read/write of
strat +0x30/+0x50 and the floor record):
| written pos (x y z) | game afterwards: pos / floor h / poly | re-implementation |
|---|---|---|
| boot start 57.204 2.474 43.733 | 57.204/2.474/43.733, h 2.474, mat 0, tile 443 **poly 15** | poly 15, 2.4744 |
| 57.204 **2.27** 43.733 (on the path) | snapped **up** to y 2.474, poly 15 | poly 15, 2.4744 |
| 57.204 2.27 43.736 | y 2.469, poly 15 | poly 15, 2.4690 |
| 57.204 2.27 43.745 | y 2.270, poly 2 | poly 2, 2.2700 |
| 57.15 2.27 43.733 | y 2.266, poly 2 | poly 2, 2.2661 |
| 57.25 2.3 43.70 | pushed to x 57.204, y 2.270, poly 2 | – |
Screenshots: `img/floor_float_t1l4m002.jpg` (left: boot at the floating spot, poly 15; right: 0.012 further
in z on the path, poly 2; debug wireframe on), `img/floor_float_t1l4m002_full.jpg`.
After teleporting onto poly 15 with the debugger the llama sometimes slid off within seconds (steep
plane, ny 0.51); started there by boot-to-level it stays [observed, not analysed].
**How common.** Scan of all 31 t1 level WADs (sample points 0.3/0.6/0.9 of the way from each corner to its
tolerance tip, exact engine test over all tiles of that cell, counted when this polygon wins, the point
is ≥ 0.08 outside the real polygon and the next lower floor is > 0.05 below): 2–526 polygons per level,
t1l4m002: 110 (of 6468 floor-polygon instances), poly 443/15 among them (predicted spot 57.199/2.469/43.735).
Most are sharp triangle tips of terrain/rock meshes at ledges; normal right-angled log/crate edges
reach only 0.023 and never show it. [Counts are a heuristic scan; reachability of the spots not checked.]
Scripts: `tools/floortest.py` (single position); scan code: `$CLAUDE_JOB_DIR/tmp/floorre/exposed.py` + `reach.py` (scratch, not in the repo).
### 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).
* `floortest.py WAD X Y Z` – bit-exact re-implementation of the engine's map floor test (FUN_004036d0) at a world
position: every candidate polygon of the cell, its edge values/tolerance, and the chosen floor. Note: the engine's
plane scale is n/127 with d/4064 (normal×32 vs d×4096), `geomlib.coll_decode` uses d/4096 (0.8 % off).