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); + } +}