diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 562d204..4fd40ef 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Numerics;
namespace AcDream.Core.Physics;
@@ -51,6 +52,27 @@ public sealed class PhysicsEngine
float WorldOffsetX,
float WorldOffsetY);
+ ///
+ /// UCG W2b doorway-hysteresis band (metres) added to the foot-sphere radius when deciding
+ /// whether to HOLD the previous indoor cell at the indoor→outdoor seam. Must exceed the
+ /// ~10 cm push-back oscillation at the threshold (handoff §5: front-door 0170↔0031) while
+ /// still releasing on a genuine step-out. The single value the visual gate may tune.
+ ///
+ private const float DoorwayHoldMargin = 0.2f;
+
+ ///
+ /// UCG W2b: does the foot-sphere (at , inflated to
+ /// ) overlap 's containment BSP? Returns false
+ /// when the cell has no containment BSP (can't test ⇒ release, never hold forever).
+ /// Retail: CCellStruct::sphere_intersects_cell (acclient_2013_pseudo_c.txt:317666).
+ ///
+ private static bool SphereOverlapsEnvCell(World.Cells.EnvCell cell, Vector3 worldPos, float radius)
+ {
+ if (cell.ContainmentBsp?.Root is null) return false;
+ var local = Vector3.Transform(worldPos, cell.InverseWorldTransform);
+ return BSPQuery.SphereIntersectsCellBsp(cell.ContainmentBsp.Root, local, radius);
+ }
+
///
/// Register a landblock with its terrain surface, indoor cells, portal
/// planes, and world-space origin offset.
@@ -377,6 +399,27 @@ public sealed class PhysicsEngine
}
}
+ // UCG W2b — retail find_cell_list do_not_load_cells prune
+ // (acclient_2013_pseudo_c.txt:308829-308867). Retail drops from the candidate
+ // cell-array any cell that is neither the current cell nor in its stab list
+ // (VisibleCells). Adapted here as doorway hysteresis at the indoor→outdoor seam:
+ // if the previous membership answer is an indoor cell and the outdoor candidate
+ // is NOT in its stab list (outdoor landcells never are), HOLD the indoor cell
+ // while the foot-sphere still overlaps that cell's containment BSP expanded by
+ // DoorwayHoldMargin. The strict overlap check at the indoor branch (~line 340)
+ // just released (the center was pushed past the wall), but the expanded test holds
+ // us across the ~10 cm push-back oscillation at the threshold (handoff §5:
+ // 0170↔0031). Releases on a genuine step-out — by then the bare overlap is also
+ // false, so CheckBuildingTransit won't re-grab the cell next tick. Anti-ping-pong
+ // the #98 saga never had; fires only at the front-door seam (the cellar has no
+ // exit portal → it never falls through here → #98 cellar-up is untouched).
+ if (DataCache?.CellGraph.CurrCell is World.Cells.EnvCell prevCell
+ && !prevCell.StabList.Contains(outdoorCellId)
+ && SphereOverlapsEnvCell(prevCell, worldPos, sphereRadius + DoorwayHoldMargin))
+ {
+ return SetCurrAndReturn(prevCell.Id);
+ }
+
return SetCurrAndReturn(outdoorCellId);
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs b/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
index 15faa9d..0801243 100644
--- a/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
@@ -1,10 +1,13 @@
+using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World.Cells;
+using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
+using UcgCellPortal = AcDream.Core.World.Cells.CellPortal;
namespace AcDream.Core.Tests.Physics;
@@ -36,4 +39,204 @@ public class CellGraphMembershipTests
Assert.Equal(result, cache.CellGraph.CurrCell!.Id);
Assert.Equal(0xA9B40174u, cache.CellGraph.CurrCell!.Id);
}
+
+ // ── UCG W2 Task 3 — stab-list doorway hysteresis tests ───────────────────
+ //
+ // Geometry: indoor cell 0xA9B40175 with a CellBSP splitting plane at Y=3.5,
+ // normal (0,-1,0). The interior is Y ≤ 3.5 (positive side of the plane).
+ //
+ // SphereIntersectsCellBsp with radius=0.3 fails (returns false) when:
+ // dist = dot((0,-1,0), center) + 3.5 = 3.5 - center.Y < -(0.3 + 0.01) = -0.31
+ // i.e. center.Y > 3.81
+ //
+ // SphereIntersectsCellBsp with expanded radius=0.3+0.2=0.5 fails when:
+ // center.Y > 3.5 + 0.51 = 4.01
+ //
+ // So the hysteresis band is Y ∈ (3.81, 4.01]:
+ // - Strict check failed (falls through to outdoor)
+ // - Expanded check passes (hold the indoor cell)
+ //
+ // Test positions:
+ // Y=3.9 → strict fails, expanded holds → hysteresis HOLD
+ // Y=4.5 → both fail → genuine step-out, RELEASE
+ //
+ // The landblock is registered at worldOffset=(0,0) so localX=worldPos.X,
+ // localY=worldPos.Y. At X=0, Y=3.9: cx=0, cy=0 → lowCellId=1 →
+ // outdoorCellId = 0xA9B40001.
+ //
+ // Retail anchor: CObjCell::find_cell_list do_not_load_cells prune
+ // (acclient_2013_pseudo_c.txt:308829-308867).
+
+ private const uint DoorwayIndoorId = 0xA9B40175u;
+ private const uint DoorwayLbId = 0xA9B40000u;
+ private const float SphereRadius = 0.3f;
+ // Y at which strict check fails but expanded (margin=0.2) still holds.
+ private static readonly Vector3 HoldPos = new(0f, 3.9f, 95f);
+ // Y at which both strict and expanded fail (genuine step-out).
+ private static readonly Vector3 ReleasedPos = new(0f, 4.5f, 95f);
+
+ ///
+ /// Builds the engine+cache+prevCell fixture shared by the three hysteresis tests.
+ /// controls whether the outdoor candidate is in the stab list.
+ /// controls whether CellGraph.CurrCell is pre-set to prev.
+ /// Returns the engine, cache, and the EnvCell registered as the previous indoor membership.
+ ///
+ private static (PhysicsEngine engine, PhysicsDataCache cache, EnvCell prev)
+ BuildDoorwayFixture(IReadOnlyList stabList, bool setCurrCell = true)
+ {
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+
+ // ── CellBSP: plane at Y=3.5 with normal (0,-1,0) ─────────────────
+ // Interior is at Y ≤ 3.5 (positive-side leaf).
+ // SphereIntersectsCellBsp(root, center, r) checks:
+ // dist = dot((0,-1,0), center) + 3.5 = 3.5 - center.Y
+ // if dist < -(r + 0.01) → sphere is fully outside → return false
+ var cellBspLeaf = new CellBSPNode { Type = BSPNodeType.Leaf };
+ var cellBspRoot = new CellBSPNode
+ {
+ SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), 3.5f),
+ PosNode = cellBspLeaf,
+ };
+ var cellBsp = new CellBSPTree { Root = cellBspRoot };
+
+ // ── CellPhysics registered for the indoor sphere-check branch ─────
+ // This is what ResolveCellId's indoor branch looks up via GetCellStruct.
+ // We set CellBSP to cellBsp so the overlap test uses it.
+ var indoorCell = new CellPhysics
+ {
+ BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } },
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ CellBSP = cellBsp,
+ Portals = Array.Empty(),
+ PortalPolygons = new Dictionary(),
+ VisibleCellIds = new System.Collections.Generic.HashSet(),
+ };
+ cache.RegisterCellStructForTest(DoorwayIndoorId, indoorCell);
+
+ // ── EnvCell in the CellGraph with the same ContainmentBsp ─────────
+ // This is what the hysteresis prune reads from CellGraph.CurrCell.
+ // We use the SAME cellBsp instance so the test geometry is consistent.
+ var prev = new EnvCell(
+ id: DoorwayIndoorId,
+ worldTransform: Matrix4x4.Identity,
+ inverseWorldTransform: Matrix4x4.Identity,
+ localBoundsMin: new Vector3(-20f, -20f, -20f),
+ localBoundsMax: new Vector3(20f, 20f, 20f),
+ portals: Array.Empty(),
+ stabList: stabList,
+ seenOutside: true,
+ containmentBsp: cellBsp);
+
+ cache.CellGraph.Add(prev);
+ if (setCurrCell)
+ cache.CellGraph.CurrCell = prev;
+
+ // ── Stub landblock: terrain far below, worldOffset=(0,0) ──────────
+ // worldPos.X, worldPos.Y map directly to localX, localY.
+ // At X=0, Y=3.9: cx=0, cy=0 → lowCellId=1 → outdoorCellId=0xA9B40001.
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
+ var stubTerrain = new TerrainSurface(heights, heightTable);
+ engine.AddLandblock(
+ landblockId: DoorwayLbId,
+ terrain: stubTerrain,
+ cells: Array.Empty(),
+ portals: Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+
+ return (engine, cache, prev);
+ }
+
+ ///
+ /// UCG W2 Task 3 — HOLD (the RED→GREEN test).
+ ///
+ /// Position Y=3.9 is inside the hysteresis band:
+ /// - The strict overlap check (radius=0.3) fails → indoor branch falls through to outdoor.
+ /// - The expanded check (radius=0.3+0.2=0.5) still passes → hold the indoor cell.
+ /// - The outdoor candidate (0xA9B40001) is NOT in the stab list → prune fires.
+ ///
+ /// Expected: ResolveCellId returns 0xA9B40175 (held indoor).
+ /// Before the fix: returns 0xA9B40001 (outdoor — ping-pong side).
+ /// After the fix: returns 0xA9B40175 (held indoor — anti-ping-pong).
+ ///
+ /// Retail anchor: CObjCell::find_cell_list do_not_load_cells prune
+ /// (acclient_2013_pseudo_c.txt:308829-308867).
+ ///
+ [Fact]
+ public void ResolveCellId_DoorwayHysteresis_HoldsIndoorWhenInsideMargin()
+ {
+ var (engine, _, _) = BuildDoorwayFixture(
+ stabList: Array.Empty(),
+ setCurrCell: true);
+
+ uint result = engine.ResolveCellId(HoldPos, SphereRadius, DoorwayIndoorId);
+
+ Assert.Equal(DoorwayIndoorId, result);
+ }
+
+ ///
+ /// UCG W2 Task 3 — RELEASE when fully outside (genuine step-out).
+ ///
+ /// Position Y=4.5 exceeds the expanded margin (4.01) — both the strict
+ /// and expanded overlap checks fail. The prune must NOT hold — the player
+ /// genuinely stepped out.
+ ///
+ /// Expected: ResolveCellId returns an outdoor cell id (low 16 bits < 0x100)
+ /// that is NOT 0xA9B40175.
+ ///
+ [Fact]
+ public void ResolveCellId_DoorwayHysteresis_ReleasesWhenFullyOutside()
+ {
+ var (engine, _, _) = BuildDoorwayFixture(
+ stabList: Array.Empty(),
+ setCurrCell: true);
+
+ uint result = engine.ResolveCellId(ReleasedPos, SphereRadius, DoorwayIndoorId);
+
+ // Must be an outdoor cell (low 16 bits < 0x100) and not the indoor cell.
+ Assert.NotEqual(DoorwayIndoorId, result);
+ Assert.True((result & 0xFFFFu) < 0x100u,
+ $"Expected an outdoor cell (low 16 < 0x100), got 0x{result:X8}");
+ }
+
+ ///
+ /// UCG W2 Task 3 — RELEASE when candidate is visible (stab-list gate).
+ ///
+ /// The prune only fires when the outdoor candidate is NOT in the previous
+ /// indoor cell's stab list. If the candidate IS in the stab list (genuinely
+ /// visible from the indoor cell), the transition should proceed normally —
+ /// hysteresis must not block it.
+ ///
+ /// Expected: ResolveCellId returns the outdoor cell id even though Y=3.9
+ /// is inside the hysteresis band.
+ ///
+ [Fact]
+ public void ResolveCellId_DoorwayHysteresis_ReleasesWhenCandidateIsInStabList()
+ {
+ // Step 1: determine the outdoor id the engine picks at Y=3.9
+ // by running with CurrCell=null (no hysteresis).
+ var (engineNull, _, _) = BuildDoorwayFixture(
+ stabList: Array.Empty(),
+ setCurrCell: false);
+ uint outdoorId = engineNull.ResolveCellId(HoldPos, SphereRadius, DoorwayIndoorId);
+
+ // Sanity: must be outdoor (low 16 < 0x100).
+ Assert.True((outdoorId & 0xFFFFu) < 0x100u,
+ $"Expected outdoor fallback id (low 16 < 0x100), got 0x{outdoorId:X8}");
+
+ // Step 2: build a fresh fixture with the outdoor id IN the stab list.
+ var (engine, _, _) = BuildDoorwayFixture(
+ stabList: new uint[] { outdoorId },
+ setCurrCell: true);
+
+ uint result = engine.ResolveCellId(HoldPos, SphereRadius, DoorwayIndoorId);
+
+ // Stab-list gate: outdoor candidate is visible → prune must NOT hold.
+ Assert.Equal(outdoorId, result);
+ }
}