diff --git a/docs/launch-options.md b/docs/launch-options.md
index 7037b2a2..4da25126 100644
--- a/docs/launch-options.md
+++ b/docs/launch-options.md
@@ -315,6 +315,7 @@ issue is closed, the strip was missed; delete both.
| `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` |
| `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-disp]` / `[clip-route-scis]` lines: outside-slice clip routing, region-SSBO bytes, terrain-UBO head, actual GL/RHI scissor state | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` |
| `ACDREAM_PROBE_WALK_SHADOW` | Campaign FW3.2b-2 flip apparatus — dies with the flip commit | `=1` | runs the PRODUCTION retail frame walk per frame in SHADOW (no draws change) and prints one `[walk-shadow]` line per frame whose visited cell/building sets diverge from the old visibility path (the I5 dual-shadow pattern applied to the FW3 static cutover) | print-only; adds the per-frame CPU cost of the shadow walk while set | `RenderingDiagnostics.ProbeWalkShadowEnabled` |
+| `ACDREAM_PROBE_WALK_ROOT` | FW3 visual-gate apparatus (stairwell-transition defect) — dies with that fix | `=1` | after every interior/outdoor root flip, prints 8 `[walk-root]` lines (one per frame): viewer cell, root cell, camera-cell resolution, slice count, eye position, and the walk's visited cell list — pins which crossing frames root wrong or flood short | print-only | `RenderingDiagnostics.ProbeWalkRootEnabled` |
| `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` |
| `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B |
| `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` |
diff --git a/docs/plans/2026-08-30-campaign-fw-frame-walk.md b/docs/plans/2026-08-30-campaign-fw-frame-walk.md
index 3c457685..fbfaf8d2 100644
--- a/docs/plans/2026-08-30-campaign-fw-frame-walk.md
+++ b/docs/plans/2026-08-30-campaign-fw-frame-walk.md
@@ -475,6 +475,37 @@ hygiene).
**Goal:** everything draws at its walk turn; the patch apparatus and the
old visibility layer are gone.
+**FW4 SLICE 1 — the interior outside-view source cutover (2026-08-30,
+pulled forward from the FW3 visual gate).** The owner's stairwell/grass
+transition report (grass briefly covering floor openings at doorway
+crossings, intermittent — the #119 family) probe-pinned to the FW3 dual
+path: the walk decided WHETHER terrain draws (`_interiorPView.OutsideView.
+ViewCount > 0`) while the old `PortalVisibilityBuilder` assembly decided
+WHERE (slice clip planes + count + scissor), and the walk's punch fans
+indexed the OLD slice array with WALK view indices. At boundary frames
+(`ACDREAM_PROBE_WALK_ROOT` captures: the slices=2 short-flood family at
+OUT→IN, a 1-frame outdoor blip mid-crossing) the old builder's exit views
+went fat/degenerate, terrain splashed over interior pixels, and the
+depth-clear preserves COLOR — any cell absent from the walk's flood never
+repainted its pixels. Retail cannot produce this: it has ONE visibility
+structure. The cutover: `ClipFrameAssembler.ReassembleOutsideViewFromWalk`
+materializes the walk's own `outside_view` (pixel points → standard NDC →
+the existing `ClipPlaneSet.From` machinery) into the assembly's
+outside-view block right after Collect, before the single
+`PrepareClipFrame` publication (moved below the walk block); the
+`Landscape` event now carries the walk's active-view count (rides the
+record's existing `OutsideViewCount` field — trace mapping compares kind
+only, ZERO oracle-fixture churn) and the driver fans exactly that many
+terrain slices — `activeTerrainSliceCount` is deleted end to end. Outdoor
+roots keep the assembler's single full-screen slice (asserted ==1;
+identical content by construction). Suites: hermetic 6,762/0 (4 new
+materializer tests pinning the y-flip + plane-sign conventions), Walk
+lane 209/1, InstalledDat walk conformance 40/1 — all unchanged or grown.
+Remaining desync surfaces deliberately left for the rest of FW4: exit
+SEALS + cell slices still assemble from the old per-cell views (identical
+dat portal polygons; only the visible SET can differ), and interior-root
+look-in seeding still reads the old outside view.
+
- Entities gate per view via `Render::viewconeCheck` at their cell's walk
turn; dynamics-last and alpha interleave per the walk's stages
(`RetailAlphaQueue` becomes the stream's alpha stage or is absorbed —
diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs
index c7b50def..4b12fda6 100644
--- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs
+++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs
@@ -144,6 +144,18 @@ public sealed class ClipFrameAssembly
internal void SetOutsideViewSlices(ClipViewSlice[] slices) => OutsideViewSlices = slices;
+ /// FW4 slice 1: returns the outside-view slice array to the
+ /// pool ahead of a same-frame reassembly from the walk's own views
+ /// () —
+ /// without this the replaced array would leak from the slice pool for
+ /// the frame.
+ internal void ReturnOutsideViewSlicesForReassembly()
+ {
+ if (OutsideViewSlices.Length != 0)
+ ReturnSlices(OutsideViewSlices);
+ OutsideViewSlices = System.Array.Empty();
+ }
+
private ClipViewSlice[] RentSlices(int length)
{
if (_sliceArraysByLength.TryGetValue(length, out Stack? pool)
@@ -380,6 +392,143 @@ public static class ClipFrameAssembler
return assembly;
}
+ ///
+ /// Campaign FW4 slice 1 — the interior root's outside-view cutover.
+ /// Replaces the assembly's outside-view block (slices, terrain mode,
+ /// scissor/NDC bounds, plane count) with slices derived from THE WALK'S
+ /// OWN outside_view (),
+ /// filled by the walk's ConstructView during Collect. Retail has
+ /// exactly ONE visibility structure per frame: LScape::draw's
+ /// terrain clip, the punch fans' building_view planes, and the
+ /// landscape turn's view count all read the views the walk itself
+ /// installed. Feeding these from the old PortalVisibilityBuilder
+ /// assembly let the two systems desynchronize on camera-transition
+ /// boundary frames — terrain splashed through stale/fat old-apparatus
+ /// exit views over interior pixels the walk's flood never repainted
+ /// (the FW3 visual-gate stairwell/grass flash, probe-pinned
+ /// 2026-08-30), and punch fans indexed the old slice array with walk
+ /// view indices.
+ ///
+ /// The walk stores view vertices as PIXEL screen points
+ /// (copy_view's post-divide viewport coordinates, origin
+ /// top-left, +Y down — :
+ /// x=(W/2)(x_c+w), y=(H/2)(w−y_c)); inverting that mapping yields the
+ /// standard NDC this assembler's s already use
+ /// (ndcX = 2x/W − 1, ndcY = 1 − 2y/H). Winding is normalized inside
+ /// , and the closing
+ /// duplicate vertex copy_view stores is merged there too.
+ ///
+ /// Must run AFTER the walk's Collect and BEFORE
+ /// PrepareClipFrame publishes the clip regions — appended slots
+ /// join the same single publication.
+ ///
+ public static void ReassembleOutsideViewFromWalk(
+ ClipFrameAssembly assembly,
+ Walk.WalkPortalView outsideView,
+ float viewportWidth,
+ float viewportHeight)
+ {
+ System.ArgumentNullException.ThrowIfNull(assembly);
+ System.ArgumentNullException.ThrowIfNull(outsideView);
+ if (viewportWidth <= 0f || viewportHeight <= 0f)
+ {
+ throw new System.ArgumentOutOfRangeException(
+ nameof(viewportWidth),
+ $"viewport {viewportWidth}x{viewportHeight} — the walk projected its "
+ + "views through a real viewport; a non-positive extent here means the "
+ + "caller handed a different frame's context (fail-loud rule).");
+ }
+
+ ClipFrame frame = assembly.Frame;
+ int viewCount = outsideView.ViewCount;
+ var polys = outsideView.View.Polys;
+ var pool = outsideView.View.Vertices;
+ if (polys.Count < viewCount)
+ {
+ throw new System.InvalidOperationException(
+ $"walk outside_view holds {polys.Count} polys for ViewCount={viewCount} — "
+ + "the view set's append bookkeeping desynchronized (fail-loud rule).");
+ }
+
+ assembly.ReturnOutsideViewSlicesForReassembly();
+
+ List outsideSlicesList = assembly.SliceScratch;
+ outsideSlicesList.Clear();
+ int outsideMaxPlaneCount = 0;
+ bool outsideHasScissorFallback = false;
+ int scissorFallbacks = assembly.ScissorFallbacks;
+ float unionMinX = float.MaxValue, unionMinY = float.MaxValue;
+ float unionMaxX = float.MinValue, unionMaxY = float.MinValue;
+
+ for (int v = 0; v < viewCount; v++)
+ {
+ Walk.WalkViewPoly walkPoly = polys[v];
+ var vertices = new Vector2[walkPoly.VertexCount];
+ for (int k = 0; k < walkPoly.VertexCount; k++)
+ {
+ Vector2 px = pool[walkPoly.VertexIndex + k].Point;
+ vertices[k] = new Vector2(
+ px.X / viewportWidth * 2f - 1f,
+ 1f - px.Y / viewportHeight * 2f);
+ }
+ var poly = new ViewPolygon(vertices);
+ if (!poly.IsEmpty)
+ {
+ if (poly.MinX < unionMinX) unionMinX = poly.MinX;
+ if (poly.MinY < unionMinY) unionMinY = poly.MinY;
+ if (poly.MaxX > unionMaxX) unionMaxX = poly.MaxX;
+ if (poly.MaxY > unionMaxY) unionMaxY = poly.MaxY;
+ }
+
+ var cps = ClipPlaneSet.From(poly);
+ if (cps.IsNothingVisible)
+ continue;
+
+ int slot;
+ Vector4[] planes;
+ if (cps.Count > 0)
+ {
+ planes = cps.PlaneArray;
+ slot = frame.AppendSlot(planes);
+ if (cps.Count > outsideMaxPlaneCount)
+ outsideMaxPlaneCount = cps.Count;
+ }
+ else
+ {
+ planes = System.Array.Empty();
+ slot = 0;
+ outsideHasScissorFallback = true;
+ scissorFallbacks++;
+ }
+
+ outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
+ }
+
+ ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
+ bool outdoorVisible = outsideViewSlices.Length > 0;
+ int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
+ TerrainClipMode terrainMode = !outdoorVisible
+ ? TerrainClipMode.Skip
+ : (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes);
+
+ Vector4 outsideViewNdcAabb = outdoorVisible
+ ? new Vector4(unionMinX, unionMinY, unionMaxX, unionMaxY)
+ : Vector4.Zero;
+ Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
+ ? outsideViewNdcAabb
+ : Vector4.Zero;
+
+ assembly.SetOutsideViewSlices(outsideViewSlices);
+ assembly.OutdoorSlot = outdoorSlot;
+ assembly.OutdoorVisible = outdoorVisible;
+ assembly.TerrainMode = terrainMode;
+ assembly.TerrainScissorNdcAabb = terrainScissor;
+ assembly.HasOutsideView = outdoorVisible;
+ assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
+ assembly.OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0;
+ assembly.ScissorFallbacks = scissorFallbacks;
+ }
+
///
/// Appends the cell views used by nested DrawBuilding -> DrawPortal
/// PViews to the already assembled frame. Retail installs each nested
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index f2f82e2c..decc9296 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -100,6 +100,13 @@ public sealed class RetailPViewRenderer
// cleared in finally.
private Action? _walkPreClearDynamics;
+ // ACDREAM_PROBE_WALK_ROOT (FW3 visual-gate apparatus, throwaway): the
+ // previous frame's root kind + a post-flip frame countdown so each
+ // interior/outdoor transition dumps 8 frames of rooting facts.
+ private bool? _probeWalkRootPrevOutdoor;
+ private int _probeWalkRootFramesLeft;
+ private ulong _probeWalkRootFrame;
+
// Campaign FW3.2b-2: the walk's production world-data registries
// (published/retired by LandblockRenderPublisher) plus the per-frame
// driver state. Null until the composition passes them; the static
@@ -189,8 +196,10 @@ public sealed class RetailPViewRenderer
pvFrame,
_clipAssemblyScratch);
passes.AppendLookInClipFrames(_lookInFrames, clipAssembly);
- int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
- passes.PrepareClipFrame(terrainUploadCount);
+ // FW4 slice 1: PrepareClipFrame (the one clip-region publication)
+ // moved BELOW the walk block — an interior-rooted walk frame
+ // re-derives the outside-view slices from the walk's own views
+ // first, so the appended slots join the same single publication.
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
@@ -288,15 +297,15 @@ public sealed class RetailPViewRenderer
}
}
- int activeTerrainSliceCount = clipAssembly.OutsideViewSlices.Length;
- if (ctx.RootCell.IsOutdoorNode && activeTerrainSliceCount != 1)
+ if (ctx.RootCell.IsOutdoorNode && clipAssembly.OutsideViewSlices.Length != 1)
{
throw new InvalidOperationException(
"walk static cutover: an outdoor root's clip assembly produced "
- + $"{activeTerrainSliceCount} outside-view slices, not the expected 1 — "
- + "WalkFrameDriver's landscape turn assumes the outdoor root's default "
- + "full-screen view (plan §FW3 item 2c's pinned assumption; assert "
- + "rather than silently coercing to 1).");
+ + $"{clipAssembly.OutsideViewSlices.Length} outside-view slices, not the "
+ + "expected 1 — the outdoor root draws through the full-screen default "
+ + "view (retail set_default_view; the walk fans exactly its own 1), and "
+ + "the outdoor slice data still comes from the assembler (plan §FW3 item "
+ + "2c's pinned assumption; assert rather than silently coercing to 1).");
}
_walkWorldData!.BeginFrame(
@@ -333,9 +342,60 @@ public sealed class RetailPViewRenderer
walkDriver.Collect(
_frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext,
- ctx.ViewProjection, ctx.CameraWorldPosition, activeTerrainSliceCount);
+ ctx.ViewProjection, ctx.CameraWorldPosition);
+
+ // FW4 slice 1: an interior root's terrain/sky/punch clip slices
+ // come from THE WALK'S OWN outside_view — retail's one
+ // visibility structure. See ClipFrameAssembler.
+ // ReassembleOutsideViewFromWalk's doc comment for the boundary-
+ // frame desync (the stairwell/grass flash) this retires. The
+ // outdoor root keeps the assembler's single full-screen slice
+ // (asserted ==1 above; identical content by construction).
+ if (walkCameraCell is not null)
+ {
+ ClipFrameAssembler.ReassembleOutsideViewFromWalk(
+ clipAssembly,
+ _frameWalk.InteriorOutsideView,
+ viewportWidth,
+ viewportHeight);
+ }
+
+ if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled)
+ {
+ _probeWalkRootFrame++;
+ bool outdoorNow = ctx.RootCell.IsOutdoorNode;
+ if (_probeWalkRootPrevOutdoor is bool prev && prev != outdoorNow)
+ {
+ _probeWalkRootFramesLeft = 8;
+ Console.WriteLine(
+ $"[walk-root] ---- FLIP {(prev ? "OUT->IN" : "IN->OUT")} at frame {_probeWalkRootFrame} ----");
+ }
+ _probeWalkRootPrevOutdoor = outdoorNow;
+ if (_probeWalkRootFramesLeft > 0)
+ {
+ _probeWalkRootFramesLeft--;
+ var cellsList = new List(walkDriver.VisitedCells);
+ cellsList.Sort();
+ string cells = string.Join(",", cellsList.ConvertAll(c => c.ToString("x8"))
+ .GetRange(0, Math.Min(cellsList.Count, 10)));
+ Console.WriteLine(
+ $"[walk-root] f={_probeWalkRootFrame} out={(outdoorNow ? 1 : 0)} "
+ + $"viewer=0x{ctx.ViewerCellId:X8} root=0x{ctx.RootCell.CellId:X8} "
+ + $"res={ctx.CameraCellResolution} slices={clipAssembly.OutsideViewSlices.Length} "
+ + $"eye=({ctx.ViewerEyePos.X:F2},{ctx.ViewerEyePos.Y:F2},{ctx.ViewerEyePos.Z:F2}) "
+ + $"walkCells={walkDriver.VisitedCells.Count} bld={walkDriver.VisitedBuildings.Count} "
+ + $"cells=[{cells}]");
+ }
+ }
}
+ // FW4 slice 1: the ONE clip-region publication, after any walk
+ // reassembly so the walk-derived outside-view slots are included
+ // (moved from directly after AssembleClipFrame; the count is
+ // reservation metadata the RHI arm ignores).
+ int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
+ passes.PrepareClipFrame(terrainUploadCount);
+
// #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
index 11a13f3f..52b5cdfe 100644
--- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
+++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
@@ -68,6 +68,16 @@ public sealed class RetailFrameWalk
/// under the same conditions).
public float DegradeMultiplier = WalkBuilding.DefaultDegradeMultiplier;
+ /// FW4 slice 1: the interior root's outside_view — the
+ /// exit-view polygons THIS walk's own ConstructView installed
+ /// during the most recent . The production
+ /// renderer materializes the frame's terrain/sky/punch clip slices from
+ /// these views (retail has exactly one visibility structure; the old
+ /// PortalVisibilityBuilder assembly no longer feeds the walk path's
+ /// outside views). Valid after returns for an
+ /// interior root, until the next interior root's view push resets it.
+ internal WalkPortalView InteriorOutsideView => _interiorPView.OutsideView;
+
/// The per-frame root (SmartBox::RenderNormalMode).
/// may be null only when the camera is
/// outdoors.
@@ -137,7 +147,7 @@ public sealed class RetailFrameWalk
WalkLandscape landscape, WalkPortalView activeViews,
IRetailFrameWalkContext ctx, IWalkEventSink sink)
{
- sink.Emit(WalkEvent.Landscape());
+ sink.Emit(WalkEvent.Landscape(activeViews.ViewCount));
landscape.CalcDrawOrder();
landscape.CheckBlocks(ctx.CyPlane, activeViews);
diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs
index 53517ffb..a2c911d3 100644
--- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs
@@ -35,8 +35,17 @@ public readonly record struct WalkEvent(
int OutsideViewCount,
IReadOnlyList Cells)
{
- public static WalkEvent Landscape()
- => new(WalkEventKind.Landscape, 0, 0, Array.Empty());
+ /// FW4 slice 1: the landscape turn carries the ACTIVE view
+ /// set's ViewCount (retail LScape::draw iterates the
+ /// views the walk itself installed — 1 for the outdoor root's
+ /// full-screen default view, the interior root's own surviving
+ /// exit-view count otherwise). The driver fans exactly this many
+ /// terrain slices; the old clip apparatus no longer supplies the
+ /// count. Reuses the record's existing
+ /// field; the conformance trace mapping compares the kind only
+ /// (LS), so oracle fixtures are unaffected.
+ public static WalkEvent Landscape(int activeViewCount)
+ => new(WalkEventKind.Landscape, 0, activeViewCount, Array.Empty());
public static WalkEvent Building(uint positionCellId)
=> new(WalkEventKind.Building, positionCellId, 0, Array.Empty());
diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
index 66dc548e..475f0a1a 100644
--- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
@@ -395,7 +395,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink
private IWalkBuildingFrameContext? _ctx;
private Matrix4x4 _viewProjection;
private Vector3 _cameraWorldPosition;
- private int _activeTerrainSliceCount;
private bool _skyDrawnThisFrame;
private WalkDrawStage? _currentDcStage;
private bool _readyToReplay;
@@ -429,15 +428,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink
IGpuFrame frame,
IGpuPassEncoder encoder,
Matrix4x4 viewProjection,
- Vector3 cameraWorldPosition,
- int activeTerrainSliceCount = 1)
+ Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(encoder);
Collect(
walk, cameraCellId, cameraCell, landscape, ctx,
- viewProjection, cameraWorldPosition, activeTerrainSliceCount);
+ viewProjection, cameraWorldPosition);
Replay(frame, encoder);
}
@@ -454,14 +452,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink
WalkLandscape landscape,
IRetailFrameWalkContext ctx,
Matrix4x4 viewProjection,
- Vector3 cameraWorldPosition,
- int activeTerrainSliceCount)
+ Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(walk);
ArgumentNullException.ThrowIfNull(landscape);
ArgumentNullException.ThrowIfNull(ctx);
- BeginFrame(ctx, viewProjection, cameraWorldPosition, activeTerrainSliceCount);
+ BeginFrame(ctx, viewProjection, cameraWorldPosition);
walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this);
EndFrame();
}
@@ -478,16 +475,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink
internal void BeginFrame(
IWalkBuildingFrameContext ctx,
Matrix4x4 viewProjection,
- Vector3 cameraWorldPosition,
- int activeTerrainSliceCount)
+ Vector3 cameraWorldPosition)
{
ArgumentNullException.ThrowIfNull(ctx);
- if (activeTerrainSliceCount < 0)
- {
- throw new ArgumentOutOfRangeException(
- nameof(activeTerrainSliceCount), activeTerrainSliceCount,
- "The active terrain slice count cannot be negative.");
- }
if (_ctx is not null)
{
throw new InvalidOperationException(
@@ -499,7 +489,6 @@ internal sealed class WalkFrameDriver : IWalkEventSink
_ctx = ctx;
_viewProjection = viewProjection;
_cameraWorldPosition = cameraWorldPosition;
- _activeTerrainSliceCount = activeTerrainSliceCount;
_skyDrawnThisFrame = false;
_currentDcStage = null;
_readyToReplay = false;
@@ -616,7 +605,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink
VisitedCells.Add(walkEvent.CellId);
break;
case WalkEventKind.Landscape:
- HandleLandscapeTurn();
+ HandleLandscapeTurn(walkEvent.OutsideViewCount);
break;
case WalkEventKind.DrawCells:
foreach (uint id in walkEvent.Cells)
@@ -710,9 +699,18 @@ internal sealed class WalkFrameDriver : IWalkEventSink
// Turn handlers
// ------------------------------------------------------------------
- private void HandleLandscapeTurn()
+ private void HandleLandscapeTurn(int activeViewCount)
{
RequireOpenFrame();
+ if (activeViewCount < 1)
+ {
+ throw new InvalidOperationException(
+ $"A Landscape turn fired with {activeViewCount} active views — "
+ + "RetailFrameWalk only draws the landscape through an installed view set "
+ + "(the outdoor root's full-screen default view, or an interior root's "
+ + "surviving exit views, both at least 1). A zero/negative count is a "
+ + "walk/driver desync (Campaign FW fail-loud rule).");
+ }
if (_skyDrawnThisFrame)
{
throw new InvalidOperationException(
@@ -727,7 +725,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink
MarkIfGrown();
_events.Add(WalkFrameEvent.Sky());
_skyDrawnThisFrame = true;
- for (int slice = 0; slice < _activeTerrainSliceCount; slice++)
+ // FW4 slice 1: the fan count is the WALK'S OWN active view count
+ // (carried on the Landscape event) — retail LScape::draw runs once
+ // per view the walk installed. The old clip apparatus's slice count
+ // no longer participates; the renderer re-derives the actual slice
+ // clip data from the same walk views before replay, so index i here
+ // and OutsideViewSlices[i] there are the same view by construction.
+ for (int slice = 0; slice < activeViewCount; slice++)
_events.Add(WalkFrameEvent.TerrainSlice(slice));
}
diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
index d7dcdef9..c2d39323 100644
--- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
+++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
@@ -171,6 +171,17 @@ public static class RenderingDiagnostics
public static bool ProbeWalkShadowEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_WALK_SHADOW") == "1";
+ ///
+ /// FW3 visual-gate apparatus (2026-08-30, throwaway — dies with the
+ /// stairwell-transition fix): when true, RetailPViewRenderer prints one
+ /// [walk-root] line per frame for the 8 frames after every
+ /// interior/outdoor ROOT flip — viewer cell, resolution, slice count,
+ /// and the walk's visited cell list — pinning which frames draw grass
+ /// over floor openings during a doorway crossing and why.
+ ///
+ public static bool ProbeWalkRootEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_WALK_ROOT") == "1";
+
///
/// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits
/// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues,
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index 85e44e27..48b7dd51 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -24,8 +24,12 @@ public sealed class RetailPViewPassExecutorTests
"begin",
"assemble",
"append-look-in-clips",
- "prepare-clip:3",
+ // FW4 slice 1: the clip-region publication moved below the
+ // walk block (a walk-rooted interior frame re-derives the
+ // outside-view slices from the walk's own views first, and
+ // the appended slots must join the same single publication).
"indoor-routing",
+ "prepare-clip:3",
"prepare-cells",
"diagnostics",
"terrain-clip",
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
index 1da4ff6e..e8022358 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
@@ -233,8 +233,7 @@ public sealed class WalkFrameDriverTests
using DrawScope draw = fx.BeginDraw();
driver.RunFrame(
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape,
- ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero,
- activeTerrainSliceCount: 1);
+ ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
Assert.Equal(
new[]
@@ -306,8 +305,7 @@ public sealed class WalkFrameDriverTests
using DrawScope draw = fx.BeginDraw();
driver.RunFrame(
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: new WalkLandscape(),
- ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero,
- activeTerrainSliceCount: 0);
+ ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
// No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape
// at all — but CLEAR/SEALS still fire unconditionally.
@@ -401,7 +399,7 @@ public sealed class WalkFrameDriverTests
Assert.Equal(1, activeView.ViewCount);
using DrawScope draw = fx.BeginDraw();
- driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
walk.DrawBuilding(building, activeView, ctx, driver);
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
@@ -434,7 +432,7 @@ public sealed class WalkFrameDriverTests
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
using DrawScope draw = fx.BeginDraw();
- driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
Assert.Throws(
() => ((IWalkEventSink)driver).Emit(WalkEvent.DrawCells(0, [0x100u])));
@@ -451,15 +449,15 @@ public sealed class WalkFrameDriverTests
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
using DrawScope draw = fx.BeginDraw();
- driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
Assert.Throws(
() => driver.BeginFrame(
- ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0));
+ ctx, Matrix4x4.Identity, Vector3.Zero));
driver.EndFrame();
// EndFrame cleared the open-frame guard: BeginFrame is usable again.
- driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
driver.EndFrame();
}
@@ -488,7 +486,7 @@ public sealed class WalkFrameDriverTests
var driver = new WalkFrameDriver(fx.Dispatcher, new RecordingLeafRenderer(log), new FakeWorldData());
using DrawScope draw = fx.BeginDraw();
- driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
Assert.Throws(() => driver.Replay(draw.Frame, draw.Pass));
}
@@ -516,7 +514,7 @@ public sealed class WalkFrameDriverTests
using DrawScope draw = fx.BeginDraw();
driver.Collect(
walk, cameraCellId: 0u, cameraCell: null, landscape, ctx,
- Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero, activeTerrainSliceCount: 1);
+ Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
// No GPU calls at all yet — Collect is CPU-only.
Assert.Empty(log);
@@ -549,7 +547,7 @@ public sealed class WalkFrameDriverTests
fx.Dispatcher, new RecordingLeafRenderer(log), worldData, new RecordingTrace(log));
using DrawScope draw = fx.BeginDraw();
- driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero, activeTerrainSliceCount: 0);
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u);
Assert.Empty(log); // no GPU work at Collect time; nothing recorded to the log yet
driver.EndFrame();
diff --git a/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs b/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs
new file mode 100644
index 00000000..ab07b7b6
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs
@@ -0,0 +1,147 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Walk;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// Campaign FW4 slice 1 — :
+/// an interior root's outside-view slices come from the walk's OWN
+/// outside_view (pixel screen points, origin top-left, +Y down),
+/// converted to the assembler's standard-NDC s.
+///
+public class WalkOutsideViewReassemblyTests
+{
+ private const float W = 1024f, H = 720f;
+
+ private sealed class StubRays : IWalkRayCaster
+ {
+ public Vector3 RayThrough(float screenX, float screenY) =>
+ Vector3.Normalize(new Vector3(screenX - W / 2f, screenY - H / 2f, 1000f));
+ }
+
+ private static WalkPortalView WalkViewOfPixelQuads(params Vector2[][] pixelQuads)
+ {
+ var view = new WalkPortalView();
+ var rays = new StubRays();
+ foreach (Vector2[] quad in pixelQuads)
+ {
+ var pts = new WalkScreenPoint[quad.Length];
+ for (int i = 0; i < quad.Length; i++)
+ pts[i] = new WalkScreenPoint(quad[i].X, quad[i].Y, 0f, 1f);
+ Assert.True(WalkCopyView.Append(view, pts, rays, Vector3.Zero));
+ }
+ return view;
+ }
+
+ private static ClipFrameAssembly AssembledWithOldOutside()
+ {
+ var pv = new PortalVisibilityFrame();
+ pv.OutsideView.Add(new ViewPolygon(new[]
+ {
+ new Vector2(-0.9f, -0.9f), new Vector2(0.9f, -0.9f),
+ new Vector2(0.9f, 0.9f), new Vector2(-0.9f, 0.9f),
+ }));
+ return ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
+ }
+
+ [Fact]
+ public void PixelDoorway_MapsToExpectedNdcAabbAndPlanes()
+ {
+ ClipFrameAssembly asm = AssembledWithOldOutside();
+ // Pixel quad x∈[256,512], y∈[180,360] → NDC x∈[-0.5,0], y∈[0,0.5]
+ // (yNdc = 1 − 2·py/H flips the axis: py=180 → +0.5, py=360 → 0).
+ WalkPortalView walkView = WalkViewOfPixelQuads(new[]
+ {
+ new Vector2(256f, 180f), new Vector2(512f, 180f),
+ new Vector2(512f, 360f), new Vector2(256f, 360f),
+ });
+
+ ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, walkView, W, H);
+
+ ClipViewSlice slice = Assert.Single(asm.OutsideViewSlices);
+ Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
+ Assert.True(asm.OutdoorVisible);
+ Assert.Equal(4, asm.OutsidePlaneCount);
+ Assert.Equal(slice.Slot, asm.OutdoorSlot);
+ Assert.NotEqual(0, slice.Slot);
+ Assert.Equal(-0.5f, slice.NdcAabb.X, 3);
+ Assert.Equal(0f, slice.NdcAabb.Y, 3);
+ Assert.Equal(0f, slice.NdcAabb.Z, 3);
+ Assert.Equal(0.5f, slice.NdcAabb.W, 3);
+ Assert.Equal(slice.NdcAabb, asm.OutsideViewNdcAabb);
+
+ // Every corner of the doorway satisfies every inward plane
+ // (n·p + d >= 0), and a point far outside fails at least one.
+ Vector2[] ndcCorners =
+ {
+ new(-0.5f, 0f), new(0f, 0f), new(0f, 0.5f), new(-0.5f, 0.5f),
+ };
+ foreach (Vector2 corner in ndcCorners)
+ {
+ foreach (Vector4 plane in slice.Planes)
+ Assert.True(plane.X * corner.X + plane.Y * corner.Y + plane.W >= -1e-4f);
+ }
+ bool outsideFails = false;
+ foreach (Vector4 plane in slice.Planes)
+ outsideFails |= plane.X * 0.9f + plane.Y * -0.9f + plane.W < 0f;
+ Assert.True(outsideFails);
+ }
+
+ [Fact]
+ public void FullViewportWalkQuad_CoversFullNdc()
+ {
+ ClipFrameAssembly asm = AssembledWithOldOutside();
+ var walkView = new WalkPortalView();
+ Assert.True(WalkCopyView.AppendFullViewportQuad(
+ walkView, new StubRays(), Vector3.Zero, W, H));
+
+ ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, walkView, W, H);
+
+ ClipViewSlice slice = Assert.Single(asm.OutsideViewSlices);
+ Assert.Equal(new Vector4(-1f, -1f, 1f, 1f), slice.NdcAabb);
+ Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
+ }
+
+ [Fact]
+ public void EmptyWalkView_YieldsSkipMode()
+ {
+ ClipFrameAssembly asm = AssembledWithOldOutside();
+ Assert.True(asm.OutdoorVisible); // the old view was visible pre-cutover
+
+ ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, new WalkPortalView(), W, H);
+
+ Assert.Empty(asm.OutsideViewSlices);
+ Assert.False(asm.OutdoorVisible);
+ Assert.False(asm.HasOutsideView);
+ Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
+ Assert.Equal(0, asm.OutsidePlaneCount);
+ Assert.Equal(0, asm.OutdoorSlot);
+ }
+
+ [Fact]
+ public void TwoWalkViews_ProduceTwoSlicesWithDistinctSlots()
+ {
+ ClipFrameAssembly asm = AssembledWithOldOutside();
+ WalkPortalView walkView = WalkViewOfPixelQuads(
+ new[]
+ {
+ new Vector2(100f, 100f), new Vector2(300f, 100f),
+ new Vector2(300f, 300f), new Vector2(100f, 300f),
+ },
+ new[]
+ {
+ new Vector2(600f, 400f), new Vector2(900f, 400f),
+ new Vector2(900f, 650f), new Vector2(600f, 650f),
+ });
+
+ ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, walkView, W, H);
+
+ Assert.Equal(2, asm.OutsideViewSlices.Length);
+ Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot);
+ // The union AABB spans both doorways.
+ Assert.True(asm.OutsideViewNdcAabb.X < asm.OutsideViewSlices[0].NdcAabb.Z);
+ Assert.True(asm.OutsideViewNdcAabb.Z >= asm.OutsideViewSlices[1].NdcAabb.X);
+ }
+}