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 d775a0d..ed0cfdc 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,90 +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 — REFRAMED 2026-05-25 evening)
+## What's next (separate bug)
-**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.
+**Investigation status (corrected 2026-05-25 late evening).** Two new
+directional tests + a geometric pin test all PASS:
-A geometric pin test (`Geometric_DoorSlabZRange_AbovePlayerSphereTop`)
-reveals the real story:
+- `Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace` PASSES.
+- `Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace` PASSES.
+- `Geometric_DoorSlabAtSphereHeight_OverlapsInZ` PASSES.
+
+The geometric test reveals (correctly computed this time):
```
-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
+Setup 0x020019FF (cottage door) PhysicsPolygons local AABB:
+ min=(-0.954, -0.134, -1.236) max=(0.971, 0.127, 1.255)
+ (slab origin at GEOMETRIC CENTER, not the bottom)
-Player at floor Z=94:
- sphere height = 1.20, sphere top = 95.20
+partFrame[0].Origin = (-0.006, 0.125, 1.275) → lifts slab origin
+ 1.275 m above entity Z
- 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.
+With entity at world (132.6, 17.1, 94.1) + 180° entity rotation:
+ partWorldPos = (132.606, 16.975, 95.375)
+
+Slab WORLD AABB:
+ X: [131.635, 133.560] (1.925 m wide)
+ Y: [16.848, 17.109] (0.261 m thick)
+ Z: [94.139, 96.630] (2.491 m tall, bottom JUST above floor)
+
+Player sphere at foot Z=94:
+ Z: [94, 95.20]
+
+Slab DOES overlap sphere in Z (overlap Z=[94.139, 95.20] = 1.061 m).
```
-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 slab IS at sphere height — it should collide.** Both directional
+tests prove BSP collision response is symmetric for sphere-to-slab
+approach. Yet production shows asymmetric inside-out walkthrough at
+off-center positions. The bug must be in one of:
-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.
+1. **The portal-reachable cells from indoor cell 0x0150 still miss the
+ door's shadow at certain sphere positions**, despite the
+ AddAllOutsideCells fix. The user's walkthrough at X=133.655 (1.05 m
+ east of door center) puts the sphere mostly east of slab X range
+ [131.635, 133.560]. The sphere's WEST edge (X=133.175) is barely
+ inside the slab. If GetNearbyObjects's outdoor radial sweep uses
+ sphere center XY for cell lookup, it computes
+ gridX = (int)(133.655 / 24) = 5 → cell 0xA9B40029. But AddAllOutsideCells
+ only adds cells based on the sphere's PRIMARY position. The east-cell
+ neighbor might not be added if the sphere is wholly within the primary
+ cell's grid XY. Worth verifying.
-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.
+2. **The BSP polygon-level test for partial-overlap geometry.** Sphere
+ half-east-of-slab, sphere south edge at slab north edge, moving +Y:
+ sphere is on the verge of leaving the slab volume. BSPQuery's polygon
+ intersection might consider this a "leaving collision" with no
+ response, even though the sphere body still partially occupies the
+ slab volume. Retail might handle this as "depenetration push" to
+ resolve the overlap.
-**This is a door-geometry interpretation question, NOT a BSP query bug.**
-Three candidate next-step investigations:
+3. **Cell BSP (cell 0x0150's PhysicsPolygons) is missing**. The doorway
+ alcove cell has 4 physics polygons — likely walls + floor. If retail
+ relies on the cell's walls to catch sphere-vs-doorway-side-wall
+ collisions (in addition to the door slab), and we're not loading /
+ testing the cell BSP correctly for the player's foot at sphere
+ height, the side walls would miss.
-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.
+Three candidate investigations, ranked by ROI:
-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.
+**A. cdb attach to retail** at a Holtburg cottage doorway. Break on
+`CTransition::FindObjCollisions` for the door entity. Inspect what
+shapes retail actually tests against. THIS IS DEFINITIVE — answers
+"what should we be doing differently" in 15-30 min. CLAUDE.md has the
+toolchain ready.
-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.
+**B. Reproduce inside-out walkthrough at unit-test speed.** Load real
+cell 0x0150 BSP into the harness (via CacheCellStruct from dat) +
+register door at faithful transform + replay captured tick 3262.
+If walkthrough reproduces at unit speed, can iterate on the fix in
+<500 ms.
-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).
+**C. Audit GetNearbyObjects radial sweep + AddAllOutsideCells coverage**
+for east-neighbor cell when sphere XY is at primary cell boundary.
-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.
+Recommendation: **A first** (cdb), then **B** to validate the fix at
+unit-test speed.
## Commits
diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
index dc81a21..f1711d5 100644
--- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
@@ -300,9 +300,9 @@ public class DoorBugTrajectoryReplayTests
///
///
///
- /// Geometric finding (2026-05-25 evening) — pins the door geometry
- /// math that explains why the "inside-out walkthrough" persists
- /// after the cell-visibility fix.
+ /// Geometric pin (2026-05-25 evening, CORRECTED) — pins where the
+ /// cottage door's BSP slab actually lives in world space relative
+ /// to the player's sphere.
///
///
/// The cottage door Setup 0x020019FF has:
@@ -319,58 +319,55 @@ public class DoorBugTrajectoryReplayTests
///
///
///
- /// With entity at (132.6, 17.1, 94.1) (the captured Holtburg cottage
- /// door spawn position):
+ /// AABB measured: min=(-0.954,-0.134,-1.236) max=(0.971,0.127,1.255).
+ /// The slab's local origin is at the slab's GEOMETRIC CENTER (each
+ /// axis is roughly symmetric around 0). With partFrame.Z = +1.275
+ /// lifting the local origin up from the entity, the slab's world
+ /// extents are:
+ ///
+ ///
///
- /// - 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.
+ /// - X: [131.635, 133.560] (1.925 m wide; after 180° entity Z rot)
+ /// - Y: [16.848, 17.109] (0.261 m thick)
+ /// - Z: [94.139, 96.630] (2.491 m tall, bottom JUST above floor)
///
+ ///
+ ///
+ /// Player sphere (radius 0.48, height 1.20) at floor Z=94 extends
+ /// Z=[94, 95.20]. Slab bottom (94.139) is BELOW sphere top (95.20)
+ /// by 1.061 m. The slab DOES overlap the sphere in Z over
+ /// world Z range [94.139, 95.20]. The slab is at sphere height,
+ /// not above it.
///
///
///
- /// 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.
+ /// The foot cylinder (r=0.10, h=0.20) sits at world Z [94.118, 94.318]
+ /// — barely above the floor, well within the sphere's foot region.
///
///
///
- /// 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.
- ///
+ /// Both shapes are at collision-able height. So the post-fix
+ /// inside-out walkthrough at off-center positions is NOT explained
+ /// by the slab being above the sphere. The bug must be in the BSP
+ /// polygon-level collision response, OR in how the multi-cell
+ /// portal-reachable cells produce the shapes list for a player on
+ /// the indoor side of the doorway. Next session investigation:
+ /// add a focused test that replays the captured inside-out
+ /// walkthrough tick with the door registered at its FAITHFUL
+ /// production transform (180° entity rot + dat-loaded partFrame)
+ /// and shows what BSPQuery.FindCollisions actually does at that
+ /// tick.
///
///
[Fact]
- public void Geometric_DoorSlabZRange_AbovePlayerSphereTop()
+ public void Geometric_DoorSlabAtSphereHeight_OverlapsInZ()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
- // Load the door setup + part 0's PlacementFrame.
DatReaderWriter.Types.Frame partFrame;
- float slabLocalZMax;
+ float slabLocalZMin = float.MaxValue;
+ float slabLocalZMax = float.MinValue;
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var setup = dats.Get(0x020019FFu)!;
@@ -380,38 +377,37 @@ public class DoorBugTrajectoryReplayTests
var gfx = dats.Get(DoorGfxObjId)!;
Assert.NotNull(gfx.PhysicsPolygons);
- // Compute local AABB Z max from vertex array.
- slabLocalZMax = float.MinValue;
+ // Walk every physics polygon vertex to find local Z extents.
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;
+ if (!gfx.VertexArray.Vertices.TryGetValue(vid, out var sv)) continue;
+ if (sv.Origin.Z < slabLocalZMin) slabLocalZMin = sv.Origin.Z;
+ 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;
+ // Slab local origin shifted up by partFrame.Z. Slab world Z extents:
+ float partWorldZ = DoorSpawnPos.Z + partFrame.Origin.Z;
+ float slabWorldZBottom = partWorldZ + slabLocalZMin;
+ float slabWorldZTop = partWorldZ + 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;
+ const float SphereHeight = 1.20f;
+ const float PlayerFootZ = 94f;
+ 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.");
+ // The slab IS at sphere height — bottom should be below sphere top.
+ Assert.True(slabWorldZBottom < sphereTopZ,
+ $"Door slab bottom ({slabWorldZBottom:F3}) should be BELOW " +
+ $"player sphere top ({sphereTopZ:F3}). Slab Z range = " +
+ $"[{slabWorldZBottom:F3}, {slabWorldZTop:F3}]. Player sphere Z = " +
+ $"[{PlayerFootZ:F3}, {sphereTopZ:F3}]. The slab IS at " +
+ $"sphere height (overlap from {MathF.Max(slabWorldZBottom, PlayerFootZ):F3} " +
+ $"to {MathF.Min(slabWorldZTop, sphereTopZ):F3}). So the inside-out " +
+ $"walkthrough is NOT caused by the slab being above the sphere — " +
+ $"the bug must be in BSP polygon-level collision response.");
}
[Fact]