313 lines
24 KiB
Markdown
313 lines
24 KiB
Markdown
# 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
|
||
|
||

|
||
|
||
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).
|