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>
This commit is contained in:
Erik 2026-05-25 07:53:34 +02:00
parent 6a2c432e5a
commit 28cd97be62
8 changed files with 1134 additions and 40 deletions

View file

@ -110,21 +110,11 @@ public class CellTransitFindCellSetTests
public void FindCellSet_OutdoorSeed_IncludesNeighbourLandcells()
{
var cache = new PhysicsDataCache();
// Outdoor seed near a cell boundary — expand to neighbours via
// AddAllOutsideCells. Landcells have no CellPhysics in cache, so
// they appear in the set but the containing-cell loop falls back
// to currentCellId. The point of this test: the SET captures
// them even though FindCellList's single-uint return cannot.
//
// World coords for landblock 0xA9B4FFFF: origin at
// (0xA9*192, 0xB4*192) = (32448, 34560). Cell grid(0,0) covers
// world XY in [(32448,34560), (32472,34584)). Place the sphere
// center near the east boundary of grid(0,0) so AddAllOutsideCells
// 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).
uint lbPrefix = 0xA9B40000u;
float lbX = ((lbPrefix >> 24) & 0xFFu) * 192f;
float lbY = ((lbPrefix >> 16) & 0xFFu) * 192f;
var sphereCenter = new Vector3(lbX + 23.8f, lbY + 12f, 0f);
var sphereCenter = new Vector3(23.8f, 12f, 0f);
uint containing = CellTransit.FindCellSet(
cache, sphereCenter, sphereRadius: 0.5f,
@ -138,17 +128,12 @@ public class CellTransitFindCellSetTests
[Fact]
public void IndoorSeed_ExitPortalTouchedOnlyBySecondSphere_AddsOutdoorLandcell()
{
// Retail CObjCell::find_cell_list passes every SPHEREPATH sphere into
// CEnvCell::find_transit_cells. When any sphere straddles an outdoor
// exit portal, CLandCell::add_all_outside_cells runs for the whole
// sphere array.
uint lbPrefix = 0xA9B40000u;
float lbX = ((lbPrefix >> 24) & 0xFFu) * 192f;
float lbY = ((lbPrefix >> 16) & 0xFFu) * 192f;
var cellTransform = Matrix4x4.CreateTranslation(new Vector3(lbX, lbY, 0f));
// 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(
cellTransform,
Matrix4x4.Identity,
otherCellId: 0xFFFF,
flags: 0);
@ -158,10 +143,10 @@ public class CellTransitFindCellSetTests
var spheres = new[]
{
// Foot sphere is not near the exit portal plane at local x=2.5.
new Sphere { Origin = new Vector3(lbX + 0.0f, lbY + 12.0f, 2.5f), Radius = 0.5f },
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(lbX + 2.0f, lbY + 12.0f, 3.2f), Radius = 0.5f },
new Sphere { Origin = new Vector3(2f, 12f, 3.2f), Radius = 0.5f },
};
uint containing = CellTransit.FindCellSet(