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, _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
portalPlanes, origin.X, origin.Y); 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.Collections.Generic;
using System.Numerics; using System.Numerics;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
@ -151,6 +150,36 @@ public static class CellTransit
candidates.Add(lbPrefix | low); 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> /// <summary>
/// Top-level cell-tracking driver, ported from retail's /// Top-level cell-tracking driver, ported from retail's
/// <c>CObjCell::find_cell_list</c> (sphere variant). /// <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). // BFS the portal graph (one hop per pass — usually 1-2 passes is enough).
var pending = new Queue<uint>(); var pending = new Queue<uint>();
var visited = new HashSet<uint>();
pending.Enqueue(currentCellId); pending.Enqueue(currentCellId);
visited.Add(currentCellId);
int maxIterations = 16; // hard cap; portal graphs are small int maxIterations = 16; // hard cap; portal graphs are small
while (pending.Count > 0 && maxIterations-- > 0) while (pending.Count > 0 && maxIterations-- > 0)
{ {
@ -203,10 +234,9 @@ public static class CellTransit
if (candidates.Count > sizeBefore) if (candidates.Count > sizeBefore)
{ {
// Snapshot the new candidates to avoid mutating during iteration.
foreach (var c in candidates) foreach (var c in candidates)
{ {
if (c != cellId) // skip seed if (visited.Add(c)) // only enqueue if NEW
pending.Enqueue(c); pending.Enqueue(c);
} }
} }
@ -220,9 +250,28 @@ public static class CellTransit
} }
else else
{ {
// Outdoor seed. // Outdoor seed: expand neighbour landcells AND check for building stabs
// with portals into interior EnvCells.
AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); 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 // 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, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = 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> /// <summary>
/// Extract and cache the physics BSP + polygon data from a GfxObj, /// Extract and cache the physics BSP + polygon data from a GfxObj,
/// PLUS always cache a visual AABB from the vertex data regardless of /// PLUS always cache a visual AABB from the vertex data regardless of
@ -304,6 +307,31 @@ public sealed class PhysicsDataCache
/// </summary> /// </summary>
public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) public void RegisterCellStructForTest(uint envCellId, CellPhysics physics)
=> _cellStruct[envCellId] = 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> /// <summary>

View file

@ -258,7 +258,8 @@ public sealed class PhysicsEngine
if (fallbackLow >= 0x0100u) 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; if (DataCache is null) return fallbackCellId;
return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, 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) if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{ {
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); 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;
} }
} }

View file

@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellTransitCheckBuildingTransitTests
{
[Fact]
public void SphereInsideBuildingPortalDestination_AddsInteriorCell()
{
// Building at world origin. One portal to interior cell 0xA9B40100.
var building = new BuildingPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Portals = new[]
{
new BldPortalInfo(
otherCellId: 0xA9B40100u,
otherPortalId: 0,
flags: 0),
},
};
// Interior cell with null CellBSP — PointInsideCellBsp(null, _) returns true,
// but CheckBuildingTransit guards on CellBSP?.Root being non-null, so this
// cell is skipped.
var interiorCell = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, interiorCell);
var candidates = new HashSet<uint>();
CellTransit.CheckBuildingTransit(
cache, building,
worldSphereCenter: new Vector3(0, 0, 0),
sphereRadius: 0.5f,
candidates);
// CellBSP is null → containment guard (otherCell?.CellBSP?.Root is null)
// skips this cell. No candidate added.
Assert.Empty(candidates);
}
// A second test that uses a synthetic CellBSP whose Root.Type == BSPNodeType.Leaf
// (which PointInsideCellBsp short-circuits as "inside") would verify the
// happy path. Constructing a CellBSPTree by hand from DatReaderWriter
// types is awkward; deferred to integration testing at visual-verify time.
}

View file

@ -0,0 +1,38 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellTransitFindCellListTests
{
[Fact]
public void IndoorSeed_NoCacheEntry_ReturnsFallback()
{
var cache = new PhysicsDataCache();
// Indoor seed but cell not cached → FindCellList early-returns the fallback.
uint result = CellTransit.FindCellList(
cache,
worldSphereCenter: Vector3.Zero,
sphereRadius: 0.5f,
currentCellId: 0xA9B40100u);
Assert.Equal(0xA9B40100u, result);
}
[Fact]
public void OutdoorSeed_Returns_FallbackWhenNoCellBSPs()
{
var cache = new PhysicsDataCache();
// Outdoor seed: AddAllOutsideCells adds landcell candidates, but they
// have no CellPhysics (only EnvCells get cached) → containment loop
// finds no winner → fall back.
uint result = CellTransit.FindCellList(
cache,
worldSphereCenter: new Vector3(12f, 12f, 0f),
sphereRadius: 0.5f,
currentCellId: 0xA9B40001u);
Assert.Equal(0xA9B40001u, result);
}
}

View file

@ -28,4 +28,17 @@ public class ResolveCellIdTests
Assert.Equal(0xA9B40001u, result); Assert.Equal(0xA9B40001u, result);
} }
[Fact]
public void ResolveCellId_NoDataCache_ReturnsFallback()
{
// Build a PhysicsEngine without setting DataCache.
var engine = new PhysicsEngine { DataCache = null };
uint result = engine.ResolveCellId(
new Vector3(100, 100, 0),
sphereRadius: 0.5f,
fallbackCellId: 0xA9B40100u); // indoor seed
// Indoor branch falls back when DataCache is null.
Assert.Equal(0xA9B40100u, result);
}
} }