test(phys): A6.P4 door inside-out — collision-geometry gap diagnosis

Added diagnostic apparatus that pinpoints the inside-out walkthrough
as a collision-geometry GAP, not a collision-detection bug.

New tests in DoorBugTrajectoryReplayTests:
- InsideOut_Tick3254_WithCottageWalls_ShouldBlock: hypothesis test that
  registered cottage GfxObj 0x01000A2B and replayed the captured tick.
  Cottage blocked sphere but with cn=(0,0,1) floor-cap normal, not a
  wall normal — first signal that cottage geometry near the sphere
  isn't a wall.
- Diagnostic_CottagePolys_NearWalkthroughPosition: dumps cottage polys
  near sphere XY=(133.655, 17.59) at any Z. Result: ZERO cottage
  polygons in that area. The cottage GfxObj has no geometry where the
  sphere walks through.

DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection
extended to dump cell 0xA9B40150's 4 physics polygons in world frame:
- floor (Z=94), ceiling (Z=96.5), west wall (X=131.6), east wall (X=133.5)
- All walls only span Y=[16.5, 17.1] — the small doorway alcove volume
- North of Y=17.1, no wall

Captured sphere at (133.655, 17.59) is 0.155 m east of cell east wall
AND 0.49 m north of the wall's Y range. No collision geometry exists
at that XY past Y=17.1. The collision representation has a gap that
the visual cottage covers with a wall.

Production capture confirms the diagnosis: cottage GfxObj fires
[bsp-test] 425 times during inside-out walking — visibility IS
correct post-AddAllOutsideCells fix. Door slab fires 245 times. But
the BSP queries find no polygon at (133.655, 17.6+, 94-95.20). The
slab's east face blocks WEST motion (cn=(+1,0,0) as captured), sphere
free to move +Y past it because no wall is there to block.

Three candidates for next-session investigation:
1. Different cottage GfxObj (Holtburg cottages may be multi-piece)
2. Landblock-baked stab static at the cottage exterior wall location
3. Cottage GfxObj's visual polygons wider than physics polygons (dat fact)

Cheapest next step: add LandblockStatics_DatInspection test that
loads LandBlockInfo 0xA9B4FFFE + iterates StaticObjects + prints
every entity at world XY in [131,135] x [16,19]. Reveals what other
entities live at the cottage doorway.

Full handoff: docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 08:27:52 +02:00
parent 85a164f4a8
commit da798b2071
3 changed files with 333 additions and 0 deletions

View file

@ -359,6 +359,157 @@ public class DoorBugTrajectoryReplayTests
/// tick.
/// </para>
/// </summary>
/// <summary>
/// A6.P4 inside-out bug investigation (2026-05-25 late evening) —
/// hypothesis test: the asymmetric inside-out walkthrough is the
/// sphere walking AROUND the door slab via the cottage wall area
/// east/west of the doorway opening. The cottage exterior walls
/// are part of GfxObj 0x01000A2B (the cottage building, same one
/// from issue #98's cellar floor cap). Issue #98's indoor-primary-cell
/// gate removed cottage-WALL visibility along with the cottage FLOOR
/// — too aggressive. From indoor primary cells, the cottage walls
/// adjacent to the doorway can't block the sphere.
///
/// <para>
/// This test reproduces the captured tick 3254 (sphere at
/// (133.655, 17.590, 94) in indoor cell 0xA9B40150, moving to
/// (133.549, 17.599, 94)) with the cottage GfxObj registered as
/// landblock-baked static. If, with the cottage walls visible, the
/// sphere is blocked from being at X=133.655 (which is OUTSIDE the
/// doorway opening, INSIDE the cottage wall geometry), the bug
/// is confirmed as #98's overly-aggressive gate.
/// </para>
/// </summary>
[Fact]
public void InsideOut_Tick3254_WithCottageWalls_ShouldBlock()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, cache) = BuildFaithfulDoorEngine(datDir);
// Add cottage GfxObj 0x01000A2B as landblock-baked static,
// mirroring production GameWindow.RegisterLiveEntityCollision's
// cellScope=0u (landblock-wide).
const uint CottageGfxId = 0x01000A2Bu;
const uint CottageEntityId = 0x00A9B479u; // matches issue #98 fixture id
var cottageFixturePath = Path.Combine(SolutionRoot(),
"tests", "AcDream.Core.Tests", "Fixtures", "issue98",
"0x01000A2B.gfxobj.json");
Assert.True(File.Exists(cottageFixturePath));
var cottageDump = GfxObjDumpSerializer.Read(cottageFixturePath);
var cottagePhysics = GfxObjDumpSerializer.Hydrate(cottageDump);
cache.RegisterGfxObjForTest(CottageGfxId, cottagePhysics);
engine.ShadowObjects.Register(
entityId: CottageEntityId,
gfxObjId: CottageGfxId,
worldPos: new Vector3(130.5f, 11.5f, 94.0f),
rotation: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI),
radius: cottagePhysics.BoundingSphere?.Radius ?? 14f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: DoorLandblockId,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
cellScope: 0u);
// Replay captured tick 3254 inputs exactly.
var currentPos = new Vector3(133.65524f, 17.58999f, 94f);
var targetPos = new Vector3(133.54903f, 17.599283f, 94f);
var (result, body) = ResolveAt(engine, currentPos, targetPos, 0xA9B40150u);
// Expected: cottage wall east of doorway blocks the sphere
// from being at X=133.655 (or, at minimum, blocks the +Y slide).
// Currently (per the user's report) the sphere walks past unimpeded.
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"Harness tick 3254 reply: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) " +
"cnValid={6} cell=0x{7:X8}",
result.Position.X, result.Position.Y, result.Position.Z,
result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z,
result.CollisionNormalValid, result.CellId));
// Document the production state via assertion: the sphere DID make
// Y motion (+0.009) at this tick (target.Y > input.Y). If the
// cottage wall blocks correctly, harness Y should stay at input Y
// (sphere fully blocked, can't move north past cottage wall).
// Currently this test demonstrates the bug shape.
Assert.True(result.Position.Y < targetPos.Y - 0.005f,
$"BUG REPRODUCTION: harness allowed Y motion ({result.Position.Y}) toward " +
$"target ({targetPos.Y}). Cottage wall should block sphere at X=133.655 " +
$"(0.095 m east of slab east edge). If this assertion FAILS, the cottage " +
$"wall is now blocking as expected — the #98 gate fix landed.");
}
/// <summary>
/// Diagnostic: dump cottage GfxObj 0x01000A2B polygons near world
/// position (133.655, 17.59, 94.5) — the sphere position where the
/// inside-out walkthrough happens. Identifies which cottage polys
/// are at sphere height in that area, to know whether walls / floors
/// / nothing.
/// </summary>
[Fact]
public void Diagnostic_CottagePolys_NearWalkthroughPosition()
{
var fixturePath = Path.Combine(SolutionRoot(),
"tests", "AcDream.Core.Tests", "Fixtures", "issue98",
"0x01000A2B.gfxobj.json");
var dump = GfxObjDumpSerializer.Read(fixturePath);
var physics = GfxObjDumpSerializer.Hydrate(dump);
// Cottage world transform: pos (130.5, 11.5, 94), rotation 180° Z.
var cottagePos = new Vector3(130.5f, 11.5f, 94.0f);
var cottageRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
// Failing sphere position: (133.655, 17.59, 94 .. 95.20)
// Sphere world AABB: X[133.175, 134.135], Y[17.110, 18.070], Z[94, 95.20]
var sphereCenterX = 133.655f;
var sphereCenterY = 17.59f;
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"Cottage GfxObj 0x01000A2B: {0} polys total, BS radius {1:F3}",
physics.Resolved.Count, physics.BoundingSphere?.Radius ?? 0f));
Console.WriteLine("Looking for polys whose world-vertex bbox overlaps sphere AABB:");
Console.WriteLine($" Sphere X=[{sphereCenterX-0.48f:F3}, {sphereCenterX+0.48f:F3}]");
Console.WriteLine($" Sphere Y=[{sphereCenterY-0.48f:F3}, {sphereCenterY+0.48f:F3}]");
Console.WriteLine($" Sphere Z=[94.000, 95.200]");
int matched = 0;
int matchedXY = 0;
Console.WriteLine("");
Console.WriteLine("=== All cottage polys with XY overlap (any Z) ===");
foreach (var (polyId, poly) in physics.Resolved)
{
// Transform vertices to world space.
float wxMin = float.MaxValue, wxMax = float.MinValue;
float wyMin = float.MaxValue, wyMax = float.MinValue;
float wzMin = float.MaxValue, wzMax = float.MinValue;
foreach (var v in poly.Vertices)
{
var rotated = Vector3.Transform(v, cottageRot);
var world = cottagePos + rotated;
if (world.X < wxMin) wxMin = world.X; if (world.X > wxMax) wxMax = world.X;
if (world.Y < wyMin) wyMin = world.Y; if (world.Y > wyMax) wyMax = world.Y;
if (world.Z < wzMin) wzMin = world.Z; if (world.Z > wzMax) wzMax = world.Z;
}
bool xOverlap = wxMax >= sphereCenterX - 0.48f && wxMin <= sphereCenterX + 0.48f;
bool yOverlap = wyMax >= sphereCenterY - 0.48f && wyMin <= sphereCenterY + 0.48f;
bool zOverlap = wzMax >= 94f && wzMin <= 95.20f;
if (xOverlap && yOverlap)
{
matchedXY++;
var nWorld = Vector3.Transform(poly.Plane.Normal, cottageRot);
string zMark = zOverlap ? " *** Z-OVERLAP ***" : "";
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
" poly 0x{0:X4} n=({1:F3},{2:F3},{3:F3}) bbox X=[{4:F3},{5:F3}] Y=[{6:F3},{7:F3}] Z=[{8:F3},{9:F3}]{10}",
polyId, nWorld.X, nWorld.Y, nWorld.Z, wxMin, wxMax, wyMin, wyMax, wzMin, wzMax, zMark));
}
if (xOverlap && yOverlap && zOverlap) matched++;
}
Console.WriteLine($" XY-overlap polys (any Z): {matchedXY}");
Console.WriteLine($" XYZ-overlap polys: {matched}");
}
[Fact]
public void Geometric_DoorSlabAtSphereHeight_OverlapsInZ()
{