From 1969c55823c017bd49bc6b4b86a7ad2f4bec76a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 16:52:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(physics):=20Phase=202=20=E2=80=94=20wire?= =?UTF-8?q?=20CellBSP=20+=20Portals=20into=20CellPhysics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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 --- src/AcDream.App/Rendering/GameWindow.cs | 2 +- src/AcDream.Core/Physics/PhysicsDataCache.cs | 152 +++++++----------- src/AcDream.Core/Physics/PhysicsEngine.cs | 23 +-- src/AcDream.Core/Physics/PortalInfo.cs | 45 ++++++ .../Physics/CellPhysicsPortalWiringTests.cs | 67 ++++++++ .../Physics/PortalInfoTests.cs | 35 ++++ .../Physics/ResolveOutdoorCellIdTests.cs | 4 - 7 files changed, 215 insertions(+), 113 deletions(-) create mode 100644 src/AcDream.Core/Physics/PortalInfo.cs create mode 100644 tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 59226ad..98fa6e1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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); } } } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 291aabf..45faa8e 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -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. /// - 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(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 per the DatReaderWriter shape — iterate directly, no .Keys. + var visibleCellIds = new System.Collections.Generic.HashSet(); + 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. /// - private static Dictionary ResolvePolygons( + internal static Dictionary ResolvePolygons( Dictionary polys, VertexArray vertexArray) { @@ -291,53 +289,6 @@ public sealed class PhysicsDataCache /// public IReadOnlyCollection CellStructIds => (IReadOnlyCollection)_cellStruct.Keys; - /// - /// Indoor walking Phase D (2026-05-19). Returns the full id of the first - /// cached EnvCell whose local AABB contains , - /// or false if no cached EnvCell contains it. Used by - /// to promote the player's - /// CellId to an indoor EnvCell when the player is geometrically inside one. - /// - /// - /// AABBs are pre-computed in from each - /// cell's resolved polygon vertices, transformed into local space via - /// . Iteration is O(N) over - /// cached cells; N is bounded by the streaming radius (~80 cells at - /// radius 4). - /// - /// - /// - /// 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). - /// - /// - 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; - } - /// /// Register a pre-built directly. /// Intended for unit-test fixtures that construct synthetic BSP trees @@ -438,21 +389,38 @@ public sealed class CellPhysics /// public required Dictionary Resolved { get; init; } - /// - /// Indoor walking Phase D (2026-05-19). Local-space AABB minimum corner, - /// computed from the resolved polygon vertices at - /// time. Initialized to float.MaxValue so that - /// silently skips - /// cells with no vertex data. - /// - public Vector3 LocalAabbMin { get; init; } = new Vector3(float.MaxValue); + // ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ─────── /// - /// Indoor walking Phase D (2026-05-19). Local-space AABB maximum corner, - /// computed from the resolved polygon vertices at - /// time. Initialized to float.MinValue so that - /// silently skips - /// cells with no vertex data. + /// The cell BSP used for + /// (point-in-cell tests). Separate tree from + /// (collision) and from the renderer's drawing-BSP. + /// Source: cellStruct.CellBSP at cache time. + /// Nullable: cells without a CellBSP cannot participate in portal + /// containment and are skipped by . /// - public Vector3 LocalAabbMax { get; init; } = new Vector3(float.MinValue); + public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } + + /// + /// Portal connections to neighbouring cells, in cell-local space. + /// Default: empty list. Source: envCell.CellPortals. + /// + public IReadOnlyList Portals { get; init; } = System.Array.Empty(); + + /// + /// Resolved VISIBLE polygons (from cellStruct.Polygons), + /// keyed by polygon id. Distinct from which + /// holds PhysicsPolygons. Portal lookup via + /// resolves through this dict. + /// Nullable when the cell has no visible polys (rare). + /// + public Dictionary? PortalPolygons { get; init; } + + /// + /// The full cell ids visible from this cell (with landblock prefix). + /// Populated from envCell.VisibleCells at cache time. Unused + /// this phase; reserved for the optional find_cell_list + /// visibility filter. + /// + public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet(); } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index cfafab8..bf59e7d 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -230,18 +230,15 @@ public sealed class PhysicsEngine } /// - /// Resolve a position's CellId. Tries indoor EnvCell containment first - /// (via ); 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. /// /// - /// 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 's - /// indoor branch (gated on cellLow >= 0x0100) take effect. + /// Phase D (2026-05-19) previously used an AABB containment check + /// (TryFindContainingCell) to promote the player into an indoor + /// EnvCell. Phase 2 (2026-05-19) removes that AABB shortcut; the + /// portal-graph CellTransit traversal (next subagent) replaces it + /// with retail-faithful BSP point-in-cell tests. /// /// /// @@ -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 diff --git a/src/AcDream.Core/Physics/PortalInfo.cs b/src/AcDream.Core/Physics/PortalInfo.cs new file mode 100644 index 0000000..8b117d0 --- /dev/null +++ b/src/AcDream.Core/Physics/PortalInfo.cs @@ -0,0 +1,45 @@ +namespace AcDream.Core.Physics; + +/// +/// Indoor walking Phase 2 (2026-05-19). Portal connection between two +/// EnvCells. Each carries a list of these, +/// mirroring retail's CCellStruct.portals array. +/// +/// +/// is a low-16 cell index (combined with the +/// owning landblock prefix at lookup time) or 0xFFFF to mean +/// "exit to outdoor world" (the player crosses this portal to leave +/// the building). +/// +/// +/// +/// indexes the OWNING cell's +/// dict (the visible-polygon +/// table, NOT which holds physics +/// polys). +/// +/// +/// +/// decodes bit 2 of : +/// (Flags & 2) == 0 → portal's polygon normal points INTO +/// the owning cell (so dist > 0 in cell-local space means "outside +/// the cell, beyond the portal"). Used in find_transit_cells's +/// load-hint path for unloaded neighbours. +/// +/// +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; } + + /// Bit 2 of . See struct docstring. + public bool PortalSide => (Flags & 2) == 0; +} diff --git a/tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs b/tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs new file mode 100644 index 0000000..00e7693 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs @@ -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(), + }; + + 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(), + Portals = new[] { portal }, + VisibleCellIds = new System.Collections.Generic.HashSet { 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(), + 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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs b/tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs new file mode 100644 index 0000000..218309d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs @@ -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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs b/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs index 12c1d65..b428db2 100644 --- a/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs @@ -46,8 +46,6 @@ public class ResolveOutdoorCellIdIndoorContainmentTests Resolved = new Dictionary { [0] = poly }, WorldTransform = world, InverseWorldTransform = inv, - LocalAabbMin = min, - LocalAabbMax = max, }; } @@ -121,8 +119,6 @@ public class ResolveOutdoorCellIdIndoorContainmentTests Resolved = new Dictionary { [0] = poly }, WorldTransform = rotation, InverseWorldTransform = inv, - LocalAabbMin = -halfExtent, - LocalAabbMax = halfExtent, }; var engine = new PhysicsEngine();