diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 915f571f..4f66b5b8 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -343,6 +343,69 @@ public static class CellTransit } } + /// + /// Verbatim port of CEnvCell::find_visible_child_cell + /// (acclient_2013_pseudo_c.txt:311397). Returns the cell whose cell-BSP + /// point_in_cell contains , checking the + /// start cell first (:311402), then — when is + /// true (retail arg3 != 0, :311444) — the start's stab_list + /// (), else (arg3 == 0, :311411) + /// its direct portal neighbours. Returns 0 when no cell contains the point + /// (retail return 0 at :311469). + /// + /// + /// Sibling of (retail find_cell_list) — both + /// resolve membership from the cell graph via . + /// Used by CPhysicsObj::AdjustPosition (pc:280028, arg5 = 1 → + /// stab-list mode) to seat the camera sweep's start cell at the head-pivot. + /// + /// + /// + /// acdream adaptation (matches at line 518): a cell + /// with no hydrated cannot run + /// point_in_cell, so it is treated as NOT containing the point (skipped), + /// rather than letting 's null-node + /// "inside" default make it spuriously claim every point. + /// + /// + public static uint FindVisibleChildCell( + PhysicsDataCache cache, uint startCellId, Vector3 worldPoint, bool useStabList) + { + var start = cache.GetCellStruct(startCellId); + if (start is null) return 0u; + + // this->point_in_cell(point) → return this (:311402-311405) + if (PointInCell(start, worldPoint)) return startCellId; + + if (useStabList) + { + // arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465) + foreach (uint id in start.VisibleCellIds) + if (PointInCell(cache.GetCellStruct(id), worldPoint)) return id; + } + else + { + // arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434) + foreach (var portal in start.Portals) + if (PointInCell(cache.GetCellStruct(portal.OtherCellId), worldPoint)) return portal.OtherCellId; + } + + return 0u; + } + + /// + /// CEnvCell::point_in_cell (cell-BSP vtable[0x84]) against a world point: + /// transform to the cell's local frame, then . + /// A cell with no hydrated returns false (see + /// 's adaptation note). + /// + private static bool PointInCell(CellPhysics? cell, Vector3 worldPoint) + { + if (cell?.CellBSP?.Root is null) return false; + var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform); + return BSPQuery.PointInsideCellBsp(cell.CellBSP.Root, local); + } + /// /// Top-level cell-tracking driver, ported from retail's /// CObjCell::find_cell_list (sphere variant). diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index dba7fc04..68d5d4f6 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -397,6 +397,55 @@ public sealed class PhysicsEngine return fallbackCellId; } + /// + /// Verbatim port of CPhysicsObj::AdjustPosition + /// (acclient_2013_pseudo_c.txt:280009): resolve which cell actually + /// contains , given a seed cell. Indoor + /// (objcell_id ≥ 0x100, :280020) → + /// in stab-list mode (retail arg5 = 1, :280028); outdoor (:280050) → + /// snap to the landcell under the point (retail LandDefs::adjust_to_outside, + /// the same grid lookup uses). Returns + /// found = false with the seed id unchanged when no cell resolves + /// (retail return 0, :280065). + /// + /// + /// SmartBox::update_viewer calls this to seat the camera sweep's start + /// cell at the head-pivot (:280032, indoor branch only) and again as fallback 1 + /// at the sought eye (:280078). Retail's indoor seen_outside → + /// adjust_to_outside sub-fallback (:280037-280046) is deferred — not on the + /// cottage/cellar camera path (see the design spec §6). + /// + /// + public (uint cellId, bool found) AdjustPosition(uint seedCellId, Vector3 worldPoint) + { + if (seedCellId == 0u) return (seedCellId, false); + + if ((seedCellId & 0xFFFFu) >= 0x0100u) + { + // Indoor: find_visible_child_cell(this, point, arg3 = 1) (:280028). + if (DataCache is null) return (seedCellId, false); + uint child = CellTransit.FindVisibleChildCell(DataCache, seedCellId, worldPoint, useStabList: true); + return child != 0u ? (child, true) : (seedCellId, false); + } + + // Outdoor: LandDefs::adjust_to_outside — snap to the landcell under the + // point (same grid lookup as ResolveCellId, lines 363-371). No building + // re-entry here: AdjustPosition's outdoor branch is the bare landcell snap. + foreach (var kvp in _landblocks) + { + var lb = kvp.Value; + float localX = worldPoint.X - lb.WorldOffsetX; + float localY = worldPoint.Y - lb.WorldOffsetY; + if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) + { + uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); + return ((kvp.Key & 0xFFFF0000u) | lowCellId, true); + } + } + + return (seedCellId, false); + } + /// /// Resolve an entity's movement from by /// applying (XY only) and computing the correct Z diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs new file mode 100644 index 00000000..a5613fb6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindVisibleChildCellTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Render Residual A — verbatim port of CEnvCell::find_visible_child_cell +/// (acclient_2013_pseudo_c.txt:311397): given a start cell, a world point, +/// and a mode, return the cell whose cell-BSP point_in_cell contains the +/// point — checking the start cell itself, then (stab-list mode) the start's +/// VisibleCellIds or (portal mode) its direct portal neighbours. +/// +/// +/// This is the sibling of (retail +/// find_cell_list); both resolve cell membership from the cell graph. The +/// camera's SmartBox::update_viewer start-cell uses the stab-list mode +/// (AdjustPosition at pc:280028 passes arg5=1) to seat the sweep at +/// the PIVOT's cell, which differs from the feet cell at a low connector (the +/// cellar lip), where the pivot is up at floor level in a different cell. +/// +/// +/// +/// Geometry is identity-transform (cell-local == world) so the synthetic CellBSP +/// splitting planes read directly: cell A is the half-space Y≤3, cell B (in A's +/// stab list) is the half-space Y≥7, and Y∈(3,7) belongs to neither. +/// +/// +public class CellTransitFindVisibleChildCellTests +{ + private const uint StartCellId = 0xA9B40174u; // low 0x0174 ≥ 0x0100 → indoor + private const uint SiblingCellId = 0xA9B40171u; // the "room above" in StartCell's stab list + + [Fact] + public void PointInsideStartCell_ReturnsStartCell() + { + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(StartCellId, MakeCell(InteriorYAtMost(3f), new uint[] { SiblingCellId })); + cache.RegisterCellStructForTest(SiblingCellId, MakeCell(InteriorYAtLeast(7f), Array.Empty())); + + // P at Y=1 is inside A (Y≤3) → the "this" branch returns the start cell. + uint result = CellTransit.FindVisibleChildCell(cache, StartCellId, new Vector3(0f, 1f, 0f), useStabList: true); + + Assert.Equal(StartCellId, result); + } + + [Fact] + public void PointInStabListSibling_ReturnsSibling() + { + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(StartCellId, MakeCell(InteriorYAtMost(3f), new uint[] { SiblingCellId })); + cache.RegisterCellStructForTest(SiblingCellId, MakeCell(InteriorYAtLeast(7f), Array.Empty())); + + // P at Y=8 is outside A (Y≤3) but inside B (Y≥7), and B is in A's stab list. + uint result = CellTransit.FindVisibleChildCell(cache, StartCellId, new Vector3(0f, 8f, 0f), useStabList: true); + + Assert.Equal(SiblingCellId, result); + } + + [Fact] + public void PointInNoCell_ReturnsZero() + { + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(StartCellId, MakeCell(InteriorYAtMost(3f), new uint[] { SiblingCellId })); + cache.RegisterCellStructForTest(SiblingCellId, MakeCell(InteriorYAtLeast(7f), Array.Empty())); + + // P at Y=5 is in the gap: outside A (Y≤3) and outside B (Y≥7). + uint result = CellTransit.FindVisibleChildCell(cache, StartCellId, new Vector3(0f, 5f, 0f), useStabList: true); + + Assert.Equal(0u, result); + } + + [Fact] + public void UnknownStartCell_ReturnsZero() + { + var cache = new PhysicsDataCache(); + uint result = CellTransit.FindVisibleChildCell(cache, 0xDEADBEEFu, new Vector3(0f, 1f, 0f), useStabList: true); + Assert.Equal(0u, result); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + /// CellBSP root for the half-space Y ≤ + /// (interior on the −Y side; point_in_cell true when Y ≤ boundary). + private static CellBSPNode InteriorYAtMost(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), boundary), // dist = boundary − Y ≥ 0 ⇔ Y ≤ boundary + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + /// CellBSP root for the half-space Y ≥ . + private static CellBSPNode InteriorYAtLeast(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, 1f, 0f), -boundary), // dist = Y − boundary ≥ 0 ⇔ Y ≥ boundary + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + private static CellPhysics MakeCell(CellBSPNode cellBspRoot, uint[] visibleCellIds) => new() + { + BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree { Root = cellBspRoot }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(visibleCellIds), + }; +} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs new file mode 100644 index 00000000..631ac435 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineAdjustPositionTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Render Residual A — verbatim port of CPhysicsObj::AdjustPosition +/// (acclient_2013_pseudo_c.txt:280009): resolve which cell actually +/// contains a world point, given a seed cell. Indoor (objcell_id ≥ 0x100) +/// delegates to (stab-list mode, +/// retail arg5 = 1); outdoor snaps to the landcell under the point +/// (retail LandDefs::adjust_to_outside). SmartBox::update_viewer +/// uses it to seat the camera sweep's start cell at the head-pivot, and again +/// as fallback 1 at the sought eye. +/// +public class PhysicsEngineAdjustPositionTests +{ + private const uint FeetCellId = 0xA9B40174u; // indoor connector (the cellar lip) + private const uint RoomCellId = 0xA9B40171u; // indoor room above, in the feet cell's stab list + private const uint LandblockId = 0xA9B40000u; + + [Fact] + public void Indoor_PivotInStabListSibling_ResolvesSiblingAndFound() + { + var engine = BuildIndoorEngine(); + + // Pivot at Y=8 is outside the feet cell (Y≤3) but inside the room cell (Y≥7). + var (cellId, found) = engine.AdjustPosition(FeetCellId, new Vector3(0f, 8f, 0f)); + + Assert.True(found); + Assert.Equal(RoomCellId, cellId); + } + + [Fact] + public void Indoor_PointInNoCell_NotFound() + { + var engine = BuildIndoorEngine(); + + // Y=5 is in the gap between the feet cell (Y≤3) and the room cell (Y≥7). + var (_, found) = engine.AdjustPosition(FeetCellId, new Vector3(0f, 5f, 0f)); + + Assert.False(found); + } + + [Fact] + public void Outdoor_PointInLandblock_SnapsToLandcell() + { + var engine = BuildIndoorEngine(); // also registers the landblock + + // Outdoor seed (low byte < 0x100). A point inside the landblock snaps to a + // prefixed landcell with found=true (retail LandDefs::adjust_to_outside). + var (cellId, found) = engine.AdjustPosition(0xA9B40001u, new Vector3(12f, 12f, 50f)); + + Assert.True(found); + Assert.Equal(LandblockId, cellId & 0xFFFF0000u); // correct landblock prefix + Assert.True((cellId & 0xFFFFu) < 0x0100u); // an outdoor landcell low byte + } + + // ── fixture ──────────────────────────────────────────────────────────── + + private static PhysicsEngine BuildIndoorEngine() + { + var cache = new PhysicsDataCache(); + var engine = new PhysicsEngine { DataCache = cache }; + + cache.RegisterCellStructForTest(FeetCellId, MakeCell(InteriorYAtMost(3f), new uint[] { RoomCellId })); + cache.RegisterCellStructForTest(RoomCellId, MakeCell(InteriorYAtLeast(7f), Array.Empty())); + + // A flat stub landblock so the outdoor branch has a grid to snap to. + var heights = new byte[81]; + var heightTable = new float[256]; + engine.AddLandblock( + landblockId: LandblockId, + terrain: new TerrainSurface(heights, heightTable), + cells: Array.Empty(), + portals: Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + return engine; + } + + private static CellBSPNode InteriorYAtMost(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), boundary), + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + private static CellBSPNode InteriorYAtLeast(float boundary) => new() + { + SplittingPlane = new Plane(new Vector3(0f, 1f, 0f), -boundary), + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }; + + private static CellPhysics MakeCell(CellBSPNode cellBspRoot, uint[] visibleCellIds) => new() + { + BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree { Root = cellBspRoot }, + Portals = Array.Empty(), + PortalPolygons = new Dictionary(), + VisibleCellIds = new HashSet(visibleCellIds), + }; +}