fix(physics): validate retail cell containment roots

This commit is contained in:
Erik 2026-07-31 14:48:26 +02:00
parent 7716c2ee89
commit 3e0f3b6206
23 changed files with 429 additions and 197 deletions

View file

@ -64,8 +64,10 @@ accepted-divergence entries (#96, #49, #50).
## 2. Adaptation (AD) — 44 active rows ## 2. Adaptation (AD) — 44 active rows
Recent retirements: AD-3/AD-4 retired 2026-07-31 by the exact loaded-cell Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate
availability and null-root containment port; AD-25 retired 2026-07-30 by the 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 shared `PhysicsObjUpdate.HandleAllCollisions` remote path; AD-11 retired
2026-07-23 by the exact low-bit ItemUses predicate; AD-31 retired 2026-07-15 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: by the DAT-authored portal-space viewport. Recent additions and splits:

View file

@ -178,13 +178,18 @@ package schema, bake, DAT reader, collision formula, or render portal graph
changed. Evidence: changed. Evidence:
`docs/research/2026-07-26-prepared-indoor-transit-regression.md`. `docs/research/2026-07-26-prepared-indoor-transit-regression.md`.
**Cell availability semantics (2026-07-31).** Raw and prepared CellStruct **Cell availability semantics (2026-07-31, corrected after full-catalog
publication now retains a `CellPhysics` record even when authored physics or audit).** Raw and prepared CellStruct publication retains a `CellPhysics`
containment roots are empty. Payload absence is the unavailable state; a loaded record when the physics root is empty but requires a valid containment root.
null/-1 containment root keeps retail's universal-inside BSP base case. The installed 729,888-record raw and prepared catalogs contain zero rootless
Registration-side outdoor floods still add outside cells but skip transit when containment payloads. A malformed null/-1 root is quarantined atomically; the
the active CLandCell is unavailable, then recover through the existing reflood recursive inside base case applies only to a missing positive child below a
after terrain/cell hydration. No package schema or DAT reader changed. 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`. Evidence: `docs/research/2026-07-31-cell-availability-semantics.md`.
**Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter **Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter

View file

@ -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 ## Scope
This note closes divergence rows AD-3 and AD-4. It does not begin AD-6's This note closes divergence rows AD-3 and AD-4. It does not begin AD-6's
atomic streaming-generation work. atomic streaming-generation work.
The bug was one collapsed state. acdream treated all three of these as The corrected port distinguishes these states:
“containment unavailable”:
1. no visible cell payload is loaded; 1. no visible cell payload is loaded;
2. a loaded CellStruct has a null containment root; 2. a malformed raw/prepared payload has no containment root;
3. a loaded CellStruct has an authored 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 Only (3) is published. State (1) remains unavailable and retryable. State (2)
unavailable. A loaded CellStruct remains a real cell even when its BSPTREE is quarantined atomically so a later valid hydration can retry; it must not
root is null, and the containment query's null-node base case is inside. 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 ## Retail oracle
`CObjCell::find_cell_list @ 0x0052B4E0` in `CObjCell::find_cell_list @ 0x0052B4E0` in
`docs/research/named-retail/acclient_2013_pseudo_c.txt:308742` establishes the `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 - `CEnvCell::GetVisible` / `CLandCell::GetVisible` resolves the active seed at
`0x0052B50C..0x0052B515`; `0x0052B50C..0x0052B515`;
@ -31,32 +46,47 @@ availability gate:
- each later candidate is independently skipped when its stored cell pointer - each later candidate is independently skipped when its stored cell pointer
is null at `0x0052B58E`. is null at `0x0052B58E`.
`CCellStruct::point_in_cell @ 0x005338F0` delegates directly to `CEnvCell::point_in_cell @ 0x0052C300` first returns false when
`BSPTREE::point_inside_cell_bsp @ 0x005398C0`. The already-ported graph and `this->portals == 0`, then transforms the point and calls
flat BSP queries preserve the retail null-root base case: a negative/null root `CCellStruct::point_in_cell`.
returns true. Root presence is therefore not an availability predicate.
`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 ## Ported behavior
- `PhysicsDataCache` now publishes a `CellPhysics` record whenever an authored - `PhysicsDataCache` publishes graph, collision, and prepared records only
raw or prepared CellStruct payload exists, even if its physics BSP and/or after a valid raw/prepared containment root is present. A missing physics
containment BSP root is absent. root is retained as a valid non-colliding cell. Invalid containment
- `CollisionTraversal.HasCellContainment` tests representation payload publication changes no cache, so later hydration can retry.
availability, not `Root` / `RootIndex`. `PointInsideCell` then lets the - `CollisionTraversal.HasCellContainment` tests `Root` / `RootIndex`.
graph or flat query return true for the null-root base case. - 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, - `CellTransit.BuildShadowCellSet` still seeds all overlapped outdoor cells,
but skips the transit walk when the active outdoor seed cannot be resolved but skips the transit walk when the active outdoor seed cannot be resolved
from `CellGraph`. A separately cached building can no longer promote an from `CellGraph`. Every later outdoor candidate independently resolves via
object through an unavailable landcell. `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 - 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. portal/building relationships without reconstructing a different rule.
## Gates ## Gates
Focused tests cover raw graph and prepared flat cache publication, absent Focused tests cover raw/prepared rootless quarantine and valid retry, valid
versus loaded-null-root containment, indoor and outdoor seeds, preservation of containment with missing physics, raw/prepared zero-portal parity, indoor and
outside-cell seeding, suppression of spurious building promotion, and outdoor seeds, preservation of outside-cell seeding, per-candidate adjacent
hydration/reflood recovery. Final Release gates passed: Core 4,162 / 1 skipped, landcell availability, suppression of stale-building promotion, and
Runtime 440 / 0 skipped, App 4,002 / 3 skipped, plus the complete solution hydration/reflood recovery. The corrective checkpoint passes:
build with zero errors.
- 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**.

View file

@ -608,6 +608,13 @@ public static class CellTransit
} }
else 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): // CLandCell::find_transit_cells (0x00533800):
// add_all_outside_cells (added_outside-guarded) then the // add_all_outside_cells (added_outside-guarded) then the
// building bridge for the landcell's building, if any. // building bridge for the landcell's building, if any.
@ -666,9 +673,9 @@ public static class CellTransit
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// A missing <see cref="CellPhysics"/> record is unavailable and skipped. /// A missing or rootless <see cref="CellPhysics"/> record is unavailable
/// A loaded record whose authored containment root is null retains retail's /// and skipped. The retail inside base case belongs to a missing positive
/// <see cref="BSPQuery.PointInsideCellBsp"/> universal-inside base case. /// child below a valid root, not to the root itself.
/// </para> /// </para>
/// </summary> /// </summary>
public static uint FindVisibleChildCell( public static uint FindVisibleChildCell(
@ -705,8 +712,8 @@ public static class CellTransit
/// <summary> /// <summary>
/// <c>CEnvCell::point_in_cell</c> (cell-BSP vtable[0x84]) against a world point: /// <c>CEnvCell::point_in_cell</c> (cell-BSP vtable[0x84]) against a world point:
/// transform to the cell's local frame, then <see cref="BSPQuery.PointInsideCellBsp"/>. /// transform to the cell's local frame, then <see cref="BSPQuery.PointInsideCellBsp"/>.
/// A missing cell payload returns false; a loaded payload with a null root /// A missing/rootless payload returns false. Retail also returns false
/// returns true through the retail BSP base case. /// before containment when <c>CEnvCell::portals</c> is null.
/// </summary> /// </summary>
private static bool PointInCell( private static bool PointInCell(
PhysicsDataCache cache, PhysicsDataCache cache,
@ -714,6 +721,7 @@ public static class CellTransit
Vector3 worldPoint) Vector3 worldPoint)
{ {
if (cell is null || if (cell is null ||
cell.Portals.Count == 0 ||
!CollisionTraversal.HasCellContainment(cache, cell)) !CollisionTraversal.HasCellContainment(cache, cell))
{ {
return false; return false;
@ -920,6 +928,11 @@ public static class CellTransit
if ((cellId & 0xFFFFu) < 0x0100u) 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) // Landcell dispatch — CLandCell::find_transit_cells (0x00533800)
// → CSortCell::find_transit_cells (0x00534060, this->building) // → CSortCell::find_transit_cells (0x00534060, this->building)
// → CBuildingObj::find_building_transit_cells (0x006b5230) // → 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]). // Interior candidate — point_in_cell via the cell BSP (vtable[0x84]).
var cand = cache.GetCellStruct(candId); var cand = cache.GetCellStruct(candId);
if (cand is null || if (PointInCell(cache, cand, worldSphereCenter))
!CollisionTraversal.HasCellContainment(cache, cand))
{
continue;
}
var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
if (CollisionTraversal.PointInsideCell(cache, cand, local))
return candId; // interior-wins, stop (pseudo_c:308819) 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 // Outdoor candidate — CLandCell::point_in_cell is the XY-column the
// sphere is over (acdream landcells have no BSP point_in_cell; the // sphere is over (acdream landcells have no BSP point_in_cell; the

View file

@ -25,14 +25,9 @@ internal static class CollisionTraversal
{ {
if (UseFlat(cache)) if (UseFlat(cache))
{ {
// Availability is the authored CellStruct payload, not the FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
// 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 ??
throw MissingFlat("cell containment"); throw MissingFlat("cell containment");
const bool flatAuthorityResult = true; bool flatAuthorityResult = flat.RootIndex >= 0;
CollisionShadowVerifier? flatShadow = cache.CollisionShadow; CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
if (flatShadow is null || if (flatShadow is null ||
!flatShadow.TrySample(out long flatAuthoritySample)) !flatShadow.TrySample(out long flatAuthoritySample))
@ -43,7 +38,7 @@ internal static class CollisionTraversal
flatShadow.BeginGraphPass(); flatShadow.BeginGraphPass();
try try
{ {
graphRefereeResult = true; graphRefereeResult = cell.CellBSP?.Root is not null;
} }
catch (Exception fault) catch (Exception fault)
{ {
@ -79,16 +74,15 @@ internal static class CollisionTraversal
CollisionShadowVerifier? shadow = cache.CollisionShadow; CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample)) if (shadow is null || !shadow.TrySample(out long sample))
return true; return cell.CellBSP?.Root is not null;
bool flatResult = false; bool flatResult = false;
Exception? flatFault = null; Exception? flatFault = null;
shadow.BeginFlatPass(); shadow.BeginFlatPass();
try try
{ {
_ = cell.FlatContainmentBsp ?? flatResult = (cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"); throw MissingFlat("cell containment")).RootIndex >= 0;
flatResult = true;
} }
catch (Exception fault) catch (Exception fault)
{ {
@ -99,7 +93,7 @@ internal static class CollisionTraversal
shadow.EndFlatPass(); shadow.EndFlatPass();
} }
const bool graphResult = true; bool graphResult = cell.CellBSP?.Root is not null;
if (flatFault is null) if (flatFault is null)
{ {
shadow.RecordBoolean( shadow.RecordBoolean(

View file

@ -82,8 +82,8 @@ public sealed class PhysicsDataCache
/// <summary> /// <summary>
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry. /// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
/// Populated unconditionally in <see cref="CacheCellStruct"/> so BSP-less /// Populated by <see cref="CacheCellStruct"/> for cells with valid
/// authored cells are registered too, and /// containment (including cells with no physics root), and
/// consumed across the engine: the player render/lighting root /// consumed across the engine: the player render/lighting root
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint /// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the /// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
@ -364,11 +364,10 @@ public sealed class PhysicsDataCache
} }
/// <summary> /// <summary>
/// Extract and cache the authored CellStruct payload (indoor room /// Extract and cache an authored CellStruct payload (indoor room geometry).
/// geometry), including cells whose physics or containment BSP has a null /// A missing physics root is valid (the cell can still own containment and
/// root. Retail keeps those loaded cells distinct from an unavailable /// portals); a missing containment root is not a loadable CEnvCell and is
/// visible-cell lookup; the null containment root is universally inside. /// rejected before either the graph or collision record is published.
/// No-ops only when the id is already cached.
/// </summary> /// </summary>
public void CacheCellStruct( public void CacheCellStruct(
uint envCellId, uint envCellId,
@ -398,11 +397,6 @@ public sealed class PhysicsDataCache
!_flatEnvCell.ContainsKey(envCellId)) !_flatEnvCell.ContainsKey(envCellId))
throw MissingPreparedCollision("EnvCell topology", 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) if (_requirePreparedCollision)
{ {
CachePreparedCellStruct( CachePreparedCellStruct(
@ -416,7 +410,27 @@ public sealed class PhysicsDataCache
return; 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)) if (!CellGraph.Contains(envCellId))
{ {
CellGraph.Add(UcgEnvCell.FromDat( CellGraph.Add(UcgEnvCell.FromDat(
@ -618,6 +632,13 @@ public sealed class PhysicsDataCache
FlatCellStructureCollisionAsset preparedStructure, FlatCellStructureCollisionAsset preparedStructure,
FlatEnvCellTopology preparedTopology) 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); _flatCellStruct.TryAdd(envCellId, preparedStructure);
_flatEnvCell.TryAdd(envCellId, preparedTopology); _flatEnvCell.TryAdd(envCellId, preparedTopology);
@ -630,9 +651,8 @@ public sealed class PhysicsDataCache
preparedTopology)); preparedTopology));
} }
// The prepared structure itself is the loaded CellStruct payload. // Physics may be rootless even though the cell's containment and
// Empty physics and containment roots remain meaningful authored // topology are valid; preserve that loaded, non-colliding cell.
// values; neither means that the cell is unavailable.
if (_cellStruct.ContainsKey(envCellId)) if (_cellStruct.ContainsKey(envCellId))
return; return;
@ -1023,9 +1043,9 @@ public sealed class CellPhysics
/// (point-in-cell tests). Separate tree from <see cref="BSP"/> /// (point-in-cell tests). Separate tree from <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP. /// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time. /// Source: <c>cellStruct.CellBSP</c> at cache time.
/// A nullable root is an authored, universally-inside containment tree. /// Root presence is required for a published cell. Missing positive
/// Cell availability is represented by presence of this /// children inside a valid tree are the retail inside base case; a missing
/// <see cref="CellPhysics"/> record, not by root presence. /// root is rejected by <see cref="PhysicsDataCache.CacheCellStruct"/>.
/// </summary> /// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }

View file

@ -7,9 +7,9 @@ namespace AcDream.Core.World.Cells;
/// <summary> /// <summary>
/// The unified cell graph: the active, authoritative id-&gt;cell resolver and registry. /// The unified cell graph: the active, authoritative id-&gt;cell resolver and registry.
/// Populated unconditionally from /// Populated from validated
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> (including /// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> payloads
/// authored cells with null physics or containment roots) and consumed across /// (a physics root is optional; a containment root is required) and consumed across
/// the engine: <see cref="GetVisible"/> resolves any cell id, <see cref="CurrCell"/> is /// the engine: <see cref="GetVisible"/> resolves any cell id, <see cref="CurrCell"/> is
/// the player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the /// the player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the
/// 3rd-person camera cell, and <see cref="TryGetTerrainOrigin"/> supplies the block-local /// 3rd-person camera cell, and <see cref="TryGetTerrainOrigin"/> supplies the block-local

View file

@ -11,9 +11,9 @@ namespace AcDream.Core.World.Cells;
public sealed class EnvCell : ObjCell public sealed class EnvCell : ObjCell
{ {
/// <summary> /// <summary>
/// Cell-containment BSP (retail CellStruct.CellBSP). A present tree with a /// Cell-containment BSP (retail CellStruct.CellBSP). Production publication
/// null root is universally inside; an absent test/tooling payload uses the /// requires a non-null root; prepared production uses
/// legacy AABB fallback. /// <see cref="FlatContainmentBsp"/> instead.
/// </summary> /// </summary>
public CellBSPTree? ContainmentBsp { get; } public CellBSPTree? ContainmentBsp { get; }
@ -38,14 +38,18 @@ public sealed class EnvCell : ObjCell
public override bool PointInCell(Vector3 worldPoint) 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); var local = Vector3.Transform(worldPoint, InverseWorldTransform);
if (FlatContainmentBsp is not null) if (FlatContainmentBsp is { RootIndex: >= 0 })
return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local); 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 BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034
return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X return false;
&& local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y
&& local.Z >= LocalBoundsMin.Z && local.Z <= LocalBoundsMax.Z;
} }
/// <summary> /// <summary>

View file

@ -57,6 +57,13 @@ public sealed class PlayerMovementPlacementTransactionTests
Vertices = new Dictionary<ushort, SWVertex>(), Vertices = new Dictionary<ushort, SWVertex>(),
}, },
Polygons = new Dictionary<ushort, Polygon>(), Polygons = new Dictionary<ushort, Polygon>(),
CellBSP = new CellBSPTree
{
Root = new CellBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
},
},
}; };
var envCell = new DatEnvCell var envCell = new DatEnvCell
{ {

View file

@ -71,10 +71,11 @@ public class CameraCollisionUpdateViewerTests
var cache = new PhysicsDataCache(); var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache }; var engine = new PhysicsEngine { DataCache = cache };
// Feet cell: interior Z ≤ 94, in its stab list the room cell above. No portals // Feet cell: interior Z ≤ 94, in its stab list the room cell above.
// (so the collision sweep cannot transit to the room — the start cell is decisive). // 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 })); 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<uint>())); cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorZAtLeast(94f), Array.Empty<uint>()));
var heights = new byte[81]; var heights = new byte[81];
@ -110,7 +111,7 @@ public class CameraCollisionUpdateViewerTests
InverseWorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(), Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = cellBspRoot }, CellBSP = new CellBSPTree { Root = cellBspRoot },
Portals = Array.Empty<PortalInfo>(), Portals = [new PortalInfo(0xFFFF, 0, 0)],
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(), PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
VisibleCellIds = new HashSet<uint>(visibleCellIds), VisibleCellIds = new HashSet<uint>(visibleCellIds),
}; };

View file

@ -879,6 +879,13 @@ public sealed class LandblockPhysicsPublisherTests
{ {
[0] = new Polygon { VertexIds = [0, 1, 2] }, [0] = new Polygon { VertexIds = [0, 1, 2] },
}, },
CellBSP = new CellBSPTree
{
Root = new CellBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
},
},
}; };
var environment = new DatReaderWriter.DBObjs.Environment var environment = new DatReaderWriter.DBObjs.Environment
{ {

View file

@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
@ -63,6 +62,7 @@ public class BuildShadowCellSetTests
private static CellPhysics MakeLeafCell(Matrix4x4 worldTransform) private static CellPhysics MakeLeafCell(Matrix4x4 worldTransform)
{ {
Matrix4x4.Invert(worldTransform, out var inv); Matrix4x4.Invert(worldTransform, out var inv);
var root = new CellBSPNode { Type = BSPNodeType.Leaf };
return new CellPhysics return new CellPhysics
{ {
WorldTransform = worldTransform, WorldTransform = worldTransform,
@ -70,40 +70,25 @@ public class BuildShadowCellSetTests
Resolved = new Dictionary<ushort, ResolvedPolygon>(), Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree CellBSP = new CellBSPTree
{ {
Root = new CellBSPNode { Type = BSPNodeType.Leaf }, Root = root,
}, },
FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root),
}; };
} }
private static CellPhysics MakeNullRootCell(Matrix4x4 worldTransform) private static CellPhysics MakeValidCellWithExteriorPortal(
{
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = null },
FlatContainmentBsp = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty),
};
}
private static CellPhysics MakeNullRootCellWithExteriorPortal(
Matrix4x4 worldTransform) Matrix4x4 worldTransform)
{ {
Matrix4x4.Invert(worldTransform, out var inv); Matrix4x4.Invert(worldTransform, out var inv);
var portalPlane = new Plane(new Vector3(1f, 0f, 0f), -2.5f); var portalPlane = new Plane(new Vector3(1f, 0f, 0f), -2.5f);
var root = new CellBSPNode { Type = BSPNodeType.Leaf };
return new CellPhysics return new CellPhysics
{ {
WorldTransform = worldTransform, WorldTransform = worldTransform,
InverseWorldTransform = inv, InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(), Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = null }, CellBSP = new CellBSPTree { Root = root },
FlatContainmentBsp = new FlatCellContainmentBsp( FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root),
-1,
ImmutableArray<FlatCellBspNode>.Empty),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> PortalPolygons = new Dictionary<ushort, ResolvedPolygon>
{ {
[10] = new ResolvedPolygon [10] = new ResolvedPolygon
@ -191,7 +176,7 @@ public class BuildShadowCellSetTests
[Theory] [Theory]
[InlineData(false)] [InlineData(false)]
[InlineData(true)] [InlineData(true)]
public void IndoorSeed_RefloodsAfterNullRootPayloadHydrates( public void IndoorSeed_RefloodsAfterValidPayloadHydrates(
bool useFlat) bool useFlat)
{ {
var cache = new PhysicsDataCache var cache = new PhysicsDataCache
@ -212,7 +197,7 @@ public class BuildShadowCellSetTests
cache.RegisterCellStructForTest( cache.RegisterCellStructForTest(
IndoorSeed, IndoorSeed,
MakeNullRootCellWithExteriorPortal(Matrix4x4.Identity)); MakeValidCellWithExteriorPortal(Matrix4x4.Identity));
IReadOnlyList<uint> hydrated = CellTransit.BuildShadowCellSet( IReadOnlyList<uint> hydrated = CellTransit.BuildShadowCellSet(
cache, cache,
IndoorSeed, IndoorSeed,
@ -322,7 +307,7 @@ public class BuildShadowCellSetTests
}; };
cache.RegisterCellStructForTest( cache.RegisterCellStructForTest(
NeighborCell, NeighborCell,
MakeNullRootCell(Matrix4x4.Identity)); MakeLeafCell(Matrix4x4.Identity));
var sphere = One(new Vector3(12f, 12f, 0f), 0.5f); var sphere = One(new Vector3(12f, 12f, 0f), 0.5f);
IReadOnlyList<uint> seeded = CellTransit.BuildShadowCellSet( IReadOnlyList<uint> seeded = CellTransit.BuildShadowCellSet(
@ -365,6 +350,65 @@ public class BuildShadowCellSetTests
Assert.Contains(NeighborCell, hydrated); 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<uint> 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<uint> hydrated = CellTransit.BuildShadowCellSet(
cache,
seedCell,
sphere,
1,
isStatic: false);
Assert.Contains(adjacentCell, hydrated);
Assert.Contains(interiorCell, hydrated);
}
// ── Exterior straddle from an indoor seed ────────────────────────── // ── Exterior straddle from an indoor seed ──────────────────────────
[Fact] [Fact]

View file

@ -27,6 +27,10 @@ public class CellGraphMembershipTests
VertexArray = new VertexArray { Vertices = new Dictionary<ushort, SWVertex>() }, VertexArray = new VertexArray { Vertices = new Dictionary<ushort, SWVertex>() },
Polygons = new Dictionary<ushort, Polygon>(), Polygons = new Dictionary<ushort, Polygon>(),
PhysicsBSP = null, PhysicsBSP = null,
CellBSP = new CellBSPTree
{
Root = new CellBSPNode { Type = DatReaderWriter.Enums.BSPNodeType.Leaf },
},
}; };
var dat = new DatEnvCell var dat = new DatEnvCell
{ {

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World.Cells; using AcDream.Core.World.Cells;
using DatReaderWriter.Enums;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using Xunit; using Xunit;
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
@ -11,7 +12,7 @@ namespace AcDream.Core.Tests.Physics;
public class CellGraphPopulationTests public class CellGraphPopulationTests
{ {
[Fact] [Fact]
public void CacheCellStruct_PublishesLoadedCell_WhenPhysicsAndContainmentRootsAreNull() public void CacheCellStruct_RejectsRootlessContainment_ThenAllowsValidRetry()
{ {
var cache = new PhysicsDataCache(); var cache = new PhysicsDataCache();
var cellStruct = new CellStruct var cellStruct = new CellStruct
@ -29,13 +30,16 @@ public class CellGraphPopulationTests
cache.CacheCellStruct(0xA9B40174u, dat, cellStruct, Matrix4x4.Identity); 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<CellPhysics>( CellPhysics loaded = Assert.IsType<CellPhysics>(
cache.GetCellStruct(0xA9B40174u)); cache.GetCellStruct(0xA9B40174u));
Assert.False(CollisionTraversal.HasPhysics(cache, loaded));
Assert.True(CollisionTraversal.HasCellContainment(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.NotNull(cache.CellGraph.GetVisible(0xA9B40174u));
Assert.IsType<EnvCell>(cache.CellGraph.GetVisible(0xA9B40174u)); Assert.IsType<EnvCell>(cache.CellGraph.GetVisible(0xA9B40174u));
} }

View file

@ -8,11 +8,11 @@ namespace AcDream.Core.Tests.Physics;
public class CellTransitCheckBuildingTransitTests public class CellTransitCheckBuildingTransitTests
{ {
[Fact] [Fact]
public void BuildingPortalWithLoadedNullRoot_CellIsAdmitted() public void BuildingPortalWithRootlessContainment_CellIsRejected()
{ {
// Retail separates an unavailable CEnvCell lookup from an authored // Retail dereferences CCellStruct.cell_bsp->root_node before calling
// CellStruct whose cell_bsp root is null. The latter is loaded, and // the recursive query. A missing positive child means inside; a
// the null-root sphere query is the universal-inside base case. // missing root is not a valid loaded CEnvCell.
// Building at world origin. One portal to interior cell 0xA9B40100. // Building at world origin. One portal to interior cell 0xA9B40100.
var building = new BuildingPhysics 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 var interiorCell = new CellPhysics
{ {
WorldTransform = Matrix4x4.Identity, WorldTransform = Matrix4x4.Identity,
@ -47,7 +48,7 @@ public class CellTransitCheckBuildingTransitTests
sphereRadius: 0.5f, sphereRadius: 0.5f,
candidates); candidates);
Assert.Contains(0xA9B40100u, candidates); Assert.Empty(candidates);
} }
[Fact] [Fact]

View file

@ -172,7 +172,7 @@ public class CellTransitFindCellSetTests
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
[Fact] [Fact]
public void OutdoorSeed_CrossesLandblockBoundary_South() public void OutdoorSeed_CrossesLandblockBoundary_South_AfterDestinationHydrates()
{ {
// The #106 acceptance golden: walking south out of A9B4, the outdoor // The #106 acceptance golden: walking south out of A9B4, the outdoor
// cell must advance to the southern neighbour block's cell. Origin of // cell must advance to the southern neighbour block's cell. Origin of
@ -183,14 +183,25 @@ public class CellTransitFindCellSetTests
var cache = new PhysicsDataCache(); var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); 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, cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40031u, currentCellId: 0xA9B40031u,
out var cellSet); out var cellSet);
Assert.Equal(0xA9B30038u, containing); Assert.Equal(0xA9B40031u, unavailable);
Assert.Contains(0xA9B30038u, cellSet); Assert.Contains(0xA9B30038u, cellSet);
Assert.Contains(0xA9B40031u, cellSet); // +Y neighbour still in the set 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] [Fact]
@ -227,6 +238,10 @@ public class CellTransitFindCellSetTests
0xA9B30000u, 0xA9B30000u,
new TerrainSurface(new byte[81], new float[256]), new TerrainSurface(new byte[81], new float[256]),
new Vector3(0f, -192f, 0f)); new Vector3(0f, -192f, 0f));
cache.CellGraph.RegisterTerrain(
0xA9B40000u,
new TerrainSurface(new byte[81], new float[256]),
Vector3.Zero);
uint containing = CellTransit.FindCellSet( uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, 1f, 0f), sphereRadius: 0.5f, cache, new Vector3(150f, 1f, 0f), sphereRadius: 0.5f,
@ -315,27 +330,29 @@ public class CellTransitFindCellSetTests
} }
[Fact] [Fact]
public void IndoorSeed_LoadedNullRoot_IsUniversallyInside_StaysCurrent() public void FindVisibleChildCell_RootlessContainment_IsUnavailable()
{ {
// Retail distinguishes a failed cell lookup from a loaded CellStruct // Bypass production cache validation to pin the traversal boundary:
// whose containment root is null. The latter is the BSP query's // a rootless fixture cannot claim a point even though the recursive
// universally-inside base case, so the current cell wins immediately. // helper's missing-positive-child base case returns inside.
Matrix4x4.Invert(Matrix4x4.Identity, out var inv); Matrix4x4.Invert(Matrix4x4.Identity, out var inv);
var cellNoBsp = new CellPhysics var cellNoBsp = new CellPhysics
{ {
WorldTransform = Matrix4x4.Identity, WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = inv, InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(), Resolved = new Dictionary<ushort, ResolvedPolygon>(),
Portals = [new PortalInfo(0x0101, 0, 0)],
}; };
var cache = new PhysicsDataCache(); var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40150u, cellNoBsp); cache.RegisterCellStructForTest(0xA9B40150u, cellNoBsp);
uint containing = CellTransit.FindCellSet( uint containing = CellTransit.FindVisibleChildCell(
cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f, cache,
currentCellId: 0xA9B40150u, 0xA9B40150u,
out _); new Vector3(-10f, 12f, 0f),
useStabList: true);
Assert.Equal(0xA9B40150u, containing); Assert.Equal(0u, containing);
} }
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────

View file

@ -106,7 +106,9 @@ public class CellTransitFindVisibleChildCellTests
InverseWorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(), Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = cellBspRoot }, CellBSP = new CellBSPTree { Root = cellBspRoot },
Portals = Array.Empty<PortalInfo>(), // 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<ushort, ResolvedPolygon>(), PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
VisibleCellIds = new HashSet<uint>(visibleCellIds), VisibleCellIds = new HashSet<uint>(visibleCellIds),
}; };

View file

@ -134,7 +134,10 @@ public class Issue133DungeonTeleportPrefixTests
// Leaf root → point_in_cell true for any point → AdjustPosition // Leaf root → point_in_cell true for any point → AdjustPosition
// validates the claim (found=true, cell unchanged). // validates the claim (found=true, cell unchanged).
CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } }, CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } },
Portals = Array.Empty<PortalInfo>(), // 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<ushort, ResolvedPolygon>(), PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
VisibleCellIds = new HashSet<uint>(), VisibleCellIds = new HashSet<uint>(),
}; };

View file

@ -107,8 +107,13 @@ public sealed class PhysicsDataCacheProductionTests
var structure = new FlatCellStructureCollisionAsset( var structure = new FlatCellStructureCollisionAsset(
physicsBsp, physicsBsp,
new FlatCellContainmentBsp( new FlatCellContainmentBsp(
-1, 0,
ImmutableArray<FlatCellBspNode>.Empty), ImmutableArray.Create(new FlatCellBspNode(
BSPNodeType.Leaf,
default,
-1,
-1,
0))),
FlatPolygonTable.Empty); FlatPolygonTable.Empty);
var topology = new FlatEnvCellTopology( var topology = new FlatEnvCellTopology(
ImmutableArray<FlatEnvCellPortal>.Empty, ImmutableArray<FlatEnvCellPortal>.Empty,
@ -162,7 +167,7 @@ public sealed class PhysicsDataCacheProductionTests
} }
[Fact] [Fact]
public void ProductionCellPublication_PreservesLoadedCellWithEmptyRoots() public void ProductionCellPublication_RejectsRootlessContainment_ThenAllowsValidRetry()
{ {
const uint cellId = 0xA9B4_0174u; const uint cellId = 0xA9B4_0174u;
PhysicsDataCache cache = PhysicsDataCache.CreateProduction(); PhysicsDataCache cache = PhysicsDataCache.CreateProduction();
@ -190,18 +195,33 @@ public sealed class PhysicsDataCacheProductionTests
structure, structure,
topology); topology);
CellPhysics loaded = Assert.IsType<CellPhysics>( Assert.Null(cache.GetCellStruct(cellId));
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<CellPhysics>(cache.GetCellStruct(cellId));
Assert.False(CollisionTraversal.HasPhysics(cache, loaded)); Assert.False(CollisionTraversal.HasPhysics(cache, loaded));
Assert.True(CollisionTraversal.HasCellContainment(cache, loaded)); Assert.True(CollisionTraversal.HasCellContainment(cache, loaded));
Assert.True(CollisionTraversal.PointInsideCell( Assert.NotNull(cache.CellGraph.GetVisible(cellId));
cache,
loaded,
new Vector3(10_000f, -10_000f, 500f)));
var graphCell = Assert.IsType<AcDream.Core.World.Cells.EnvCell>(
cache.CellGraph.GetVisible(cellId));
Assert.True(graphCell.PointInCell(
new Vector3(10_000f, -10_000f, 500f)));
Assert.Equal(1, cache.CellStructCount); Assert.Equal(1, cache.CellStructCount);
Assert.Equal(1, cache.FlatCellStructCount); Assert.Equal(1, cache.FlatCellStructCount);
Assert.Equal(0, cache.GraphCellStructCount); Assert.Equal(0, cache.GraphCellStructCount);

View file

@ -104,7 +104,9 @@ public class PhysicsEngineAdjustPositionTests
InverseWorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(), Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = cellBspRoot }, CellBSP = new CellBSPTree { Root = cellBspRoot },
Portals = Array.Empty<PortalInfo>(), // 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<ushort, ResolvedPolygon>(), PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
VisibleCellIds = new HashSet<uint>(visibleCellIds), VisibleCellIds = new HashSet<uint>(visibleCellIds),
}; };

View file

@ -418,6 +418,18 @@ public sealed class Ts4ProductionQuantumConformanceTests
Array.Empty<PortalPlane>(), Array.Empty<PortalPlane>(),
0f, 0f,
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<CellSurface>(),
Array.Empty<PortalPlane>(),
-192f,
0f);
engine.ShadowObjects.Register( engine.ShadowObjects.Register(
GfxId, GfxId,
GfxId, GfxId,

View file

@ -18,7 +18,10 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.World.Cells; using AcDream.Core.World.Cells;
using DatReaderWriter.Enums;
using Xunit; using Xunit;
using CellBSPNode = DatReaderWriter.Types.CellBSPNode;
using CellBSPTree = DatReaderWriter.Types.CellBSPTree;
namespace AcDream.Core.Tests.Rendering; namespace AcDream.Core.Tests.Rendering;
@ -29,8 +32,8 @@ public class CellGraphRootTests
// ------------------------------------------------------------------ // ------------------------------------------------------------------
/// <summary> /// <summary>
/// Synthetic EnvCell with an identity transform and axis-aligned bounds so /// Synthetic EnvCell with an authored six-plane containment BSP for
/// PointInCell returns true for points inside [min, max]. /// [min,max] and one portal so retail CEnvCell::point_in_cell is eligible.
/// seenOutside = false → sealed dungeon; true → building interior/exterior. /// seenOutside = false → sealed dungeon; true → building interior/exterior.
/// </summary> /// </summary>
private static EnvCell MakeEnvCell(uint id, Vector3 min, Vector3 max, bool seenOutside = false) private static EnvCell MakeEnvCell(uint id, Vector3 min, Vector3 max, bool seenOutside = false)
@ -39,10 +42,10 @@ public class CellGraphRootTests
Matrix4x4.Identity, Matrix4x4.Identity,
Matrix4x4.Identity, Matrix4x4.Identity,
min, max, min, max,
portals: new List<CellPortal>(), portals: new List<CellPortal> { new(0xFFFFu, 0, 0, 0) },
stabList: new List<uint>(), stabList: new List<uint>(),
seenOutside: seenOutside, seenOutside: seenOutside,
containmentBsp: null); containmentBsp: new CellBSPTree { Root = BoundsBsp(min, max) });
/// <summary> /// <summary>
/// EnvCell with an explicit stab list (used by FindVisibleChildCell tests). /// EnvCell with an explicit stab list (used by FindVisibleChildCell tests).
@ -54,10 +57,30 @@ public class CellGraphRootTests
Matrix4x4.Identity, Matrix4x4.Identity,
Matrix4x4.Identity, Matrix4x4.Identity,
min, max, min, max,
portals: new List<CellPortal>(), portals: new List<CellPortal> { new(0xFFFFu, 0, 0, 0) },
stabList: stabList, stabList: stabList,
seenOutside: seenOutside, 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) // Predicate helpers — mirror the formulas in GameWindow.OnRender (Stage 3)

View file

@ -10,46 +10,67 @@ namespace AcDream.Core.Tests.World.Cells;
public class EnvCellTests 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; var t = transform ?? Matrix4x4.Identity;
Matrix4x4.Invert(t, out var inv); Matrix4x4.Invert(t, out var inv);
return new EnvCell(0xA9B40174u, t, inv, min, max, return new EnvCell(
System.Array.Empty<UcgCellPortal>(), System.Array.Empty<uint>(), 0xA9B40174u,
seenOutside: false, containmentBsp: null); t,
} inv,
-Vector3.One,
[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,
Vector3.One, Vector3.One,
Array.Empty<UcgCellPortal>(), hasPortals ? OnePortal : Array.Empty<UcgCellPortal>(),
Array.Empty<uint>(), Array.Empty<uint>(),
seenOutside: false, 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] [Fact]
public void PointInCell_TransformsWorldToLocalBeforeTesting() public void PointInCell_TransformsWorldToLocalBeforeTesting()
{ {
var c = Make(new Vector3(0,0,0), new Vector3(10,10,10), Matrix4x4.CreateTranslation(100,0,0)); var root = new CellBSPNode
Assert.True(c.PointInCell(new Vector3(105,5,5))); {
Assert.False(c.PointInCell(new Vector3(5,5,5))); 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] [Fact]
@ -74,7 +95,7 @@ public class EnvCellTests
Matrix4x4.Identity, Matrix4x4.Identity,
-Vector3.One, -Vector3.One,
Vector3.One, Vector3.One,
Array.Empty<UcgCellPortal>(), OnePortal,
Array.Empty<uint>(), Array.Empty<uint>(),
seenOutside: false, seenOutside: false,
containmentBsp: new CellBSPTree { Root = graphRoot }, containmentBsp: new CellBSPTree { Root = graphRoot },