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:
1 parent
c39d58bdac
commit
9e8e7f9a10
5 files changed
+183
-1
No files matched your search
+90
-1
@@ -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!
|
* 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].
|
* 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).
|
* 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)
|
### Face flags (u16)
|
||||||
| Bit | Meaning | Confidence |
|
| 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_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.
|
* `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).
|
* `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 |
@@ -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')
|
||||||
Reference in new issue
Block a user