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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-10 14:21:59 +02:00
parent 5f1eb7c4b1
commit 5706e0e10a
2 changed files with 69 additions and 7 deletions

View file

@ -76,6 +76,11 @@ public sealed class CellSurface
/// <summary>
/// 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 <see cref="SampleFloorZNearest"/>
/// when a reference height is known.
/// </summary>
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.)
/// <summary>
/// Test if (px, py) falls inside triangle (a, b, c) projected onto
/// the XY plane. If inside, computes the barycentric Z interpolation

View file

@ -430,6 +430,55 @@ public sealed class PhysicsEngine
/// green).
/// </para>
/// </summary>
/// <summary>
/// #111: the walkable floor Z of <paramref name="cellId"/>'s PHYSICS
/// polygons under the world XY, nearest to <paramref name="referenceZ"/>.
/// Walkable = plane normal.Z ≥ <see cref="PhysicsGlobals.FloorZ"/> (retail
/// BSPTREE::find_walkable's filter) — ceilings/roof tops never qualify,
/// unlike the <see cref="CellSurface"/> 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.
/// </summary>
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;
}
/// <summary>Even-odd XY-projection point-in-polygon test (cell-local frame).</summary>
private static bool PointInPolygonXY(IReadOnlyList<Vector3> 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;
}
/// <summary>
/// #107: does any loaded landblock carry a <see cref="CellSurface"/> 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),