fix(physics): validate retail cell containment roots
This commit is contained in:
parent
7716c2ee89
commit
3e0f3b6206
23 changed files with 429 additions and 197 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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**.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,8 @@ public sealed class PhysicsDataCache
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The unified cell graph (UCG): the active id->cell resolver and registry.
|
/// The unified cell graph (UCG): the active id->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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ namespace AcDream.Core.World.Cells;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The unified cell graph: the active, authoritative id->cell resolver and registry.
|
/// The unified cell graph: the active, authoritative id->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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue