diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 9f2be66..df0af71 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1152,11 +1152,14 @@ public static class BSPQuery /// /// /// - /// Intended call site: indoor walkable-plane synthesis in - /// Transition.TryFindIndoorWalkablePlane when the indoor cell-BSP - /// collision returns OK (no wall hit) and the resolver still needs a - /// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path - /// () and does not use this. + /// Out-of-band "find a walkable plane indoors" entry point for callers + /// that genuinely need to query a cell's walkable floor (spawn-placement + /// validation, teleport-target verification, future debug overlays). + /// NOT called from the per-frame physics resolver — the original + /// per-frame caller (TryFindIndoorWalkablePlane) was deleted 2026-05-20 + /// because retail's BSPTREE::find_collisions does NOT re-synthesize the + /// ContactPlane on the OK path. The wrapper is kept here as the + /// underlying retail-faithful walkable-finder API. /// /// /// diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index c941f67..a1cc43c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1266,120 +1266,6 @@ public sealed class Transition // Environment collision — outdoor terrain // ----------------------------------------------------------------------- - /// - /// Synthesize the indoor walkable contact plane for the player's current - /// position when the cell BSP returns OK (no wall collision). - /// - /// - /// Routes through the retail-faithful BSP walkable-finder - /// () — which traverses the cell - /// PhysicsBSP and picks the polygon closest to the foot along the up vector. - /// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a - /// stop-gap; that scan picked the wrong floor whenever two polygons - /// overlapped in XY at different Z (cellars, 2nd floors, balconies). - /// - /// - /// - /// Returns false if no walkable floor poly is found under the - /// player. The caller falls through to outdoor terrain in that case - /// (defensive backstop — should not normally happen inside a sealed cell). - /// - /// - /// - /// Retail oracle: BSPLEAF::find_walkable (acclient_2013_pseudo_c.txt:326793), - /// BSPNODE::find_walkable (:326211), CPolygon::walkable_hits_sphere (:323006), - /// CPolygon::adjust_sphere_to_plane (:322032). - /// - /// - internal bool TryFindIndoorWalkablePlane( - CellPhysics cellPhysics, - Vector3 localFootCenter, - float sphereRadius, - out System.Numerics.Plane worldPlane, - out Vector3[] worldVertices, - out uint hitPolyId) - { - worldPlane = default; - worldVertices = System.Array.Empty(); - hitPolyId = 0; - - if (cellPhysics.BSP?.Root is null) return false; - - // Build foot sphere in cell-local space. Caller passes localFootCenter - // already transformed into cell-local space and the resolver's - // foot-sphere radius. - var localSphere = new DatReaderWriter.Types.Sphere - { - Origin = localFootCenter, - Radius = sphereRadius, - }; - - // Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads - // path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For - // "standing here, find my floor" we want the walkability slope - // threshold FloorZ. The outer resolver may have set it to LandingZ - // (airborne→ground transition) or another value; we must not leak our - // change back to the resolver. try/finally so an exception inside - // FindWalkableSphere doesn't leak the modified state. - float savedWalkableAllowance = this.SpherePath.WalkableAllowance; - this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ; - - ResolvedPolygon? hitPoly = null; - ushort hitId = 0; - Vector3 adjustedCenter; - bool found; - - try - { - found = BSPQuery.FindWalkableSphere( - cellPhysics.BSP.Root, - cellPhysics.Resolved, - this, - localSphere, - INDOOR_WALKABLE_PROBE_DISTANCE, - Vector3.UnitZ, // local Z is up for indoor cells (identity transform) - out hitPoly, - out hitId, - out adjustedCenter); - } - finally - { - this.SpherePath.WalkableAllowance = savedWalkableAllowance; - } - - // adjustedCenter (sphere slid onto polygon plane) is intentionally - // discarded — ValidateWalkable recomputes contact geometry from the - // world-space plane + foot position, consistent with the outdoor terrain - // path (SampleTerrainWalkable returns only plane + vertices, no adjusted - // sphere). The local is held only to satisfy the out param. - - if (!found || hitPoly is null) return false; - - // Transform hit polygon's plane + vertices to world space. Math is - // unchanged from the previous TryFindIndoorWalkablePlane implementation. - var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform); - worldNormal = Vector3.Normalize(worldNormal); - var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform); - float worldD = -Vector3.Dot(worldNormal, worldV0); - worldPlane = new System.Numerics.Plane(worldNormal, worldD); - - worldVertices = new Vector3[hitPoly.Vertices.Length]; - for (int i = 0; i < hitPoly.Vertices.Length; i++) - worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform); - - hitPolyId = hitId; - return true; - } - - /// - /// Downward probe distance used by - /// when scanning for the indoor walkable contact plane. 50 cm. - /// Larger than the +0.02f cell-origin Z-bump and larger than any realistic - /// step riser; smaller than a full cell height so we don't reach through - /// a thin floor into the cell above/below. - /// - private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f; - /// /// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic. /// Indoor BSP collision is deferred to Task 6c. @@ -1503,59 +1389,22 @@ public sealed class Transition return cellState; } - // ── Synthesize indoor walkable contact plane ────────────── - // Indoor walking Phase 2 follow-up (2026-05-19). When the BSP - // returns OK (no wall collision), the player is standing on a - // floor poly inside the cell. We must NOT fall through to - // outdoor terrain (SampleTerrainWalkable) — the outdoor terrain - // Z is below the indoor floor due to the +0.02f Z-bump applied - // for render z-fight prevention. ValidateWalkable would then see - // the player 0.5m above the outdoor plane → marks them as - // airborne → walkable=False → falling animation, never recovers. + // Indoor BSP returned OK — no wall collision. ContactPlane + // is RETAINED from the prior tick's seed + // (PhysicsEngine.ResolveWithTransition:583, the + // init_contact_plane equivalent) OR refreshed by Path 3 + // step-down / Path 4 land if those fired this tick. Either + // way, no synthesis is needed here — matches retail's + // BSPTREE::find_collisions OK path + // (acclient_2013_pseudo_c.txt:323938). // - // Retail: CEnvCell::find_env_collisions returns from the cell - // branch with the cell's walkable plane set — no fall-through - // to terrain. - bool walkableHit = TryFindIndoorWalkablePlane( - cellPhysics, localCenter, sphereRadius, - out var indoorPlane, - out var indoorVertices, - out uint hitPolyId); - - if (PhysicsDiagnostics.ProbeIndoorBspEnabled) - { - if (walkableHit) - { - // dz = signed gap between foot and synthesized plane. - // Plane: N·p + D = 0 ⇒ pZ_on_plane = -D/N.z (for upward-facing planes) - // gap = foot.Z - pZ_on_plane = foot.Z - (-D/N.z) = foot.Z + D/N.z - float dz = footCenter.Z + indoorPlane.D / indoorPlane.Normal.Z; - Console.WriteLine(System.FormattableString.Invariant( - $"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=HIT poly=0x{hitPolyId:X4} wn=({indoorPlane.Normal.X:F3},{indoorPlane.Normal.Y:F3},{indoorPlane.Normal.Z:F3}) wD={indoorPlane.D:F3} dz={dz:+0.00;-0.00;+0.00}")); - } - else - { - Console.WriteLine(System.FormattableString.Invariant( - $"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=MISS")); - } - } - - if (walkableHit) - { - return ValidateWalkable( - footCenter, - sphereRadius, - indoorPlane, - isWater: false, - waterDepth: 0f, - cellId: sp.CheckCellId, - walkableVertices: indoorVertices); - } - // If no walkable floor was found under the player indoors - // (rare — cell with only walls/ceiling), fall through to - // outdoor terrain as a defensive backstop. Indoor walking - // will report walkable=False until the player moves over a - // cell with a proper floor poly. + // Do NOT fall through to outdoor terrain backstop: the + // player is in an indoor cell, and the outdoor terrain + // Z is below the indoor floor by ~0.02m (the render Z-bump), + // which would mark the player as airborne and trigger the + // falling-animation stuck symptom (the original Bug A). + // 2026-05-20 slice 2 of indoor ContactPlane retention. + return TransitionState.OK; } } diff --git a/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs deleted file mode 100644 index 75f136e..0000000 --- a/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs +++ /dev/null @@ -1,291 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Unit tests for . -/// -/// Indoor walking Phase 2 follow-up (2026-05-19): the helper synthesizes -/// a walkable contact plane from cell floor polys so the resolver does not -/// fall through to outdoor terrain when the player is standing indoors. -/// -/// Task 3 (2026-05-19): refactored to route through BSPQuery.FindWalkableSphere. -/// Fixtures now include a PhysicsBSPTree with a Leaf node listing all polygon ids, -/// and calls pass sphereRadius explicitly. PointInPolygonXY tests removed since -/// that helper was deleted (it was the dead linear-scan body). -/// -public class IndoorWalkablePlaneTests -{ - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - /// - /// Build a BSP Leaf node that lists the given polygon ids, with a bounding - /// sphere large enough to always contain the test geometry. - /// - private static PhysicsBSPTree BuildLeafBsp(IEnumerable polyIds, - Vector3 center, float radius) - { - var node = new PhysicsBSPNode - { - Type = BSPNodeType.Leaf, - BoundingSphere = new Sphere { Origin = center, Radius = radius }, - }; - foreach (var id in polyIds) - node.Polygons.Add(id); - return new PhysicsBSPTree { Root = node }; - } - - /// - /// Builds a CellPhysics with a single upward-facing floor polygon - /// (a 10×10 square in the XY plane at local Z=0), plus identity transforms - /// and a BSP leaf that covers all polygons. - /// - private static CellPhysics BuildCellWithFloor(float floorZ = 0f) - { - var verts = new[] - { - new Vector3(-5f, -5f, floorZ), - new Vector3( 5f, -5f, floorZ), - new Vector3( 5f, 5f, floorZ), - new Vector3(-5f, 5f, floorZ), - }; - var normal = new Vector3(0f, 0f, 1f); // straight up - float D = -Vector3.Dot(normal, verts[0]); // = -floorZ - - var floorPoly = new ResolvedPolygon - { - Vertices = verts, - Plane = new Plane(normal, D), - NumPoints = 4, - SidesType = CullMode.None, - }; - - var resolved = new Dictionary { [0] = floorPoly }; - var bsp = BuildLeafBsp(new ushort[] { 0 }, new Vector3(0f, 0f, floorZ), 10f); - - return new CellPhysics - { - BSP = bsp, - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - Resolved = resolved, - }; - } - - // ----------------------------------------------------------------------- - // TryFindIndoorWalkablePlane - // ----------------------------------------------------------------------- - - [Fact] - public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue() - { - var cell = BuildCellWithFloor(floorZ: 0f); - var transition = new Transition(); - // Foot sphere centre at Z=0.4, radius=0.48 → overlaps floor at Z=0. - var localFoot = new Vector3(0f, 0f, 0.4f); - - bool found = transition.TryFindIndoorWalkablePlane( - cell, localFoot, sphereRadius: 0.48f, - out _, out _, out _); - - Assert.True(found); - } - - [Fact] - public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp() - { - var cell = BuildCellWithFloor(floorZ: 0f); - var transition = new Transition(); - var localFoot = new Vector3(0f, 0f, 0.4f); - - transition.TryFindIndoorWalkablePlane( - cell, localFoot, sphereRadius: 0.48f, - out var plane, out _, out _); - - // The floor's normal must point up (Z close to 1). - Assert.True(plane.Normal.Z > 0.99f, - $"Expected plane.Normal.Z > 0.99, got {plane.Normal.Z}"); - } - - [Fact] - public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneAtFloorZ() - { - const float floorZ = 2.5f; - var cell = BuildCellWithFloor(floorZ); - var transition = new Transition(); - // Foot sphere overlaps floor: centre at floorZ + 0.4, radius=0.48 → dist=0.4 < 0.48. - var localFoot = new Vector3(0f, 0f, floorZ + 0.4f); - - transition.TryFindIndoorWalkablePlane( - cell, localFoot, sphereRadius: 0.48f, - out var plane, out _, out _); - - // With identity transform and an upward normal, plane.D = -floorZ. - // The plane equation: normal·p + D = 0 → p.Z = floorZ when normal=(0,0,1). - Assert.True(MathF.Abs(plane.D - (-floorZ)) < 1e-4f, - $"Expected plane.D ≈ {-floorZ}, got {plane.D}"); - } - - [Fact] - public void TryFindIndoorWalkablePlane_PlayerOutsidePolygonXY_ReturnsFalse() - { - var cell = BuildCellWithFloor(); - var transition = new Transition(); - // XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes). - var localFoot = new Vector3(20f, 20f, 0.4f); - - bool found = transition.TryFindIndoorWalkablePlane( - cell, localFoot, sphereRadius: 0.48f, - out _, out _, out _); - - Assert.False(found); - } - - [Fact] - public void TryFindIndoorWalkablePlane_NoBsp_ReturnsFalse() - { - // CellPhysics without a BSP → BSP?.Root is null → early return false. - var cell = new CellPhysics - { - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - Resolved = new Dictionary(), - }; - var transition = new Transition(); - - bool found = transition.TryFindIndoorWalkablePlane( - cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f, - out _, out _, out _); - - Assert.False(found); - } - - [Fact] - public void TryFindIndoorWalkablePlane_WallPolyInBsp_ReturnsFalse() - { - // A polygon with a horizontal normal (Z = 0) is a wall, not a floor. - // walkable_hits_sphere rejects it: dp = dot(UnitZ, (0,1,0)) = 0 <= FloorZ. - // Regression coverage for the previous NoWalkablePolys_ReturnsFalse intent - // (the renamed NoBsp_ReturnsFalse only covers the null-BSP early-return). - Vector3[] wallVerts = - { - new Vector3(0f, 0f, 0f), - new Vector3(1f, 0f, 0f), - new Vector3(1f, 0f, 1f), - new Vector3(0f, 0f, 1f), - }; - var resolved = new Dictionary - { - [0] = new ResolvedPolygon - { - Vertices = wallVerts, - Plane = new Plane(new Vector3(0f, 1f, 0f), 0f), // wall facing +Y - NumPoints = 4, - SidesType = CullMode.None, - }, - }; - - var center = new Vector3(0.5f, 0f, 0.5f); - var bsp = BuildLeafBsp(new ushort[] { 0 }, center, 2f); - - var cell = new CellPhysics - { - BSP = bsp, - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - Resolved = resolved, - }; - - var transition = new Transition(); - transition.SpherePath.WalkInterp = 1.0f; - - // Foot sphere positioned to overlap the wall's plane (|Y - 0| = 0 < radius 0.48). - bool found = transition.TryFindIndoorWalkablePlane( - cell, - localFootCenter: new Vector3(0.5f, 0f, 0.5f), - sphereRadius: 0.48f, - out _, - out _, - out _); - - Assert.False(found); - } - - [Fact] - public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse() - { - // BSP leaf exists but references no polygons → FindWalkableSphere returns false. - var bsp = BuildLeafBsp(System.Array.Empty(), Vector3.Zero, 10f); - var cell = new CellPhysics - { - BSP = bsp, - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - Resolved = new Dictionary(), - }; - var transition = new Transition(); - - bool found = transition.TryFindIndoorWalkablePlane( - cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f, - out _, out _, out _); - - Assert.False(found); - } - - [Fact] - public void TryFindIndoorWalkablePlane_WithWorldTranslation_PlaneInWorldSpace() - { - // Cell is translated 100 units in X and 200 units in Y. - var translation = Matrix4x4.CreateTranslation(100f, 200f, 94f); - Matrix4x4.Invert(translation, out var inv); - - var localVerts = new[] - { - new Vector3(-5f, -5f, 0f), - new Vector3( 5f, -5f, 0f), - new Vector3( 5f, 5f, 0f), - new Vector3(-5f, 5f, 0f), - }; - var floorPoly = new ResolvedPolygon - { - Vertices = localVerts, - Plane = new Plane(new Vector3(0f, 0f, 1f), 0f), - NumPoints = 4, - SidesType = CullMode.None, - }; - var resolved = new Dictionary { [0] = floorPoly }; - var bsp = BuildLeafBsp(new ushort[] { 0 }, Vector3.Zero, 10f); - - var cell = new CellPhysics - { - BSP = bsp, - WorldTransform = translation, - InverseWorldTransform = inv, - Resolved = resolved, - }; - - // The player's local foot sphere centre at (0,0,0.4) overlaps the floor at Z=0. - var localFoot = new Vector3(0f, 0f, 0.4f); - var transition = new Transition(); - - bool found = transition.TryFindIndoorWalkablePlane( - cell, localFoot, sphereRadius: 0.48f, - out var plane, out var worldVerts, out _); - - Assert.True(found); - // World normal should still be (0,0,1). - Assert.True(plane.Normal.Z > 0.99f); - // World vertex[0] should be at local (-5,-5,0) + translation = (95, 195, 94). - Assert.True(MathF.Abs(worldVerts[0].X - 95f) < 1e-3f); - Assert.True(MathF.Abs(worldVerts[0].Y - 195f) < 1e-3f); - Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f, - $"Expected worldVerts[0].Z ≈ 94, got {worldVerts[0].Z}"); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs deleted file mode 100644 index e4dd879..0000000 --- a/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Numerics; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; -using AcDream.Core.Physics; -using Xunit; -using Plane = System.Numerics.Plane; - -namespace AcDream.Core.Tests.Physics; - -public class TransitionTypesTests -{ - [Fact] - public void TryFindIndoorWalkablePlane_TwoOverlappingFloors_PicksClosestBelowFoot_PreservesAllowance() - { - // Build a CellPhysics with two horizontal walkable polygons at - // local Z=0 and Z=3, both covering the unit square X[0..1] × Y[0..1]. - // Foot sphere at local Z=0.4 → sphere overlaps the Z=0 polygon - // (|0.4| < radius 0.48); Z=3 is out of range. Expect the lower poly - // to be returned. Sentinel WalkableAllowance value must be preserved - // across the call. - - var cellPhysics = BuildTwoFloorCellPhysics(lowerZ: 0f, upperZ: 3f); - - var transition = new Transition(); - const float sentinelAllowance = 0.42f; - transition.SpherePath.WalkableAllowance = sentinelAllowance; - transition.SpherePath.WalkInterp = 1.0f; - - bool found = transition.TryFindIndoorWalkablePlane( - cellPhysics, - localFootCenter: new Vector3(0.5f, 0.5f, 0.4f), - sphereRadius: 0.48f, - out var worldPlane, - out var worldVertices, - out var hitPolyId); - - Assert.True(found); - // Lower polygon's local plane Normal.Z = 1.0; identity world transform - // means world Normal.Z is also 1.0. - Assert.Equal(1.0f, worldPlane.Normal.Z, precision: 3); - // World vertices match the lower polygon (Z=0 in world space, identity transform). - Assert.Equal(4, worldVertices.Length); - Assert.Equal(0f, worldVertices[0].Z, precision: 3); - // hitPolyId is the dictionary key — lower polygon was inserted as key 0. - Assert.Equal(0u, hitPolyId); - // WalkableAllowance must be restored to the sentinel. - Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance); - } - - /// - /// Build a minimal CellPhysics with two horizontal walkable polygons at - /// local Z=lowerZ and Z=upperZ. Identity world transform so world == local. - /// - private static CellPhysics BuildTwoFloorCellPhysics(float lowerZ, float upperZ) - { - Vector3[] lowerVerts = - { - new Vector3(0f, 0f, lowerZ), - new Vector3(1f, 0f, lowerZ), - new Vector3(1f, 1f, lowerZ), - new Vector3(0f, 1f, lowerZ), - }; - Vector3[] upperVerts = - { - new Vector3(0f, 0f, upperZ), - new Vector3(1f, 0f, upperZ), - new Vector3(1f, 1f, upperZ), - new Vector3(0f, 1f, upperZ), - }; - - var resolved = new Dictionary - { - [0] = new ResolvedPolygon - { - Vertices = lowerVerts, - Plane = new Plane(Vector3.UnitZ, -lowerZ), - NumPoints = 4, - SidesType = CullMode.None, - }, - [1] = new ResolvedPolygon - { - Vertices = upperVerts, - Plane = new Plane(Vector3.UnitZ, -upperZ), - NumPoints = 4, - SidesType = CullMode.None, - }, - }; - - var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f); - float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f; - float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight); - - var root = new PhysicsBSPNode - { - Type = BSPNodeType.Leaf, - BoundingSphere = new Sphere { Origin = center, Radius = radius }, - }; - root.Polygons.Add(0); - root.Polygons.Add(1); - - var bsp = new PhysicsBSPTree { Root = root }; - - return new CellPhysics - { - BSP = bsp, - Resolved = resolved, - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - }; - } -}