test(phys): A6.P4 door — corner-slide hypothesis falsified, bug is state-related

CornerSlide_AlcoveEastToCottageNorth_ShouldBlock test:
- Registers cottage GfxObj 0x01000A2B (contains north exterior walls)
- Registers cell 0xA9B40150 BSP via dat-direct load (alcove walls)
- Places sphere at (132.95, 16.8, 94) inside alcove near east wall
- Walks sphere +Y 50 times at walk speed (0.05 m/tick)

Result: sphere STAYS at (132.95, 16.8) for all 50 ticks with collision
normal cn=(0.71, -0.71, 0) — the average of alcove east wall normal
and cottage north wall normal at their meeting corner. The corner
handling works correctly in the harness.

So production's inside-out walkthrough is NOT a geometric or BSP
collision-detection bug. The geometry exists, the collision detection
fires symmetrically at corners. The discrepancy must be a STATE
difference between harness and production:
- Real walkable polygons with edges (harness uses big quad)
- Real terrain (harness uses Z=-1000 stub)
- Accumulated body state across many prior ticks (harness uses fresh)
- Possibly cell ping-pong between 0x0150 and 0x0029 in production

Cottage GfxObj wall polygons at the doorway area confirmed:
- North exterior wall east of doorway: polys 0x0032, 0x0033
  X=[133.5, 136.3], Y=17.10, Z=[94, 97], normal +Y
- North exterior wall west of doorway: polys 0x0030, 0x0031, 0x0034,
  0x0035 (X<131.6 various ranges)
- Lintel polys above doorway: 0x0037, 0x0038, 0x003A, 0x003B at Z>96.5

Next-session moves (per handoff):
1. Replay captured tick 2586 (where sphere went from cell 0x0150 to
   0x0029 at X=134.022, way past alcove east wall). Inspect engine
   behavior at exactly that tick's body state.
2. cdb attach to retail at Holtburg cottage doorway — verify whether
   retail also lets sphere walk through at off-center, OR blocks
   cleanly. If retail also allows walkthrough, this might be
   retail-faithful behavior we should accept.

Updated 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:37:31 +02:00
parent fe29db5691
commit a657ca946c
2 changed files with 179 additions and 17 deletions

View file

@ -441,6 +441,145 @@ public class DoorBugTrajectoryReplayTests
$"wall is now blocking as expected — the #98 gate fix landed.");
}
/// <summary>
/// A6.P4 corner-slide hypothesis (2026-05-25 late) — reproduces the
/// inside-out walkthrough at unit-test speed. Builds engine with
/// BOTH cottage GfxObj 0x01000A2B (which contains the north exterior
/// wall east of doorway at X=[133.5, 136.3], Y=17.10) AND cell
/// 0xA9B40150's BSP (alcove east wall at X=133.5, Y=[16.5, 17.1]).
/// Sphere starts inside alcove sliding against east wall, then
/// walks NORTH. If harness slides sphere past the corner at
/// (133.5, 17.10) to end up at X > 133.5 Y > 17.10, bug reproduced.
/// </summary>
[Fact]
public void CornerSlide_AlcoveEastToCottageNorth_ShouldBlock()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, cache) = BuildFaithfulDoorEngine(datDir);
// 1. Register cottage GfxObj (contains the north exterior wall).
const uint CottageGfxId = 0x01000A2Bu;
const uint CottageEntityId = 0x00A9B479u;
var cottageFixturePath = Path.Combine(SolutionRoot(),
"tests", "AcDream.Core.Tests", "Fixtures", "issue98",
"0x01000A2B.gfxobj.json");
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);
// 2. Load cell 0xA9B40150 BSP into cache (the alcove walls).
const uint AlcoveCellId = 0xA9B40150u;
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var envCell = dats.Get<DatReaderWriter.DBObjs.EnvCell>(AlcoveCellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(
0x0D000000u | envCell!.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cellStruct));
var cellOriginWorld = envCell.Position.Origin;
var cellTransform =
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
Matrix4x4.CreateTranslation(cellOriginWorld);
cache.CacheCellStruct(AlcoveCellId, envCell, cellStruct!, cellTransform);
}
Assert.NotNull(cache.GetCellStruct(AlcoveCellId));
// 3. Sphere setup: inside alcove, near east wall.
// Alcove east wall at world X=133.5, Y=[16.5, 17.1]. Sphere at
// X=132.95 (sphere east edge 133.43 just west of wall), Y=16.8
// (inside alcove Y range).
var currentPos = new Vector3(132.95f, 16.8f, 94f);
// Walk sphere in +Y direction (toward cottage exterior north wall).
// Repeat several ticks with small steps to mimic walk-speed motion.
Vector3 pos = currentPos;
uint cellId = AlcoveCellId;
bool isOnGround = true;
var body = new PhysicsBody
{
Position = pos,
Orientation = Quaternion.Identity,
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -94f),
ContactPlaneCellId = cellId,
WalkablePolygonValid = true,
WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -94f),
WalkableVertices = new[]
{
new Vector3(120f, 10f, 94f),
new Vector3(145f, 10f, 94f),
new Vector3(145f, 30f, 94f),
new Vector3(120f, 30f, 94f),
},
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
Console.WriteLine($"Start: pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) cell=0x{cellId:X8}");
for (int t = 1; t <= 50; t++)
{
var target = pos + new Vector3(0f, 0.05f, 0f); // walk speed
var result = engine.ResolveWithTransition(
currentPos: pos,
targetPos: target,
cellId: cellId,
sphereRadius: 0.48f,
sphereHeight: 1.20f,
stepUpHeight: 0.60f,
stepDownHeight: 1.5f,
isOnGround: isOnGround,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: DoorEntityId + 1);
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
body.Position = pos;
if (t % 5 == 0 || result.CollisionNormalValid)
{
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"t={0,2} pos=({1:F3},{2:F3},{3:F3}) cell=0x{4:X8} cnValid={5} cn=({6:F2},{7:F2},{8:F2})",
t, pos.X, pos.Y, pos.Z, cellId,
result.CollisionNormalValid,
result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z));
}
// Stop if sphere has clearly walked through the wall.
if (pos.Y > 18f) break;
}
Console.WriteLine($"Final pos: ({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) cell=0x{cellId:X8}");
// Document expected: sphere should stop at sphere center Y =
// 17.10 - 0.48 = 16.62 (cottage north wall + sphere reach).
// Bug: sphere slides past corner and exits north.
Assert.True(pos.Y < 17.20f,
$"BUG REPRODUCTION: sphere walked from inside alcove to Y={pos.Y:F3} " +
$"(past cottage north wall at Y=17.10). Cottage wall should have blocked " +
$"sphere at Y ≈ 16.62 (wall - sphere reach). If this assertion FAILS, " +
$"the corner handling at (X=133.5, Y=17.10) is letting sphere slide past.");
}
/// <summary>
/// Diagnostic: dump cottage GfxObj 0x01000A2B polygons near world
/// position (133.655, 17.59, 94.5) — the sphere position where the