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
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ public sealed class PhysicsDataCache
|
|||
|
||||
/// <summary>
|
||||
/// The unified cell graph (UCG): the active id->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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ namespace AcDream.Core.World.Cells;
|
|||
|
||||
/// <summary>
|
||||
/// The unified cell graph: the active, authoritative id->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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -107,8 +107,13 @@ public sealed class PhysicsDataCacheProductionTests
|
|||
var structure = new FlatCellStructureCollisionAsset(
|
||||
physicsBsp,
|
||||
new FlatCellContainmentBsp(
|
||||
-1,
|
||||
ImmutableArray<FlatCellBspNode>.Empty),
|
||||
0,
|
||||
ImmutableArray.Create(new FlatCellBspNode(
|
||||
BSPNodeType.Leaf,
|
||||
default,
|
||||
-1,
|
||||
-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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue