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