From 37febd1fe681e7d803ff20f341bcc60cb07cef7f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 18:21:08 +0200 Subject: [PATCH] fix(render): FW4 slice 1 - interior outside-view slices come from the walk The FW3 visual gate's stairwell/grass transition flash (grass briefly covering floor openings at doorway crossings - the #119 family) was the FW3 dual path leaking: the walk decided WHETHER terrain draws while the old PortalVisibilityBuilder assembly decided WHERE (slice planes, count, scissor), and punch fans indexed the old slice array with walk view indices. The new ACDREAM_PROBE_WALK_ROOT apparatus pinned the boundary frames: fat/degenerate old-apparatus exit views splash terrain over interior pixels, the interior depth-clear preserves color, and cells absent from the walk's flood never repaint. Retail has ONE visibility structure and cannot produce this. ClipFrameAssembler.ReassembleOutsideViewFromWalk now materializes the walk's own outside_view (pixel screen points -> standard NDC -> the existing ClipPlaneSet machinery) into the assembly's outside-view block after Collect, ahead of the single PrepareClipFrame publication (moved below the walk block). The Landscape event carries the walk's active view count on 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. Hermetic 6,762/0 (4 new materializer tests pin the y-flip and plane-sign conventions), Walk lane 209/1, InstalledDat walk conformance 40/1. Seals/cell slices/look-in seeding stay on the old per-cell views for the rest of FW4 (identical dat polygons; only the visible set can differ). Co-Authored-By: Claude Fable 5 --- docs/launch-options.md | 1 + .../2026-08-30-campaign-fw-frame-walk.md | 31 ++++ .../Rendering/ClipFrameAssembler.cs | 149 ++++++++++++++++++ .../Rendering/RetailPViewRenderer.cs | 78 +++++++-- .../Rendering/Walk/RetailFrameWalk.cs | 12 +- src/AcDream.App/Rendering/Walk/WalkEvents.cs | 13 +- .../Rendering/Walk/WalkFrameDriver.cs | 42 ++--- .../Rendering/RenderingDiagnostics.cs | 11 ++ .../Rendering/RetailPViewPassExecutorTests.cs | 6 +- .../Rendering/Walk/WalkFrameDriverTests.cs | 22 ++- .../WalkOutsideViewReassemblyTests.cs | 147 +++++++++++++++++ 11 files changed, 468 insertions(+), 44 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs 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); + } +}