test(phys): A6.P4 door — directional + geometric pin tests reframe inside-out bug

Built three new tests to investigate the inside-out asymmetric collision
that persists after the AddAllOutsideCells coord fix:

1. Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace — sphere
   south of door moving NORTH; expects block with cn.Y less than -0.5
2. Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace — sphere
   north of door moving SOUTH; expects block with cn.Y greater than +0.5
3. Geometric_DoorSlabZRange_AbovePlayerSphereTop — pins the slab Z
   range vs sphere top math

BOTH directional tests PASS — collision is symmetric at unit-test level.
The asymmetric production bug therefore comes from something the unit
tests do not capture (multi-tick state, cell-tracking flicker, walkable
polygon edge interactions).

The geometric pin test reveals the real story: Setup 0x020019FF places
the part-0 BSP slab 1.275 m ABOVE the entity origin via
PlacementFrames[Default][0].Origin. With the cottage door entity at
world Z=94.1, the slab world Z range is [95.375, 97.865]. Player sphere
top reaches Z=95.20. The slab BOTTOM is 0.175 m ABOVE the sphere top —
the slab NEVER collides with the player.

The slab is a LINTEL (door frame above the doorway), not a leaf. The
door's only effective collider at sphere height is the 0.10 m radius
foot cylinder. The directional tests pass because the cylinder blocks,
not the BSP.

User-reported inside-out off-center walkthrough is the sphere walking
AROUND the foot cylinder (sphere reach 0.48 + cyl 0.10 = 0.58 m; any
sphere center over 0.58 m from cylinder center passes freely). The
visual "body partially intersects door" is the character model
occupying the visual door volume while the collision sphere passes
beside the cylinder.

Reframed handoff in docs/research/2026-05-25-door-bug-partial-fix-shipped.md
points to three candidate next-step investigations:
- Retail-faithfulness audit on setup.Radius / setup.Height interpretation
- Re-inspect door parts 1+2 (GfxObj 0x010044B6) for missed physics shapes
- Test the cottage cell BSP (cell 0x0150 walls) + door together — the
  COMBINED collision may be what retail relies on

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 08:08:42 +02:00
parent 28cd97be62
commit c27fded61e
2 changed files with 413 additions and 20 deletions

View file

@ -260,6 +260,338 @@ public class DoorBugTrajectoryReplayTests
string.Join(",", candidates.Select(c => $"0x{c:X8}")));
}
/// <summary>
/// A6.P4 inside-out asymmetric collision (2026-05-25 evening) —
/// synthesizes a sphere approaching a faithfully-registered door
/// from each side and asserts the BSP collision fires symmetrically.
///
/// <para>
/// Door registered via the SAME path production uses
/// (ShadowShapeBuilder.FromSetup + RegisterMultiPart) with the
/// real Setup 0x020019FF loaded from the dat, including the
/// part-0 BSP's PlacementFrame[Default][0] origin of
/// (-0.006, 0.125, 1.275). Entity world rotation is 180° around Z
/// to match the cottage's world transform (per the cellar fixture
/// pattern + observed [bsp-test] world position alignment).
/// </para>
///
/// <para>
/// After the 180° entity rotation, the slab's local Y thickness axis
/// maps to world -Y. Slab spans world Y in approximately
/// [<see cref="DoorSpawnPos"/>.Y - 0.261, <see cref="DoorSpawnPos"/>.Y]
/// (entity Y minus 0 to entity Y minus 0.261 thickness). Two faces
/// matter:
/// <list type="bullet">
/// <item><b>Higher-Y face</b> (world Y ≈ entity.Y) has world
/// normal +Y. A sphere NORTH of the slab moving SOUTH (-Y)
/// hits this face. <c>cn</c> should be near (0, +1, 0).</item>
/// <item><b>Lower-Y face</b> (world Y ≈ entity.Y - 0.261) has world
/// normal -Y. A sphere SOUTH of the slab moving NORTH (+Y)
/// hits this face. <c>cn</c> should be near (0, -1, 0).</item>
/// </list>
/// </para>
///
/// <para>
/// User-reported behavior post-AddAllOutsideCells-fix:
/// outside→inside blocks cleanly; inside→outside shows the body
/// partially intersecting the door before sphere slides through.
/// If the asymmetry is in BSP collision, these tests will
/// reproduce it at unit-test speed.
/// </para>
/// </summary>
/// <summary>
/// Geometric finding (2026-05-25 evening) — pins the door geometry
/// math that explains why the "inside-out walkthrough" persists
/// after the cell-visibility fix.
///
/// <para>
/// The cottage door Setup 0x020019FF has:
/// <list type="bullet">
/// <item>One CylSphere (r=0.10, h=0.20, origin=(0, 0, 0.018)) — a
/// TINY foot collider at entity Z + 0.018, extending Z just
/// to 0.218 above entity Z.</item>
/// <item>Part 0 = GfxObj 0x010044B5 (BSP slab 1.925 × 0.261 × 2.490 m),
/// placed via PlacementFrames[Default][0].Origin =
/// (-0.006, 0.125, 1.275). The slab's local Z=0 origin sits
/// at entity Z + 1.275 — i.e., the slab's BOTTOM is 1.275 m
/// ABOVE the door's entity foot.</item>
/// </list>
/// </para>
///
/// <para>
/// With entity at (132.6, 17.1, 94.1) (the captured Holtburg cottage
/// door spawn position):
/// <list type="bullet">
/// <item>Cylinder world Z range: [94.118, 94.318] — touches the
/// ground (sphere foot Z=94 to top Z=95.20 overlaps cyl Z up to 94.318).</item>
/// <item>Slab world Z range: [95.375, 97.865]. <b>The slab's BOTTOM
/// (95.375 m) is 0.175 m ABOVE the player's sphere top
/// (95.20 m).</b> The slab NEVER intersects the player's
/// body sphere vertically.</item>
/// </list>
/// </para>
///
/// <para>
/// Implication: the door's only effective collision against a player
/// at floor level is the 0.10 m radius foot cylinder. The 1.93 m wide
/// slab is collision-irrelevant at sphere height — it's a LINTEL
/// (the door frame above), not a leaf collision. The user-reported
/// "off-center walkthrough" is the sphere walking AROUND the
/// 0.10 m foot cylinder (sphere reach 0.48 + cyl 0.10 = 0.58 m;
/// any sphere center &gt;0.58 m from cylinder center passes freely).
/// The "body partially intersects door" is the rendered character model
/// occupying volume the door visual fills, but no collision body to
/// stop it because the slab is too high.
/// </para>
///
/// <para>
/// Next session: this is a DAT or registration issue, not a BSP query
/// bug. Options:
/// <list type="number">
/// <item>Verify retail actually USES this geometry — maybe the door
/// relies on the cottage CELL'S walls (cell 0x0150's
/// PhysicsPolygons) to enclose the doorway, and the door's
/// only collision is the foot cylinder + a leaf shape we're
/// missing.</item>
/// <item>Inspect parts 1+2 (the door LEAVES, GfxObj 0x010044B6) to
/// confirm they're truly visual-only or if we missed a
/// physics shape.</item>
/// <item>cdb attach to retail at a Holtburg cottage door — set a
/// breakpoint on CTransition::FindObjCollisions for the door
/// entity and inspect what shapes retail tests against.</item>
/// </list>
/// </para>
/// </summary>
[Fact]
public void Geometric_DoorSlabZRange_AbovePlayerSphereTop()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
// Load the door setup + part 0's PlacementFrame.
DatReaderWriter.Types.Frame partFrame;
float slabLocalZMax;
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(0x020019FFu)!;
Assert.NotNull(setup);
Assert.True(setup.PlacementFrames.ContainsKey(DatReaderWriter.Enums.Placement.Default));
partFrame = setup.PlacementFrames[DatReaderWriter.Enums.Placement.Default].Frames[0];
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(DoorGfxObjId)!;
Assert.NotNull(gfx.PhysicsPolygons);
// Compute local AABB Z max from vertex array.
slabLocalZMax = float.MinValue;
foreach (var poly in gfx.PhysicsPolygons.Values)
{
foreach (ushort vid in poly.VertexIds)
{
if (gfx.VertexArray.Vertices.TryGetValue(vid, out var sv))
if (sv.Origin.Z > slabLocalZMax) slabLocalZMax = sv.Origin.Z;
}
}
}
// The door's entity is placed at world Z=94.1 in Holtburg (per
// captured spawn log). Slab local Z=0 origin offsets to:
float slabWorldZBottom = DoorSpawnPos.Z + partFrame.Origin.Z;
float slabWorldZTop = slabWorldZBottom + slabLocalZMax;
// The player has sphereHeight=1.20, sphereRadius=0.48. Sphere top
// in world = foot.Z + height - radius + radius = foot.Z + height
// (the top of the head sphere centered at foot.Z + height - radius).
const float SphereHeight = 1.20f;
const float PlayerFootZ = 94f; // standard Holtburg floor
float sphereTopZ = PlayerFootZ + SphereHeight;
// The crucial assertion: slab bottom is above sphere top.
Assert.True(slabWorldZBottom > sphereTopZ,
$"Door slab bottom ({slabWorldZBottom:F3}) should be ABOVE " +
$"player sphere top ({sphereTopZ:F3}). Gap = " +
$"{slabWorldZBottom - sphereTopZ:F3} m. This pins the geometric " +
$"fact that the slab does not collide with the player at floor " +
$"level — only the foot cylinder does. The inside-out 'walkthrough' " +
$"is the sphere passing around the cylinder, not through the slab.");
}
[Fact]
public void Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, _) = BuildFaithfulDoorEngine(datDir);
// Sphere starts SOUTH of slab (low Y), moves NORTH (+Y) toward door.
// Slab world Y ∈ [16.84, 17.10] approximately after 180° entity rot.
// Sphere south edge needs to be just south of slab south face.
var currentPos = new Vector3(132.5f, 16.3f, 94f);
var targetPos = new Vector3(132.5f, 16.7f, 94f); // +0.4 m north
var (result, body) = ResolveAt(engine, currentPos, targetPos, DoorCellOutdoor);
// The slab's south face has world normal (0, -1, 0) after the
// 180° entity rotation. Sphere moving +Y hits it; collision
// normal should oppose motion, i.e., negative Y component.
Assert.True(result.CollisionNormalValid,
$"Outside-in: door should block sphere. Got: pos={result.Position}, " +
$"cnValid={result.CollisionNormalValid}, cn={result.CollisionNormal}.");
Assert.True(result.CollisionNormal.Y < -0.5f,
$"Outside-in: cn.Y should be negative (south face normal). " +
$"Got cn={result.CollisionNormal}.");
}
[Fact]
public void Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, _) = BuildFaithfulDoorEngine(datDir);
// Sphere starts NORTH of slab (high Y), moves SOUTH (-Y) toward door.
var currentPos = new Vector3(132.5f, 17.6f, 94f);
var targetPos = new Vector3(132.5f, 17.2f, 94f); // -0.4 m south
var (result, body) = ResolveAt(engine, currentPos, targetPos, DoorCellOutdoor);
// The slab's north face has world normal (0, +1, 0) after the
// 180° entity rotation. Sphere moving -Y hits it; collision
// normal should oppose motion, i.e., positive Y component.
Assert.True(result.CollisionNormalValid,
$"Inside-out: door should block sphere. Got: pos={result.Position}, " +
$"cnValid={result.CollisionNormalValid}, cn={result.CollisionNormal}.");
Assert.True(result.CollisionNormal.Y > 0.5f,
$"Inside-out: cn.Y should be positive (north face normal). " +
$"Got cn={result.CollisionNormal}.");
}
/// <summary>
/// Faithful engine: registers the real Setup 0x020019FF door via
/// ShadowShapeBuilder.FromSetup at the captured entity world position
/// (132.6, 17.1, 94.1) with the cottage's 180° Z rotation. Mirrors
/// production GameWindow.RegisterLiveEntityCollision exactly.
/// </summary>
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildFaithfulDoorEngine(string datDir)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// Cache GfxObj 0x010044B5 (the BSP slab) from dat.
DatReaderWriter.DBObjs.Setup setup;
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(DoorGfxObjId);
Assert.NotNull(gfx);
cache.CacheGfxObj(DoorGfxObjId, gfx!);
setup = dats.Get<DatReaderWriter.DBObjs.Setup>(0x020019FFu)!;
Assert.NotNull(setup);
}
// Stub landblock at (0, 0) so TryGetLandblockContext succeeds.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(
landblockId: DoorLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// Build shape list the same way production does
// (GameWindow.RegisterLiveEntityCollision):
// 1. ShadowShapeBuilder.FromSetup with entScale=1
// 2. Substitute BSP shape's radius with the real BoundingSphere.Radius
var rawShapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f,
id => cache.GetGfxObj(id)?.BSP?.Root is not null);
var shapes = new List<ShadowShape>(rawShapes.Count);
foreach (var s in rawShapes)
{
if (s.CollisionType == ShadowCollisionType.BSP)
{
var phys = cache.GetGfxObj(s.GfxObjId);
float bspR = phys?.BoundingSphere?.Radius ?? 2f;
shapes.Add(s with { Radius = bspR });
}
else
{
shapes.Add(s);
}
}
Assert.Contains(shapes, s => s.CollisionType == ShadowCollisionType.BSP);
// Register the door at the cottage's entity world transform:
// - Position from the captured spawn data: (132.6, 17.1, 94.1)
// - Rotation 180° around Z to match cottage orientation
// (consistent with [bsp-test] world position alignment)
engine.ShadowObjects.RegisterMultiPart(
entityId: DoorEntityId,
entityWorldPos: DoorSpawnPos,
entityWorldRot: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI),
shapes: shapes,
state: DoorClosedState,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: DoorLandblockId);
return (engine, cache);
}
/// <summary>
/// Run one <see cref="PhysicsEngine.ResolveWithTransition"/> call
/// against <paramref name="engine"/> at the given positions/cell,
/// returning the result + the body's post-call state.
/// </summary>
private static (ResolveResult result, PhysicsBody body)
ResolveAt(PhysicsEngine engine, Vector3 currentPos, Vector3 targetPos, uint cellId)
{
var body = new PhysicsBody
{
Position = currentPos,
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[]
{
// Big walkable poly covering Y in [10, 30], X in [120, 145].
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,
};
var result = engine.ResolveWithTransition(
currentPos: currentPos,
targetPos: targetPos,
cellId: cellId,
sphereRadius: 0.48f,
sphereHeight: 1.20f,
stepUpHeight: 0.60f,
stepDownHeight: 1.5f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: DoorEntityId + 1);
return (result, body);
}
// The captured door spawn position from launch.log [entity-source]:
// "live: spawn ... name=Door setup=0x020019FF pos=(132.6,17.1,94.1)@0xA9B40029"
private static readonly Vector3 DoorSpawnPos = new(132.6f, 17.1f, 94.1f);
private const uint DoorCellOutdoor = 0xA9B40029u;
/// <summary>
/// Direct test of <see cref="CellTransit.AddAllOutsideCells"/> with
/// the captured sphere position (132.36, 16.81, 94) and currentCellId