acdream/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
Erik 28cd97be62 fix(phys): A6.P4 door bug — AddAllOutsideCells coord convention + replay apparatus
CellTransit.AddAllOutsideCells assumed sphere coords were absolute world
coords (subtracting lbXf = 0xA9 * 192 = 32448 from the sphere position).
Production has used landblock-local coords since Phase A.1
(streaming-center landblock at world origin), so the subtraction
produced localX = -32316, gridX = -1346 → out-of-range → early return
→ ZERO outdoor cells added.

For outdoor primary cells the bug was masked by GetNearbyObjects's
radial sweep. For indoor primary cells (where #98 gates the outdoor
sweep), the door's outdoor cell 0xA9B40029 never reached
portalReachableCells, the door's BSP was never queried, and the player
walked through Holtburg cottage doors unimpeded.

Fix: AddAllOutsideCells treats worldSphereCenter as landblock-local
directly. Matches retail CLandCell::add_all_outside_cells which uses
the per-cell 6-byte landblock-relative position struct.

Existing CellTransitAddAllOutsideCellsTests + CellTransitFindCellSetTests
updated to use landblock-local sphere coords (they were the only callers
using the world-coord convention; production never did).

Apparatus shipped:
- DoorBugTrajectoryReplayTests — live-capture-driven replay harness
  that pinpointed the bug per-field at unit-test speed (<500ms iteration)
- AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell — direct
  unit test that demonstrates the fix
- FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos
  — verifies cell-portal traversal at the captured sphere position
- DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection
  — dat-direct EnvCell + Environment.Cells + portal-poly inspector
- Fixture: tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl
  (tick 13558 walkthrough + tick 22760 outdoor block)

Visual verification (user-driven at Holtburg cottage door, ~50cm off-center):
- outside→inside RUN: now BLOCKS (was: walks through)
- outside→inside WALK: presumed blocks (not retested)
- inside→outside RUN: PARTIAL — body intersects door, sphere slides through
- inside→outside WALK: same partial behavior

The remaining inside→outside asymmetry is a SEPARATE bug in BSP
collision response for two-sided polygons. The [bsp-test] probe now
fires 245 times for the door entity from indoor (was 0 pre-fix) —
door IS being queried; the BSP polygon-level collision response is
the new bug. Handoff at
docs/research/2026-05-25-door-bug-partial-fix-shipped.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:53:34 +02:00

161 lines
6.5 KiB
C#

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();
// 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);
}
}