diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 031d8f6..55dc792 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -928,16 +928,24 @@ public static class BSPQuery // ========================================================================= /// - /// BSPNode.point_inside_cell_bsp — test if a 3D point is inside the cell BSP. + /// BSPNode.point_inside_cell_bsp — recursive cell-BSP point containment test. /// /// - /// Follows the front side of each splitting plane. A point is inside when it - /// reaches a front leaf or null PosNode (solid interior). + /// Indoor walking Phase 2 (2026-05-19): retyped from PhysicsBSPNode? to + /// CellBSPNode? — the function operates on the CellBSP tree (which is + /// distinct from the PhysicsBSP tree). The dead-code typing was wrong; + /// no callers existed, so the retype is safe. + /// + /// + /// + /// Walks down the tree following splitting planes; returns true when the + /// point reaches a front leaf or null PosNode (solid interior). Behind + /// any splitting plane → outside. /// /// /// ACE: BSPNode.cs point_inside_cell_bsp. /// - public static bool PointInsideCellBsp(PhysicsBSPNode? node, Vector3 point) + public static bool PointInsideCellBsp(CellBSPNode? node, Vector3 point) { if (node is null) return true; if (node.Type == BSPNodeType.Leaf) return true; diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs new file mode 100644 index 0000000..45096f7 --- /dev/null +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -0,0 +1,243 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics; + +/// +/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal, +/// ported from retail's CObjCell::find_cell_list family +/// (sphere variant for the player's single foot sphere). +/// +/// +/// Replaces Phase D's AABB containment. Uses the cell BSP for retail- +/// faithful point-in-cell tests via +/// . Walks the portal graph +/// starting from a given current cell to find which cells a moving +/// sphere overlaps. +/// +/// +/// +/// Reference pseudocode: +/// docs/research/acclient_indoor_transitions_pseudocode.md +/// (2026-04-13). Retail decomp: CEnvCell::find_transit_cells +/// (sphere variant) at acclient_2013_pseudo_c.txt. +/// +/// +public static class CellTransit +{ + /// + /// Small radius padding matching retail's EPSILON usage in the + /// sphere-plane distance test (research doc §"EnvCell.find_transit_cells"). + /// + private const float EPSILON = 0.02f; + + /// + /// Indoor portal-neighbour expansion. For each portal of + /// , test whether the sphere overlaps + /// the portal polygon's plane in cell-local space. If so, add the + /// neighbour cell to . + /// + /// + /// Ported from CEnvCell::find_transit_cells (sphere variant) + /// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)". + /// + /// + public static void FindTransitCellsSphere( + PhysicsDataCache cache, + CellPhysics currentCell, + uint currentCellId, + Vector3 worldSphereCenter, + float sphereRadius, + HashSet candidates, + out bool exitOutside) + { + exitOutside = false; + if (currentCell.PortalPolygons is null) return; + + uint lbPrefix = currentCellId & 0xFFFF0000u; + float rad = sphereRadius + EPSILON; + + // Cell-local sphere center. + var localCenter = Vector3.Transform(worldSphereCenter, currentCell.InverseWorldTransform); + + foreach (var portal in currentCell.Portals) + { + if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly)) + continue; + + // Signed distance from sphere center to portal plane (cell-local). + float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D; + + if (portal.OtherCellId == 0xFFFF) + { + // Exit portal. Sphere must straddle the plane. + if (dist > -rad && dist < rad) + exitOutside = true; + continue; + } + + uint otherId = lbPrefix | portal.OtherCellId; + + // Conservative add: the sphere is near the portal plane and on the + // outward side (per PortalSide). This is the load-hint branch from + // the research doc. A more retail-faithful path would call + // CellBSP.sphere_intersects_cell on the neighbour — deferred. + if (portal.PortalSide ? dist > -rad : dist < rad) + candidates.Add(otherId); + } + } + + /// + /// Outdoor neighbour expansion. Ported from + /// CLandCell::add_all_outside_cells (sphere variant) per the + /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)". + /// + /// + /// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index + /// within a landblock is computed from local X/Y mod 24. The sphere + /// adds the primary cell plus up to 3 neighbours when the radius + /// reaches a cell boundary. + /// + /// + public static void AddAllOutsideCells( + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId, + HashSet candidates) + { + const float CellSize = 24f; + + uint lbPrefix = currentCellId & 0xFFFF0000u; + + float lbXf = ((lbPrefix >> 24) & 0xFFu) * 192f; + float lbYf = ((lbPrefix >> 16) & 0xFFu) * 192f; + float localX = worldSphereCenter.X - lbXf; + float localY = worldSphereCenter.Y - lbYf; + + float cellLocalX = localX % CellSize; + float cellLocalY = localY % CellSize; + float minRad = sphereRadius; + float maxRad = CellSize - sphereRadius; + + int gridX = (int)(localX / CellSize); + int gridY = (int)(localY / CellSize); + if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return; + + AddOutsideCell(candidates, lbPrefix, gridX, gridY); + + if (cellLocalX > maxRad) + { + AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY); + if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1); + if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1); + } + if (cellLocalX < minRad) + { + AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY); + if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1); + if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1); + } + if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1); + if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1); + } + + private static void AddOutsideCell(HashSet candidates, uint lbPrefix, int gridX, int gridY) + { + if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return; + + // Cell index within landblock: row-major (X * 8 + Y) + 1. + uint low = (uint)(gridX * 8 + gridY + 1); + candidates.Add(lbPrefix | low); + } + + /// + /// Top-level cell-tracking driver, ported from retail's + /// CObjCell::find_cell_list (sphere variant). + /// + /// + /// Walks the portal graph from , + /// finds the cell whose contains + /// the sphere center, and returns its full id (landblock-prefixed). + /// Falls back to when no candidate + /// matches. + /// + /// + /// + /// Pseudocode reference: + /// docs/research/acclient_indoor_transitions_pseudocode.md + /// §"Overall Driver: find_cell_list". + /// + /// + public static uint FindCellList( + PhysicsDataCache cache, + Vector3 worldSphereCenter, + float sphereRadius, + uint currentCellId) + { + var candidates = new HashSet(); + uint currentLow = currentCellId & 0xFFFFu; + + if (currentLow >= 0x0100u) + { + // Indoor seed. + var currentCell = cache.GetCellStruct(currentCellId); + if (currentCell is null) return currentCellId; + + candidates.Add(currentCellId); + + // BFS the portal graph (one hop per pass — usually 1-2 passes is enough). + var pending = new Queue(); + pending.Enqueue(currentCellId); + int maxIterations = 16; // hard cap; portal graphs are small + while (pending.Count > 0 && maxIterations-- > 0) + { + uint cellId = pending.Dequeue(); + var cell = cache.GetCellStruct(cellId); + if (cell is null) continue; + + var sizeBefore = candidates.Count; + FindTransitCellsSphere( + cache, cell, cellId, worldSphereCenter, sphereRadius, + candidates, out bool exitOutside); + + if (candidates.Count > sizeBefore) + { + // Snapshot the new candidates to avoid mutating during iteration. + foreach (var c in candidates) + { + if (c != cellId) // skip seed + pending.Enqueue(c); + } + } + + if (exitOutside) + { + // Add neighbour outdoor cells too. + AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); + } + } + } + else + { + // Outdoor seed. + AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates); + // Outdoor→indoor entry (CheckBuildingTransit) wires in a follow-up commit. + } + + // Containment test: for each candidate, transform worldSphereCenter to + // local and test PointInsideCellBsp. + foreach (uint candId in candidates) + { + var cand = cache.GetCellStruct(candId); + if (cand?.CellBSP?.Root is null) continue; + + var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform); + if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local)) + return candId; + } + + // No cell contained the sphere center. Stay in the input cell. + return currentCellId; + } +} diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index bf59e7d..cdb120a 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -230,39 +230,42 @@ public sealed class PhysicsEngine } /// - /// Resolve a position's CellId. Falls back to outdoor terrain landcell - /// resolution or trusts an already-indoor fallbackCellId. + /// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a + /// given world position via retail's portal-graph traversal for indoor + /// cells, or via terrain grid lookup for outdoor cells. /// /// - /// 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. + /// Indoor seed: delegates to which + /// BFS-walks the portal graph and uses + /// for containment. This replaces Phase D's AABB shortcut. /// /// /// - /// 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). + /// Outdoor seed: uses the registered landblock terrain grid to compute + /// the correct prefixed cell ID, preserving the pre-existing outdoor + /// resolution behavior (the L.2e prefix-preservation fix). + /// + /// + /// + /// Design: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md /// /// - internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId) + internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId) { - if (fallbackCellId == 0) - return 0; + if (fallbackCellId == 0) return 0; - // 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). + if (fallbackLow >= 0x0100u) + { + // Indoor seed: use portal-graph traversal. + if (DataCache is null) return fallbackCellId; + return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId); + } + + // Outdoor seed: use terrain grid to compute the prefixed cell id. + // Preserves the L.2e prefix-preservation fix (always apply the matched + // landblock's high-16 prefix even when fallbackCellId arrived bare-low-byte). foreach (var kvp in _landblocks) { var lb = kvp.Value; @@ -743,7 +746,7 @@ public sealed class PhysicsEngine return new ResolveResult( sp.CheckPos, - ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId), + ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId), onGround, collisionNormalValid, collisionNormal); @@ -761,7 +764,7 @@ public sealed class PhysicsEngine uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId; return new ResolveResult( sp.CheckPos, - ResolveOutdoorCellId(sp.CheckPos, partialCellId), + ResolveCellId(sp.CheckPos, sphereRadius, partialCellId), partialOnGround, collisionNormalValid, collisionNormal); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 7d33d97..4a6d696 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1178,13 +1178,13 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; - uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId); - if (resolvedOutdoorCellId != sp.CheckCellId) - sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); - Vector3 footCenter = sp.GlobalSphere[0].Origin; float sphereRadius = sp.GlobalSphere[0].Radius; + uint resolvedOutdoorCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId); + if (resolvedOutdoorCellId != sp.CheckCellId) + sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); + // ── Indoor cell BSP collision ──────────────────────────────────── // If the player is in an indoor cell (low 16 bits >= 0x0100), // query the CellStruct's PhysicsBSP for wall/floor/ceiling collision. diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs new file mode 100644 index 0000000..5ef0b74 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellTransitAddAllOutsideCellsTests +{ + [Fact] + public void SphereWellInsideCell_AddsOneCell() + { + // Player at world (12, 12, 0) in landblock 0xA9B40000 → cell (0,0). + // Landblock origin: 0xA9 = 169 → world X = 169*192 = 32448. + // 0xB4 = 180 → world Y = 180*192 = 34560. + // Player needs to be in cell (0,0) RELATIVE to landblock origin: + // world X = 32448 + 12 = 32460 + // world Y = 34560 + 12 = 34572 + var candidates = new HashSet(); + CellTransit.AddAllOutsideCells( + worldSphereCenter: new Vector3(32460f, 34572f, 0f), + sphereRadius: 0.5f, + currentCellId: 0xA9B40001u, + candidates); + + Assert.Single(candidates); + Assert.Contains(0xA9B40001u, candidates); + } + + [Fact] + public void SphereAtCellEastBoundary_AddsTwoCells() + { + // Player at world (32448 + 23.6, 34560 + 12, 0) — near +X edge of cell (0,0). + // Sphere reach to localX = 23.6 + 0.5 = 24.1 → cell (1,0) added. + var candidates = new HashSet(); + CellTransit.AddAllOutsideCells( + worldSphereCenter: new Vector3(32448f + 23.6f, 34560f + 12f, 0f), + sphereRadius: 0.5f, + currentCellId: 0xA9B40001u, + candidates); + + Assert.Equal(2, candidates.Count); + Assert.Contains(0xA9B40001u, candidates); + // Cell (1,0): low-16 id = 1 * 8 + 0 + 1 = 9 → 0x0009. + Assert.Contains(0xA9B40009u, candidates); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs new file mode 100644 index 0000000..cc9db97 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class CellTransitFindTransitCellsSphereTests +{ + private static CellPhysics MakeCellWithPortalAtRightWall( + Matrix4x4 worldTransform, uint otherCellId, ushort flags) + { + // Portal poly at local x=2.5 (right wall), normal +X. + var portalPolyA = new ResolvedPolygon + { + Vertices = new[] + { + new Vector3(2.5f, -2.5f, 0f), + new Vector3(2.5f, 2.5f, 0f), + new Vector3(2.5f, 2.5f, 5f), + new Vector3(2.5f, -2.5f, 5f), + }, + Plane = new Plane(new Vector3(1, 0, 0), -2.5f), // x = 2.5 + NumPoints = 4, + SidesType = DatReaderWriter.Enums.CullMode.None, + }; + + Matrix4x4.Invert(worldTransform, out var inv); + return new CellPhysics + { + WorldTransform = worldTransform, + InverseWorldTransform = inv, + Resolved = new Dictionary(), + PortalPolygons = new Dictionary { [10] = portalPolyA }, + Portals = new[] + { + new PortalInfo(otherCellId: (ushort)otherCellId, polygonId: 10, flags: flags), + }, + }; + } + + [Fact] + public void SphereInsideCellA_NearPortal_AddsCellB() + { + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); + + var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f)); + Matrix4x4.Invert(cellBT, out var cellBInv); + var cellB = new CellPhysics + { + WorldTransform = cellBT, + InverseWorldTransform = cellBInv, + Resolved = new Dictionary(), + }; + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + cache.RegisterCellStructForTest(0xA9B40101u, cellB); + + // Sphere center near portal (local x=2.0, radius=0.5 → reaches x=2.5 = portal plane). + var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f); + + var candidates = new HashSet(); + CellTransit.FindTransitCellsSphere( + cache, cellA, currentCellId: 0xA9B40100u, + worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside); + + Assert.Contains(0xA9B40101u, candidates); + Assert.False(exitOutside); + } + + [Fact] + public void SphereInsideCellA_FarFromPortal_DoesNotAddCellB() + { + var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0); + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, cellA); + + // Sphere far from portal (local x=-1.0, reach to x=-0.5 — nowhere near portal at x=2.5). + var worldSphereCenter = new Vector3(-1.0f, 0f, 2.5f); + + var candidates = new HashSet(); + CellTransit.FindTransitCellsSphere( + cache, cellA, currentCellId: 0xA9B40100u, + worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside); + + Assert.DoesNotContain(0xA9B40101u, candidates); + } + + [Fact] + public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside() + { + var exitCell = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0xFFFF, flags: 0); + + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40100u, exitCell); + + var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f); + var candidates = new HashSet(); + + CellTransit.FindTransitCellsSphere( + cache, exitCell, currentCellId: 0xA9B40100u, + worldSphereCenter, sphereRadius: 0.5f, candidates, out bool exitOutside); + + Assert.True(exitOutside); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs b/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs new file mode 100644 index 0000000..afbb08d --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs @@ -0,0 +1,31 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class ResolveCellIdTests +{ + [Fact] + public void ResolveCellId_FallbackZero_ReturnsZero() + { + var engine = new PhysicsEngine(); + uint result = engine.ResolveCellId(Vector3.Zero, sphereRadius: 0.5f, fallbackCellId: 0u); + Assert.Equal(0u, result); + } + + [Fact] + public void ResolveCellId_NoLandblock_OutdoorSeed_ReturnsFallback() + { + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + // Outdoor seed with no landblock added → AddAllOutsideCells produces + // candidates but none have a CellBSP → falls back to input. + uint result = engine.ResolveCellId( + new Vector3(100, 100, 0), + sphereRadius: 0.5f, + fallbackCellId: 0xA9B40001u); + + Assert.Equal(0xA9B40001u, result); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs b/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs deleted file mode 100644 index b428db2..0000000 --- a/tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs +++ /dev/null @@ -1,150 +0,0 @@ -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, - }; - } - - // ----------------------------------------------------------------------- - // 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, - }; - - 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); - } -}