diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 55ba9be8..5a383538 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -103,6 +103,33 @@ Copy this block when adding a new issue:
---
+## #246 — Prepared indoor cells stopped traversing their portals
+
+**Status:** DONE — 2026-07-26; connected house/dungeon gate passed
+**Severity:** HIGH
+**Filed:** 2026-07-26
+**Component:** physics / cell membership / portal visibility
+
+**Description:** Houses and dungeons could collide with empty space, expose
+the background through walls, retain the wrong room, and fail on stairs after
+the parsed collision graph was removed.
+
+**Root cause / status:** The prepared package retained exact portal topology,
+planes, and BSPs, but `CellTransit.FindTransitCellsSphere` still returned
+immediately when the old parsed portal-polygon dictionary was absent. It now
+consumes the prepared topology's direct polygon index, preserving retail's
+portal traversal and failing loudly on corrupt topology. Synthetic graph-free
+tests and the exact connected Holtburg cottage camera path pass, along with
+1,778 physics tests / 1 skip.
+
+**Research:**
+[`research/2026-07-26-prepared-indoor-transit-regression.md`](research/2026-07-26-prepared-indoor-transit-regression.md).
+
+**Acceptance:** PASSED — the user confirmed house/dungeon collision, doorway
+entry, stairs, and interior rendering work in the corrected connected build.
+
+---
+
## #245 — Distant Use stayed busy without turning or walking
**Status:** DONE — 2026-07-25; connected gate passed
diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
index 83f2d89c..e414f8d0 100644
--- a/docs/architecture/worldbuilder-inventory.md
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -168,6 +168,16 @@ This remains one `DatCollection`, one preparation algorithm, and one package
mmap. Evidence:
`docs/research/2026-07-25-slice-i7-closeout.md`.
+**Prepared indoor-transit consumer correction (2026-07-26).** The package
+already retained exact portal planes through
+`FlatEnvCellTopology.PolygonIndex` plus the aliased CellStruct portal-polygon
+table. The production `CellTransit` consumer now reads that prepared
+relationship directly; it no longer treats the intentionally absent parsed
+`CellPhysics.PortalPolygons` dictionary as “this cell has no portals.” No
+package schema, bake, DAT reader, collision formula, or render portal graph
+changed. Evidence:
+`docs/research/2026-07-26-prepared-indoor-transit-regression.md`.
+
**Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter
2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while
retail inherits the complete `CreateParticleHook` payload. The narrow readers in
diff --git a/docs/plans/2026-07-25-modern-runtime-slice-i.md b/docs/plans/2026-07-25-modern-runtime-slice-i.md
index 6be2ea9b..6c83cb61 100644
--- a/docs/plans/2026-07-25-modern-runtime-slice-i.md
+++ b/docs/plans/2026-07-25-modern-runtime-slice-i.md
@@ -343,6 +343,15 @@ GPU/frame-time and ordinary-process memory comparison remains a physical-
display measurement, not a Slice-I correctness blocker. Evidence:
[`../research/2026-07-25-slice-i7-closeout.md`](../research/2026-07-25-slice-i7-closeout.md).
+**Corrective gate 2026-07-26.** Later connected house/dungeon testing exposed
+one I7 consumer omission: `CEnvCell::find_transit_cells` still required the
+deleted parsed portal-polygon dictionary, even though the prepared topology
+and polygon table retained the exact same plane. The production consumer now
+uses that direct prepared index. Synthetic graph-free tests and the captured
+Holtburg cottage camera path prove identical graph/prepared results; no
+package rebake or parsed-graph restoration is needed. Evidence:
+[`../research/2026-07-26-prepared-indoor-transit-regression.md`](../research/2026-07-26-prepared-indoor-transit-regression.md).
+
## 4. Commit order
Each unit is independently buildable and reversible:
diff --git a/docs/research/2026-07-26-prepared-indoor-transit-regression.md b/docs/research/2026-07-26-prepared-indoor-transit-regression.md
new file mode 100644
index 00000000..1a2f41f2
--- /dev/null
+++ b/docs/research/2026-07-26-prepared-indoor-transit-regression.md
@@ -0,0 +1,90 @@
+# Prepared indoor transit regression
+
+**Date:** 2026-07-26
+**Scope:** Slice-I prepared collision consumer correction
+**Behavioral boundary:** No collision formula, portal rule, or renderer
+behavior changed. This restores the existing retail traversal over the
+production flat representation.
+
+## Symptom and connected evidence
+
+After the Slice-I parsed-graph removal, entering houses or dungeons could:
+
+- collide with empty space at a doorway or room boundary;
+- retain the wrong indoor cell on stairs or upper floors;
+- expose the world background through the interior portal view.
+
+The connected capture at
+`.test-out/indoor-visibility-20260726-220007/stdout.log` placed the player in
+Holtburg cottage vestibule `0xA9B40150`, with the chase-camera eye outside the
+doorway in outdoor landcell `0xA9B40029`. The original DAT graph advanced the
+camera transition from `0xA9B40150` to `0xA9B40029`. The prepared production
+path retained `0xA9B40150`, causing collision and portal rendering to share
+the same wrong viewer-cell root.
+
+## Retail oracle
+
+Named retail:
+
+- `CEnvCell::find_transit_cells @ 0x0052C820`
+- `CObjCell::find_cell_list`
+- `CEnvCell::find_visible_child_cell`
+
+Consolidated pseudocode:
+[`acclient_indoor_transitions_pseudocode.md`](acclient_indoor_transitions_pseudocode.md).
+
+For each `CCellPortal`, retail reads that portal's polygon plane from the
+owning `CCellStruct`. An exterior portal admits outdoor cells when any path
+sphere straddles the plane. An interior portal tests the loaded neighbor's
+cell-containment BSP, using the plane only as an unloaded-cell hint.
+
+## Root cause
+
+The prepared package was complete:
+
+- `FlatEnvCellTopology.Portals[i].PolygonIndex` retained the direct portal
+ polygon index;
+- `FlatCellStructureCollisionAsset.PortalPolygons` retained the exact plane;
+- prepared containment and physics BSPs matched the original DAT graph.
+
+`CellTransit.FindTransitCellsSphere`, however, still began with:
+
+```text
+if currentCell.PortalPolygons is null: return
+```
+
+`PortalPolygons` is the old parsed-DAT dictionary and is intentionally null in
+production after Slice I7. Therefore every prepared indoor cell skipped its
+entire portal loop. The I4/I5 differential referee covered BSP queries and
+complete resolver results, but did not exercise portal-plane lookup after the
+parsed graph had been removed, allowing the representation-boundary defect to
+escape.
+
+## Correction
+
+`CellTransit` now resolves the exact same portal plane from:
+
+1. the parsed dictionary in explicit graph-oracle fixtures; or
+2. the prepared topology's direct polygon index and flat polygon table in
+ production.
+
+Prepared topology/list mismatches and invalid polygon indices fail loudly as
+corrupt publication rather than silently disabling indoor traversal. Portal
+iteration order, comparisons, sphere transforms, neighbor containment, and
+outside admission remain unchanged.
+
+## Automated evidence
+
+- Synthetic prepared exterior-portal test with no parsed polygon dictionary.
+- Synthetic prepared loaded-neighbor test with no parsed containment tree.
+- Exact connected Holtburg cottage camera path:
+ graph and prepared paths both end in `0xA9B40029` at identical position.
+- Installed-DAT graph/flat coverage expanded across the Holtburg cottage and
+ inn vestibules, connectors, stairwell, upper floor, and inn shell.
+- Physics-focused Release tests: 1,778 passed / 1 skipped.
+- Interaction/projection focused App tests: 127 passed.
+- Client-object ordering tests: 82 passed.
+- Complete Release build: passed with zero warnings.
+
+No divergence-register row is required: the correction removes an accidental
+production divergence and introduces no replacement behavior.
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index f84737d2..3c5cc1ed 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -101,12 +101,21 @@ public static class CellTransit
uint lbPrefix = currentCellId & 0xFFFF0000u;
int sphereCount = EffectiveSphereCount(worldSpheres, numSpheres);
- if (currentCell.PortalPolygons is null || sphereCount == 0) return;
+ if (sphereCount == 0) return;
- foreach (var portal in currentCell.Portals)
+ for (int portalIndex = 0;
+ portalIndex < currentCell.Portals.Count;
+ portalIndex++)
{
- if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
+ PortalInfo portal = currentCell.Portals[portalIndex];
+ if (!TryGetPortalPlane(
+ currentCell,
+ portalIndex,
+ portal,
+ out Plane portalPlane))
+ {
continue;
+ }
if (portal.OtherCellId == 0xFFFF)
{
@@ -142,7 +151,9 @@ public static class CellTransit
float pad = sphere.Radius + FEpsilon;
var localCenter = Vector3.Transform(
sphere.Origin, currentCell.InverseWorldTransform);
- float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
+ float dist =
+ Vector3.Dot(localCenter, portalPlane.Normal) +
+ portalPlane.D;
if (dist > -pad && dist < pad)
{
exitOutside = true;
@@ -190,7 +201,9 @@ public static class CellTransit
float rad = sphere.Radius + EPSILON;
var localCenter = Vector3.Transform(
sphere.Origin, currentCell.InverseWorldTransform);
- float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
+ float dist =
+ Vector3.Dot(localCenter, portalPlane.Normal) +
+ portalPlane.D;
bool hit = portal.PortalSide ? dist > -rad : dist < rad;
if (hit)
{
@@ -201,6 +214,64 @@ public static class CellTransit
}
}
+ ///
+ /// Resolves the portal plane from whichever immutable representation owns
+ /// this cell. Graph fixtures retain the DAT polygon dictionary; production
+ /// cells intentionally retain only the prepared topology's direct polygon
+ /// index and flat polygon table.
+ ///
+ private static bool TryGetPortalPlane(
+ CellPhysics cell,
+ int portalIndex,
+ PortalInfo portal,
+ out Plane plane)
+ {
+ if (cell.PortalPolygons is not null &&
+ cell.PortalPolygons.TryGetValue(
+ portal.PolygonId,
+ out ResolvedPolygon? polygon))
+ {
+ plane = polygon.Plane;
+ return true;
+ }
+
+ FlatEnvCellTopology? topology = cell.FlatTopology;
+ FlatPolygonTable? polygonTable = cell.FlatPortalPolygons;
+ if (topology is null || polygonTable is null)
+ {
+ plane = default;
+ return false;
+ }
+
+ if ((uint)portalIndex >= (uint)topology.Portals.Length)
+ {
+ throw new InvalidDataException(
+ $"Cell 0x{cell.SourceId:X8} portal {portalIndex} is absent " +
+ "from its prepared topology.");
+ }
+
+ FlatEnvCellPortal flatPortal = topology.Portals[portalIndex];
+ if (flatPortal.OtherCellId != portal.OtherCellId ||
+ flatPortal.PolygonId != portal.PolygonId ||
+ flatPortal.Flags != portal.Flags)
+ {
+ throw new InvalidDataException(
+ $"Cell 0x{cell.SourceId:X8} portal {portalIndex} does not " +
+ "match its prepared topology.");
+ }
+
+ int polygonIndex = flatPortal.PolygonIndex;
+ if ((uint)polygonIndex >= (uint)polygonTable.Polygons.Length)
+ {
+ throw new InvalidDataException(
+ $"Cell 0x{cell.SourceId:X8} portal {portalIndex} references " +
+ $"invalid prepared polygon index {polygonIndex}.");
+ }
+
+ plane = polygonTable.Polygons[polygonIndex].Plane;
+ return true;
+ }
+
///
/// Outdoor neighbour expansion. Ported from
/// CLandCell::add_all_outside_cells (sphere variant,
diff --git a/tests/AcDream.Core.Tests/Physics/CameraCornerSealReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CameraCornerSealReplayTests.cs
index 1d94e784..7977211b 100644
--- a/tests/AcDream.Core.Tests/Physics/CameraCornerSealReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CameraCornerSealReplayTests.cs
@@ -290,4 +290,88 @@ public class CameraCornerSealReplayTests
"Viewer sweep through a verified-open doorway path was obstructed or cut short " +
"(camera would wrongly pull in at openings) for: " + string.Join(", ", failures));
}
+
+ [Fact]
+ public void HoltburgCottageExit_ViewerSweep_AdvancesFromAlcoveToOutdoor()
+ {
+ var datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null) return;
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var graphCache = new PhysicsDataCache();
+ var preparedCache = PhysicsDataCache.CreateProduction();
+ for (uint low = 0x013Fu; low <= 0x0150u; low++)
+ {
+ uint cellId = ConformanceDats.HoltburgLandblock | low;
+ ConformanceDats.LoadEnvCell(
+ dats,
+ graphCache,
+ cellId);
+
+ CellPhysics graphCell = Assert.IsType(
+ graphCache.GetCellStruct(cellId));
+ FlatCellCollisionAsset prepared =
+ FlatCollisionAssetBuilder.FlattenCell(graphCell);
+ var datCell = Assert.IsType(
+ dats.Get(cellId));
+ preparedCache.CacheCellStruct(
+ cellId,
+ datCell,
+ graphCell.WorldTransform,
+ prepared.Structure,
+ prepared.Topology);
+ }
+
+ var graphEngine = new PhysicsEngine { DataCache = graphCache };
+ var preparedEngine = new PhysicsEngine { DataCache = preparedCache };
+
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ for (int i = 0; i < heightTable.Length; i++)
+ heightTable[i] = -1000f;
+ foreach (PhysicsEngine engine in new[] { graphEngine, preparedEngine })
+ {
+ engine.AddLandblock(
+ ConformanceDats.HoltburgLandblock,
+ new TerrainSurface(heights, heightTable),
+ Array.Empty(),
+ Array.Empty(),
+ 0f,
+ 0f);
+ }
+
+ // Connected capture indoor-visibility-20260726-220007. The player is
+ // just inside the narrow 0x0150 vestibule, facing into the cottage.
+ // Its chase camera is behind it, outside the open doorway. Retail's
+ // viewer CTransition crosses 0x0150's 0xFFFF portal and carries the
+ // outdoor landcell as viewer_cell.
+ var player = new Vector3(132.70f, 16.80f, 94.00f);
+ var pivot = player + new Vector3(0f, 0f, PivotHeight);
+ var eye = new Vector3(131.30f, 19.97f, 96.25f);
+
+ var graphPivot = graphEngine.AdjustPosition(0xA9B40150u, pivot);
+ var preparedPivot = preparedEngine.AdjustPosition(0xA9B40150u, pivot);
+ Assert.True(graphPivot.found);
+ Assert.True(preparedPivot.found);
+ Assert.Equal(0xA9B40150u, graphPivot.cellId);
+ Assert.Equal(graphPivot.cellId, preparedPivot.cellId);
+
+ ResolveResult graphResult = SweepViewer(
+ graphEngine,
+ pivot,
+ eye,
+ 0xA9B40150u);
+ ResolveResult preparedResult = SweepViewer(
+ preparedEngine,
+ pivot,
+ eye,
+ 0xA9B40150u);
+
+ _out.WriteLine(FormattableString.Invariant(
+ $"graph=0x{graphResult.CellId:X8} prepared=0x{preparedResult.CellId:X8} graphPos=({graphResult.Position.X:F3},{graphResult.Position.Y:F3},{graphResult.Position.Z:F3}) preparedPos=({preparedResult.Position.X:F3},{preparedResult.Position.Y:F3},{preparedResult.Position.Z:F3})"));
+
+ Assert.Equal(0xA9B40029u, graphResult.CellId);
+ Assert.Equal(graphResult.CellId, preparedResult.CellId);
+ Assert.Equal(graphResult.Position, preparedResult.Position);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs
index d6bffb22..a6c2f253 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs
@@ -16,6 +16,7 @@ public class CellTransitFindTransitCellsSphereTests
Root = new CellBSPNode
{
// Local x >= 0 is inside this synthetic cell.
+ Type = DatReaderWriter.Enums.BSPNodeType.BPIn,
SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f),
PosNode = leaf,
}
@@ -28,6 +29,7 @@ public class CellTransitFindTransitCellsSphereTests
// Portal poly at local x=2.5 (right wall), normal +X.
var portalPolyA = new ResolvedPolygon
{
+ Id = 10,
Vertices = new[]
{
new Vector3(2.5f, -2.5f, 0f),
@@ -54,6 +56,24 @@ public class CellTransitFindTransitCellsSphereTests
};
}
+ private static CellPhysics PrepareCell(CellPhysics graphCell, uint sourceId)
+ {
+ FlatCellCollisionAsset prepared =
+ FlatCollisionAssetBuilder.FlattenCell(graphCell);
+ return new CellPhysics
+ {
+ SourceId = sourceId,
+ WorldTransform = graphCell.WorldTransform,
+ InverseWorldTransform = graphCell.InverseWorldTransform,
+ Resolved = new Dictionary(),
+ FlatPhysicsBsp = prepared.Structure.PhysicsBsp,
+ FlatContainmentBsp = prepared.Structure.ContainmentBsp,
+ FlatPortalPolygons = prepared.Structure.PortalPolygons,
+ FlatTopology = prepared.Topology,
+ Portals = graphCell.Portals,
+ };
+ }
+
[Fact]
public void SphereInsideCellA_NearPortal_AddsCellB()
{
@@ -216,4 +236,73 @@ public class CellTransitFindTransitCellsSphereTests
Assert.True(exitOutside);
}
+
+ [Fact]
+ public void PreparedExitPortal_UsesIndexedPlaneWithoutGraphDictionary()
+ {
+ const uint cellId = 0xA9B40100u;
+ CellPhysics graphCell = MakeCellWithPortalAtRightWall(
+ Matrix4x4.Identity,
+ otherCellId: 0xFFFF,
+ flags: 0);
+ CellPhysics preparedCell = PrepareCell(graphCell, cellId);
+ var cache = PhysicsDataCache.CreateProduction();
+ cache.RegisterCellStructForTest(cellId, preparedCell);
+
+ var candidates = new HashSet();
+ CellTransit.FindTransitCellsSphere(
+ cache,
+ preparedCell,
+ cellId,
+ new Vector3(2.0f, 0f, 2.5f),
+ sphereRadius: 0.5f,
+ candidates,
+ out bool exitOutside);
+
+ Assert.Null(preparedCell.PortalPolygons);
+ Assert.True(exitOutside);
+ }
+
+ [Fact]
+ public void PreparedLoadedNeighbor_UsesFlatContainmentWithoutGraphObjects()
+ {
+ const uint cellAId = 0xA9B40100u;
+ const uint cellBId = 0xA9B40101u;
+ CellPhysics graphCellA = MakeCellWithPortalAtRightWall(
+ Matrix4x4.Identity,
+ otherCellId: 0x0101,
+ flags: 2);
+
+ var cellBTransform = Matrix4x4.CreateTranslation(
+ new Vector3(5f, 0f, 0f));
+ Matrix4x4.Invert(cellBTransform, out Matrix4x4 cellBInverse);
+ var graphCellB = new CellPhysics
+ {
+ WorldTransform = cellBTransform,
+ InverseWorldTransform = cellBInverse,
+ Resolved = new Dictionary(),
+ CellBSP = SinglePlaneCellBsp(),
+ };
+
+ CellPhysics preparedCellA = PrepareCell(graphCellA, cellAId);
+ CellPhysics preparedCellB = PrepareCell(graphCellB, cellBId);
+ var cache = PhysicsDataCache.CreateProduction();
+ cache.RegisterCellStructForTest(cellAId, preparedCellA);
+ cache.RegisterCellStructForTest(cellBId, preparedCellB);
+
+ var candidates = new HashSet();
+ CellTransit.FindTransitCellsSphere(
+ cache,
+ preparedCellA,
+ cellAId,
+ new Vector3(4.75f, 0f, 2.5f),
+ sphereRadius: 0.5f,
+ candidates,
+ out bool exitOutside);
+
+ Assert.Null(preparedCellA.PortalPolygons);
+ Assert.Null(preparedCellB.CellBSP);
+ Assert.Contains(cellBId, candidates);
+ Assert.False(exitOutside);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/FlatBspQueryDifferentialTests.cs b/tests/AcDream.Core.Tests/Physics/FlatBspQueryDifferentialTests.cs
index 25354389..99a9eee1 100644
--- a/tests/AcDream.Core.Tests/Physics/FlatBspQueryDifferentialTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/FlatBspQueryDifferentialTests.cs
@@ -425,6 +425,13 @@ public sealed class FlatBspQueryDifferentialTests
0x8A02_016Eu,
0x8A02_017Au,
0xA9B4_013Fu,
+ 0xA9B4_0150u,
+ 0xA9B4_0159u,
+ 0xA9B4_015Au,
+ 0xA9B4_0161u,
+ 0xA9B4_0162u,
+ 0xA9B4_0164u,
+ 0xA9B4_0166u,
})
{
var cache = new PhysicsDataCache();
diff --git a/tests/AcDream.Core.Tests/Physics/FlatCollisionInstalledDatTests.cs b/tests/AcDream.Core.Tests/Physics/FlatCollisionInstalledDatTests.cs
index 4f60b45e..8a493228 100644
--- a/tests/AcDream.Core.Tests/Physics/FlatCollisionInstalledDatTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/FlatCollisionInstalledDatTests.cs
@@ -32,6 +32,13 @@ public sealed class FlatCollisionInstalledDatTests
0x8A02_016Eu, // Facility Hub corridor
0x8A02_017Au, // asymmetric adjoining corridor
0xA9B4_013Fu, // Holtburg cottage
+ 0xA9B4_0150u, // Holtburg cottage exterior vestibule
+ 0xA9B4_0159u, // Holtburg inn south room
+ 0xA9B4_015Au, // Holtburg inn ground-floor connector
+ 0xA9B4_0161u, // Holtburg inn upper-floor connector
+ 0xA9B4_0162u, // Holtburg inn main room / stairwell
+ 0xA9B4_0164u, // Holtburg inn exterior vestibule
+ 0xA9B4_0166u, // Holtburg inn upper floor
})
{
var cache = new PhysicsDataCache();
@@ -118,6 +125,7 @@ public sealed class FlatCollisionInstalledDatTests
foreach (uint gfxObjId in new[]
{
0x0100_0A2Bu, // Holtburg cottage shell
+ 0x0100_0C17u, // Holtburg inn shell
0x0100_0AC5u, // outdoor stair/ramp fixture
0x0100_44B5u, // multipart door shell
})