diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 169bb2d1..5d243c77 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -64,8 +64,10 @@ accepted-divergence entries (#96, #49, #50).
## 2. Adaptation (AD) — 44 active rows
-Recent retirements: AD-3/AD-4 retired 2026-07-31 by the exact loaded-cell
-availability and null-root containment port; AD-25 retired 2026-07-30 by the
+Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate
+visible-cell availability, full-catalog containment-root validation, and the
+zero-portals point-in-cell guard (rootless payloads are quarantined; only a
+missing positive child below a valid root is the inside base case); AD-25 retired 2026-07-30 by the
shared `PhysicsObjUpdate.HandleAllCollisions` remote path; AD-11 retired
2026-07-23 by the exact low-bit ItemUses predicate; AD-31 retired 2026-07-15
by the DAT-authored portal-space viewport. Recent additions and splits:
diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
index 0b1cd17f..a449b22b 100644
--- a/docs/architecture/worldbuilder-inventory.md
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -178,13 +178,18 @@ package schema, bake, DAT reader, collision formula, or render portal graph
changed. Evidence:
`docs/research/2026-07-26-prepared-indoor-transit-regression.md`.
-**Cell availability semantics (2026-07-31).** Raw and prepared CellStruct
-publication now retains a `CellPhysics` record even when authored physics or
-containment roots are empty. Payload absence is the unavailable state; a loaded
-null/-1 containment root keeps retail's universal-inside BSP base case.
-Registration-side outdoor floods still add outside cells but skip transit when
-the active CLandCell is unavailable, then recover through the existing reflood
-after terrain/cell hydration. No package schema or DAT reader changed.
+**Cell availability semantics (2026-07-31, corrected after full-catalog
+audit).** Raw and prepared CellStruct publication retains a `CellPhysics`
+record when the physics root is empty but requires a valid containment root.
+The installed 729,888-record raw and prepared catalogs contain zero rootless
+containment payloads. A malformed null/-1 root is quarantined atomically; the
+recursive inside base case applies only to a missing positive child below a
+valid root. Registration-side outdoor floods still add outside cells but skip
+transit when the active CLandCell is unavailable, and every later outdoor
+candidate independently requires its own visible landcell before building
+transit. The existing reflood retries after terrain/cell hydration. Both raw
+and prepared point-in-cell paths preserve retail's zero-portals guard. No
+package schema or DAT reader changed.
Evidence: `docs/research/2026-07-31-cell-availability-semantics.md`.
**Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter
diff --git a/docs/research/2026-07-31-cell-availability-semantics.md b/docs/research/2026-07-31-cell-availability-semantics.md
index a2bd019b..979dfbe9 100644
--- a/docs/research/2026-07-31-cell-availability-semantics.md
+++ b/docs/research/2026-07-31-cell-availability-semantics.md
@@ -1,26 +1,41 @@
-# Retail cell availability and null-root containment — 2026-07-31
+# Retail cell availability and containment-root validation — 2026-07-31
## Scope
This note closes divergence rows AD-3 and AD-4. It does not begin AD-6's
atomic streaming-generation work.
-The bug was one collapsed state. acdream treated all three of these as
-“containment unavailable”:
+The corrected port distinguishes these states:
1. no visible cell payload is loaded;
-2. a loaded CellStruct has a null containment root;
-3. a loaded CellStruct has an authored containment root.
+2. a malformed raw/prepared payload has no containment root;
+3. a loaded CellStruct has a valid authored containment root (its physics root
+ may independently be absent).
-Retail distinguishes (1) from (2). A failed visible-cell lookup is
-unavailable. A loaded CellStruct remains a real cell even when its BSPTREE
-root is null, and the containment query's null-node base case is inside.
+Only (3) is published. State (1) remains unavailable and retryable. State (2)
+is quarantined atomically so a later valid hydration can retry; it must not
+become a world-wide containing cell.
+
+## Installed-data audit
+
+The complete installed EoR catalog and matching prepared package were audited
+before choosing this invariant:
+
+- enumerated EnvCells: **729,888**;
+- raw: 0 missing EnvCells, 0 missing Environments, 0 missing CellStructs,
+ 0 null `CellBSP` objects, **0 null `CellBSP.Root`**, 729,888 valid roots;
+- prepared `acdream.pak`: 0 missing aliases, 0 corrupt payloads,
+ **0 `ContainmentBsp.RootIndex < 0`**, 729,888 valid roots;
+- 6,940 raw EnvCells have zero portals, so the retail portal-pointer guard is
+ a real catalog path rather than dead defensive code.
+
+There are therefore no root-null record IDs to preserve in either source.
## Retail oracle
`CObjCell::find_cell_list @ 0x0052B4E0` in
`docs/research/named-retail/acclient_2013_pseudo_c.txt:308742` establishes the
-availability gate:
+availability gates:
- `CEnvCell::GetVisible` / `CLandCell::GetVisible` resolves the active seed at
`0x0052B50C..0x0052B515`;
@@ -31,32 +46,47 @@ availability gate:
- each later candidate is independently skipped when its stored cell pointer
is null at `0x0052B58E`.
-`CCellStruct::point_in_cell @ 0x005338F0` delegates directly to
-`BSPTREE::point_inside_cell_bsp @ 0x005398C0`. The already-ported graph and
-flat BSP queries preserve the retail null-root base case: a negative/null root
-returns true. Root presence is therefore not an availability predicate.
+`CEnvCell::point_in_cell @ 0x0052C300` first returns false when
+`this->portals == 0`, then transforms the point and calls
+`CCellStruct::point_in_cell`.
+
+`CCellStruct::point_in_cell @ 0x005338F0` calls
+`BSPTREE::point_inside_cell_bsp @ 0x005398C0`, which immediately invokes
+`BSPNODE::point_inside_cell_bsp(this->root_node, ...)`. The BSP node method at
+`0x0053C1F0` dereferences `this` before walking positive children. Only a
+missing **positive child below a valid root** is the inside terminal case. A
+missing root is not.
## Ported behavior
-- `PhysicsDataCache` now publishes a `CellPhysics` record whenever an authored
- raw or prepared CellStruct payload exists, even if its physics BSP and/or
- containment BSP root is absent.
-- `CollisionTraversal.HasCellContainment` tests representation payload
- availability, not `Root` / `RootIndex`. `PointInsideCell` then lets the
- graph or flat query return true for the null-root base case.
+- `PhysicsDataCache` publishes graph, collision, and prepared records only
+ after a valid raw/prepared containment root is present. A missing physics
+ root is retained as a valid non-colliding cell. Invalid containment
+ publication changes no cache, so later hydration can retry.
+- `CollisionTraversal.HasCellContainment` tests `Root` / `RootIndex`.
+- Both raw and prepared `EnvCell.PointInCell` paths apply the zero-portals
+ guard before containment. `CellTransit` applies the same guard to its
+ `CellPhysics` representation.
- `CellTransit.BuildShadowCellSet` still seeds all overlapped outdoor cells,
but skips the transit walk when the active outdoor seed cannot be resolved
- from `CellGraph`. A separately cached building can no longer promote an
- object through an unavailable landcell.
+ from `CellGraph`. Every later outdoor candidate independently resolves via
+ `GetVisible` before building transit, so a stale building cannot promote an
+ object through an unavailable adjacent landcell.
- The existing reflood lifecycle remains the recovery mechanism. Once terrain
- or an indoor CellStruct publishes, the next reflood walks the same authored
+ or a valid indoor CellStruct publishes, the next reflood walks the authored
portal/building relationships without reconstructing a different rule.
## Gates
-Focused tests cover raw graph and prepared flat cache publication, absent
-versus loaded-null-root containment, indoor and outdoor seeds, preservation of
-outside-cell seeding, suppression of spurious building promotion, and
-hydration/reflood recovery. Final Release gates passed: Core 4,162 / 1 skipped,
-Runtime 440 / 0 skipped, App 4,002 / 3 skipped, plus the complete solution
-build with zero errors.
+Focused tests cover raw/prepared rootless quarantine and valid retry, valid
+containment with missing physics, raw/prepared zero-portal parity, indoor and
+outdoor seeds, preservation of outside-cell seeding, per-candidate adjacent
+landcell availability, suppression of stale-building promotion, and
+hydration/reflood recovery. The corrective checkpoint passes:
+
+- focused cell-availability suite: **54/54**;
+- Core Release: **4,165 passed / 1 skipped**;
+- Runtime Release: **440/440**;
+- App Release: **4,002 passed / 3 skipped**;
+- complete Release solution: **10,122 passed / 4 skipped**;
+- `dotnet build AcDream.slnx -c Release`: **0 warnings / 0 errors**.
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 1c2855e8..71ded556 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -608,6 +608,13 @@ public static class CellTransit
}
else
{
+ // CELLARRAY stores GetVisible's result beside every id.
+ // Retail skips a later candidate whose cell pointer is
+ // null (0052b588..0052b59f), even when a stale building
+ // record for that landcell remains cached.
+ if (cache.CellGraph.GetVisible(cellId) is null)
+ continue;
+
// CLandCell::find_transit_cells (0x00533800):
// add_all_outside_cells (added_outside-guarded) then the
// building bridge for the landcell's building, if any.
@@ -666,9 +673,9 @@ public static class CellTransit
///
///
///
- /// A missing record is unavailable and skipped.
- /// A loaded record whose authored containment root is null retains retail's
- /// universal-inside base case.
+ /// A missing or rootless record is unavailable
+ /// and skipped. The retail inside base case belongs to a missing positive
+ /// child below a valid root, not to the root itself.
///
///
public static uint FindVisibleChildCell(
@@ -705,8 +712,8 @@ public static class CellTransit
///
/// CEnvCell::point_in_cell (cell-BSP vtable[0x84]) against a world point:
/// transform to the cell's local frame, then .
- /// A missing cell payload returns false; a loaded payload with a null root
- /// returns true through the retail BSP base case.
+ /// A missing/rootless payload returns false. Retail also returns false
+ /// before containment when CEnvCell::portals is null.
///
private static bool PointInCell(
PhysicsDataCache cache,
@@ -714,6 +721,7 @@ public static class CellTransit
Vector3 worldPoint)
{
if (cell is null ||
+ cell.Portals.Count == 0 ||
!CollisionTraversal.HasCellContainment(cache, cell))
{
return false;
@@ -920,6 +928,11 @@ public static class CellTransit
if ((cellId & 0xFFFFu) < 0x0100u)
{
+ // Match CELLARRAY's stored GetVisible pointer: an adjacent
+ // landcell id may be present because the sphere overlaps it,
+ // while that landblock is not loaded yet.
+ if (cache.CellGraph.GetVisible(cellId) is null)
+ continue;
// Landcell dispatch — CLandCell::find_transit_cells (0x00533800)
// → CSortCell::find_transit_cells (0x00534060, this->building)
// → CBuildingObj::find_building_transit_cells (0x006b5230)
@@ -994,17 +1007,13 @@ public static class CellTransit
{
// Interior candidate — point_in_cell via the cell BSP (vtable[0x84]).
var cand = cache.GetCellStruct(candId);
- if (cand is null ||
- !CollisionTraversal.HasCellContainment(cache, cand))
- {
- continue;
- }
-
- var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
- if (CollisionTraversal.PointInsideCell(cache, cand, local))
+ if (PointInCell(cache, cand, worldSphereCenter))
return candId; // interior-wins, stop (pseudo_c:308819)
}
- else if (outdoorResult == 0u && containingOutdoorId != 0u && outdoorPickAllowed)
+ else if (outdoorResult == 0u &&
+ containingOutdoorId != 0u &&
+ outdoorPickAllowed &&
+ cache.CellGraph.GetVisible(candId) is not null)
{
// Outdoor candidate — CLandCell::point_in_cell is the XY-column the
// sphere is over (acdream landcells have no BSP point_in_cell; the
diff --git a/src/AcDream.Core/Physics/CollisionTraversal.cs b/src/AcDream.Core/Physics/CollisionTraversal.cs
index a08a188b..d75f4bea 100644
--- a/src/AcDream.Core/Physics/CollisionTraversal.cs
+++ b/src/AcDream.Core/Physics/CollisionTraversal.cs
@@ -25,14 +25,9 @@ internal static class CollisionTraversal
{
if (UseFlat(cache))
{
- // Availability is the authored CellStruct payload, not the
- // containment root. Retail's loaded BSPTREE may have a null
- // root; BSPNODE::point_inside_cell_bsp treats that base case as
- // universally inside. A missing flat payload is still a broken
- // production publication and must fail loudly.
- _ = cell.FlatContainmentBsp ??
+ FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
- const bool flatAuthorityResult = true;
+ bool flatAuthorityResult = flat.RootIndex >= 0;
CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
if (flatShadow is null ||
!flatShadow.TrySample(out long flatAuthoritySample))
@@ -43,7 +38,7 @@ internal static class CollisionTraversal
flatShadow.BeginGraphPass();
try
{
- graphRefereeResult = true;
+ graphRefereeResult = cell.CellBSP?.Root is not null;
}
catch (Exception fault)
{
@@ -79,16 +74,15 @@ internal static class CollisionTraversal
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
- return true;
+ return cell.CellBSP?.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
- _ = cell.FlatContainmentBsp ??
- throw MissingFlat("cell containment");
- flatResult = true;
+ flatResult = (cell.FlatContainmentBsp ??
+ throw MissingFlat("cell containment")).RootIndex >= 0;
}
catch (Exception fault)
{
@@ -99,7 +93,7 @@ internal static class CollisionTraversal
shadow.EndFlatPass();
}
- const bool graphResult = true;
+ bool graphResult = cell.CellBSP?.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index 43d2fbb4..6e8f019c 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -82,8 +82,8 @@ public sealed class PhysicsDataCache
///
/// The unified cell graph (UCG): the active id->cell resolver and registry.
- /// Populated unconditionally in so BSP-less
- /// authored cells are registered too, and
+ /// Populated by for cells with valid
+ /// containment (including cells with no physics root), and
/// consumed across the engine: the player render/lighting root
/// (CellGraph.CurrCell, written at the player chokepoint
/// PhysicsEngine.UpdatePlayerCurrCell and read by the renderer), the
@@ -364,11 +364,10 @@ public sealed class PhysicsDataCache
}
///
- /// Extract and cache the authored CellStruct payload (indoor room
- /// geometry), including cells whose physics or containment BSP has a null
- /// root. Retail keeps those loaded cells distinct from an unavailable
- /// visible-cell lookup; the null containment root is universally inside.
- /// No-ops only when the id is already cached.
+ /// Extract and cache an authored CellStruct payload (indoor room geometry).
+ /// A missing physics root is valid (the cell can still own containment and
+ /// portals); a missing containment root is not a loadable CEnvCell and is
+ /// rejected before either the graph or collision record is published.
///
public void CacheCellStruct(
uint envCellId,
@@ -398,11 +397,6 @@ public sealed class PhysicsDataCache
!_flatEnvCell.ContainsKey(envCellId))
throw MissingPreparedCollision("EnvCell topology", envCellId);
- if (preparedStructure is not null)
- _flatCellStruct.TryAdd(envCellId, preparedStructure);
- if (preparedTopology is not null)
- _flatEnvCell.TryAdd(envCellId, preparedTopology);
-
if (_requirePreparedCollision)
{
CachePreparedCellStruct(
@@ -416,7 +410,27 @@ public sealed class PhysicsDataCache
return;
}
- // UCG Stage 1: register in the unified graph for every authored cell.
+ // CCellStruct::point_in_cell dereferences cell_bsp->root_node before
+ // entering BSPNODE::point_inside_cell_bsp. A null ROOT is therefore
+ // not the recursive missing-positive-child "inside" sentinel. The
+ // installed 2013 catalog contains zero such payloads; quarantine one
+ // rather than publishing a cell that claims the whole world.
+ if (cellStruct.CellBSP?.Root is null)
+ return;
+
+ // A malformed optional prepared shadow must not attach to an otherwise
+ // valid raw cell. Production takes the prepared-only overload below.
+ if (preparedStructure?.ContainmentBsp.RootIndex < 0)
+ {
+ preparedStructure = null;
+ preparedTopology = null;
+ }
+ if (preparedStructure is not null)
+ _flatCellStruct.TryAdd(envCellId, preparedStructure);
+ if (preparedTopology is not null)
+ _flatEnvCell.TryAdd(envCellId, preparedTopology);
+
+ // UCG Stage 1: register only a loadable authored cell.
if (!CellGraph.Contains(envCellId))
{
CellGraph.Add(UcgEnvCell.FromDat(
@@ -618,6 +632,13 @@ public sealed class PhysicsDataCache
FlatCellStructureCollisionAsset preparedStructure,
FlatEnvCellTopology preparedTopology)
{
+ // Same invariant as the raw loader. RootIndex -1 is the flattened
+ // encoding of a missing ROOT, not a recursive positive-child sentinel.
+ // Reject it atomically so graph, collision, and prepared caches agree
+ // that this cell is unavailable and a later valid hydration may retry.
+ if (preparedStructure.ContainmentBsp.RootIndex < 0)
+ return;
+
_flatCellStruct.TryAdd(envCellId, preparedStructure);
_flatEnvCell.TryAdd(envCellId, preparedTopology);
@@ -630,9 +651,8 @@ public sealed class PhysicsDataCache
preparedTopology));
}
- // The prepared structure itself is the loaded CellStruct payload.
- // Empty physics and containment roots remain meaningful authored
- // values; neither means that the cell is unavailable.
+ // Physics may be rootless even though the cell's containment and
+ // topology are valid; preserve that loaded, non-colliding cell.
if (_cellStruct.ContainsKey(envCellId))
return;
@@ -1023,9 +1043,9 @@ public sealed class CellPhysics
/// (point-in-cell tests). Separate tree from
/// (collision) and from the renderer's drawing-BSP.
/// Source: cellStruct.CellBSP at cache time.
- /// A nullable root is an authored, universally-inside containment tree.
- /// Cell availability is represented by presence of this
- /// record, not by root presence.
+ /// Root presence is required for a published cell. Missing positive
+ /// children inside a valid tree are the retail inside base case; a missing
+ /// root is rejected by .
///
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs
index d8eed135..48fc0951 100644
--- a/src/AcDream.Core/World/Cells/CellGraph.cs
+++ b/src/AcDream.Core/World/Cells/CellGraph.cs
@@ -7,9 +7,9 @@ namespace AcDream.Core.World.Cells;
///
/// The unified cell graph: the active, authoritative id->cell resolver and registry.
-/// Populated unconditionally from
-/// (including
-/// authored cells with null physics or containment roots) and consumed across
+/// Populated from validated
+/// payloads
+/// (a physics root is optional; a containment root is required) and consumed across
/// the engine: resolves any cell id, is
/// the player render/lighting root, resolves the
/// 3rd-person camera cell, and supplies the block-local
diff --git a/src/AcDream.Core/World/Cells/EnvCell.cs b/src/AcDream.Core/World/Cells/EnvCell.cs
index d18039c6..404fc483 100644
--- a/src/AcDream.Core/World/Cells/EnvCell.cs
+++ b/src/AcDream.Core/World/Cells/EnvCell.cs
@@ -11,9 +11,9 @@ namespace AcDream.Core.World.Cells;
public sealed class EnvCell : ObjCell
{
///
- /// Cell-containment BSP (retail CellStruct.CellBSP). A present tree with a
- /// null root is universally inside; an absent test/tooling payload uses the
- /// legacy AABB fallback.
+ /// Cell-containment BSP (retail CellStruct.CellBSP). Production publication
+ /// requires a non-null root; prepared production uses
+ /// instead.
///
public CellBSPTree? ContainmentBsp { get; }
@@ -38,14 +38,18 @@ public sealed class EnvCell : ObjCell
public override bool PointInCell(Vector3 worldPoint)
{
+ // Retail CEnvCell::point_in_cell @ 0x0052C300 returns false before
+ // touching the CellStruct when this->portals is null. Installed data
+ // contains real zero-portal cells, so this guard is behavior-bearing.
+ if (Portals.Count == 0)
+ return false;
+
var local = Vector3.Transform(worldPoint, InverseWorldTransform);
- if (FlatContainmentBsp is not null)
+ if (FlatContainmentBsp is { RootIndex: >= 0 })
return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local);
- if (ContainmentBsp is not null)
+ if (ContainmentBsp?.Root is not null)
return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034
- return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X
- && local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y
- && local.Z >= LocalBoundsMin.Z && local.Z <= LocalBoundsMax.Z;
+ return false;
}
///
diff --git a/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs
index e61c0e50..5183e01b 100644
--- a/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs
+++ b/tests/AcDream.App.Tests/Input/PlayerMovementPlacementTransactionTests.cs
@@ -57,6 +57,13 @@ public sealed class PlayerMovementPlacementTransactionTests
Vertices = new Dictionary(),
},
Polygons = new Dictionary(),
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode
+ {
+ Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
+ },
+ },
};
var envCell = new DatEnvCell
{
diff --git a/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs
index a5dae8ce..128fc2c1 100644
--- a/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/CameraCollisionUpdateViewerTests.cs
@@ -71,10 +71,11 @@ public class CameraCollisionUpdateViewerTests
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
- // Feet cell: interior Z ≤ 94, in its stab list the room cell above. No portals
- // (so the collision sweep cannot transit to the room — the start cell is decisive).
+ // Feet cell: interior Z ≤ 94, in its stab list the room cell above.
+ // The inert sentinel portal keeps the synthetic cell eligible for
+ // retail CEnvCell::point_in_cell without creating a usable transit.
cache.RegisterCellStructForTest(FeetCellId, MakeCell(InteriorZAtMost(94f), new uint[] { RoomCellId }));
- // Room cell: interior Z ≥ 94, no walls, no portals.
+ // Room cell: interior Z ≥ 94, no walls and no usable portals.
cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorZAtLeast(94f), Array.Empty()));
var heights = new byte[81];
@@ -110,7 +111,7 @@ public class CameraCollisionUpdateViewerTests
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary(),
CellBSP = new CellBSPTree { Root = cellBspRoot },
- Portals = Array.Empty(),
+ Portals = [new PortalInfo(0xFFFF, 0, 0)],
PortalPolygons = new Dictionary(),
VisibleCellIds = new HashSet(visibleCellIds),
};
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs
index 822bd86e..b05880e0 100644
--- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs
@@ -879,6 +879,13 @@ public sealed class LandblockPhysicsPublisherTests
{
[0] = new Polygon { VertexIds = [0, 1, 2] },
},
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode
+ {
+ Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
+ },
+ },
};
var environment = new DatReaderWriter.DBObjs.Environment
{
diff --git a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
index 274ad311..e0908912 100644
--- a/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/BuildShadowCellSetTests.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using System.Collections.Immutable;
using System.Linq;
using System.Numerics;
using DatReaderWriter.Enums;
@@ -63,6 +62,7 @@ public class BuildShadowCellSetTests
private static CellPhysics MakeLeafCell(Matrix4x4 worldTransform)
{
Matrix4x4.Invert(worldTransform, out var inv);
+ var root = new CellBSPNode { Type = BSPNodeType.Leaf };
return new CellPhysics
{
WorldTransform = worldTransform,
@@ -70,40 +70,25 @@ public class BuildShadowCellSetTests
Resolved = new Dictionary(),
CellBSP = new CellBSPTree
{
- Root = new CellBSPNode { Type = BSPNodeType.Leaf },
+ Root = root,
},
+ FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root),
};
}
- private static CellPhysics MakeNullRootCell(Matrix4x4 worldTransform)
- {
- Matrix4x4.Invert(worldTransform, out var inv);
- return new CellPhysics
- {
- WorldTransform = worldTransform,
- InverseWorldTransform = inv,
- Resolved = new Dictionary(),
- CellBSP = new CellBSPTree { Root = null },
- FlatContainmentBsp = new FlatCellContainmentBsp(
- -1,
- ImmutableArray.Empty),
- };
- }
-
- private static CellPhysics MakeNullRootCellWithExteriorPortal(
+ private static CellPhysics MakeValidCellWithExteriorPortal(
Matrix4x4 worldTransform)
{
Matrix4x4.Invert(worldTransform, out var inv);
var portalPlane = new Plane(new Vector3(1f, 0f, 0f), -2.5f);
+ var root = new CellBSPNode { Type = BSPNodeType.Leaf };
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary(),
- CellBSP = new CellBSPTree { Root = null },
- FlatContainmentBsp = new FlatCellContainmentBsp(
- -1,
- ImmutableArray.Empty),
+ CellBSP = new CellBSPTree { Root = root },
+ FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root),
PortalPolygons = new Dictionary
{
[10] = new ResolvedPolygon
@@ -191,7 +176,7 @@ public class BuildShadowCellSetTests
[Theory]
[InlineData(false)]
[InlineData(true)]
- public void IndoorSeed_RefloodsAfterNullRootPayloadHydrates(
+ public void IndoorSeed_RefloodsAfterValidPayloadHydrates(
bool useFlat)
{
var cache = new PhysicsDataCache
@@ -212,7 +197,7 @@ public class BuildShadowCellSetTests
cache.RegisterCellStructForTest(
IndoorSeed,
- MakeNullRootCellWithExteriorPortal(Matrix4x4.Identity));
+ MakeValidCellWithExteriorPortal(Matrix4x4.Identity));
IReadOnlyList hydrated = CellTransit.BuildShadowCellSet(
cache,
IndoorSeed,
@@ -322,7 +307,7 @@ public class BuildShadowCellSetTests
};
cache.RegisterCellStructForTest(
NeighborCell,
- MakeNullRootCell(Matrix4x4.Identity));
+ MakeLeafCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
IReadOnlyList seeded = CellTransit.BuildShadowCellSet(
@@ -365,6 +350,65 @@ public class BuildShadowCellSetTests
Assert.Contains(NeighborCell, hydrated);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void LoadedSeed_AbsentAdjacentLandcell_SkipsStaleBuildingUntilAdjacentHydrates(
+ bool useFlat)
+ {
+ const uint seedCell = 0xA9B4_0031u;
+ const uint adjacentCell = 0xA9B3_0038u;
+ const uint interiorCell = 0xA9B3_0100u;
+ var cache = new PhysicsDataCache
+ {
+ CollisionTraversalMode = useFlat
+ ? CollisionTraversalMode.Flat
+ : CollisionTraversalMode.Graph,
+ };
+ cache.CellGraph.RegisterTerrain(
+ 0xA9B4_0000u,
+ new TerrainSurface(new byte[81], new float[256]),
+ Vector3.Zero);
+ cache.RegisterCellStructForTest(
+ interiorCell,
+ MakeLeafCell(Matrix4x4.Identity));
+ cache.RegisterBuildingForTest(adjacentCell, new BuildingPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Portals =
+ [
+ new BldPortalInfo(interiorCell, otherPortalId: 0, flags: 0),
+ ],
+ });
+ Sphere[] sphere = One(new Vector3(150f, 0.2f, 0f), 0.5f);
+
+ IReadOnlyList unavailable = CellTransit.BuildShadowCellSet(
+ cache,
+ seedCell,
+ sphere,
+ 1,
+ isStatic: false);
+
+ Assert.Contains(seedCell, unavailable);
+ Assert.Contains(adjacentCell, unavailable);
+ Assert.DoesNotContain(interiorCell, unavailable);
+
+ cache.CellGraph.RegisterTerrain(
+ 0xA9B3_0000u,
+ new TerrainSurface(new byte[81], new float[256]),
+ new Vector3(0f, -192f, 0f));
+ IReadOnlyList hydrated = CellTransit.BuildShadowCellSet(
+ cache,
+ seedCell,
+ sphere,
+ 1,
+ isStatic: false);
+
+ Assert.Contains(adjacentCell, hydrated);
+ Assert.Contains(interiorCell, hydrated);
+ }
+
// ── Exterior straddle from an indoor seed ──────────────────────────
[Fact]
diff --git a/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs b/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
index 89680918..802e1eef 100644
--- a/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs
@@ -27,6 +27,10 @@ public class CellGraphMembershipTests
VertexArray = new VertexArray { Vertices = new Dictionary() },
Polygons = new Dictionary(),
PhysicsBSP = null,
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode { Type = DatReaderWriter.Enums.BSPNodeType.Leaf },
+ },
};
var dat = new DatEnvCell
{
diff --git a/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs b/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs
index 843c0512..80b0a031 100644
--- a/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellGraphPopulationTests.cs
@@ -2,6 +2,7 @@ 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;
@@ -11,7 +12,7 @@ namespace AcDream.Core.Tests.Physics;
public class CellGraphPopulationTests
{
[Fact]
- public void CacheCellStruct_PublishesLoadedCell_WhenPhysicsAndContainmentRootsAreNull()
+ public void CacheCellStruct_RejectsRootlessContainment_ThenAllowsValidRetry()
{
var cache = new PhysicsDataCache();
var cellStruct = new CellStruct
@@ -29,13 +30,16 @@ public class CellGraphPopulationTests
cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity);
+ Assert.Null(cache.GetCellStruct(0xA9B40174u));
+ Assert.Null(cache.CellGraph.GetVisible(0xA9B40174u));
+
+ cellStruct.CellBSP.Root = new CellBSPNode { Type = BSPNodeType.Leaf };
+ cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity);
+
CellPhysics loaded = Assert.IsType(
cache.GetCellStruct(0xA9B40174u));
+ Assert.False(CollisionTraversal.HasPhysics(cache, loaded));
Assert.True(CollisionTraversal.HasCellContainment(cache, loaded));
- Assert.True(CollisionTraversal.PointInsideCell(
- cache,
- loaded,
- new Vector3(10_000f, -10_000f, 500f)));
Assert.NotNull(cache.CellGraph.GetVisible(0xA9B40174u));
Assert.IsType(cache.CellGraph.GetVisible(0xA9B40174u));
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
index 265fc0d6..d6f4c478 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
@@ -8,11 +8,11 @@ namespace AcDream.Core.Tests.Physics;
public class CellTransitCheckBuildingTransitTests
{
[Fact]
- public void BuildingPortalWithLoadedNullRoot_CellIsAdmitted()
+ public void BuildingPortalWithRootlessContainment_CellIsRejected()
{
- // Retail separates an unavailable CEnvCell lookup from an authored
- // CellStruct whose cell_bsp root is null. The latter is loaded, and
- // the null-root sphere query is the universal-inside base case.
+ // Retail dereferences CCellStruct.cell_bsp->root_node before calling
+ // the recursive query. A missing positive child means inside; a
+ // missing root is not a valid loaded CEnvCell.
// Building at world origin. One portal to interior cell 0xA9B40100.
var building = new BuildingPhysics
@@ -28,7 +28,8 @@ public class CellTransitCheckBuildingTransitTests
},
};
- // Interior cell with an authored null containment root.
+ // Rootless fixture bypasses the production quarantine to verify that
+ // the traversal boundary still rejects it safely.
var interiorCell = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
@@ -47,7 +48,7 @@ public class CellTransitCheckBuildingTransitTests
sphereRadius: 0.5f,
candidates);
- Assert.Contains(0xA9B40100u, candidates);
+ Assert.Empty(candidates);
}
[Fact]
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
index dd849175..80dd9544 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
@@ -172,7 +172,7 @@ public class CellTransitFindCellSetTests
// ──────────────────────────────────────────────────────────────────
[Fact]
- public void OutdoorSeed_CrossesLandblockBoundary_South()
+ public void OutdoorSeed_CrossesLandblockBoundary_South_AfterDestinationHydrates()
{
// The #106 acceptance golden: walking south out of A9B4, the outdoor
// cell must advance to the southern neighbour block's cell. Origin of
@@ -183,14 +183,25 @@ public class CellTransitFindCellSetTests
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
- uint containing = CellTransit.FindCellSet(
+ uint unavailable = CellTransit.FindCellSet(
cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40031u,
out var cellSet);
- Assert.Equal(0xA9B30038u, containing);
+ Assert.Equal(0xA9B40031u, unavailable);
Assert.Contains(0xA9B30038u, cellSet);
Assert.Contains(0xA9B40031u, cellSet); // +Y neighbour still in the set
+
+ cache.CellGraph.RegisterTerrain(
+ 0xA9B30000u,
+ new TerrainSurface(new byte[81], new float[256]),
+ new Vector3(0f, -192f, 0f));
+ uint containing = CellTransit.FindCellSet(
+ cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f,
+ currentCellId: 0xA9B40031u,
+ out _);
+
+ Assert.Equal(0xA9B30038u, containing);
}
[Fact]
@@ -227,6 +238,10 @@ public class CellTransitFindCellSetTests
0xA9B30000u,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(0f, -192f, 0f));
+ cache.CellGraph.RegisterTerrain(
+ 0xA9B40000u,
+ new TerrainSurface(new byte[81], new float[256]),
+ Vector3.Zero);
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, 1f, 0f), sphereRadius: 0.5f,
@@ -315,27 +330,29 @@ public class CellTransitFindCellSetTests
}
[Fact]
- public void IndoorSeed_LoadedNullRoot_IsUniversallyInside_StaysCurrent()
+ public void FindVisibleChildCell_RootlessContainment_IsUnavailable()
{
- // Retail distinguishes a failed cell lookup from a loaded CellStruct
- // whose containment root is null. The latter is the BSP query's
- // universally-inside base case, so the current cell wins immediately.
+ // Bypass production cache validation to pin the traversal boundary:
+ // a rootless fixture cannot claim a point even though the recursive
+ // helper's missing-positive-child base case returns inside.
Matrix4x4.Invert(Matrix4x4.Identity, out var inv);
var cellNoBsp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = inv,
Resolved = new Dictionary(),
+ Portals = [new PortalInfo(0x0101, 0, 0)],
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40150u, cellNoBsp);
- uint containing = CellTransit.FindCellSet(
- cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f,
- currentCellId: 0xA9B40150u,
- out _);
+ uint containing = CellTransit.FindVisibleChildCell(
+ cache,
+ 0xA9B40150u,
+ new Vector3(-10f, 12f, 0f),
+ useStabList: true);
- Assert.Equal(0xA9B40150u, containing);
+ Assert.Equal(0u, containing);
}
// ──────────────────────────────────────────────────────────────────
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs
index a5613fb6..88afa121 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs
@@ -106,7 +106,9 @@ public class CellTransitFindVisibleChildCellTests
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary(),
CellBSP = new CellBSPTree { Root = cellBspRoot },
- Portals = Array.Empty(),
+ // Keep this synthetic cell eligible for retail point_in_cell; the
+ // test varies containment and stab-list behavior, not portal absence.
+ Portals = [new PortalInfo(0xFFFF, 0, 0)],
PortalPolygons = new Dictionary(),
VisibleCellIds = new HashSet(visibleCellIds),
};
diff --git a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs
index e429f100..8cb3606a 100644
--- a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs
@@ -134,7 +134,10 @@ public class Issue133DungeonTeleportPrefixTests
// Leaf root → point_in_cell true for any point → AdjustPosition
// validates the claim (found=true, cell unchanged).
CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } },
- Portals = Array.Empty(),
+ // Retail CEnvCell::point_in_cell rejects cells with no portal
+ // array before consulting the containment BSP. The synthetic
+ // cell is intended to exercise an eligible loaded dungeon cell.
+ Portals = [new PortalInfo(0xFFFF, 0, 0)],
PortalPolygons = new Dictionary(),
VisibleCellIds = new HashSet(),
};
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
index 45b95f5c..69f9fa95 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsDataCacheProductionTests.cs
@@ -107,8 +107,13 @@ public sealed class PhysicsDataCacheProductionTests
var structure = new FlatCellStructureCollisionAsset(
physicsBsp,
new FlatCellContainmentBsp(
- -1,
- ImmutableArray.Empty),
+ 0,
+ ImmutableArray.Create(new FlatCellBspNode(
+ BSPNodeType.Leaf,
+ default,
+ -1,
+ -1,
+ 0))),
FlatPolygonTable.Empty);
var topology = new FlatEnvCellTopology(
ImmutableArray.Empty,
@@ -162,7 +167,7 @@ public sealed class PhysicsDataCacheProductionTests
}
[Fact]
- public void ProductionCellPublication_PreservesLoadedCellWithEmptyRoots()
+ public void ProductionCellPublication_RejectsRootlessContainment_ThenAllowsValidRetry()
{
const uint cellId = 0xA9B4_0174u;
PhysicsDataCache cache = PhysicsDataCache.CreateProduction();
@@ -190,18 +195,33 @@ public sealed class PhysicsDataCacheProductionTests
structure,
topology);
- CellPhysics loaded = Assert.IsType(
- cache.GetCellStruct(cellId));
+ Assert.Null(cache.GetCellStruct(cellId));
+ Assert.Null(cache.CellGraph.GetVisible(cellId));
+ Assert.Equal(0, cache.FlatCellStructCount);
+ Assert.Equal(0, cache.FlatEnvCellCount);
+
+ var validContainment = new FlatCellContainmentBsp(
+ 0,
+ ImmutableArray.Create(new FlatCellBspNode(
+ BSPNodeType.Leaf,
+ default,
+ -1,
+ -1,
+ 0)));
+ cache.CacheCellStruct(
+ cellId,
+ new EnvCell(),
+ Matrix4x4.Identity,
+ new FlatCellStructureCollisionAsset(
+ emptyPhysics,
+ validContainment,
+ FlatPolygonTable.Empty),
+ topology);
+
+ CellPhysics loaded = Assert.IsType(cache.GetCellStruct(cellId));
Assert.False(CollisionTraversal.HasPhysics(cache, loaded));
Assert.True(CollisionTraversal.HasCellContainment(cache, loaded));
- Assert.True(CollisionTraversal.PointInsideCell(
- cache,
- loaded,
- new Vector3(10_000f, -10_000f, 500f)));
- var graphCell = Assert.IsType(
- cache.CellGraph.GetVisible(cellId));
- Assert.True(graphCell.PointInCell(
- new Vector3(10_000f, -10_000f, 500f)));
+ Assert.NotNull(cache.CellGraph.GetVisible(cellId));
Assert.Equal(1, cache.CellStructCount);
Assert.Equal(1, cache.FlatCellStructCount);
Assert.Equal(0, cache.GraphCellStructCount);
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs
index 631ac435..2966fb51 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs
@@ -104,7 +104,9 @@ public class PhysicsEngineAdjustPositionTests
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary(),
CellBSP = new CellBSPTree { Root = cellBspRoot },
- Portals = Array.Empty(),
+ // Keep this synthetic cell eligible for retail point_in_cell; the
+ // test varies containment and adjustment behavior, not portal absence.
+ Portals = [new PortalInfo(0xFFFF, 0, 0)],
PortalPolygons = new Dictionary(),
VisibleCellIds = new HashSet(visibleCellIds),
};
diff --git a/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs
index 783d24a2..fe318b36 100644
--- a/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs
@@ -418,6 +418,18 @@ public sealed class Ts4ProductionQuantumConformanceTests
Array.Empty(),
0f,
0f);
+ // The authored roof and the 90-tick path cross the west edge of the
+ // anchor block. Retail's CELLARRAY retains that candidate id but only
+ // dispatches/picks it when GetVisible resolves the adjacent CLandCell.
+ // Hydrate the west neighbor so this fixture continues to measure the
+ // collision response rather than unavailable-streaming behavior.
+ engine.AddLandblock(
+ 0xA8B40000u,
+ new TerrainSurface(heights, heightTable),
+ Array.Empty(),
+ Array.Empty(),
+ -192f,
+ 0f);
engine.ShadowObjects.Register(
GfxId,
GfxId,
diff --git a/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs b/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs
index 2b757174..c12129ed 100644
--- a/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs
@@ -18,7 +18,10 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World.Cells;
+using DatReaderWriter.Enums;
using Xunit;
+using CellBSPNode = DatReaderWriter.Types.CellBSPNode;
+using CellBSPTree = DatReaderWriter.Types.CellBSPTree;
namespace AcDream.Core.Tests.Rendering;
@@ -29,8 +32,8 @@ public class CellGraphRootTests
// ------------------------------------------------------------------
///
- /// Synthetic EnvCell with an identity transform and axis-aligned bounds so
- /// PointInCell returns true for points inside [min, max].
+ /// Synthetic EnvCell with an authored six-plane containment BSP for
+ /// [min,max] and one portal so retail CEnvCell::point_in_cell is eligible.
/// seenOutside = false → sealed dungeon; true → building interior/exterior.
///
private static EnvCell MakeEnvCell(uint id, Vector3 min, Vector3 max, bool seenOutside = false)
@@ -39,10 +42,10 @@ public class CellGraphRootTests
Matrix4x4.Identity,
Matrix4x4.Identity,
min, max,
- portals: new List(),
+ portals: new List { new(0xFFFFu, 0, 0, 0) },
stabList: new List(),
seenOutside: seenOutside,
- containmentBsp: null);
+ containmentBsp: new CellBSPTree { Root = BoundsBsp(min, max) });
///
/// EnvCell with an explicit stab list (used by FindVisibleChildCell tests).
@@ -54,10 +57,30 @@ public class CellGraphRootTests
Matrix4x4.Identity,
Matrix4x4.Identity,
min, max,
- portals: new List(),
+ portals: new List { new(0xFFFFu, 0, 0, 0) },
stabList: stabList,
seenOutside: seenOutside,
- containmentBsp: null);
+ containmentBsp: new CellBSPTree { Root = BoundsBsp(min, max) });
+
+ private static CellBSPNode BoundsBsp(Vector3 min, Vector3 max)
+ {
+ var leaf = new CellBSPNode { Type = BSPNodeType.Leaf };
+ CellBSPNode Add(Plane plane, CellBSPNode positive) => new()
+ {
+ Type = BSPNodeType.BPIn,
+ SplittingPlane = plane,
+ PosNode = positive,
+ };
+
+ CellBSPNode root = leaf;
+ root = Add(new Plane(-Vector3.UnitZ, max.Z), root);
+ root = Add(new Plane(Vector3.UnitZ, -min.Z), root);
+ root = Add(new Plane(-Vector3.UnitY, max.Y), root);
+ root = Add(new Plane(Vector3.UnitY, -min.Y), root);
+ root = Add(new Plane(-Vector3.UnitX, max.X), root);
+ root = Add(new Plane(Vector3.UnitX, -min.X), root);
+ return root;
+ }
// ------------------------------------------------------------------
// Predicate helpers — mirror the formulas in GameWindow.OnRender (Stage 3)
diff --git a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
index e099c2ae..8e82ee59 100644
--- a/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
+++ b/tests/AcDream.Core.Tests/World/Cells/EnvCellTests.cs
@@ -10,46 +10,67 @@ namespace AcDream.Core.Tests.World.Cells;
public class EnvCellTests
{
- private static EnvCell Make(Vector3 min, Vector3 max, Matrix4x4? transform = null)
+ private static readonly UcgCellPortal[] OnePortal =
+ [new UcgCellPortal(0xA9B4_0175u, 0, 0, 0)];
+
+ private static EnvCell Make(
+ CellBSPNode? root,
+ bool prepared,
+ bool hasPortals,
+ Matrix4x4? transform = null)
{
var t = transform ?? Matrix4x4.Identity;
Matrix4x4.Invert(t, out var inv);
- return new EnvCell(0xA9B40174u, t, inv, min, max,
- System.Array.Empty(), System.Array.Empty(),
- seenOutside: false, containmentBsp: null);
- }
-
- [Fact]
- public void PointInCell_NullBsp_Aabb_InsideIsTrue()
- => Assert.True(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(5,5,5)));
-
- [Fact]
- public void PointInCell_NullBsp_Aabb_OutsideIsFalse()
- => Assert.False(Make(new Vector3(0,0,0), new Vector3(10,10,10)).PointInCell(new Vector3(20,5,5)));
-
- [Fact]
- public void PointInCell_LoadedNullRoot_IsUniversallyInside()
- {
- var cell = new EnvCell(
- 0xA9B4_0174u,
- Matrix4x4.Identity,
- Matrix4x4.Identity,
- Vector3.Zero,
+ return new EnvCell(
+ 0xA9B40174u,
+ t,
+ inv,
+ -Vector3.One,
Vector3.One,
- Array.Empty(),
+ hasPortals ? OnePortal : Array.Empty(),
Array.Empty(),
seenOutside: false,
- containmentBsp: new CellBSPTree { Root = null });
+ containmentBsp: new CellBSPTree { Root = root },
+ flatContainmentBsp: prepared
+ ? FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root)
+ : null);
+ }
- Assert.True(cell.PointInCell(new Vector3(10_000f, -10_000f, 500f)));
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void PointInCell_ZeroPortals_RejectsBeforeContainment(bool prepared)
+ {
+ var root = new CellBSPNode { Type = BSPNodeType.Leaf };
+
+ Assert.False(Make(root, prepared, hasPortals: false).PointInCell(Vector3.Zero));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void PointInCell_RootlessContainment_IsRejected(bool prepared)
+ {
+ Assert.False(Make(null, prepared, hasPortals: true).PointInCell(Vector3.Zero));
}
[Fact]
public void PointInCell_TransformsWorldToLocalBeforeTesting()
{
- var c = Make(new Vector3(0,0,0), new Vector3(10,10,10), Matrix4x4.CreateTranslation(100,0,0));
- Assert.True(c.PointInCell(new Vector3(105,5,5)));
- Assert.False(c.PointInCell(new Vector3(5,5,5)));
+ var root = new CellBSPNode
+ {
+ Type = BSPNodeType.BPIn,
+ SplittingPlane = new Plane(Vector3.UnitX, 0f),
+ PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
+ };
+ var cell = Make(
+ root,
+ prepared: false,
+ hasPortals: true,
+ transform: Matrix4x4.CreateTranslation(100f, 0f, 0f));
+
+ Assert.True(cell.PointInCell(new Vector3(105f, 0f, 0f)));
+ Assert.False(cell.PointInCell(new Vector3(95f, 0f, 0f)));
}
[Fact]
@@ -74,7 +95,7 @@ public class EnvCellTests
Matrix4x4.Identity,
-Vector3.One,
Vector3.One,
- Array.Empty(),
+ OnePortal,
Array.Empty(),
seenOutside: false,
containmentBsp: new CellBSPTree { Root = graphRoot },