diff --git a/docs/research/2026-05-25-door-bug-partial-fix-shipped.md b/docs/research/2026-05-25-door-bug-partial-fix-shipped.md
index 9fe5310..d775a0d 100644
--- a/docs/research/2026-05-25-door-bug-partial-fix-shipped.md
+++ b/docs/research/2026-05-25-door-bug-partial-fix-shipped.md
@@ -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
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
-when the sphere is already overlapping the slab.** Retail's
-`CBSPTree::find_collisions` family handles this specifically — the
-sphere's path through the slab faces gets traced and the FIRST face
-crossed in motion direction is the collision. With two-sided polygons,
-both faces are collidable; the front-vs-back determination is by
-sphere-velocity vs face-normal dot product.
+**Initial hypothesis was wrong.** Two new directional tests built
+post-fix (`Directional_OutsideIn_*`, `Directional_InsideOut_*`) BOTH
+PASS — the BSP collision response is symmetric at unit-test level.
+The asymmetric production bug must come from something the unit tests
+weren't capturing.
-Likely files:
-- `src/AcDream.Core/Physics/BSPQuery.cs` — the BSP traversal +
- sphere-poly intersection logic.
-- Retail decomp anchors:
- `acclient_2013_pseudo_c.txt:BSPTREE::find_collisions` +
- `SPHEREPATH::sphere_intersects_poly` family.
+A geometric pin test (`Geometric_DoorSlabZRange_AbovePlayerSphereTop`)
+reveals the real story:
-Apparatus to write next: a focused test that registers the door at its
-actual production world transform (entity origin + partFrame offset
-from the dat, with correct rotation) and replays a sphere passing
-through it from EACH side at various speeds. Compare collision normal
-+ position-resolution per side. The asymmetric response will be
-reproducible at unit-test speed.
+```
+Setup 0x020019FF (cottage door):
+ CylSphere[0]: r=0.10, h=0.20, origin=(0, 0, 0.018)
+ → world Z [94.118, 94.318] when entity at Z=94.1
+ Part 0 (GfxObj 0x010044B5, the BSP "slab"):
+ 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
diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
index b9fab9d..dc81a21 100644
--- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
@@ -260,6 +260,338 @@ public class DoorBugTrajectoryReplayTests
string.Join(",", candidates.Select(c => $"0x{c:X8}")));
}
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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).
+ ///
+ ///
+ ///
+ /// After the 180° entity rotation, the slab's local Y thickness axis
+ /// maps to world -Y. Slab spans world Y in approximately
+ /// [.Y - 0.261, .Y]
+ /// (entity Y minus 0 to entity Y minus 0.261 thickness). Two faces
+ /// matter:
+ ///
+ /// - Higher-Y face (world Y ≈ entity.Y) has world
+ /// normal +Y. A sphere NORTH of the slab moving SOUTH (-Y)
+ /// hits this face. cn should be near (0, +1, 0).
+ /// - Lower-Y face (world Y ≈ entity.Y - 0.261) has world
+ /// normal -Y. A sphere SOUTH of the slab moving NORTH (+Y)
+ /// hits this face. cn should be near (0, -1, 0).
+ ///
+ ///
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ ///
+ /// Geometric finding (2026-05-25 evening) — pins the door geometry
+ /// math that explains why the "inside-out walkthrough" persists
+ /// after the cell-visibility fix.
+ ///
+ ///
+ /// The cottage door Setup 0x020019FF has:
+ ///
+ /// - 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.
+ /// - 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.
+ ///
+ ///
+ ///
+ ///
+ /// With entity at (132.6, 17.1, 94.1) (the captured Holtburg cottage
+ /// door spawn position):
+ ///
+ /// - 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).
+ /// - Slab world Z range: [95.375, 97.865]. The slab's BOTTOM
+ /// (95.375 m) is 0.175 m ABOVE the player's sphere top
+ /// (95.20 m). The slab NEVER intersects the player's
+ /// body sphere vertically.
+ ///
+ ///
+ ///
+ ///
+ /// 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 >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.
+ ///
+ ///
+ ///
+ /// Next session: this is a DAT or registration issue, not a BSP query
+ /// bug. Options:
+ ///
+ /// - 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.
+ /// - Inspect parts 1+2 (the door LEAVES, GfxObj 0x010044B6) to
+ /// confirm they're truly visual-only or if we missed a
+ /// physics shape.
+ /// - 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.
+ ///
+ ///
+ ///
+ [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(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(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}.");
+ }
+
+ ///
+ /// 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.
+ ///
+ 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(DoorGfxObjId);
+ Assert.NotNull(gfx);
+ cache.CacheGfxObj(DoorGfxObjId, gfx!);
+
+ setup = dats.Get(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(),
+ portals: Array.Empty(),
+ 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(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);
+ }
+
+ ///
+ /// Run one call
+ /// against at the given positions/cell,
+ /// returning the result + the body's post-call state.
+ ///
+ 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;
+
///
/// Direct test of with
/// the captured sphere position (132.36, 16.81, 94) and currentCellId