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