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

@ -106,29 +106,90 @@ The `[bsp-test]` probe fires 245 times for the door entity during the
post-fix inside-out attempts — door IS being queried. The post-fix inside-out attempts — door IS being queried. The
collision-detection mechanics produce the wrong response. collision-detection mechanics produce the wrong response.
## What's next (separate bug) ## What's next (separate bug — REFRAMED 2026-05-25 evening)
**Investigate BSPQuery.FindCollisions's response for two-sided polygons **Initial hypothesis was wrong.** Two new directional tests built
when the sphere is already overlapping the slab.** Retail's post-fix (`Directional_OutsideIn_*`, `Directional_InsideOut_*`) BOTH
`CBSPTree::find_collisions` family handles this specifically — the PASS — the BSP collision response is symmetric at unit-test level.
sphere's path through the slab faces gets traced and the FIRST face The asymmetric production bug must come from something the unit tests
crossed in motion direction is the collision. With two-sided polygons, weren't capturing.
both faces are collidable; the front-vs-back determination is by
sphere-velocity vs face-normal dot product.
Likely files: A geometric pin test (`Geometric_DoorSlabZRange_AbovePlayerSphereTop`)
- `src/AcDream.Core/Physics/BSPQuery.cs` — the BSP traversal + reveals the real story:
sphere-poly intersection logic.
- Retail decomp anchors:
`acclient_2013_pseudo_c.txt:BSPTREE::find_collisions` +
`SPHEREPATH::sphere_intersects_poly` family.
Apparatus to write next: a focused test that registers the door at its ```
actual production world transform (entity origin + partFrame offset Setup 0x020019FF (cottage door):
from the dat, with correct rotation) and replays a sphere passing CylSphere[0]: r=0.10, h=0.20, origin=(0, 0, 0.018)
through it from EACH side at various speeds. Compare collision normal → world Z [94.118, 94.318] when entity at Z=94.1
+ position-resolution per side. The asymmetric response will be Part 0 (GfxObj 0x010044B5, the BSP "slab"):
reproducible at unit-test speed. placement frame [Default][0].Origin = (-0.006, 0.125, 1.275)
→ BSP world Z [95.375, 97.865] when entity at Z=94.1
Player at floor Z=94:
sphere height = 1.20, sphere top = 95.20
BSP slab BOTTOM (95.375) is ABOVE sphere TOP (95.20) by 0.175 m.
The slab NEVER collides with the player's body sphere.
```
The slab is a LINTEL (the door frame above the doorway), not a leaf.
The door's only collision against a player at floor level is the
0.10 m radius foot cylinder. Sphere radius 0.48 + cyl 0.10 = 0.58 m
collision reach. Any sphere center > 0.58 m from cylinder center
(132.6, 17.1) passes freely.
The user-reported "inside-out walkthrough at ~50 cm off-center" is
the sphere walking AROUND the cylinder, at X = 132.6 ± 0.6+ m where
collision misses entirely. "Body partially intersects door" is the
character model occupying the visual door's volume while the collision
sphere passes beside the foot cylinder.
The "outside-in works" case is also the foot cylinder doing the
blocking — when the user approaches more centered or hits the cylinder
head-on. The cell-visibility fix made the cylinder visible from indoor
cells (it wasn't before), which is why outside-in went from "walks
through" to "blocks" — but the cylinder is the actual collider.
**This is a door-geometry interpretation question, NOT a BSP query bug.**
Three candidate next-step investigations:
1. **Retail-faithfulness audit on door collision.** Read retail's
`CPhysicsObj::set_setup_for_door` (or similar) to determine what
shapes retail loads for a closed cottage door. If retail uses the
SAME setup data + finds the same shapes we do, the door geometry
in the dat IS the spec. The "blocking" the user sees in retail
might similarly be the foot cylinder + perhaps a different default
sphere from setup.Radius/setup.Height that we're not registering.
2. **Inspect door parts 1+2 (GfxObj 0x010044B6).** Per prior session's
handoff they are visual-only (HasPhysics=false). Verify by direct
dat read — maybe the PhysicsBSP is null but the cylinder/sphere
list on the GfxObj itself has collision data we're missing.
`DoorSetupGfxObjInspectionTests` already prints this; re-read.
3. **The cottage cell BSP encloses the doorway.** Cell 0x0150
(the doorway alcove) is bounded by cottage walls. The DOOR opens
through a gap in those walls. When the door is closed, the gap
is filled by the door geometry. Maybe retail's collision relies
on the cottage walls (cell BSP) for the "doorway side walls" and
the door's slab covers ONLY the opening's leaf area. The asymmetric
block we see could be the cottage cell walls catching outside-in
approach (cell BSP collision via FindEnvCollisions, before the
door's shadow ever fires) but NOT catching inside-out at the same
alcove geometry.
The 50cm off-center approach probably exits the cottage walls' BSP
through the doorway gap, walks past the foot cylinder (which is small),
and never has anything to collide against in the world's collision
representation. Retail might block this via a `setup.Radius=0.1414`
cylinder we're missing (Setup.Radius isn't currently registered by
`ShadowShapeBuilder.FromSetup` for entities WITH a CylSphere).
Apparatus to write next: a test that registers the door using
ShadowShapeBuilder.FromSetup AND verifies setup.Radius is reflected
in the shape list. If it isn't, that's a candidate fix — the door's
0.14 m radius + 0.20 m height SETUP sphere is wider than the 0.10 m
CylSphere and would catch the off-center approach.
## Commits ## Commits

View file

@ -260,6 +260,338 @@ public class DoorBugTrajectoryReplayTests
string.Join(",", candidates.Select(c => $"0x{c:X8}"))); 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> /// <summary>
/// Direct test of <see cref="CellTransit.AddAllOutsideCells"/> with /// Direct test of <see cref="CellTransit.AddAllOutsideCells"/> with
/// the captured sphere position (132.36, 16.81, 94) and currentCellId /// the captured sphere position (132.36, 16.81, 94) and currentCellId