From c19d6fb321ec09d9c46db09f253ef6a4efd89c99 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 15:20:36 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20Cluster=20A=20#84=20+=20#85=20?= =?UTF-8?q?=E2=80=94=20indoor=20cell=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveOutdoorCellId only resolved outdoor terrain landcells. A player geometrically inside an EnvCell stayed in outdoor-landcell range, so FindEnvCollisions' indoor cell-BSP branch (gated on cellLow >= 0x0100) never fired. Both #84 (blocked by air indoors) and #85 (pass through walls outside→in) are downstream of this — without indoor cell-BSP collision the player gets stuck against outdoor-stab back-faces of the building shell, and walls only block from one side. Adds an indoor-cell-containment check via PhysicsDataCache: at CacheCellStruct time, compute each cell's local AABB from its resolved polygon vertices; at ResolveOutdoorCellId time, transform the world position into each cached cell's local space and return the matched cell's full id when contained. Falls through to the existing outdoor terrain logic when no EnvCell contains the position. Also fixes a pre-existing prefix-preservation bug in the outdoor branch: the function now always applies the matched landblock's high-16 prefix even when the input fallbackCellId arrived bare-low-byte (the L.2e finding from CLAUDE.md). Updated two existing PhysicsEngineTests that encoded the old bare-low-byte output. Evidence: launch-cluster-a-capture.log @ 2026-05-19 — player at worldPos (155.376, 14.010, 94.000) geometrically inside cottage cell 0xA9B40172, but sp.CheckCellId stuck at 0x00000031 (outdoor landcell) across 454 [resolve] lines; zero [indoor-bsp] lines because the gate never opened. Closes #84. Closes #85. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/PhysicsDataCache.cs | 98 ++++++++++- src/AcDream.Core/Physics/PhysicsEngine.cs | 40 ++++- .../Physics/PhysicsEngineTests.cs | 10 +- .../Physics/ResolveOutdoorCellIdTests.cs | 154 ++++++++++++++++++ 4 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index ca5c81e..3f89793 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -136,6 +136,27 @@ public sealed class PhysicsDataCache Matrix4x4.Invert(worldTransform, out var inverseTransform); + 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) + { + 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; + } + } + _cellStruct[envCellId] = new CellPhysics { BSP = cellStruct.PhysicsBSP, @@ -143,7 +164,9 @@ public sealed class PhysicsDataCache Vertices = cellStruct.VertexArray, WorldTransform = worldTransform, InverseWorldTransform = inverseTransform, - Resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray), + Resolved = resolved, + LocalAabbMin = aabbMin, + LocalAabbMax = aabbMax, }; } @@ -219,6 +242,53 @@ 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 @@ -226,6 +296,14 @@ public sealed class PhysicsDataCache /// public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics) => _gfxObj[gfxObjId] = physics; + + /// + /// Register a pre-built directly. Intended for + /// unit-test fixtures that construct synthetic cells without going through + /// dat-driven . + /// + public void RegisterCellStructForTest(uint envCellId, CellPhysics physics) + => _cellStruct[envCellId] = physics; } /// @@ -310,4 +388,22 @@ public sealed class CellPhysics /// Pre-resolved polygon data with vertex positions and computed planes. /// 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 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. + /// + public Vector3 LocalAabbMax { get; init; } = new Vector3(float.MinValue); } diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index a5bdf92..cfafab8 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -230,20 +230,48 @@ public sealed class PhysicsEngine } /// - /// Resolve the outdoor cell id that owns a world-space position. - /// Indoor ids are preserved because EnvCell ownership still comes from - /// portal/cell BSP state; outdoor ids are derived from the registered - /// landblock that currently contains the point. + /// Resolve a position's CellId. Tries indoor EnvCell containment first + /// (via ); falls back + /// to outdoor terrain landcell resolution. + /// + /// + /// 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. + /// + /// + /// + /// Also fixes a pre-existing prefix-preservation bug: the outdoor branch + /// now always applies the matched landblock's high-16 prefix even when + /// the input arrived bare-low-byte + /// (the L.2e finding from CLAUDE.md). + /// /// internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId) { 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 + // cached yet (rare; should be impossible in steady state). uint fallbackLow = fallbackCellId & 0xFFFFu; if (fallbackLow >= 0x0100u) return fallbackCellId; + // Outdoor terrain resolution. Always applies the matched landblock's + // prefix — fixes the bare-low-byte preservation bug (L.2e). foreach (var kvp in _landblocks) { var lb = kvp.Value; @@ -252,9 +280,7 @@ public sealed class PhysicsEngine if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) { uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); - return (fallbackCellId & 0xFFFF0000u) == 0 - ? lowCellId - : (kvp.Key & 0xFFFF0000u) | lowCellId; + return (kvp.Key & 0xFFFF0000u) | lowCellId; } } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index d9d08f8..9ee570c 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -207,7 +207,10 @@ public class PhysicsEngineTests Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 24.9f, 25.1f); - Assert.Equal(0x0009u, result.CellId); + // Phase D fix: ResolveOutdoorCellId now always applies the matched + // landblock's high-16 prefix — 0xA9B4 prefix from the registered + // landblock (0xA9B4FFFF) is now included in the returned CellId. + Assert.Equal(0xA9B40009u, result.CellId); } [Fact] @@ -228,7 +231,10 @@ public class PhysicsEngineTests Assert.True(result.IsOnGround); Assert.InRange(result.Position.X, 97.9f, 98.1f); - Assert.Equal(0x0025u, result.CellId); + // Phase D fix: ResolveOutdoorCellId now always applies the matched + // landblock's high-16 prefix — 0xA9B4 prefix from the registered + // landblock (0xA9B4FFFF) is now included in the returned CellId. + Assert.Equal(0xA9B40025u, result.CellId); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs b/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs new file mode 100644 index 0000000..12c1d65 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Indoor walking Phase D (2026-05-19): tests for the indoor-cell-containment +/// check added to . +/// Covers the four scenarios described in the Phase D implementation plan. +/// +public class ResolveOutdoorCellIdIndoorContainmentTests +{ + /// + /// Build a whose local AABB spans ± + /// around the origin, placed at via the + /// WorldTransform / InverseWorldTransform pair. + /// + private static CellPhysics MakeIndoorCellAt(Vector3 worldOrigin, Vector3 halfExtent) + { + // Four vertices defining a floor quad — enough for AABB computation at + // cache time (in production this is done by CacheCellStruct, in tests + // we pre-supply LocalAabbMin / LocalAabbMax directly). + var min = -halfExtent; + var max = halfExtent; + var verts = new[] + { + new Vector3(min.X, min.Y, min.Z), + new Vector3(max.X, min.Y, min.Z), + new Vector3(max.X, max.Y, max.Z), + new Vector3(min.X, max.Y, max.Z), + }; + var poly = new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(Vector3.UnitZ, 0f), + NumPoints = 4, + SidesType = DatReaderWriter.Enums.CullMode.None, + }; + var world = Matrix4x4.CreateTranslation(worldOrigin); + Matrix4x4.Invert(world, out var inv); + return new CellPhysics + { + Resolved = new Dictionary { [0] = poly }, + WorldTransform = world, + InverseWorldTransform = inv, + LocalAabbMin = min, + LocalAabbMax = max, + }; + } + + // ----------------------------------------------------------------------- + // Test 1: player inside a cached EnvCell → returns that cell's full id. + // ----------------------------------------------------------------------- + [Fact] + public void ResolveOutdoorCellId_PlayerInsideCachedEnvCell_ReturnsEnvCellId() + { + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + + // Cache an EnvCell at world origin spanning ±5 m on each axis. + var cell = MakeIndoorCellAt(Vector3.Zero, new Vector3(5f, 5f, 5f)); + engine.DataCache.RegisterCellStructForTest(0xA9B40172u, cell); + + // Player at world origin → inside the EnvCell's AABB. + uint result = engine.ResolveOutdoorCellId(Vector3.Zero, fallbackCellId: 0x00000031u); + + Assert.Equal(0xA9B40172u, result); + } + + // ----------------------------------------------------------------------- + // Test 2: player outside all cached EnvCells → falls through to outdoor + // (and since no landblocks are registered, returns the fallback unchanged). + // ----------------------------------------------------------------------- + [Fact] + public void ResolveOutdoorCellId_PlayerOutsideAllCachedEnvCells_FallsThroughToOutdoor() + { + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + + var cell = MakeIndoorCellAt(Vector3.Zero, new Vector3(5f, 5f, 5f)); + engine.DataCache.RegisterCellStructForTest(0xA9B40172u, cell); + + // Player at (100, 100, 0) — far outside the cached EnvCell. + // No landblocks registered → outdoor branch can't match either. + uint result = engine.ResolveOutdoorCellId(new Vector3(100f, 100f, 0f), fallbackCellId: 0x00000031u); + + Assert.Equal(0x00000031u, result); + } + + // ----------------------------------------------------------------------- + // Test 3: EnvCell with a non-identity WorldTransform (rotation around Z). + // Player at world (3, 0, 0) is still inside the rotated local AABB. + // ----------------------------------------------------------------------- + [Fact] + public void ResolveOutdoorCellId_PlayerInsideEnvCellWithRotatedTransform_StillDetectsContainment() + { + var halfExtent = new Vector3(5f, 5f, 5f); + var verts = new[] + { + new Vector3(-5f, -5f, -5f), + new Vector3( 5f, -5f, -5f), + new Vector3( 5f, 5f, 5f), + new Vector3(-5f, 5f, 5f), + }; + var poly = new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(Vector3.UnitZ, 0f), + NumPoints = 4, + SidesType = DatReaderWriter.Enums.CullMode.None, + }; + // 90° rotation around Z. A point at world (3, 0, 0) transforms to + // local (0, -3, 0) — still within ±5 on every axis. + var rotation = Matrix4x4.CreateRotationZ(MathF.PI / 2f); + Matrix4x4.Invert(rotation, out var inv); + var cell = new CellPhysics + { + Resolved = new Dictionary { [0] = poly }, + WorldTransform = rotation, + InverseWorldTransform = inv, + LocalAabbMin = -halfExtent, + LocalAabbMax = halfExtent, + }; + + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + engine.DataCache.RegisterCellStructForTest(0xA9B40172u, cell); + + uint result = engine.ResolveOutdoorCellId(new Vector3(3f, 0f, 0f), fallbackCellId: 0x00000031u); + + Assert.Equal(0xA9B40172u, result); + } + + // ----------------------------------------------------------------------- + // Test 4: fallbackCellId == 0 → always returns 0 (existing early-return). + // ----------------------------------------------------------------------- + [Fact] + public void ResolveOutdoorCellId_FallbackZero_ReturnsZero() + { + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + + // Even if the player is inside a cell, fallback=0 should still return 0. + var cell = MakeIndoorCellAt(Vector3.Zero, new Vector3(5f, 5f, 5f)); + engine.DataCache.RegisterCellStructForTest(0xA9B40172u, cell); + + uint result = engine.ResolveOutdoorCellId(Vector3.Zero, fallbackCellId: 0u); + + Assert.Equal(0u, result); + } +}