fix(physics): restore retail cell availability semantics

This commit is contained in:
Erik 2026-07-31 14:22:45 +02:00
parent d3c0d9ec0e
commit 7716c2ee89
14 changed files with 393 additions and 62 deletions

View file

@ -577,10 +577,12 @@ public static class CellTransit
else
{
AddAllOutsideCells(worldSpheres, sphereCount, seedCellId, blockOrigin, candidates);
// Outdoor seeds always walk: retail's null-CLandCell case is
// "landblock not loaded at all", where our per-cell building
// lookups below come back null anyway (documented adaptation).
seedLoaded = true;
// Retail preserves the outside-cell additions above but skips
// the complete growing-array transit walk when GetVisible cannot
// resolve the ACTIVE seed CLandCell (0052b50e, 0052b576). A cached
// building alone must not promote an object through an unavailable
// landcell; the normal reflood after terrain publication retries.
seedLoaded = cache.CellGraph.GetVisible(seedCellId) is not null;
}
if (seedLoaded)
@ -664,11 +666,9 @@ public static class CellTransit
/// </para>
///
/// <para>
/// acdream adaptation (matches <see cref="FindCellList"/> at line 518): a cell
/// with no hydrated <see cref="CellPhysics.CellBSP"/> cannot run
/// <c>point_in_cell</c>, so it is treated as NOT containing the point (skipped),
/// rather than letting <see cref="BSPQuery.PointInsideCellBsp"/>'s null-node
/// "inside" default make it spuriously claim every point.
/// 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.
/// </para>
/// </summary>
public static uint FindVisibleChildCell(
@ -705,8 +705,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 cell with no hydrated <see cref="CellPhysics.CellBSP"/> returns false (see
/// <see cref="FindVisibleChildCell"/>'s adaptation note).
/// A missing cell payload returns false; a loaded payload with a null root
/// returns true through the retail BSP base case.
/// </summary>
private static bool PointInCell(
PhysicsDataCache cache,

View file

@ -25,9 +25,14 @@ internal static class CollisionTraversal
{
if (UseFlat(cache))
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
// 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 ??
throw MissingFlat("cell containment");
bool flatAuthorityResult = flat.RootIndex >= 0;
const bool flatAuthorityResult = true;
CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
if (flatShadow is null ||
!flatShadow.TrySample(out long flatAuthoritySample))
@ -38,7 +43,7 @@ internal static class CollisionTraversal
flatShadow.BeginGraphPass();
try
{
graphRefereeResult = cell.CellBSP?.Root is not null;
graphRefereeResult = true;
}
catch (Exception fault)
{
@ -74,15 +79,16 @@ internal static class CollisionTraversal
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return cell.CellBSP?.Root is not null;
return true;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = (cell.FlatContainmentBsp ??
throw MissingFlat("cell containment")).RootIndex >= 0;
_ = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
flatResult = true;
}
catch (Exception fault)
{
@ -93,7 +99,7 @@ internal static class CollisionTraversal
shadow.EndFlatPass();
}
bool graphResult = cell.CellBSP?.Root is not null;
const bool graphResult = true;
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"/> — BEFORE the
/// idempotency + null-BSP guards, so BSP-less cells are registered too — and
/// Populated unconditionally in <see cref="CacheCellStruct"/> so BSP-less
/// authored cells are registered too, 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,9 +364,11 @@ public sealed class PhysicsDataCache
}
/// <summary>
/// Extract and cache the physics BSP + polygon data from a CellStruct
/// (indoor room geometry). No-ops if the id is already cached or the
/// CellStruct has no physics BSP.
/// 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.
/// </summary>
public void CacheCellStruct(
uint envCellId,
@ -414,8 +416,7 @@ public sealed class PhysicsDataCache
return;
}
// UCG Stage 1: register in the unified graph for ALL cells — before the
// idempotency + null-BSP guards below, so BSP-less cells are still included.
// UCG Stage 1: register in the unified graph for every authored cell.
if (!CellGraph.Contains(envCellId))
{
CellGraph.Add(UcgEnvCell.FromDat(
@ -427,11 +428,12 @@ public sealed class PhysicsDataCache
}
if (_cellStruct.ContainsKey(envCellId)) return;
if (cellStruct.PhysicsBSP?.Root is null) return;
Matrix4x4.Invert(worldTransform, out var inverseTransform);
var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
var resolved = cellStruct.PhysicsPolygons is null
? new Dictionary<ushort, ResolvedPolygon>()
: ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
// Visible polygons — portals reference these (NOT PhysicsPolygons).
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
@ -628,11 +630,9 @@ public sealed class PhysicsDataCache
preparedTopology));
}
// Preserve CacheCellStruct's existing distinction: BSP-less cells
// participate in the cell graph but do not masquerade as hydrated
// collision cells.
if (preparedStructure.PhysicsBsp.RootIndex < 0)
return;
// 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.
if (_cellStruct.ContainsKey(envCellId))
return;
@ -1023,8 +1023,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.
/// Nullable: cells without a CellBSP cannot participate in portal
/// containment and are skipped by <see cref="CellTransit"/>.
/// 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.
/// </summary>
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }

View file

@ -8,8 +8,8 @@ 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"/> (before its
/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.CacheCellStruct"/> (including
/// authored cells with null physics or containment roots) 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

@ -10,7 +10,11 @@ namespace AcDream.Core.World.Cells;
/// <summary>Indoor room cell. Retail anchor: CEnvCell (acclient.h:32072).</summary>
public sealed class EnvCell : ObjCell
{
/// <summary>Cell-containment BSP (retail CellStruct.CellBSP). Null =&gt; AABB fallback.</summary>
/// <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.
/// </summary>
public CellBSPTree? ContainmentBsp { get; }
/// <summary>
@ -37,7 +41,7 @@ public sealed class EnvCell : ObjCell
var local = Vector3.Transform(worldPoint, InverseWorldTransform);
if (FlatContainmentBsp is not null)
return FlatBspQuery.PointInsideCellBsp(FlatContainmentBsp, local);
if (ContainmentBsp?.Root is not null)
if (ContainmentBsp 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