diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs
new file mode 100644
index 0000000..ec58db2
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitTests.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using Xunit;
+using Env = System.Environment;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Tests for portal-graph expansion.
+///
+///
+/// A6.P5 (2026-05-25) — unit-tests the cellSet build for the cottage door
+/// scenario, where pre-fix the exitOutside gate produced over-
+/// penetration ticks (sphere advanced 0.27 m INTO the door slab because
+/// the door's outdoor cell wasn't in the cellSet during a cell-crossing
+/// substep).
+///
+///
+///
+/// Loads cells 0xA9B4013F (player's starting indoor cell at the
+/// over-penetration tick) and 0xA9B40150 (alcove cell adjacent to
+/// the doorway) from the real dat. Asserts that BFS expansion from
+/// 0xA9B4013F reaches the door's outdoor cell 0xA9B40029
+/// via the portal chain. The fix makes this hold unconditionally;
+/// pre-fix it only held when the sphere physically straddled an exit
+/// portal.
+///
+///
+public class CellTransitTests
+{
+ [Fact]
+ public void A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell()
+ {
+ var datDir = ResolveDatDir();
+ if (datDir is null) return;
+
+ // Hydrate the cells in the portal chain.
+ // 0xA9B4013F — start cell at the over-penetration tick
+ // 0xA9B40150 — alcove cell adjacent to doorway (has exit portal)
+ // The door lives in outdoor cell 0xA9B40029 (registered as a
+ // shadow entry on the landblock — not loaded here; this test
+ // only asserts the cellSet membership, not collision).
+ var cache = new PhysicsDataCache();
+ HydrateCell(cache, datDir, 0xA9B4013Fu);
+ HydrateCell(cache, datDir, 0xA9B40150u);
+
+ Assert.NotNull(cache.GetCellStruct(0xA9B4013Fu));
+ Assert.NotNull(cache.GetCellStruct(0xA9B40150u));
+
+ // Sphere at the over-penetration tick start position: world
+ // (132.594, 16.350, 94.48 sphere center). Foot Z = 94, sphere
+ // radius 0.48 → center Z = 94 + radius? Actually the live
+ // capture's sphere center is at Z = 94.48 since foot Z = 94
+ // and sphere is foot-anchored. For this test the Z value
+ // doesn't matter — only XY portal membership.
+ var sphereWorld = new Vector3(132.5935f, 16.350428f, 94.48f);
+ const float sphereRadius = 0.48f;
+ const uint startCellId = 0xA9B4013Fu;
+ const uint doorOutdoorCell = 0xA9B40029u;
+
+ _ = CellTransit.FindCellSet(
+ cache,
+ sphereWorld,
+ sphereRadius,
+ startCellId,
+ out var cellSet);
+
+ Assert.True(
+ cellSet.Contains(doorOutdoorCell),
+ $"A6.P5: BFS portal expansion from indoor start cell " +
+ $"0x{startCellId:X8} (sphere world XY = " +
+ $"({sphereWorld.X:F3}, {sphereWorld.Y:F3})) should include " +
+ $"the door's outdoor cell 0x{doorOutdoorCell:X8} via the " +
+ $"portal chain 0x{startCellId:X8} → 0xA9B40150 → outdoor. " +
+ $"Pre-fix: the exitOutside gate required the sphere to " +
+ $"straddle 0xA9B40150's exit portal — sphere is in " +
+ $"0x{startCellId:X8}'s volume, so the gate didn't fire and " +
+ $"the door's cell wasn't added. Post-fix: gate removed; " +
+ $"exit-portal topology adds outdoor cells unconditionally. " +
+ $"Actual cellSet: {string.Join(",", cellSet.Select(c => $"0x{c:X8}"))}");
+ }
+
+ [Fact]
+ public void A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell()
+ {
+ var datDir = ResolveDatDir();
+ if (datDir is null) return;
+
+ // The alcove cell 0xA9B40150 IS the cell with the exit portal.
+ // Pre-fix this worked SOMETIMES (when the sphere straddled the
+ // portal). Post-fix it works ALWAYS. This is a regression guard
+ // for the previously-sometimes-working case.
+ var cache = new PhysicsDataCache();
+ HydrateCell(cache, datDir, 0xA9B40150u);
+
+ // Sphere at the stuck position — sphere center is NOT at the
+ // exit portal plane (sphere is in the alcove volume, away from
+ // the doorway threshold geometrically).
+ var sphereWorld = new Vector3(132.4014f, 16.761757f, 94.48f);
+ const float sphereRadius = 0.48f;
+
+ _ = CellTransit.FindCellSet(
+ cache,
+ sphereWorld,
+ sphereRadius,
+ 0xA9B40150u,
+ out var cellSet);
+
+ Assert.True(
+ cellSet.Contains(0xA9B40029u),
+ $"A6.P5: BFS from alcove cell 0xA9B40150 should always " +
+ $"include the door's outdoor cell 0xA9B40029. " +
+ $"Actual: {string.Join(",", cellSet.Select(c => $"0x{c:X8}"))}");
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static string? ResolveDatDir()
+ {
+ var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
+ ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
+ "Documents", "Asheron's Call");
+ return Directory.Exists(datDir) ? datDir : null;
+ }
+
+ private static void HydrateCell(PhysicsDataCache cache, string datDir, uint cellId)
+ {
+ const uint EnvCellPrefix = 0x0D000000u;
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+
+ var envCell = dats.Get(cellId);
+ if (envCell is null)
+ throw new InvalidOperationException(
+ $"Cell 0x{cellId:X8} missing from dat at {datDir}.");
+
+ var environment = dats.Get(
+ EnvCellPrefix | envCell.EnvironmentId);
+ if (environment is null)
+ throw new InvalidOperationException(
+ $"Environment 0x{EnvCellPrefix | envCell.EnvironmentId:X8} missing.");
+
+ if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
+ throw new InvalidOperationException(
+ $"CellStructure {envCell.CellStructure} missing in environment.");
+
+ var worldTransform =
+ Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
+ Matrix4x4.CreateTranslation(envCell.Position.Origin);
+
+ cache.CacheCellStruct(cellId, envCell, cellStruct, worldTransform);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
index c6fcb91..0c933b5 100644
--- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
@@ -1073,6 +1073,32 @@ public class DoorBugTrajectoryReplayTests
"No captured record matched the predicate. Update the fixture.");
}
+ ///
+ /// A6.P5 (2026-05-25) — loads one of the three fixed records from
+ /// over-penetration-capture.jsonl by index:
+ ///
+ /// - 0 — the over-penetration tick (cell-crossing 0xA9B4013F → 0xA9B40150)
+ /// - 1 — stuck-position hit=yes variant (door fired)
+ /// - 2 — stuck-position hit=no variant (door invisible — bug case)
+ ///
+ ///
+ private static ResolveCaptureRecord LoadOverPenRecord(int index)
+ {
+ var path = Path.Combine(FixtureDir, "over-penetration-capture.jsonl");
+ Assert.True(File.Exists(path),
+ $"A6.P5 over-penetration fixture missing: {path}. " +
+ $"Run tools/jsonl/extract-records.ps1 to rebuild.");
+
+ var lines = File.ReadAllLines(path);
+ Assert.True(lines.Length >= 3,
+ $"Expected >= 3 records in {path}; got {lines.Length}");
+
+ var raw = lines[index];
+ return System.Text.Json.JsonSerializer
+ .Deserialize(raw,
+ CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
+ }
+
///
/// Replays one captured ResolveWithTransition call against
/// , seeded with bodyBefore, and reports