Port retail CObjCell::find_cell_list do_not_load_cells prune (acclient_2013_pseudo_c.txt:308829-308867) as indoor->outdoor doorway hysteresis: hold the previous indoor cell when the outdoor candidate is not in its stab list AND the foot-sphere still overlaps the cell's containment BSP expanded by DoorwayHoldMargin. Kills the front-door 0170<->0031 ping-pong (handoff §5) the #98 saga never addressed. Fires only at the front-door seam; the cellar has no exit portal so it never falls through here (#98 cellar-up untouched). Three TDD tests in CellGraphMembershipTests: HOLD (the RED->GREEN case, Y=3.9 inside the 0.2 m margin), RELEASE when fully outside (Y=4.5 exceeds expanded margin), and stab-list gate (outdoor candidate in stab list releases even near the boundary). Adds using System.Linq for IReadOnlyList.Contains at the prune site. SphereOverlapsEnvCell helper mirrors BSPQuery.SphereIntersectsCellBsp via EnvCell.InverseWorldTransform + ContainmentBsp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
242 lines
11 KiB
C#
242 lines
11 KiB
C#
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;
|
|
|
|
public class CellGraphMembershipTests
|
|
{
|
|
[Fact]
|
|
public void ResolveCellId_Resolved_WritesCurrCellTrackingTheResolvedId()
|
|
{
|
|
var engine = new PhysicsEngine();
|
|
var cache = new PhysicsDataCache();
|
|
engine.DataCache = cache;
|
|
|
|
var cs = new CellStruct {
|
|
VertexArray = new VertexArray { Vertices = new Dictionary<ushort, SWVertex>() },
|
|
Polygons = new Dictionary<ushort, Polygon>(),
|
|
PhysicsBSP = null,
|
|
};
|
|
var dat = new DatEnvCell {
|
|
Flags = (DatReaderWriter.Enums.EnvCellFlags)0,
|
|
CellPortals = new List<DatReaderWriter.Types.CellPortal>(),
|
|
VisibleCells = new List<ushort>(),
|
|
};
|
|
cache.CacheCellStruct(0xA9B40174u, dat, cs, Matrix4x4.Identity); // registers in the graph (W1)
|
|
|
|
uint result = engine.ResolveCellId(new Vector3(0, 0, 0), 0.5f, 0xA9B40174u);
|
|
|
|
// CurrCell tracks whatever id ResolveCellId returned (when that id is in the graph).
|
|
Assert.NotNull(cache.CellGraph.CurrCell);
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Builds the engine+cache+prevCell fixture shared by the three hysteresis tests.
|
|
/// <paramref name="stabList"/> controls whether the outdoor candidate is in the stab list.
|
|
/// <paramref name="setCurrCell"/> controls whether CellGraph.CurrCell is pre-set to prev.
|
|
/// Returns the engine, cache, and the EnvCell registered as the previous indoor membership.
|
|
/// </summary>
|
|
private static (PhysicsEngine engine, PhysicsDataCache cache, EnvCell prev)
|
|
BuildDoorwayFixture(IReadOnlyList<uint> 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<ushort, ResolvedPolygon>(),
|
|
CellBSP = cellBsp,
|
|
Portals = Array.Empty<PortalInfo>(),
|
|
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
|
|
VisibleCellIds = new System.Collections.Generic.HashSet<uint>(),
|
|
};
|
|
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<UcgCellPortal>(),
|
|
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<CellSurface>(),
|
|
portals: Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
|
|
return (engine, cache, prev);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[Fact]
|
|
public void ResolveCellId_DoorwayHysteresis_HoldsIndoorWhenInsideMargin()
|
|
{
|
|
var (engine, _, _) = BuildDoorwayFixture(
|
|
stabList: Array.Empty<uint>(),
|
|
setCurrCell: true);
|
|
|
|
uint result = engine.ResolveCellId(HoldPos, SphereRadius, DoorwayIndoorId);
|
|
|
|
Assert.Equal(DoorwayIndoorId, result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ResolveCellId_DoorwayHysteresis_ReleasesWhenFullyOutside()
|
|
{
|
|
var (engine, _, _) = BuildDoorwayFixture(
|
|
stabList: Array.Empty<uint>(),
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<uint>(),
|
|
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);
|
|
}
|
|
}
|