From 5706e0e10a945a165dec07d5b327c9648741604f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 10 Jun 2026 14:21:59 +0200 Subject: [PATCH] fix(phys): #111 - ground the validated claim via PHYSICS walkable polygons, not the CellSurface triangle soup Two grounding selection rules failed against live ACE restores before the right one: (1) first-hit SampleFloorZ returned 0x171's 99.475 ceiling TOP face over its 94.0 floor (issue111-verify2.log) - the player committed onto the roof level, and the session's heartbeats poisoned ACE's save with z=99.475; (2) nearest-to-reference self-confirmed that poison (the reference SAT on the ceiling face, issue111-verify3.log). Root insight: ceiling/roof top faces are upward-facing and XY-projectable - geometrically indistinguishable from floors in the render-ish CellSurface soup. The PHYSICS walkable set (plane normal.Z >= PhysicsGlobals.FloorZ over the claim's Resolved cell-local polygons - retail BSPTREE::find_walkable's filter) contains only real floors: PhysicsEngine.WalkableFloorZNearest transforms into the cell frame, drops on each walkable plane under the XY, picks nearest the reference. Verified live (issue111-verify4.log): ACE restored the roof-poisoned (0xA9B40171, z=99.475); the snap validated the claim and grounded to z=94.000 - the first fully clean indoor login of the arc: [snap] claim=0xA9B40171 VALIDATED -> grounded to its walkable floor z=94.000 [cell-transit] 0x00000000 -> 0xA9B40171 pos=(155.525,12.416,94.000) Baseline: Core 1381 + 4 pre-existing #99 failures + 1 skip; App/UI/Net green. Co-Authored-By: Claude Fable 5 --- src/AcDream.Core/Physics/CellSurface.cs | 10 ++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 66 ++++++++++++++++++++--- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/AcDream.Core/Physics/CellSurface.cs b/src/AcDream.Core/Physics/CellSurface.cs index ca8c0616..bd792dcc 100644 --- a/src/AcDream.Core/Physics/CellSurface.cs +++ b/src/AcDream.Core/Physics/CellSurface.cs @@ -76,6 +76,11 @@ public sealed class CellSurface /// /// Project (worldX, worldY) onto this cell's floor polygons and /// return the Z. Returns null if outside all floor polygons. + /// ⚠️ Returns the FIRST triangle hit in list order — a cell's triangle + /// soup includes ceiling/roof top faces, so the result can be a surface + /// far above the actual floor (#111: 0xA9B40171 returned its 99.475 + /// ceiling over its 94.0 floor). Use + /// when a reference height is known. /// public float? SampleFloorZ(float worldX, float worldY) { @@ -87,6 +92,11 @@ public sealed class CellSurface return null; } + // (#111 note: a SampleFloorZNearest variant was tried and removed — even + // nearest-to-reference lands on ceiling faces when the reference itself + // sits on one. Placement snaps must ground via the PHYSICS walkable + // polygons instead: PhysicsEngine.WalkableFloorZNearest.) + /// /// Test if (px, py) falls inside triangle (a, b, c) projected onto /// the XY plane. If inside, computes the barycentric Z interpolation diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 437cb422..261afe6e 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -430,6 +430,55 @@ public sealed class PhysicsEngine /// green). /// /// + /// + /// #111: the walkable floor Z of 's PHYSICS + /// polygons under the world XY, nearest to . + /// Walkable = plane normal.Z ≥ (retail + /// BSPTREE::find_walkable's filter) — ceilings/roof tops never qualify, + /// unlike the triangle soup. Resolved polygons + /// are CELL-LOCAL: transform in, drop on the plane, transform out. + /// Returns null when the claim has no hydrated struct or no walkable + /// under the XY. + /// + private float? WalkableFloorZNearest(uint cellId, Vector3 worldPos, float referenceZ) + { + var cp = DataCache?.GetCellStruct(cellId); + if (cp is null) return null; + + var local = Vector3.Transform( + new Vector3(worldPos.X, worldPos.Y, referenceZ), cp.InverseWorldTransform); + + float? best = null; + float bestDist = float.MaxValue; + foreach (var kv in cp.Resolved) + { + var poly = kv.Value; + var n = poly.Plane.Normal; + if (n.Z < PhysicsGlobals.FloorZ) continue; + if (!PointInPolygonXY(poly.Vertices, local.X, local.Y)) continue; + // plane: n·p + d = 0 => z = -(n.x*x + n.y*y + d)/n.z + float lz = -(n.X * local.X + n.Y * local.Y + poly.Plane.D) / n.Z; + float wz = Vector3.Transform(new Vector3(local.X, local.Y, lz), cp.WorldTransform).Z; + float dist = MathF.Abs(wz - referenceZ); + if (dist < bestDist) { bestDist = dist; best = wz; } + } + return best; + } + + /// Even-odd XY-projection point-in-polygon test (cell-local frame). + private static bool PointInPolygonXY(IReadOnlyList verts, float x, float y) + { + bool inside = false; + for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++) + { + var vi = verts[i]; var vj = verts[j]; + if ((vi.Y > y) != (vj.Y > y) + && x < (vj.X - vi.X) * (y - vi.Y) / (vj.Y - vi.Y) + vi.X) + inside = !inside; + } + return inside; + } + /// /// #107: does any loaded landblock carry a for /// this cell id? Distinguishes "partially hydrated" (floor data present, @@ -636,16 +685,19 @@ public sealed class PhysicsEngine // surface here (thresholds, stair lips) fall through to the legacy path. if (snapDiag && adjustedFound && (cellId & 0xFFFFu) >= 0x0100u) { - CellSurface? claimSurface = null; - foreach (var c in physics.Cells) - { - if ((c.CellId & 0xFFFFu) == (cellId & 0xFFFFu)) { claimSurface = c; break; } - } - float? claimFloorZ = claimSurface?.SampleFloorZ(candidatePos.X, candidatePos.Y); + // Ground via the claim's PHYSICS WALKABLE polygons (normal.Z ≥ + // PhysicsGlobals.FloorZ), NOT the CellSurface triangle soup — the + // soup includes ceiling/roof TOP faces whose first-hit (99.475 + // over 0x171's 94.0 floor, issue111-verify2.log) and even + // nearest-to-reference (the poisoned reference SAT on the ceiling + // face, issue111-verify3.log) selections both land on non-floors. + // The walkable set contains only real floors (retail + // BSPTREE::find_walkable's polygon filter). + float? claimFloorZ = WalkableFloorZNearest(cellId, candidatePos, currentPos.Z); if (claimFloorZ is not null) { Console.WriteLine(System.FormattableString.Invariant( - $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its floor z={claimFloorZ.Value:F3}")); + $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}")); return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value), lbPrefix | (cellId & 0xFFFFu),