acdream/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
Erik 02f4be72c0 fix(teleport D): cell-march preserves seed landblock id when no resident LB (no more lbX=0 outbound)
BuildCellSetAndPickContaining discarded the bool from TryGetTerrainOrigin — when
the current landblock's terrain hadn't been applied yet (priority-apply in flight
after a teleport or dungeon exit), blockOrigin was silently set to (0,0,0). The
AdjustToOutside/GetOutsideLcoord math treated world-frame sphere coordinates as
block-local and marched the cell one landblock per tick in the direction of movement
until lbX or lbY underflowed to 0x00. ACE rejected every subsequent move as a
failed transition.

Fix: honor the bool return. When terrain is unregistered for an OUTDOOR seed
(low < 0x0100), return currentCellId verbatim — "no block-local frame →
preserve". This mirrors the NO-LANDBLOCK verbatim contract in PhysicsEngine.Resolve
and is correct: the cell stays last-known-correct until terrain registers.
Indoor seeds are explicitly excluded (blockOrigin is never consumed by the indoor
pick path; outdoorPickAllowed=false for indoor seeds).

Reproduce + verify via CellMarchLandblockPreservationTests (two new FAILING-before
tests: WestEdge and SouthEdge with empty cache, no anchor → lbX/lbY preserved).
TeleportFarTownRunawayTests updated: no-anchor path now also preserves (pre-fix it
marched south to 0x59; post-fix returns currentCell unchanged).
CellTransitFindCellSetTests, Issue112MembershipTests, PhysicsEngineTests: added
RegisterTerrain for the streaming-center block (in production it is always resident
before outdoor resolves run; tests that used blockOrigin=(0,0,0) as an implicit
fallback now register the block explicitly). All 1567 tests pass.

Divergence AD-30 added to retail-divergence-register.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:38:48 +02:00

430 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class CellTransitFindCellSetTests
{
// ──────────────────────────────────────────────────────────────────
// Helpers — mirror CellTransitFindTransitCellsSphereTests.cs pattern
// ──────────────────────────────────────────────────────────────────
private static CellPhysics MakeCellWithPortalAtRightWall(
Matrix4x4 worldTransform, uint otherCellId, ushort flags)
{
var portalPoly = 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 = CullMode.None,
};
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [10] = portalPoly },
Portals = new[]
{
new PortalInfo(otherCellId: (ushort)otherCellId, polygonId: 10, flags: flags),
},
CellBSP = new CellBSPTree
{
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
}
};
}
// ──────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────
[Fact]
public void Sphere_FullyInsidePrimaryCell_ReturnsOnlyPrimary()
{
var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0);
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
// Sphere far from any portal — local x=-1, reach to x=-0.5; portal at x=2.5.
var sphereCenter = new Vector3(-1.0f, 0f, 2.5f);
uint containing = CellTransit.FindCellSet(
cache, sphereCenter, sphereRadius: 0.5f,
currentCellId: 0xA9B40100u,
out var cellSet);
Assert.Equal(0xA9B40100u, containing);
Assert.Single(cellSet);
Assert.Contains(0xA9B40100u, cellSet);
}
[Fact]
public void Sphere_StraddlingPortal_ReturnsBothCells()
{
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<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree
{
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
}
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
cache.RegisterCellStructForTest(0xA9B40101u, cellB);
// Sphere center at local x=2.0, radius=0.5 → reaches x=2.5 = portal plane.
var sphereCenter = new Vector3(2.0f, 0f, 2.5f);
uint containing = CellTransit.FindCellSet(
cache, sphereCenter, sphereRadius: 0.5f,
currentCellId: 0xA9B40100u,
out var cellSet);
Assert.Contains(0xA9B40100u, cellSet);
Assert.Contains(0xA9B40101u, cellSet);
}
[Fact]
public void FindCellSet_OutdoorSeed_IncludesNeighbourLandcells()
{
var cache = new PhysicsDataCache();
// #145 D: register A9B4 at origin (0,0) — in production the streaming-center
// landblock is always registered before any outdoor physics resolve runs.
cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
// A6.P4 (2026-05-24): sphere coords are LANDBLOCK-LOCAL (X/Y in
// [0, 192]). Place the sphere center near the east boundary of
// landcell grid(0,0) (i.e., near local X=24) so AddAllOutsideCells
// adds the east neighbour grid(1,0).
var sphereCenter = new Vector3(23.8f, 12f, 0f);
uint containing = CellTransit.FindCellSet(
cache, sphereCenter, sphereRadius: 0.5f,
currentCellId: 0xA9B40001u, // outdoor cell, low byte < 0x100
out var cellSet);
Assert.Equal(0xA9B40001u, containing);
Assert.True(cellSet.Count >= 2, $"Expected ≥2 cells in set (primary + east neighbour), got {cellSet.Count}");
}
[Fact]
public void IndoorSeed_ExitPortalTouchedOnlyBySecondSphere_AddsOutdoorLandcell()
{
// A6.P4 (2026-05-24): landblock-local sphere coords. Cell sits at
// the landblock origin (identity transform), so cell-local == world.
// Sphere head at local (2, 12, 3.2) reaches the cell's exit portal
// plane at local X=2.5 → AddAllOutsideCells fires.
var exitCell = MakeCellWithPortalAtRightWall(
Matrix4x4.Identity,
otherCellId: 0xFFFF,
flags: 0);
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, exitCell);
var spheres = new[]
{
// Foot sphere is not near the exit portal plane at local x=2.5.
new Sphere { Origin = new Vector3(0f, 12f, 2.5f), Radius = 0.5f },
// Head sphere reaches the exit portal plane and should trigger
// outdoor landcell expansion.
new Sphere { Origin = new Vector3(2f, 12f, 3.2f), Radius = 0.5f },
};
uint containing = CellTransit.FindCellSet(
cache, spheres, spheres.Length,
currentCellId: 0xA9B40100u,
out var cellSet);
Assert.Equal(0xA9B40100u, containing);
Assert.Contains(0xA9B40100u, cellSet);
Assert.Contains(0xA9B40001u, cellSet);
}
// ──────────────────────────────────────────────────────────────────
// #106 — outdoor membership across landblock boundaries.
// Retail's add_all_outside_cells + the find_cell_list pick run in the
// GLOBAL landcell grid (LandDefs lcoords); crossing a landblock boundary
// is inherent. The pre-#106 port clamped both to the current block's 8×8
// grid → zero candidates one step over the line → membership frozen
// (the 10,449-frame playerCell freeze in flap-105-capture.log).
// ──────────────────────────────────────────────────────────────────
[Fact]
public void OutdoorSeed_CrossesLandblockBoundary_South()
{
// The #106 acceptance golden: walking south out of A9B4, the outdoor
// cell must advance to the southern neighbour block's cell. Origin of
// A9B4 is Zero (streaming center). #145 D: register A9B4 at (0,0) so
// TryGetTerrainOrigin succeeds (in production the streaming-center
// landblock is always resident before any outdoor physics resolve).
// world y = -0.2 is 0.2 m into A9B3's row 7 under x=150 → cell 0xA9B30038.
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40031u,
out var cellSet);
Assert.Equal(0xA9B30038u, containing);
Assert.Contains(0xA9B30038u, cellSet);
Assert.Contains(0xA9B40031u, cellSet); // +Y neighbour still in the set
}
[Fact]
public void OutdoorSeed_NearBoundaryButInside_StaysCurrent()
{
// 0.2 m NORTH of the boundary: the candidate set includes the A9B3
// neighbour (sphere overlaps it) but the centre column is still the
// current cell — membership must NOT flip early (single clean flip
// at the line, matching the capture's 96/96 within-block behaviour).
// #145 D: register A9B4 at (0,0) — streaming-center block is always resident.
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, 0.2f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40031u,
out var cellSet);
Assert.Equal(0xA9B40031u, containing);
Assert.Contains(0xA9B30038u, cellSet);
}
[Fact]
public void OutdoorSeed_NonAnchorBlock_UsesRegisteredTerrainOrigin()
{
// Northbound return: the player's current cell is in A9B3 (origin
// (0, -192) registered via CellGraph terrain — the production path).
// World y = +1 is 1 m back into the anchor block A9B4; the pick must
// convert through A9B3's origin (block-local y = 193) and advance to
// 0xA9B40031. Pre-#106 the world frame was silently assumed
// block-local, which is wrong for every non-anchor block.
var cache = new PhysicsDataCache();
cache.CellGraph.RegisterTerrain(
0xA9B30000u,
new TerrainSurface(new byte[81], new float[256]),
new Vector3(0f, -192f, 0f));
uint containing = CellTransit.FindCellSet(
cache, new Vector3(150f, 1f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B30038u,
out _);
Assert.Equal(0xA9B40031u, containing);
}
// ──────────────────────────────────────────────────────────────────
// #106 gate-2 escape hatch — bogus indoor claim recovery.
// Restores the #83/A1.7 + #90 verification (formerly in ResolveCellId,
// lost in the collide-then-pick rewrite): a HYDRATED indoor current
// cell whose CellBSP no longer overlaps ANY part of the foot sphere is
// a bogus claim (corrupt server save pairing an indoor cell with a
// position far outside it, or walked out through an unblocked gap).
// Staying on it wedges everything: the BFS can't reach an exit portal
// from a cell the sphere isn't in (no outdoor candidates, membership
// frozen), GetNearbyObjects' #98 gate reads "indoor primary" (no
// object collision), no wall BSP and no terrain (void fall). The pick
// must demote to the outdoor column under the sphere centre.
// Sphere-overlap (not point-in) preserves the #90 doorway-pushback
// hysteresis.
// ──────────────────────────────────────────────────────────────────
/// <summary>Cell whose CellBSP half-space is x ≥ 0 (cell-local).</summary>
private static CellPhysics MakeCellWithBoundedBsp(Matrix4x4 worldTransform)
{
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree
{
Root = new CellBSPNode
{
SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f),
PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
},
}
};
}
[Fact]
public void IndoorSeed_SphereFullyOutsideHydratedCell_KeepsCurrent_RetailNullResult()
{
// #112 (2026-06-10): REWRITTEN from the 6dbbf95 escape-hatch
// assertion (was: demote to the outdoor column 0xA8B40039). Retail
// find_cell_list leaves *result null when NOTHING contains the centre
// and the caller KEEPS curr_cell (pc:308788-308825) — the hatch's
// demote was a non-retail addition that also fired on legitimate
// sub-meter containment gaps INSIDE houses (the A9B3 cottage),
// stranding the player outdoor-classified deep indoors → transparent
// interior. The hatch's actual target (poisoned saves) is handled at
// the SNAP by PhysicsEngine.Resolve's AdjustPosition validation
// (#107/#111). With no stab-list on this fixture cell, the lateral
// recovery finds nothing → keep the claim.
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40150u, MakeCellWithBoundedBsp(Matrix4x4.Identity));
uint containing = CellTransit.FindCellSet(
cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40150u,
out _);
Assert.Equal(0xA9B40150u, containing);
}
[Fact]
public void IndoorSeed_SphereStraddlesCellBoundary_StaysCurrent()
{
// #90 hysteresis guard: centre just outside the half-space (x=-0.3)
// but the 0.5 radius still reaches back in → sphere OVERLAPS → the
// claim is legitimate (doorway push-back shape) → NO demotion.
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40150u, MakeCellWithBoundedBsp(Matrix4x4.Identity));
uint containing = CellTransit.FindCellSet(
cache, new Vector3(-0.3f, 12f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40150u,
out _);
Assert.Equal(0xA9B40150u, containing);
}
[Fact]
public void IndoorSeed_CellWithoutBsp_CannotVerify_StaysCurrent()
{
// Stale-beats-null while streaming hydrates: a registered cell with
// no CellBSP yet cannot be verified — trust the claim (no demotion).
Matrix4x4.Invert(Matrix4x4.Identity, out var inv);
var cellNoBsp = new CellPhysics
{
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40150u, cellNoBsp);
uint containing = CellTransit.FindCellSet(
cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f,
currentCellId: 0xA9B40150u,
out _);
Assert.Equal(0xA9B40150u, containing);
}
// ──────────────────────────────────────────────────────────────────
// Membership hysteresis — the R1-flap root cause.
// Retail CObjCell::find_cell_list adds the CURRENT cell at index 0
// (add_cell, pc:308766) and the pick loop iterates from index 0 with
// interior-wins-break (pc:308791-308819) — so when two cells' BSPs both
// contain the sphere center at a boundary, the CURRENT cell wins and the
// membership does NOT flip. acdream's unordered-HashSet pick dropped that
// ordering, producing the stair/room/doorway ping-pong (cell flips every
// tick while straddling). This guards the current-cell-first behaviour.
// ──────────────────────────────────────────────────────────────────
[Theory]
[InlineData(0xA9B40100u, 0xA9B40101u)]
[InlineData(0xA9B40101u, 0xA9B40100u)]
public void TwoOverlappingCells_CurrentCellWinsTheStraddle(uint currentCellId, uint otherCellId)
{
// Both cells use a Leaf BSP (contains any point) and are reciprocally portalled at the same
// plane (local x=2.5), so the candidate set is {both} from EITHER seed and the sphere center
// is inside BOTH cells' BSPs. The pick must return the CURRENT cell (hysteresis), not flip to
// the neighbour. Testing both seed directions guarantees a deterministic failure on the
// unordered pick (it returns the same enumeration-first cell regardless of which is current).
ushort currentLow = (ushort)(currentCellId & 0xFFFF);
ushort otherLow = (ushort)(otherCellId & 0xFFFF);
var current = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherLow, flags: 0);
var other = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, currentLow, flags: 0);
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(currentCellId, current);
cache.RegisterCellStructForTest(otherCellId, other);
// Center straddles the portal plane at local x=2.5 (both cells), inside both Leaf BSPs.
var sphereCenter = new Vector3(2.0f, 0f, 2.5f);
uint containing = CellTransit.FindCellSet(
cache, sphereCenter, sphereRadius: 0.5f,
currentCellId: currentCellId,
out var cellSet);
Assert.Contains(currentCellId, cellSet);
Assert.Contains(otherCellId, cellSet);
Assert.Equal(currentCellId, containing); // retail current-cell-first hysteresis
}
// The ordered-CELLARRAY contract: FindCellSet returns the candidate set in
// retail add-order with the CURRENT cell at index 0 (retail add_cell @308766).
// This is the invariant the verbatim pick relies on; the unordered HashSet
// could not guarantee it.
[Fact]
public void FindCellSet_CurrentCellIsFirstInTheSet()
{
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<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } },
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
cache.RegisterCellStructForTest(0xA9B40101u, cellB);
// Straddle the portal plane so both cells are in the set.
var sphereCenter = new Vector3(2.0f, 0f, 2.5f);
CellTransit.FindCellSet(cache, sphereCenter, 0.5f, 0xA9B40100u, out var cellSet);
Assert.Equal(0xA9B40100u, cellSet.First()); // current cell at index 0
}
// Interior-wins over the outdoor fallback: while an interior cell still
// contains the centre, it wins even though the exit portal also added the
// outdoor landcell to the set (retail interior-wins-break, pc:308814-308819).
[Fact]
public void IndoorWithExitPortal_InteriorWinsWhileItContainsCentre()
{
// Interior cell at the landblock origin with an exit portal at local x=2.5;
// Leaf BSP contains any point. Centre at local (0,12,2.5) is INSIDE the cell
// and NOT across the exit plane, so interior must win even though the head
// sphere / exit logic may add the outdoor landcell.
var exitCell = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0xFFFF, flags: 0);
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, exitCell);
var sphereCenter = new Vector3(0f, 12f, 2.5f);
uint containing = CellTransit.FindCellSet(cache, sphereCenter, 0.5f, 0xA9B40100u, out _);
Assert.Equal(0xA9B40100u, containing); // interior-wins, not the outdoor landcell
}
}