diff --git a/docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md b/docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md
new file mode 100644
index 0000000..ce3e3e6
--- /dev/null
+++ b/docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md
@@ -0,0 +1,157 @@
+# Door bug — inside-out walkthrough: missing cottage exterior wall (geometry gap)
+2026-05-25, continuation of door-collision investigation
+
+## TL;DR
+
+The inside-out walkthrough that persisted after the
+`AddAllOutsideCells` fix is **NOT a collision-detection bug**. It's a
+**collision-geometry GAP**: the cottage's north exterior wall east
+(and presumably west) of the doorway opening doesn't exist in any
+registered entity our engine knows about. The sphere walks past the
+door slab on its east side, clears the doorway alcove cell's small
+east wall (Y range [16.5, 17.1]), and then has nothing in front of it
+in the collision representation — even though the VISUAL cottage has
+a wall there.
+
+## Apparatus diagnostics
+
+Three new tests landed (in `DoorBugTrajectoryReplayTests`):
+
+1. `Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace` — sphere
+ south moving north blocks. PASSES.
+2. `Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace` — sphere
+ north moving south blocks. PASSES.
+3. `Geometric_DoorSlabAtSphereHeight_OverlapsInZ` — pins slab world Z
+ range = [94.139, 96.630]; sphere top at Z=95.20 IS within slab.
+ The slab is at sphere height — BSP collision is geometrically active.
+4. `InsideOut_Tick3254_WithCottageWalls_ShouldBlock` — hypothesis test
+ adds cottage GfxObj 0x01000A2B. Result: cottage DID block but with
+ cn=(0,0,1) — a floor-cap response, NOT a wall response.
+5. `Diagnostic_CottagePolys_NearWalkthroughPosition` — dumps cottage
+ polygons near sphere XY=(133.655, 17.59), any Z. **Result: ZERO
+ cottage polygons in that area.** The cottage GfxObj has no
+ geometry where the sphere walks through.
+
+`DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection`
+extended to dump cell 0xA9B40150's 4 physics polys in world frame:
+
+```
+[0] sides=Landblock X=[131.600, 133.500] Y=[16.500, 17.100] Z=[94.000, 94.000] FLOOR
+[1] sides=Landblock X=[131.600, 131.600] Y=[16.500, 17.100] Z=[94.000, 96.500] WEST WALL
+[2] sides=Landblock X=[131.600, 133.500] Y=[16.500, 17.100] Z=[96.500, 96.500] CEILING
+[3] sides=Landblock X=[133.500, 133.500] Y=[16.500, 17.100] Z=[94.000, 96.500] EAST WALL
+```
+
+Cell 0xA9B40150 is the **doorway alcove** — a small ~1.9m × 0.6m × 2.5m
+volume between the cottage interior and the outdoor area. Its east wall
+only extends Y=[16.5, 17.1]. **North of Y=17.1, no wall** in this cell.
+
+The captured failing sphere at (133.655, 17.59) is 0.155m east of the
+east wall AND 0.49m NORTH of the wall's Y range. The wall doesn't
+reach the sphere.
+
+## The collision-geometry gap
+
+Visual representation (in-client):
+- Cottage has a north exterior wall east and west of the doorway opening
+- The wall extends Y > 17.1 (north of the alcove)
+- User sees their character partially clipping into this wall
+
+Collision representation (what we register):
+- Cottage GfxObj 0x01000A2B: **0 polygons** in the area (133.655, 17.59, 94-95.20)
+- Cell 0xA9B40150 (alcove): walls only at Y=[16.5, 17.1]
+- Door slab: only spans X=[131.635, 133.560] — too narrow to cover the cottage opening
+- Outdoor cell 0xA9B40029: outdoor cell, no walls
+
+**Net: no entity has wall polygons at (133.655, Y > 17.1).** Sphere can
+walk there freely.
+
+## Verification in production capture
+
+`door-fix-inout2.launch.log` shows:
+- Cottage GfxObj `[bsp-test]` fires 425 times during inside-out walking
+ (so visibility is correct post-fix)
+- Door slab `[bsp-test]` fires 245 times
+- Captured tick 3254: sphere at (133.655, 17.590), target (133.549,
+ 17.599). Result: position X=133.655 unchanged (blocked westward),
+ position Y=17.599 (moved north freely). cn=(+1, 0, 0) = slab east
+ face normal.
+- The slab east face blocks WEST motion correctly. The sphere is FREE
+ to move north because no geometry covers (133.655, Y > 17.1).
+
+## What's next
+
+**Identify which entity SHOULD own the cottage's north exterior wall
+east of the doorway.** Three candidates:
+
+1. **A different cottage GfxObj.** Holtburg cottages might be
+ multi-piece (separate GfxObjs for wall sections, doorway frame, roof).
+ The cottage we have (0x01000A2B) might be one of multiple. Check
+ the landblock's static-entity list for other GfxObjs at the cottage
+ position via `[entity-source]` log + Setup file.
+
+2. **A landblock-baked "stab"** (separate static entity registered at
+ spawn time). LandblockLoader produces these. Check `LandBlockInfo`
+ dat record for landblock 0xA9B4 — what other entities are at world
+ (~133, ~18)?
+
+3. **The cottage GfxObj's drawing geometry is wider than its physics.**
+ If 0x01000A2B has `Polygons` (visual) at the wall location but no
+ `PhysicsPolygons` (collision), the visual is wider than the
+ collision. This is a dat-data fact — not fixable without retail
+ re-engineering of the dat.
+
+For candidates 1-2, the fix is "register the missing entity." For 3,
+the bug is dat-side (or retail accepts the same walkthrough we do).
+
+**Cheapest next-step test:** add a method to
+`DoorSetupGfxObjInspectionTests` that loads `LandBlockInfo` 0xA9B4FFFE
+(landblock-baked statics) and prints every static at world XY in
+[131, 135] × [16, 19]. The output will name what other GfxObjs/Setups
+are registered at the cottage doorway — if any include the missing
+wall, we know what to register additionally.
+
+## Apparatus committed
+
+- `tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs`:
+ faithful door registration, directional collision tests, geometric
+ pin test, cottage GfxObj hypothesis test, cottage polygon dump.
+- `tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs`:
+ HoltburgCottage_CellPortals_DatInspection extended with cell-poly
+ world-frame dump.
+
+All tests under `DoorBugTrajectoryReplayTests` and the extended
+`DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection`
+PASS (skip on CI when dat dir absent).
+
+## Pickup prompt for next session
+
+```
+A6.P4 door inside-out walkthrough: identified as collision-geometry
+gap, NOT collision-detection bug. The cottage's north exterior wall
+east+west of the doorway opening isn't represented in any registered
+entity. Sphere walks freely at (133.655, 17.59) — no wall to block.
+
+ Read docs/research/2026-05-25-door-bug-inside-out-geometry-gap.md
+ + Diagnostic_CottagePolys_NearWalkthroughPosition test output
+ + HoltburgCottage_CellPortals_DatInspection dump for cell 0x0150
+
+ State both altitudes:
+ Currently working toward: M1.5 — Indoor world feels right
+ Current phase: A6.P4 door bug — find missing cottage wall entity.
+ The fix isn't in BSP, cells, or AddAllOutsideCells
+ — those are correct. The collision geometry has a
+ gap. Need to identify which entity SHOULD own the
+ wall and register it.
+
+ First move: add a LandblockStatics_DatInspection test to
+ DoorSetupGfxObjInspectionTests that loads LandBlockInfo 0xA9B4FFFE
+ + iterates StaticObjects. Print every entity at world XY in
+ [131, 135] x [16, 19] — name + setup id + position. Will reveal
+ what other entities (if any) live at the cottage doorway.
+
+ If a wall-bearing entity exists but we're not registering it: fix
+ the registration path. If nothing exists: the dat doesn't have the
+ wall, and this might be retail-faithful behavior we have to accept
+ (or compensate for by widening the door slab via gameplay layer).
+```
diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
index f1711d5..716d7f6 100644
--- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
@@ -359,6 +359,157 @@ public class DoorBugTrajectoryReplayTests
/// tick.
///
///
+ ///
+ /// A6.P4 inside-out bug investigation (2026-05-25 late evening) —
+ /// hypothesis test: the asymmetric inside-out walkthrough is the
+ /// sphere walking AROUND the door slab via the cottage wall area
+ /// east/west of the doorway opening. The cottage exterior walls
+ /// are part of GfxObj 0x01000A2B (the cottage building, same one
+ /// from issue #98's cellar floor cap). Issue #98's indoor-primary-cell
+ /// gate removed cottage-WALL visibility along with the cottage FLOOR
+ /// — too aggressive. From indoor primary cells, the cottage walls
+ /// adjacent to the doorway can't block the sphere.
+ ///
+ ///
+ /// This test reproduces the captured tick 3254 (sphere at
+ /// (133.655, 17.590, 94) in indoor cell 0xA9B40150, moving to
+ /// (133.549, 17.599, 94)) with the cottage GfxObj registered as
+ /// landblock-baked static. If, with the cottage walls visible, the
+ /// sphere is blocked from being at X=133.655 (which is OUTSIDE the
+ /// doorway opening, INSIDE the cottage wall geometry), the bug
+ /// is confirmed as #98's overly-aggressive gate.
+ ///
+ ///
+ [Fact]
+ public void InsideOut_Tick3254_WithCottageWalls_ShouldBlock()
+ {
+ var datDir = ResolveDatDir();
+ if (datDir is null) return;
+
+ var (engine, cache) = BuildFaithfulDoorEngine(datDir);
+
+ // Add cottage GfxObj 0x01000A2B as landblock-baked static,
+ // mirroring production GameWindow.RegisterLiveEntityCollision's
+ // cellScope=0u (landblock-wide).
+ const uint CottageGfxId = 0x01000A2Bu;
+ const uint CottageEntityId = 0x00A9B479u; // matches issue #98 fixture id
+ var cottageFixturePath = Path.Combine(SolutionRoot(),
+ "tests", "AcDream.Core.Tests", "Fixtures", "issue98",
+ "0x01000A2B.gfxobj.json");
+ Assert.True(File.Exists(cottageFixturePath));
+ 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);
+
+ // Replay captured tick 3254 inputs exactly.
+ var currentPos = new Vector3(133.65524f, 17.58999f, 94f);
+ var targetPos = new Vector3(133.54903f, 17.599283f, 94f);
+ var (result, body) = ResolveAt(engine, currentPos, targetPos, 0xA9B40150u);
+
+ // Expected: cottage wall east of doorway blocks the sphere
+ // from being at X=133.655 (or, at minimum, blocks the +Y slide).
+ // Currently (per the user's report) the sphere walks past unimpeded.
+ Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
+ "Harness tick 3254 reply: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) " +
+ "cnValid={6} cell=0x{7:X8}",
+ result.Position.X, result.Position.Y, result.Position.Z,
+ result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z,
+ result.CollisionNormalValid, result.CellId));
+
+ // Document the production state via assertion: the sphere DID make
+ // Y motion (+0.009) at this tick (target.Y > input.Y). If the
+ // cottage wall blocks correctly, harness Y should stay at input Y
+ // (sphere fully blocked, can't move north past cottage wall).
+ // Currently this test demonstrates the bug shape.
+ Assert.True(result.Position.Y < targetPos.Y - 0.005f,
+ $"BUG REPRODUCTION: harness allowed Y motion ({result.Position.Y}) toward " +
+ $"target ({targetPos.Y}). Cottage wall should block sphere at X=133.655 " +
+ $"(0.095 m east of slab east edge). If this assertion FAILS, the cottage " +
+ $"wall is now blocking as expected — the #98 gate fix landed.");
+ }
+
+ ///
+ /// Diagnostic: dump cottage GfxObj 0x01000A2B polygons near world
+ /// position (133.655, 17.59, 94.5) — the sphere position where the
+ /// inside-out walkthrough happens. Identifies which cottage polys
+ /// are at sphere height in that area, to know whether walls / floors
+ /// / nothing.
+ ///
+ [Fact]
+ public void Diagnostic_CottagePolys_NearWalkthroughPosition()
+ {
+ var fixturePath = Path.Combine(SolutionRoot(),
+ "tests", "AcDream.Core.Tests", "Fixtures", "issue98",
+ "0x01000A2B.gfxobj.json");
+ var dump = GfxObjDumpSerializer.Read(fixturePath);
+ var physics = GfxObjDumpSerializer.Hydrate(dump);
+
+ // Cottage world transform: pos (130.5, 11.5, 94), rotation 180° Z.
+ var cottagePos = new Vector3(130.5f, 11.5f, 94.0f);
+ var cottageRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
+
+ // Failing sphere position: (133.655, 17.59, 94 .. 95.20)
+ // Sphere world AABB: X[133.175, 134.135], Y[17.110, 18.070], Z[94, 95.20]
+ var sphereCenterX = 133.655f;
+ var sphereCenterY = 17.59f;
+
+ Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
+ "Cottage GfxObj 0x01000A2B: {0} polys total, BS radius {1:F3}",
+ physics.Resolved.Count, physics.BoundingSphere?.Radius ?? 0f));
+ Console.WriteLine("Looking for polys whose world-vertex bbox overlaps sphere AABB:");
+ Console.WriteLine($" Sphere X=[{sphereCenterX-0.48f:F3}, {sphereCenterX+0.48f:F3}]");
+ Console.WriteLine($" Sphere Y=[{sphereCenterY-0.48f:F3}, {sphereCenterY+0.48f:F3}]");
+ Console.WriteLine($" Sphere Z=[94.000, 95.200]");
+
+ int matched = 0;
+ int matchedXY = 0;
+ Console.WriteLine("");
+ Console.WriteLine("=== All cottage polys with XY overlap (any Z) ===");
+ foreach (var (polyId, poly) in physics.Resolved)
+ {
+ // Transform vertices to world space.
+ float wxMin = float.MaxValue, wxMax = float.MinValue;
+ float wyMin = float.MaxValue, wyMax = float.MinValue;
+ float wzMin = float.MaxValue, wzMax = float.MinValue;
+ foreach (var v in poly.Vertices)
+ {
+ var rotated = Vector3.Transform(v, cottageRot);
+ var world = cottagePos + rotated;
+ if (world.X < wxMin) wxMin = world.X; if (world.X > wxMax) wxMax = world.X;
+ if (world.Y < wyMin) wyMin = world.Y; if (world.Y > wyMax) wyMax = world.Y;
+ if (world.Z < wzMin) wzMin = world.Z; if (world.Z > wzMax) wzMax = world.Z;
+ }
+ bool xOverlap = wxMax >= sphereCenterX - 0.48f && wxMin <= sphereCenterX + 0.48f;
+ bool yOverlap = wyMax >= sphereCenterY - 0.48f && wyMin <= sphereCenterY + 0.48f;
+ bool zOverlap = wzMax >= 94f && wzMin <= 95.20f;
+ if (xOverlap && yOverlap)
+ {
+ matchedXY++;
+ var nWorld = Vector3.Transform(poly.Plane.Normal, cottageRot);
+ string zMark = zOverlap ? " *** Z-OVERLAP ***" : "";
+ Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
+ " poly 0x{0:X4} n=({1:F3},{2:F3},{3:F3}) bbox X=[{4:F3},{5:F3}] Y=[{6:F3},{7:F3}] Z=[{8:F3},{9:F3}]{10}",
+ polyId, nWorld.X, nWorld.Y, nWorld.Z, wxMin, wxMax, wyMin, wyMax, wzMin, wzMax, zMark));
+ }
+ if (xOverlap && yOverlap && zOverlap) matched++;
+ }
+ Console.WriteLine($" XY-overlap polys (any Z): {matchedXY}");
+ Console.WriteLine($" XYZ-overlap polys: {matched}");
+ }
+
[Fact]
public void Geometric_DoorSlabAtSphereHeight_OverlapsInZ()
{
diff --git a/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs
index 1420661..723aac2 100644
--- a/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs
@@ -314,6 +314,31 @@ public class DoorSetupGfxObjInspectionTests
_out.WriteLine($" CellStruct polygons = {cellStruct.Polygons?.Count ?? 0} (visible)");
_out.WriteLine($" CellStruct physicsPolys = {cellStruct.PhysicsPolygons?.Count ?? 0}");
+ // Dump ALL physics polygons (collision walls/floor) in world frame
+ // so we can see what blocks a sphere at world (133.655, 17.59).
+ _out.WriteLine($" CellStruct PHYSICS polys (world frame):");
+ var cellOrigin = envCell.Position.Origin;
+ var cellRot = envCell.Position.Orientation;
+ for (int pi = 0; pi < cellStruct.PhysicsPolygons.Count; pi++)
+ {
+ var (pid, poly) = (cellStruct.PhysicsPolygons.Keys.ElementAt(pi),
+ cellStruct.PhysicsPolygons.Values.ElementAt(pi));
+ float wxMin = float.MaxValue, wxMax = float.MinValue;
+ float wyMin = float.MaxValue, wyMax = float.MinValue;
+ float wzMin = float.MaxValue, wzMax = float.MinValue;
+ foreach (var vid in poly.VertexIds)
+ {
+ if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)vid, out var sv)) continue;
+ var rotated = System.Numerics.Vector3.Transform(sv.Origin, cellRot);
+ var world = cellOrigin + rotated;
+ if (world.X < wxMin) wxMin = world.X; if (world.X > wxMax) wxMax = world.X;
+ if (world.Y < wyMin) wyMin = world.Y; if (world.Y > wyMax) wyMax = world.Y;
+ if (world.Z < wzMin) wzMin = world.Z; if (world.Z > wzMax) wzMax = world.Z;
+ }
+ _out.WriteLine($" [{pi}=0x{pid:X4}] sides={poly.SidesType} " +
+ $"X=[{wxMin:F3},{wxMax:F3}] Y=[{wyMin:F3},{wyMax:F3}] Z=[{wzMin:F3},{wzMax:F3}]");
+ }
+
// Dump the plane of each portal polygon (the planes
// FindTransitCellsSphere tests the sphere center against).
for (int i = 0; i < envCell.CellPortals.Count; i++)