floor test inclusion rule from the code (edge planes with a small tolerance, highest plane wins): explains kuzco floating next to the crate in jungle day 2 (needle triangle tip), tools/floortest.py re-implements the engine test

This commit is contained in:
MineTec committed 2026-11-08 16:15:28 +01:00
1 parent c39d58bdac
commit 9e8e7f9a10
5 files changed
+183 -1

No files matched your search

+90 -1
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+93
View File
@@ -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('<h', r, 6)[0]
i6 = _cdiv(d0 * 4096 - _c8(r[4]) * 32 * lz - _c8(r[2]) * 32 * lx - ny32 * ly, ny32)
dist = -i6
y = ly + i6 # the probe dropped vertically onto the plane
tol = -0x40 - ((0x1000 - ny32) >> 6)
vals = []
for k in range(4 if r[0] & 1 else 3):
nx, ny, nz, _, d = struct.unpack_from('<bbbBh', r, 8 + 6 * k)
vals.append(((ny * 32 * y + nz * 32 * lz + nx * 32 * lx) >> 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')