diff --git a/notes/geometry.md b/notes/geometry.md index 8e73b4a..71df9a5 100644 --- a/notes/geometry.md +++ b/notes/geometry.md @@ -88,7 +88,93 @@ and are set on load. * 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. + * 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 +(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 | @@ -217,3 +303,6 @@ Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the * `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). diff --git a/notes/img/floor_float_t1l4m002.jpg b/notes/img/floor_float_t1l4m002.jpg new file mode 100644 index 0000000..3aa442a Binary files /dev/null and b/notes/img/floor_float_t1l4m002.jpg differ diff --git a/notes/img/floor_float_t1l4m002_full.jpg b/notes/img/floor_float_t1l4m002_full.jpg new file mode 100644 index 0000000..3b0f8e5 Binary files /dev/null and b/notes/img/floor_float_t1l4m002_full.jpg differ diff --git a/notes/img/floor_needle_t1l4m002.png b/notes/img/floor_needle_t1l4m002.png new file mode 100644 index 0000000..fb27601 Binary files /dev/null and b/notes/img/floor_needle_t1l4m002.png differ diff --git a/tools/floortest.py b/tools/floortest.py new file mode 100644 index 0000000..860cd52 --- /dev/null +++ b/tools/floortest.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Exact re-implementation of the engine's map floor test (FUN_004036d0, floor branch) on raw collision records. + + floortest.py LEVEL.wad X Y Z world position in cells (game space, z = -A.z); prints every polygon of the + cell's tiles that is a floor candidate, whether it passes, and the winner + +Integer arithmetic as in groove.exe (20.12 fixed point, sine table read from re/groove.exe, C division). +See notes/geometry.md "Floor test: inclusion rule and the floating-next-to-the-log case". +""" +import os +import struct +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import geomlib as G # noqa: E402 + +_EXE = os.path.join(os.path.dirname(HERE), 're', 'groove.exe') +SIN = struct.unpack_from('<4096i', open(_EXE, 'rb').read(), 0x574318 - 0x400000) # DAT_00574318 +PROBE = 0x400 # DAT_0057dd98 (0.25) unless strat flag 0x200000 + + +def _c8(b): + return b - 256 if b > 127 else b + + +def _cdiv(a, b): + q = abs(a) // abs(b) + return q if (a >= 0) == (b > 0) else -q + + +def to_local(px, pz, tile_b): + """World (fixed) -> tile-local x/z exactly like 0x4037b3..0x4037f1 (rotation by -ry).""" + lx, lz = px - tile_b[3], pz - tile_b[5] + u = (-tile_b[1]) & 0xffffffff + if u: + s, c = SIN[u & 0xfff], SIN[(u + 0x400) & 0xfff] + lx, lz = (s * lz + c * lx) >> 12, (c * lz - s * lx) >> 12 + return lx, lz + + +def test_poly(r, lx, ly, lz): + """One 32-byte record. ly = probe height (feet - tile y + 0.25). Returns None (skipped) or + (dist, plane_y_local, passed, edge_values, tolerance); dist = probe height above the plane.""" + ny32 = _c8(r[3]) * 32 + if ny32 <= 0: # only up-facing planes (0x200000 strats also take down-facing ones) + return None + d0 = struct.unpack_from('> 6) + vals = [] + for k in range(4 if r[0] & 1 else 3): + nx, ny, nz, _, d = struct.unpack_from('> 12) - d) + return dist, y, all(v >= tol for v in vals), vals, tol + + +def floor(wad, x, y, z, wfpc_8000000=False): + ch, wf, trak, mp = G.load_level(wad) + px, py, pz = (int(round(v * 4096)) for v in (x, y, z)) + skip_mat = 0x12 if wfpc_8000000 else 0x11 + best = None + rows = [] + for t in range(mp['N']): + b = mp['B'][t] + if (b[3] >> 12, b[5] >> 12) != (px >> 12, pz >> 12): + continue + lx, lz = to_local(px, pz, b) + ly = py - b[4] + PROBE + recs = [r for g in trak[mp['midx'][t]].coll for r in g] + for i, r in enumerate(recs): + if r[1] == skip_mat: + continue + res = test_poly(r, lx, ly, lz) + if res is None or res[0] < -0x80: + continue # plane more than 1/32 above the probe: ceiling branch, not a floor + dist, yl, ok, vals, tol = res + rows.append((t, i, r[1], dist / 4096, (b[4] + yl) / 4096, ok, vals, tol)) + if ok and (best is None or dist < best[0]): + best = (dist, t, i, (b[4] + yl) / 4096) + return rows, best + + +if __name__ == '__main__': + if len(sys.argv) != 5: + sys.exit(__doc__) + rows, best = floor(sys.argv[1], *map(float, sys.argv[2:5])) + for t, i, mat, dist, h, ok, vals, tol in rows: + print(f'tile {t} poly {i:2d} mat {mat:3d} probe-plane {dist:+.4f} plane y {h:.4f} ' + f'{"PASS" if ok else "fail"} edges {vals} (tolerance {tol})') + print('floor:', f'tile {best[1]} poly {best[2]} height {best[3]:.4f}' if best else 'none in this cell')