diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index c3eacb08..a8209b6c 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -71,6 +71,10 @@ public sealed class RetailPViewRenderer private readonly HashSet _cellParticleOwnerScratch = new(); private readonly HashSet _dynamicParticleOwnerScratch = new(); + // The walk's TRUE root flood as a set, rebuilt per frame for the + // DynamicLast stage gate (the stage-set split — synthesis plan step 4). + private readonly HashSet _rootFloodSetScratch = new(); + // MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/ // Dynamics), reused across frames instead of `new`ing a Result (a Dictionary // + 2 Lists, plus one List per visible cell) every DrawInside @@ -507,6 +511,16 @@ public sealed class RetailPViewRenderer HashSet outsideStageFlood = walkActive ? walkDriver!.VisitedCells : drawableCells; + // The stage-set split: the walk's TRUE root flood (never the + // visited union) keys which interior-parented dynamics may ride + // the last pass — see BuildOutsideDynamicRoutes' sibling gate. + _rootFloodSetScratch.Clear(); + if (walkActive) + { + foreach (uint cellId in walkDriver!.InteriorFloodCells) + _rootFloodSetScratch.Add(cellId); + } + if (_sceneFrameProduct is not null) { frameView = _sceneFrameProduct.BuildAndBorrow( @@ -517,7 +531,11 @@ public sealed class RetailPViewRenderer outsideStageFlood, ctx.Cells, ctx.AnimatedEntityIds, - ctx.RootCell.IsOutdoorNode); + ctx.RootCell.IsOutdoorNode, + // Non-walk frames (diagnostic fakes / legacy fallback) + // keep the pre-split admission: drawableCells WAS the + // flood in the legacy meaning. + walkActive ? _rootFloodSetScratch : drawableCells); frameViewBorrowed = true; frameEntityPasses!.BeginEntityFrame(in frameView); entityFrameOpen = true; diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index b4495fbd..451a8b34 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -13,7 +13,8 @@ internal readonly record struct RenderScenePViewBuildInput( HashSet DrawableCells, IRetailPViewCellSource Cells, HashSet? AnimatedEntityIds, - bool RootIsOutdoor); + bool RootIsOutdoor, + IReadOnlySet RootFloodCells); internal readonly record struct RenderFrameProductComparisonSnapshot( bool Enabled, @@ -188,7 +189,8 @@ internal sealed class RenderScenePViewFrameProductController : HashSet drawableCells, IRetailPViewCellSource cells, HashSet? animatedEntityIds, - bool rootIsOutdoor) + bool rootIsOutdoor, + IReadOnlySet rootFloodCells) { ArgumentNullException.ThrowIfNull(portalFrame); ArgumentNullException.ThrowIfNull(clipAssembly); @@ -215,7 +217,8 @@ internal sealed class RenderScenePViewFrameProductController : drawableCells, cells, animatedEntityIds, - rootIsOutdoor); + rootIsOutdoor, + rootFloodCells); _builder.Build(_exchange, frameSequence, in input); return _exchange.BorrowLatest( scene.Generation, @@ -235,8 +238,12 @@ internal sealed class RenderScenePViewFrameProductController : HashSet? animatedEntityIds, uint tupleLandblockId, bool rootIsOutdoor, - Vector3 cameraWorldPosition = default) + Vector3 cameraWorldPosition = default, + IReadOnlySet? rootFloodCells = null) { + // Comparison/diagnostic path: absent an explicit walk root flood, + // drawableCells keeps the pre-stage-split admission (drawableCells + // WAS the flood in the legacy meaning these fixtures pin). RenderFrameView view = BuildAndBorrow( portalFrame, clipAssembly, @@ -245,7 +252,8 @@ internal sealed class RenderScenePViewFrameProductController : drawableCells, cells, animatedEntityIds, - rootIsOutdoor); + rootIsOutdoor, + rootFloodCells ?? drawableCells); try { Compare( @@ -1417,6 +1425,33 @@ internal sealed class RenderScenePViewFrameBuilder continue; } + // The stage-set split (synthesis plan step 4, [dyn-route]-pinned + // 2026-08-30): an interior-parented dynamic rides the LAST pass + // ONLY when its parent cell is in THE WALK'S OWN ROOT FLOOD. + // Retail draws a look-in cell's occupants inside the landscape + // stage through the composed portal chain, and an unreachable + // cell's occupants not at all — the legacy visibility builder + // invents cross-building cell views at the cathedral (real + // 3-5-plane cones for 0xF4180112 from roots with no sightline), + // which let a remote player draw post-clear/post-world through + // opaque walls. The walk's flood is oracle-trace-conformant; + // key the route on it. + if (indoor && !input.RootFloodCells.Contains(parentCellId!.Value)) + { + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) + { + string state = $"parent={parentCellId.Value:x8} rootflood-excluded"; + if (!_probeDynRouteStates.TryGetValue(record.Source.LocalEntityId, out string? prev) + || prev != state) + { + _probeDynRouteStates[record.Source.LocalEntityId] = state; + Console.WriteLine( + $"[dyn-route] id={record.Source.LocalEntityId:x} {state}"); + } + } + continue; + } + Sphere(in record, out Vector3 center, out float radius); bool visible = indoor ? input.Viewcone.SphereVisibleInCell( diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs index 86b08063..eba4ea90 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -162,7 +162,8 @@ public sealed class RenderScenePViewFrameProductTests [Cell], EmptyCellSource.Instance, [], - RootIsOutdoor: true); + RootIsOutdoor: true, + RootFloodCells: new HashSet { Cell }); builder.Build(exchange, frameSequence, in input); } }