From 069534a372db68048109c90adf1ff08cc2b22a5e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 17:34:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(physics):=20Phase=202=20=E2=80=94=20Buildi?= =?UTF-8?q?ngPhysics=20+=20CheckBuildingTransit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.App/Rendering/GameWindow.cs | 46 +++++++++++++++ src/AcDream.Core/Physics/BuildingPhysics.cs | 41 +++++++++++++ src/AcDream.Core/Physics/CellTransit.cs | 59 +++++++++++++++++-- src/AcDream.Core/Physics/PhysicsDataCache.cs | 28 +++++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 27 ++++++++- .../CellTransitCheckBuildingTransitTests.cs | 56 ++++++++++++++++++ .../Physics/CellTransitFindCellListTests.cs | 38 ++++++++++++ .../Physics/ResolveCellIdTests.cs | 13 ++++ 8 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 src/AcDream.Core/Physics/BuildingPhysics.cs create mode 100644 tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 98fa6e1..620c931 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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( + 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); } diff --git a/src/AcDream.Core/Physics/BuildingPhysics.cs b/src/AcDream.Core/Physics/BuildingPhysics.cs new file mode 100644 index 0000000..c05cd66 --- /dev/null +++ b/src/AcDream.Core/Physics/BuildingPhysics.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// 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 BuildingObj.Portals array +/// (per the pseudocode doc §"LandCell.find_transit_cells"). +/// +public sealed class BuildingPhysics +{ + public required Matrix4x4 WorldTransform { get; init; } + public required Matrix4x4 InverseWorldTransform { get; init; } + public required IReadOnlyList Portals { get; init; } +} + +/// +/// One building portal: the connection from a SortCell's BuildingObj to +/// an interior EnvCell. ExactMatch is decoded from +/// bit 0 (PortalFlags.ExactMatch = 0x0001). +/// +public readonly struct BldPortalInfo +{ + public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags) + { + OtherCellId = otherCellId; + OtherPortalId = otherPortalId; + Flags = flags; + } + + /// Full id of the interior EnvCell this portal connects to. + public uint OtherCellId { get; } + /// The portal id within the destination EnvCell. + public ushort OtherPortalId { get; } + public ushort Flags { get; } + + /// Bit 0 of Flags (PortalFlags.ExactMatch = 0x0001). + public bool ExactMatch => (Flags & 0x0001) != 0; +} diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 45096f7..0e3e566 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -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); } + /// + /// Outdoor→indoor entry path. Ported from retail's + /// BuildingObj::find_building_transit_cells + + /// EnvCell::check_building_transit. For each portal of the + /// outdoor building, look up the destination interior cell and test + /// whether the sphere center is inside it via + /// . If so, add the interior + /// cell to . + /// + public static void CheckBuildingTransit( + PhysicsDataCache cache, + BuildingPhysics building, + Vector3 worldSphereCenter, + float sphereRadius, + HashSet 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); + } + } + } + /// /// Top-level cell-tracking driver, ported from retail's /// CObjCell::find_cell_list (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(); + var visited = new HashSet(); 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(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 diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 45faa8e..ee58a7c 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -20,6 +20,9 @@ public sealed class PhysicsDataCache private readonly ConcurrentDictionary _setup = new(); private readonly ConcurrentDictionary _cellStruct = new(); + // ── Phase 2: building portal cache for outdoor→indoor entry ─────────── + private readonly ConcurrentDictionary _buildings = new(); + /// /// 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 /// public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) => _cellStruct[envCellId] = physics; + + /// + /// Indoor walking Phase 2 (2026-05-19). Cache the building portal list + /// for an outdoor landcell that contains a building stab. Used by + /// . + /// + public void CacheBuilding(uint landcellId, IReadOnlyList 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 BuildingIds => (IReadOnlyCollection)_buildings.Keys; + + /// Test helper, mirrors . + public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b; } /// diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index cdb120a..d9e3633 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -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(); + 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; } } diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs new file mode 100644 index 0000000..e6cb512 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs @@ -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(), + }; + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, interiorCell); + + var candidates = new HashSet(); + 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. +} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs new file mode 100644 index 0000000..823658d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs b/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs index afbb08d..c740e90 100644 --- a/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs @@ -28,4 +28,17 @@ public class ResolveCellIdTests 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); + } }