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 })