feat(A): port find_visible_child_cell + AdjustPosition (Render Residual A primitives)
The two Core physics primitives retail's SmartBox::update_viewer calls down into, ported verbatim (TDD, 7 new tests): - CellTransit.FindVisibleChildCell (CEnvCell::find_visible_child_cell, pc:311397): return the cell whose cell-BSP point_in_cell contains a world point — start cell first, then (stab-list mode) the start's VisibleCellIds or (portal mode) its direct portals. Sibling of FindCellList. Mirrors FindCellList's null-CellBSP skip (CellTransit.cs:518) so a cell lacking hydrated CellBSP doesn't spuriously claim every point via PointInsideCellBsp's null-node "inside" default. - PhysicsEngine.AdjustPosition (CPhysicsObj::AdjustPosition, pc:280009): resolve a point's cell from a seed. Indoor (>=0x100) → FindVisibleChildCell(stab-list); outdoor → landcell snap (same grid lookup as ResolveCellId). The seen_outside sub-fallback is deferred (off the cottage/cellar path; spec §6). Both are unwired into any production path — they land the machinery update_viewer's start-cell + fallback 1 need (and that residual C also needs). The App SweepEye orchestration that calls them lands next. Decomp-faithful per the live-capture finding: A's V1 sweep already contains the eye (eyeInRoot=Y 99.75%, never void); this completes A as a verbatim port. Spec: docs/superpowers/specs/2026-06-05-residual-a-camera-collision-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0ffc3f5be9
commit
5177b54bbe
4 changed files with 336 additions and 0 deletions
|
|
@ -343,6 +343,69 @@ public static class CellTransit
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim port of <c>CEnvCell::find_visible_child_cell</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:311397</c>). Returns the cell whose cell-BSP
|
||||
/// <c>point_in_cell</c> contains <paramref name="worldPoint"/>, checking the
|
||||
/// start cell first (:311402), then — when <paramref name="useStabList"/> is
|
||||
/// true (retail <c>arg3 != 0</c>, :311444) — the start's <c>stab_list</c>
|
||||
/// (<see cref="CellPhysics.VisibleCellIds"/>), else (<c>arg3 == 0</c>, :311411)
|
||||
/// its direct portal neighbours. Returns 0 when no cell contains the point
|
||||
/// (retail <c>return 0</c> at :311469).
|
||||
///
|
||||
/// <para>
|
||||
/// Sibling of <see cref="FindCellList"/> (retail <c>find_cell_list</c>) — both
|
||||
/// resolve membership from the cell graph via <see cref="BSPQuery.PointInsideCellBsp"/>.
|
||||
/// Used by <c>CPhysicsObj::AdjustPosition</c> (pc:280028, <c>arg5 = 1</c> →
|
||||
/// stab-list mode) to seat the camera sweep's start cell at the head-pivot.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// acdream adaptation (matches <see cref="FindCellList"/> at line 518): a cell
|
||||
/// with no hydrated <see cref="CellPhysics.CellBSP"/> cannot run
|
||||
/// <c>point_in_cell</c>, so it is treated as NOT containing the point (skipped),
|
||||
/// rather than letting <see cref="BSPQuery.PointInsideCellBsp"/>'s null-node
|
||||
/// "inside" default make it spuriously claim every point.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CEnvCell::point_in_cell</c> (cell-BSP vtable[0x84]) against a world point:
|
||||
/// transform to the cell's local frame, then <see cref="BSPQuery.PointInsideCellBsp"/>.
|
||||
/// A cell with no hydrated <see cref="CellPhysics.CellBSP"/> returns false (see
|
||||
/// <see cref="FindVisibleChildCell"/>'s adaptation note).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top-level cell-tracking driver, ported from retail's
|
||||
/// <c>CObjCell::find_cell_list</c> (sphere variant).
|
||||
|
|
|
|||
|
|
@ -397,6 +397,55 @@ public sealed class PhysicsEngine
|
|||
return fallbackCellId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim port of <c>CPhysicsObj::AdjustPosition</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:280009</c>): resolve which cell actually
|
||||
/// contains <paramref name="worldPoint"/>, given a seed cell. Indoor
|
||||
/// (<c>objcell_id ≥ 0x100</c>, :280020) → <see cref="CellTransit.FindVisibleChildCell"/>
|
||||
/// in stab-list mode (retail <c>arg5 = 1</c>, :280028); outdoor (:280050) →
|
||||
/// snap to the landcell under the point (retail <c>LandDefs::adjust_to_outside</c>,
|
||||
/// the same grid lookup <see cref="ResolveCellId"/> uses). Returns
|
||||
/// <c>found = false</c> with the seed id unchanged when no cell resolves
|
||||
/// (retail <c>return 0</c>, :280065).
|
||||
///
|
||||
/// <para>
|
||||
/// <c>SmartBox::update_viewer</c> 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 <c>seen_outside →
|
||||
/// adjust_to_outside</c> sub-fallback (:280037-280046) is deferred — not on the
|
||||
/// cottage/cellar camera path (see the design spec §6).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an entity's movement from <paramref name="currentPos"/> by
|
||||
/// applying <paramref name="delta"/> (XY only) and computing the correct Z
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Render Residual A — verbatim port of <c>CEnvCell::find_visible_child_cell</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:311397</c>): given a start cell, a world point,
|
||||
/// and a mode, return the cell whose cell-BSP <c>point_in_cell</c> contains the
|
||||
/// point — checking the start cell itself, then (stab-list mode) the start's
|
||||
/// <c>VisibleCellIds</c> or (portal mode) its direct portal neighbours.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the sibling of <see cref="CellTransit.FindCellList"/> (retail
|
||||
/// <c>find_cell_list</c>); both resolve cell membership from the cell graph. The
|
||||
/// camera's <c>SmartBox::update_viewer</c> start-cell uses the stab-list mode
|
||||
/// (<c>AdjustPosition</c> at pc:280028 passes <c>arg5=1</c>) 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<uint>()));
|
||||
|
||||
// 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<uint>()));
|
||||
|
||||
// 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<uint>()));
|
||||
|
||||
// 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>CellBSP root for the half-space Y ≤ <paramref name="boundary"/>
|
||||
/// (interior on the −Y side; <c>point_in_cell</c> true when Y ≤ boundary).</summary>
|
||||
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 },
|
||||
};
|
||||
|
||||
/// <summary>CellBSP root for the half-space Y ≥ <paramref name="boundary"/>.</summary>
|
||||
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<ushort, ResolvedPolygon>(),
|
||||
CellBSP = new CellBSPTree { Root = cellBspRoot },
|
||||
Portals = Array.Empty<PortalInfo>(),
|
||||
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
VisibleCellIds = new HashSet<uint>(visibleCellIds),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Render Residual A — verbatim port of <c>CPhysicsObj::AdjustPosition</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:280009</c>): resolve which cell actually
|
||||
/// contains a world point, given a seed cell. Indoor (<c>objcell_id ≥ 0x100</c>)
|
||||
/// delegates to <see cref="CellTransit.FindVisibleChildCell"/> (stab-list mode,
|
||||
/// retail <c>arg5 = 1</c>); outdoor snaps to the landcell under the point
|
||||
/// (retail <c>LandDefs::adjust_to_outside</c>). <c>SmartBox::update_viewer</c>
|
||||
/// uses it to seat the camera sweep's start cell at the head-pivot, and again
|
||||
/// as fallback 1 at the sought eye.
|
||||
/// </summary>
|
||||
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<uint>()));
|
||||
|
||||
// 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<CellSurface>(),
|
||||
portals: Array.Empty<PortalPlane>(),
|
||||
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<ushort, ResolvedPolygon>(),
|
||||
CellBSP = new CellBSPTree { Root = cellBspRoot },
|
||||
Portals = Array.Empty<PortalInfo>(),
|
||||
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
|
||||
VisibleCellIds = new HashSet<uint>(visibleCellIds),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue