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 },