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!
|
||||
* 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 |
@@ -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