feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics

Adds PortalInfo struct and extends CellPhysics with CellBSP (third BSP
for point-in-cell tests, typed CellBSPTree from DatReaderWriter),
Portals (from envCell.CellPortals), PortalPolygons (resolved
cellStruct.Polygons — portals reference visible polys, not
PhysicsPolygons), and VisibleCellIds (populated for future use;
envCell.VisibleCells is List<UInt16>, not Dictionary).

Deletes CellPhysics.LocalAabbMin/Max and PhysicsDataCache.TryFindContainingCell
— Phase D's AABB shortcut is gone. CacheCellStruct's AABB compute
removed; the [cell-cache] diagnostic updated with portal/visible counts
instead.

CacheCellStruct signature gains an EnvCell parameter (one call site in
GameWindow.cs:5384 updated). ResolveOutdoorCellId drops the
TryFindContainingCell call; portal-graph CellTransit replaces it next.

ResolveOutdoorCellIdTests object initializers had the deleted AABB
properties stripped temporarily so the build stays green; the file gets
replaced wholesale in the next commit (CellTransit integration). Those
2 AABB-containment tests continue to fail (they were pre-broken on this
branch); no new failures introduced.

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 16:52:20 +02:00
parent b282c69f28
commit 1969c55823
7 changed files with 215 additions and 113 deletions

View file

@ -5381,7 +5381,7 @@ public sealed class GameWindow : IDisposable
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
// Cache CellStruct physics BSP for indoor collision.
_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform);
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform);
}
}
}

View file

@ -128,8 +128,8 @@ public sealed class PhysicsDataCache
/// (indoor room geometry). No-ops if the id is already cached or the
/// CellStruct has no physics BSP.
/// </summary>
public void CacheCellStruct(uint envCellId, CellStruct cellStruct,
Matrix4x4 worldTransform)
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
CellStruct cellStruct, Matrix4x4 worldTransform)
{
if (_cellStruct.ContainsKey(envCellId)) return;
if (cellStruct.PhysicsBSP?.Root is null) return;
@ -138,23 +138,27 @@ public sealed class PhysicsDataCache
var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
// Indoor walking Phase D (2026-05-19): compute a tight local AABB from
// the resolved polygon vertices. Computed once at cache time so the
// per-frame TryFindContainingCell check only does AABB point tests.
var aabbMin = new Vector3(float.MaxValue);
var aabbMax = new Vector3(float.MinValue);
foreach (var (_, poly) in resolved)
// Visible polygons — portals reference these (NOT PhysicsPolygons).
var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
// Portal list from envCell.CellPortals.
var portals = new System.Collections.Generic.List<PortalInfo>(envCell.CellPortals.Count);
foreach (var p in envCell.CellPortals)
{
if (poly.Vertices is null) continue;
foreach (var v in poly.Vertices)
{
if (v.X < aabbMin.X) aabbMin.X = v.X;
if (v.Y < aabbMin.Y) aabbMin.Y = v.Y;
if (v.Z < aabbMin.Z) aabbMin.Z = v.Z;
if (v.X > aabbMax.X) aabbMax.X = v.X;
if (v.Y > aabbMax.Y) aabbMax.Y = v.Y;
if (v.Z > aabbMax.Z) aabbMax.Z = v.Z;
}
portals.Add(new PortalInfo(
otherCellId: p.OtherCellId,
polygonId: p.PolygonId,
flags: (ushort)p.Flags));
}
// VisibleCells set — populated for future use; not consulted this phase.
// envCell.VisibleCells is List<UInt16> per the DatReaderWriter shape — iterate directly, no .Keys.
var visibleCellIds = new System.Collections.Generic.HashSet<uint>();
if (envCell.VisibleCells is not null)
{
uint lbPrefix = envCellId & 0xFFFF0000u;
foreach (var lowId in envCell.VisibleCells)
visibleCellIds.Add(lbPrefix | lowId);
}
_cellStruct[envCellId] = new CellPhysics
@ -165,8 +169,11 @@ public sealed class PhysicsDataCache
WorldTransform = worldTransform,
InverseWorldTransform = inverseTransform,
Resolved = resolved,
LocalAabbMin = aabbMin,
LocalAabbMax = aabbMax,
// ── Phase 2 portal fields ──
CellBSP = cellStruct.CellBSP,
Portals = portals,
PortalPolygons = portalPolygons,
VisibleCellIds = visibleCellIds,
};
if (PhysicsDiagnostics.ProbeCellCacheEnabled)
@ -175,11 +182,6 @@ public sealed class PhysicsDataCache
int bspRootPolyCount = root?.Polygons?.Count ?? 0;
bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null;
// Recursive walk: count total leaf poly references + how many of
// those poly IDs are absent from the resolved dict. If
// bspTotalLeafPolys == 0 the BSP has no collidable polys at all.
// If bspUnmatchedIds > 0 the BSP references IDs we didn't resolve
// (data-deserialization quirk hypothesis).
int bspTotalLeafPolys = 0;
int bspUnmatchedIds = 0;
if (root is not null)
@ -208,14 +210,10 @@ public sealed class PhysicsDataCache
: System.FormattableString.Invariant(
$"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");
// World origin = cellTransform * (0,0,0,1). Tells us where this cell
// sits in world coordinates, so we can cross-check whether the
// player's worldPos actually lies inside the AABB when transformed
// back to local.
var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} aabbMin=({aabbMin.X:F2},{aabbMin.Y:F2},{aabbMin.Z:F2}) aabbMax=({aabbMax.X:F2},{aabbMax.Y:F2},{aabbMax.Z:F2}) worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})"));
$"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} portalCount={portals.Count} visibleCells={visibleCellIds.Count} cellBspRoot={(cellStruct.CellBSP?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})"));
}
}
@ -224,7 +222,7 @@ public sealed class PhysicsDataCache
/// and compute the face plane. Matches ACE's Polygon constructor which calls
/// make_plane() and resolves Vertices from VertexIDs at load time.
/// </summary>
private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
internal static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
VertexArray vertexArray)
{
@ -291,53 +289,6 @@ public sealed class PhysicsDataCache
/// </summary>
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
/// <summary>
/// Indoor walking Phase D (2026-05-19). Returns the full id of the first
/// cached EnvCell whose local AABB contains <paramref name="worldPos"/>,
/// or false if no cached EnvCell contains it. Used by
/// <see cref="PhysicsEngine.ResolveOutdoorCellId"/> to promote the player's
/// CellId to an indoor EnvCell when the player is geometrically inside one.
///
/// <para>
/// AABBs are pre-computed in <see cref="CacheCellStruct"/> from each
/// cell's resolved polygon vertices, transformed into local space via
/// <see cref="CellPhysics.InverseWorldTransform"/>. Iteration is O(N) over
/// cached cells; N is bounded by the streaming radius (~80 cells at
/// radius 4).
/// </para>
///
/// <para>
/// Local AABB is a tight bound around the cell's geometry. EnvCells in
/// Holtburg are roughly room-sized cuboids; the local AABB is therefore
/// a reasonable proxy for "is the player in this cell." For cells with
/// concave shapes or non-room geometry, the AABB will over-approximate;
/// this only matters if two cells' AABBs overlap and the player is in
/// the overlap region (rare in practice; if it becomes an issue, switch
/// to a BSP point-in-cell test).
/// </para>
/// </summary>
public bool TryFindContainingCell(Vector3 worldPos, out uint envCellId)
{
foreach (var (id, cp) in _cellStruct)
{
// Guard: if the AABB was never populated (no vertices in the cell),
// LocalAabbMin stays at float.MaxValue — the containment test will
// always fail, so we skip the cell silently.
if (cp.LocalAabbMin.X == float.MaxValue) continue;
var local = Vector3.Transform(worldPos, cp.InverseWorldTransform);
if (local.X >= cp.LocalAabbMin.X && local.X <= cp.LocalAabbMax.X &&
local.Y >= cp.LocalAabbMin.Y && local.Y <= cp.LocalAabbMax.Y &&
local.Z >= cp.LocalAabbMin.Z && local.Z <= cp.LocalAabbMax.Z)
{
envCellId = id;
return true;
}
}
envCellId = 0;
return false;
}
/// <summary>
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
/// Intended for unit-test fixtures that construct synthetic BSP trees
@ -438,21 +389,38 @@ public sealed class CellPhysics
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary>
/// Indoor walking Phase D (2026-05-19). Local-space AABB minimum corner,
/// computed from the resolved polygon vertices at <see cref="PhysicsDataCache.CacheCellStruct"/>
/// time. Initialized to <c>float.MaxValue</c> so that
/// <see cref="PhysicsDataCache.TryFindContainingCell"/> silently skips
/// cells with no vertex data.
/// </summary>
public Vector3 LocalAabbMin { get; init; } = new Vector3(float.MaxValue);
// ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
/// <summary>
/// Indoor walking Phase D (2026-05-19). Local-space AABB maximum corner,
/// computed from the resolved polygon vertices at <see cref="PhysicsDataCache.CacheCellStruct"/>
/// time. Initialized to <c>float.MinValue</c> so that
/// <see cref="PhysicsDataCache.TryFindContainingCell"/> silently skips
/// cells with no vertex data.
/// The cell BSP used for <see cref="BSPQuery.PointInsideCellBsp"/>
/// (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"/>.
/// </summary>
public Vector3 LocalAabbMax { get; init; } = new Vector3(float.MinValue);
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary>
/// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>.
/// </summary>
public IReadOnlyList<PortalInfo> Portals { get; init; } = System.Array.Empty<PortalInfo>();
/// <summary>
/// Resolved VISIBLE polygons (from <c>cellStruct.Polygons</c>),
/// keyed by polygon id. Distinct from <see cref="Resolved"/> which
/// holds <c>PhysicsPolygons</c>. Portal lookup via
/// <see cref="PortalInfo.PolygonId"/> resolves through this dict.
/// Nullable when the cell has no visible polys (rare).
/// </summary>
public Dictionary<ushort, ResolvedPolygon>? PortalPolygons { get; init; }
/// <summary>
/// The full cell ids visible from this cell (with landblock prefix).
/// Populated from <c>envCell.VisibleCells</c> at cache time. Unused
/// this phase; reserved for the optional <c>find_cell_list</c>
/// visibility filter.
/// </summary>
public IReadOnlySet<uint> VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet<uint>();
}

View file

@ -230,18 +230,15 @@ public sealed class PhysicsEngine
}
/// <summary>
/// Resolve a position's CellId. Tries indoor EnvCell containment first
/// (via <see cref="PhysicsDataCache.TryFindContainingCell"/>); falls back
/// to outdoor terrain landcell resolution.
/// Resolve a position's CellId. Falls back to outdoor terrain landcell
/// resolution or trusts an already-indoor fallbackCellId.
///
/// <para>
/// Indoor walking Phase D (2026-05-19) extended this to fix #84 + #85:
/// previously the function only resolved outdoor cells, so a player
/// geometrically inside an EnvCell stayed in outdoor-landcell range and
/// the indoor cell-BSP collision branch never fired. The indoor
/// containment check promotes the player's CellId to the matched
/// EnvCell, which lets <see cref="Transition.FindEnvCollisions"/>'s
/// indoor branch (gated on cellLow &gt;= 0x0100) take effect.
/// Phase D (2026-05-19) previously used an AABB containment check
/// (<c>TryFindContainingCell</c>) to promote the player into an indoor
/// EnvCell. Phase 2 (2026-05-19) removes that AABB shortcut; the
/// portal-graph <c>CellTransit</c> traversal (next subagent) replaces it
/// with retail-faithful BSP point-in-cell tests.
/// </para>
///
/// <para>
@ -256,12 +253,6 @@ public sealed class PhysicsEngine
if (fallbackCellId == 0)
return 0;
// Phase D: indoor-cell-containment check. If the player's worldPos
// is geometrically inside a cached EnvCell, return that cell's full
// id — overrides any prior outdoor CellId the caller passed in.
if (DataCache is not null && DataCache.TryFindContainingCell(worldPos, out var indoorId))
return indoorId;
// Pre-existing: if the caller already passes an indoor CellId AND
// the player isn't in any cached EnvCell, trust the caller. This
// preserves behaviour for indoor cells whose physics hasn't been

View file

@ -0,0 +1,45 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Portal connection between two
/// EnvCells. Each <see cref="CellPhysics"/> carries a list of these,
/// mirroring retail's <c>CCellStruct.portals</c> array.
///
/// <para>
/// <see cref="OtherCellId"/> is a low-16 cell index (combined with the
/// owning landblock prefix at lookup time) or <c>0xFFFF</c> to mean
/// "exit to outdoor world" (the player crosses this portal to leave
/// the building).
/// </para>
///
/// <para>
/// <see cref="PolygonId"/> indexes the OWNING cell's
/// <see cref="CellPhysics.PortalPolygons"/> dict (the visible-polygon
/// table, NOT <see cref="CellPhysics.Resolved"/> which holds physics
/// polys).
/// </para>
///
/// <para>
/// <see cref="PortalSide"/> decodes bit 2 of <see cref="Flags"/>:
/// <c>(Flags &amp; 2) == 0</c> → portal's polygon normal points INTO
/// the owning cell (so dist &gt; 0 in cell-local space means "outside
/// the cell, beyond the portal"). Used in <c>find_transit_cells</c>'s
/// load-hint path for unloaded neighbours.
/// </para>
/// </summary>
public readonly struct PortalInfo
{
public PortalInfo(ushort otherCellId, ushort polygonId, ushort flags)
{
OtherCellId = otherCellId;
PolygonId = polygonId;
Flags = flags;
}
public ushort OtherCellId { get; }
public ushort PolygonId { get; }
public ushort Flags { get; }
/// <summary>Bit 2 of <see cref="Flags"/>. See struct docstring.</summary>
public bool PortalSide => (Flags & 2) == 0;
}

View file

@ -0,0 +1,67 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellPhysicsPortalWiringTests
{
[Fact]
public void NewFields_HaveSensibleDefaults()
{
var cp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
};
Assert.Null(cp.CellBSP);
Assert.Empty(cp.Portals);
Assert.Null(cp.PortalPolygons);
Assert.Empty(cp.VisibleCellIds);
}
[Fact]
public void NewFields_AcceptInitValues()
{
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
var cp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
Portals = new[] { portal },
VisibleCellIds = new System.Collections.Generic.HashSet<uint> { 0xA9B40101 },
};
Assert.Single(cp.Portals);
Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
Assert.Contains(0xA9B40101u, cp.VisibleCellIds);
}
[Fact]
public void CellPhysics_PortalsRoundTrip()
{
var portals = new[]
{
new PortalInfo(otherCellId: 0x0101, polygonId: 7, flags: 0),
new PortalInfo(otherCellId: 0xFFFF, polygonId: 8, flags: 2),
};
var cp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new System.Collections.Generic.Dictionary<ushort, ResolvedPolygon>(),
Portals = portals,
};
Assert.Equal(2, cp.Portals.Count);
Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
Assert.True(cp.Portals[0].PortalSide);
Assert.Equal((ushort)0xFFFF, cp.Portals[1].OtherCellId);
Assert.False(cp.Portals[1].PortalSide);
}
}

View file

@ -0,0 +1,35 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class PortalInfoTests
{
[Fact]
public void PortalSide_FlagsBit2Clear_ReturnsTrue()
{
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
Assert.True(portal.PortalSide);
}
[Fact]
public void PortalSide_FlagsBit2Set_ReturnsFalse()
{
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 2);
Assert.False(portal.PortalSide);
}
[Fact]
public void PortalSide_OtherBitsSet_FollowsOnlyBit2()
{
var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0xFF & ~2);
Assert.True(portal.PortalSide);
}
[Fact]
public void OtherCellId_StoredAsLowSixteenBits()
{
var portal = new PortalInfo(otherCellId: 0xFFFF, polygonId: 5, flags: 0);
Assert.Equal((ushort)0xFFFF, portal.OtherCellId);
}
}

View file

@ -46,8 +46,6 @@ public class ResolveOutdoorCellIdIndoorContainmentTests
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = poly },
WorldTransform = world,
InverseWorldTransform = inv,
LocalAabbMin = min,
LocalAabbMax = max,
};
}
@ -121,8 +119,6 @@ public class ResolveOutdoorCellIdIndoorContainmentTests
Resolved = new Dictionary<ushort, ResolvedPolygon> { [0] = poly },
WorldTransform = rotation,
InverseWorldTransform = inv,
LocalAabbMin = -halfExtent,
LocalAabbMax = halfExtent,
};
var engine = new PhysicsEngine();