feat(physics): Phase 2 — BuildingPhysics + CheckBuildingTransit

Closes the outdoor→indoor entry path. New BuildingPhysics type holds
the per-SortCell BldPortal list + building world transform; PhysicsDataCache
caches it (CacheBuilding + GetBuilding); CellTransit.CheckBuildingTransit
tests each portal's destination cell via PointInsideCellBsp.

PhysicsEngine.ResolveCellId's outdoor branch now hooks CheckBuildingTransit
after the terrain-grid lookup: if the matched landcell has a cached
building stab, check whether the sphere has crossed into one of its
interior EnvCells before returning.

GameWindow at landblock-load time iterates LandBlockInfo.Buildings and
caches each via PhysicsDataCache.CacheBuilding. The landcell-id derivation
uses retail's row-major cell-index formula (gridX * 8 + gridY + 1).

Polish items from Subagent B/C reviews folded in:
- visited HashSet in FindCellList's BFS (avoids O(N^2) re-enqueue)
- ResolveCellId_NoDataCache_ReturnsFallback test (closes coverage gap)
- DataCache-asymmetry comment in PhysicsEngine.ResolveCellId
- Replaced misleading FindCellList outdoor-branch TODO with explicit
  note that ResolveCellId bypasses this branch — wired in ResolveCellId
  directly.
- Removed unused 'using DatReaderWriter.Types;' from CellTransit.cs
- 2 new CellTransitFindCellListTests integration tests
- 1 new CellTransitCheckBuildingTransitTests test (null-CellBSP guard
  case; happy path deferred to visual verification).

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 17:34:38 +02:00
parent aad697602e
commit 069534a372
8 changed files with 301 additions and 7 deletions

View file

@ -5710,6 +5710,52 @@ public sealed class GameWindow : IDisposable
}
}
// Phase 2: cache building portal lists for CellTransit.CheckBuildingTransit.
// Iterates LandBlockInfo.Buildings — each BuildingInfo has a Frame (world-
// relative origin + orientation) and a Portals list. The landcell id is
// derived from the building's frame origin using retail's row-major grid
// formula (gridX * 8 + gridY + 1) within the 192m × 192m landblock.
if (lbInfo is not null && lbInfo.Buildings.Count > 0)
{
uint lbPrefix = lb.LandblockId & 0xFFFF0000u;
foreach (var building in lbInfo.Buildings)
{
if (building.Portals.Count == 0) continue;
var bldPortals = new System.Collections.Generic.List<AcDream.Core.Physics.BldPortalInfo>(
building.Portals.Count);
foreach (var bp in building.Portals)
{
bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo(
otherCellId: lbPrefix | (uint)bp.OtherCellId,
otherPortalId: bp.OtherPortalId,
flags: (ushort)bp.Flags));
}
// Build a world transform for the building. Frame.Origin is
// landblock-relative; add the landblock world origin to get
// world space.
var bldOriginWorld = building.Frame.Origin + origin;
var buildingTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
* System.Numerics.Matrix4x4.CreateTranslation(bldOriginWorld);
// Derive the outdoor landcell id containing this building.
// Retail's cell index: row-major (gridX * 8 + gridY + 1) within
// the 8×8 grid of 24m cells in a landblock.
int bldGridX = (int)(building.Frame.Origin.X / 24f);
int bldGridY = (int)(building.Frame.Origin.Y / 24f);
if (bldGridX < 0) bldGridX = 0;
if (bldGridX >= 8) bldGridX = 7;
if (bldGridY < 0) bldGridY = 0;
if (bldGridY >= 8) bldGridY = 7;
uint landcellLow = (uint)(bldGridX * 8 + bldGridY + 1);
uint landcellId = lbPrefix | landcellLow;
_physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform);
}
}
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
portalPlanes, origin.X, origin.Y);
}

View file

@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cached building portal data
/// for outdoor→indoor cell entry. One per outdoor landcell that contains
/// a building stab. Mirrors retail's <c>BuildingObj.Portals</c> array
/// (per the pseudocode doc §"LandCell.find_transit_cells").
/// </summary>
public sealed class BuildingPhysics
{
public required Matrix4x4 WorldTransform { get; init; }
public required Matrix4x4 InverseWorldTransform { get; init; }
public required IReadOnlyList<BldPortalInfo> Portals { get; init; }
}
/// <summary>
/// One building portal: the connection from a SortCell's BuildingObj to
/// an interior EnvCell. ExactMatch is decoded from <see cref="Flags"/>
/// bit 0 (<c>PortalFlags.ExactMatch = 0x0001</c>).
/// </summary>
public readonly struct BldPortalInfo
{
public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags)
{
OtherCellId = otherCellId;
OtherPortalId = otherPortalId;
Flags = flags;
}
/// <summary>Full id of the interior EnvCell this portal connects to.</summary>
public uint OtherCellId { get; }
/// <summary>The portal id within the destination EnvCell.</summary>
public ushort OtherPortalId { get; }
public ushort Flags { get; }
/// <summary>Bit 0 of Flags (<c>PortalFlags.ExactMatch = 0x0001</c>).</summary>
public bool ExactMatch => (Flags & 0x0001) != 0;
}

View file

@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
@ -151,6 +150,36 @@ public static class CellTransit
candidates.Add(lbPrefix | low);
}
/// <summary>
/// Outdoor→indoor entry path. Ported from retail's
/// <c>BuildingObj::find_building_transit_cells</c> +
/// <c>EnvCell::check_building_transit</c>. For each portal of the
/// outdoor building, look up the destination interior cell and test
/// whether the sphere center is inside it via
/// <see cref="BSPQuery.PointInsideCellBsp"/>. If so, add the interior
/// cell to <paramref name="candidates"/>.
/// </summary>
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics building,
Vector3 worldSphereCenter,
float sphereRadius,
HashSet<uint> candidates)
{
foreach (var portal in building.Portals)
{
var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell?.CellBSP?.Root is null) continue;
// Sphere center in the OTHER cell's local space.
var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
if (BSPQuery.PointInsideCellBsp(otherCell.CellBSP.Root, localCenter))
{
candidates.Add(portal.OtherCellId);
}
}
}
/// <summary>
/// Top-level cell-tracking driver, ported from retail's
/// <c>CObjCell::find_cell_list</c> (sphere variant).
@ -188,7 +217,9 @@ public static class CellTransit
// BFS the portal graph (one hop per pass — usually 1-2 passes is enough).
var pending = new Queue<uint>();
var visited = new HashSet<uint>();
pending.Enqueue(currentCellId);
visited.Add(currentCellId);
int maxIterations = 16; // hard cap; portal graphs are small
while (pending.Count > 0 && maxIterations-- > 0)
{
@ -203,10 +234,9 @@ public static class CellTransit
if (candidates.Count > sizeBefore)
{
// Snapshot the new candidates to avoid mutating during iteration.
foreach (var c in candidates)
{
if (c != cellId) // skip seed
if (visited.Add(c)) // only enqueue if NEW
pending.Enqueue(c);
}
}
@ -220,9 +250,28 @@ public static class CellTransit
}
else
{
// Outdoor seed.
// Outdoor seed: expand neighbour landcells AND check for building stabs
// with portals into interior EnvCells.
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
// Outdoor→indoor entry (CheckBuildingTransit) wires in a follow-up commit.
// For each landcell candidate, see if it carries a building stab; if so,
// check whether the sphere has crossed into any of the building's interior
// EnvCells via CheckBuildingTransit.
//
// NOTE: PhysicsEngine.ResolveCellId currently bypasses this entire branch
// for outdoor seeds (it uses its own _landblocks terrain grid loop). The
// outdoor→indoor production path therefore runs through ResolveCellId's
// OWN outdoor branch (see below for the call there too). This block is
// exercised by direct-FindCellList callers (tests, future re-entry from
// an indoor cell exiting through a portal that lands outside near a
// building).
var landcellSnapshot = new List<uint>(candidates);
foreach (uint landcellId in landcellSnapshot)
{
var building = cache.GetBuilding(landcellId);
if (building is null) continue;
CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates);
}
}
// Containment test: for each candidate, transform worldSphereCenter to

View file

@ -20,6 +20,9 @@ public sealed class PhysicsDataCache
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
private readonly ConcurrentDictionary<uint, BuildingPhysics> _buildings = new();
/// <summary>
/// Extract and cache the physics BSP + polygon data from a GfxObj,
/// PLUS always cache a visual AABB from the vertex data regardless of
@ -304,6 +307,31 @@ public sealed class PhysicsDataCache
/// </summary>
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
=> _cellStruct[envCellId] = physics;
/// <summary>
/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
/// for an outdoor landcell that contains a building stab. Used by
/// <see cref="CellTransit.CheckBuildingTransit"/>.
/// </summary>
public void CacheBuilding(uint landcellId, IReadOnlyList<BldPortalInfo> portals, Matrix4x4 worldTransform)
{
if (_buildings.ContainsKey(landcellId)) return;
Matrix4x4.Invert(worldTransform, out var inverse);
_buildings[landcellId] = new BuildingPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inverse,
Portals = portals,
};
}
public BuildingPhysics? GetBuilding(uint landcellId)
=> _buildings.TryGetValue(landcellId, out var b) ? b : null;
public IReadOnlyCollection<uint> BuildingIds => (IReadOnlyCollection<uint>)_buildings.Keys;
/// <summary>Test helper, mirrors <see cref="RegisterCellStructForTest"/>.</summary>
public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
}
/// <summary>

View file

@ -258,7 +258,8 @@ public sealed class PhysicsEngine
if (fallbackLow >= 0x0100u)
{
// Indoor seed: use portal-graph traversal.
// Indoor branch needs DataCache to look up cells; outdoor uses
// _landblocks (no DataCache dependency).
if (DataCache is null) return fallbackCellId;
return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId);
}
@ -274,7 +275,29 @@ public sealed class PhysicsEngine
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
return (kvp.Key & 0xFFFF0000u) | lowCellId;
uint outdoorCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
// Outdoor→indoor entry: if this landcell has a cached building,
// check whether the sphere has crossed into one of its interior
// EnvCells via the building's portals.
if (DataCache is not null)
{
var building = DataCache.GetBuilding(outdoorCellId);
if (building is not null)
{
var candidates = new System.Collections.Generic.HashSet<uint>();
CellTransit.CheckBuildingTransit(
DataCache, building, worldPos, sphereRadius, candidates);
if (candidates.Count > 0)
{
// First candidate wins — building portal containment is
// mutually exclusive in retail (one interior cell per portal).
foreach (var c in candidates) return c;
}
}
}
return outdoorCellId;
}
}