fix(physics): validate retail cell containment roots

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

View file

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

View file

@ -25,14 +25,9 @@ internal static class CollisionTraversal
{
if (UseFlat(cache))
{
// Availability is the authored CellStruct payload, not the
// containment root. Retail's loaded BSPTREE may have a null
// root; BSPNODE::point_inside_cell_bsp treats that base case as
// universally inside. A missing flat payload is still a broken
// production publication and must fail loudly.
_ = cell.FlatContainmentBsp ??
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
const bool flatAuthorityResult = true;
bool flatAuthorityResult = flat.RootIndex >= 0;
CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
if (flatShadow is null ||
!flatShadow.TrySample(out long flatAuthoritySample))
@ -43,7 +38,7 @@ internal static class CollisionTraversal
flatShadow.BeginGraphPass();
try
{
graphRefereeResult = true;
graphRefereeResult = cell.CellBSP?.Root is not null;
}
catch (Exception fault)
{
@ -79,16 +74,15 @@ internal static class CollisionTraversal
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return true;
return cell.CellBSP?.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
_ = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
flatResult = true;
flatResult = (cell.FlatContainmentBsp ??
throw MissingFlat("cell containment")).RootIndex >= 0;
}
catch (Exception fault)
{
@ -99,7 +93,7 @@ internal static class CollisionTraversal
shadow.EndFlatPass();
}
const bool graphResult = true;
bool graphResult = cell.CellBSP?.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(

View file

@ -82,8 +82,8 @@ public sealed class PhysicsDataCache
/// <summary>
/// The unified cell graph (UCG): the active id-&gt;cell resolver and registry.
/// Populated unconditionally in <see cref="CacheCellStruct"/> so BSP-less
/// authored cells are registered too, and
/// Populated by <see cref="CacheCellStruct"/> for cells with valid
/// containment (including cells with no physics root), and
/// consumed across the engine: the player render/lighting root
/// (<c>CellGraph.CurrCell</c>, written at the player chokepoint
/// <c>PhysicsEngine.UpdatePlayerCurrCell</c> and read by the renderer), the
@ -364,11 +364,10 @@ public sealed class PhysicsDataCache
}
/// <summary>
/// Extract and cache the authored CellStruct payload (indoor room
/// geometry), including cells whose physics or containment BSP has a null
/// root. Retail keeps those loaded cells distinct from an unavailable
/// visible-cell lookup; the null containment root is universally inside.
/// No-ops only when the id is already cached.
/// Extract and cache an authored CellStruct payload (indoor room geometry).
/// A missing physics root is valid (the cell can still own containment and
/// portals); a missing containment root is not a loadable CEnvCell and is
/// rejected before either the graph or collision record is published.
/// </summary>
public void CacheCellStruct(
uint envCellId,
@ -398,11 +397,6 @@ public sealed class PhysicsDataCache
!_flatEnvCell.ContainsKey(envCellId))
throw MissingPreparedCollision("EnvCell topology", envCellId);
if (preparedStructure is not null)
_flatCellStruct.TryAdd(envCellId, preparedStructure);
if (preparedTopology is not null)
_flatEnvCell.TryAdd(envCellId, preparedTopology);
if (_requirePreparedCollision)
{
CachePreparedCellStruct(
@ -416,7 +410,27 @@ public sealed class PhysicsDataCache
return;
}
// UCG Stage 1: register in the unified graph for every authored cell.
// CCellStruct::point_in_cell dereferences cell_bsp->root_node before
// entering BSPNODE::point_inside_cell_bsp. A null ROOT is therefore
// not the recursive missing-positive-child "inside" sentinel. The
// installed 2013 catalog contains zero such payloads; quarantine one
// rather than publishing a cell that claims the whole world.
if (cellStruct.CellBSP?.Root is null)
return;
// A malformed optional prepared shadow must not attach to an otherwise
// valid raw cell. Production takes the prepared-only overload below.
if (preparedStructure?.ContainmentBsp.RootIndex < 0)
{
preparedStructure = null;
preparedTopology = null;
}
if (preparedStructure is not null)
_flatCellStruct.TryAdd(envCellId, preparedStructure);
if (preparedTopology is not null)
_flatEnvCell.TryAdd(envCellId, preparedTopology);
// UCG Stage 1: register only a loadable authored cell.
if (!CellGraph.Contains(envCellId))
{
CellGraph.Add(UcgEnvCell.FromDat(
@ -618,6 +632,13 @@ public sealed class PhysicsDataCache
FlatCellStructureCollisionAsset preparedStructure,
FlatEnvCellTopology preparedTopology)
{
// Same invariant as the raw loader. RootIndex -1 is the flattened
// encoding of a missing ROOT, not a recursive positive-child sentinel.
// Reject it atomically so graph, collision, and prepared caches agree
// that this cell is unavailable and a later valid hydration may retry.
if (preparedStructure.ContainmentBsp.RootIndex < 0)
return;
_flatCellStruct.TryAdd(envCellId, preparedStructure);
_flatEnvCell.TryAdd(envCellId, preparedTopology);
@ -630,9 +651,8 @@ public sealed class PhysicsDataCache
preparedTopology));
}
// The prepared structure itself is the loaded CellStruct payload.
// Empty physics and containment roots remain meaningful authored
// values; neither means that the cell is unavailable.
// Physics may be rootless even though the cell's containment and
// topology are valid; preserve that loaded, non-colliding cell.
if (_cellStruct.ContainsKey(envCellId))
return;
@ -1023,9 +1043,9 @@ public sealed class CellPhysics
/// (point-in-cell tests). Separate tree from <see cref="BSP"/>
/// (collision) and from the renderer's drawing-BSP.
/// Source: <c>cellStruct.CellBSP</c> at cache time.
/// A nullable root is an authored, universally-inside containment tree.
/// Cell availability is represented by presence of this
/// <see cref="CellPhysics"/> record, not by root presence.
/// Root presence is required for a published cell. Missing positive
/// children inside a valid tree are the retail inside base case; a missing
/// root is rejected by <see cref="PhysicsDataCache.CacheCellStruct"/>.
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }

View file

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

View file

@ -11,9 +11,9 @@ namespace AcDream.Core.World.Cells;
public sealed class EnvCell : ObjCell
{
/// <summary>
/// Cell-containment BSP (retail CellStruct.CellBSP). A present tree with a
/// null root is universally inside; an absent test/tooling payload uses the
/// legacy AABB fallback.
/// Cell-containment BSP (retail CellStruct.CellBSP). Production publication
/// requires a non-null root; prepared production uses
/// <see cref="FlatContainmentBsp"/> instead.
/// </summary>
public CellBSPTree? ContainmentBsp { get; }
@ -38,14 +38,18 @@ public sealed class EnvCell : ObjCell
public override bool PointInCell(Vector3 worldPoint)
{
// Retail CEnvCell::point_in_cell @ 0x0052C300 returns false before
// touching the CellStruct when this->portals is null. Installed data
// contains real zero-portal cells, so this guard is behavior-bearing.
if (Portals.Count == 0)
return false;
var local = Vector3.Transform(worldPoint, InverseWorldTransform);
if (FlatContainmentBsp is not null)
if (FlatContainmentBsp is { RootIndex: >= 0 })
return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local);
if (ContainmentBsp is not null)
if (ContainmentBsp?.Root is not null)
return BSPQuery.PointInsideCellBsp(ContainmentBsp.Root, local); // BSPQuery.cs:1034
return local.X >= LocalBoundsMin.X && local.X <= LocalBoundsMax.X
&& local.Y >= LocalBoundsMin.Y && local.Y <= LocalBoundsMax.Y
&& local.Z >= LocalBoundsMin.Z && local.Z <= LocalBoundsMax.Z;
return false;
}
/// <summary>