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
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:

View file

@ -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

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
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**.

View file

@ -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
/// </para>
///
/// <para>
/// A missing <see cref="CellPhysics"/> record is unavailable and skipped.
/// A loaded record whose authored containment root is null retains retail's
/// <see cref="BSPQuery.PointInsideCellBsp"/> universal-inside base case.
/// A missing or rootless <see cref="CellPhysics"/> 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.
/// </para>
/// </summary>
public static uint FindVisibleChildCell(
@ -705,8 +712,8 @@ public static class CellTransit
/// <summary>
/// <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"/>.
/// 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 <c>CEnvCell::portals</c> is null.
/// </summary>
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

View file

@ -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(

View file

@ -82,8 +82,8 @@ public sealed class PhysicsDataCache
/// <summary>
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
/// Populated unconditionally in <see cref="CacheCellStruct"/> so BSP-less
/// authored cells are registered too, and
/// Populated by <see cref="CacheCellStruct"/> for cells with valid
/// containment (including cells with no physics root), and
/// consumed across the engine: the player render/lighting root
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
@ -364,11 +364,10 @@ public sealed class PhysicsDataCache
}
/// <summary>
/// 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.
/// </summary>
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 <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time.
/// A nullable root is an authored, universally-inside containment tree.
/// Cell availability is represented by presence of this
/// <see cref="CellPhysics"/> 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 <see cref="PhysicsDataCache.CacheCellStruct"/>.
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }

View file

@ -7,9 +7,9 @@ namespace AcDream.Core.World.Cells;
/// <summary>
/// The unified cell graph: the active, authoritative id-&gt;cell resolver and registry.
/// Populated unconditionally from
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> (including
/// authored cells with null physics or containment roots) and consumed across
/// Populated from validated
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> payloads
/// (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 player render/lighting root, <see cref="FindVisibleChildCell"/> resolves the
/// 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
{
/// <summary>
/// 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
/// <see cref="FlatContainmentBsp"/> instead.
/// </summary>
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;
}
/// <summary>

View file

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

View file

@ -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<uint>()));
var heights = new byte[81];
@ -110,7 +111,7 @@ public class CameraCollisionUpdateViewerTests
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = cellBspRoot },
Portals = Array.Empty<PortalInfo>(),
Portals = [new PortalInfo(0xFFFF, 0, 0)],
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
VisibleCellIds = new HashSet<uint>(visibleCellIds),
};

View file

@ -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
{

View file

@ -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<ushort, ResolvedPolygon>(),
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<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = null },
FlatContainmentBsp = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.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<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = null },
FlatContainmentBsp = new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty),
CellBSP = new CellBSPTree { Root = root },
FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>
{
[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<uint> 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<uint> 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<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 ──────────────────────────
[Fact]

View file

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

View file

@ -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<CellPhysics>(
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<EnvCell>(cache.CellGraph.GetVisible(0xA9B40174u));
}

View file

@ -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]

View file

@ -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<ushort, ResolvedPolygon>(),
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);
}
// ──────────────────────────────────────────────────────────────────

View file

@ -106,7 +106,9 @@ public class CellTransitFindVisibleChildCellTests
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
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>(),
VisibleCellIds = new HashSet<uint>(visibleCellIds),
};

View file

@ -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<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>(),
VisibleCellIds = new HashSet<uint>(),
};

View file

@ -107,8 +107,13 @@ public sealed class PhysicsDataCacheProductionTests
var structure = new FlatCellStructureCollisionAsset(
physicsBsp,
new FlatCellContainmentBsp(
0,
ImmutableArray.Create(new FlatCellBspNode(
BSPNodeType.Leaf,
default,
-1,
ImmutableArray<FlatCellBspNode>.Empty),
-1,
0))),
FlatPolygonTable.Empty);
var topology = new FlatEnvCellTopology(
ImmutableArray<FlatEnvCellPortal>.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<CellPhysics>(
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<CellPhysics>(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<AcDream.Core.World.Cells.EnvCell>(
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);

View file

@ -104,7 +104,9 @@ public class PhysicsEngineAdjustPositionTests
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
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>(),
VisibleCellIds = new HashSet<uint>(visibleCellIds),
};

View file

@ -418,6 +418,18 @@ public sealed class Ts4ProductionQuantumConformanceTests
Array.Empty<PortalPlane>(),
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(
GfxId,
GfxId,

View file

@ -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
// ------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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<CellPortal>(),
portals: new List<CellPortal> { new(0xFFFFu, 0, 0, 0) },
stabList: new List<uint>(),
seenOutside: seenOutside,
containmentBsp: null);
containmentBsp: new CellBSPTree { Root = BoundsBsp(min, max) });
/// <summary>
/// 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<CellPortal>(),
portals: new List<CellPortal> { 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)

View file

@ -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<UcgCellPortal>(), System.Array.Empty<uint>(),
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<UcgCellPortal>(),
hasPortals ? OnePortal : Array.Empty<UcgCellPortal>(),
Array.Empty<uint>(),
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<UcgCellPortal>(),
OnePortal,
Array.Empty<uint>(),
seenOutside: false,
containmentBsp: new CellBSPTree { Root = graphRoot },