diff --git a/docs/launch-options.md b/docs/launch-options.md index fe1f77e8..83ffba72 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -304,7 +304,6 @@ issue is closed, the strip was missed; delete both. | Flag | Owning investigation | Value | What it does | Side effects | Read by | |---|---|---|---|---|---| -| `ACDREAM_CLIP_DEBUG` | #176 | `=1` | forces the EnvCell SHELL pass to map every instance to clip slot 0 (no-clip) instead of its cell's portal-slice region | ALTERS RENDERED OUTPUT: shells draw whole/unclipped instead of trimmed — a visual isolation mode, not a log-only probe; no DebugPanel mirror | `RenderingDiagnostics.ClipDebugNoShellTrim` | | `ACDREAM_DUMP_APPEARANCE` | #5 | `="1"` | Logs every `0xF625` ObjDescEvent + `0xF7DB` UpdateObject with body length, target guid, hex preview — used to debug remote-player appearance asymmetry | print-only (`Console.WriteLine`) | `WorldSession` static field `DumpAppearanceEnabled` (`WorldSession.cs:792-793`), raw scattered read, issue #5 diagnostic | | `ACDREAM_DUMP_OPCODES` | #5 | `="1"` | Logs first occurrence of each genuinely-unhandled inbound opcode (deduped by opcode) | print-only. Must stay the LAST else-if in the dispatch chain per comment (else it would intercept handled opcodes) — currently correct. | `WorldSession` static field `DumpOpcodesEnabled` (`WorldSession.cs:788-789`, consumed `WorldSession.cs:2391-2398`), issue #5 diagnostic. Also mirrored (display-only, non-functional) via `DebugPanel.cs:241`/`DebugVM.cs:227`. | | `ACDREAM_DUMP_SCENERY_Z` | #48 | `=1` | Per-spawn Z-placement diagnostic for procedural scenery (trees/bushes/rocks), added for issue #48 (the "trees-in-sky" bug). | **NOT print-only** — this is a real behavior fork, not just added logging. `LandblockBuildFactory.cs:167-178`: when the flag is on, the streaming worker calls a **separate, duplicate scenery-building method** (`BuildSceneryEntitiesForStreaming`, a full parallel reimplementation of GfxObj/Setup mesh resolution + placement inline in this file) instead of production's `LandblockPhysicsContentBuilder.HydrateProceduralScenery`. Any visual/measurement run taken with this flag set is exercising a different scenery-placement code path than production, which can drift from it silently. | `RuntimeOptions.DumpSceneryZ` → `SessionPlayerComposition.cs:280` → `LandblockBuildFactory.cs:23,42,168,335` | @@ -314,7 +313,7 @@ issue is closed, the strip was missed; delete both. | `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` | | `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` | | `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_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-scis]` lines: outside-slice clip routing + region-SSBO bytes, actual GL/RHI scissor state (S3 review fix round 1, F5: the third producer, `[clip-route-disp]` — WbDrawDispatcher's per-entity clip-slot routing histogram — is deleted with the routing state it reported on) | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` | | `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/src/AcDream.App/Rendering/ClipFrame.cs b/src/AcDream.App/Rendering/ClipFrame.cs index 1231a9d6..4222e2b0 100644 --- a/src/AcDream.App/Rendering/ClipFrame.cs +++ b/src/AcDream.App/Rendering/ClipFrame.cs @@ -1,29 +1,37 @@ // ClipFrame.cs // -// Phase U.3: the per-frame container for the mesh SSBO clip-region table -// (binding=2) that mesh_modern.vert reads. The per-instance slot index +// Phase U.3: the per-frame CPU plane store — AppendSlot/GetSlotPlanes — that +// WalkFrameDriver.InteriorFloodViewClipPlanesAt resolves through for the +// exit-seal/punch-fan KEEP clips (S3 §8/§10). The per-instance slot index // buffer (SSBO binding=3) is PER-RENDERER and owned by each renderer // (WbDrawDispatcher / EnvCellRenderer), parallel to its instance buffer — // it is NOT here. // -// === The contract (the shader side obeys) ===================================== +// S3 review fix round 1 (F5): the mesh SSBO clip-region table (binding=2) +// this file used to also publish for mesh_modern.vert (and its four sibling +// mesh/particle vertex shaders) is DELETED, not merely dormant — the ONE +// WbDrawDispatcher API that could ever have armed a non-zero clip slot for +// an instance had ZERO production callers, so every instance mapped to slot +// 0 (no-clip) in every shipped build, and the shaders' own per-cell fetch + +// clip-distance loop are now gone too (see mesh_modern.vert's own doc +// comment). RegionBytes/RegionBytesForTest below — +// the GPU-shaped byte blob PrepareClipFrame used to publish for that dead +// consumer — stay as UNTOUCHED RESIDUE this round (WorldPassSurface.cs's +// PrepareClipFrame doc comment names it); AppendSlot/GetSlotPlanes are the +// part every consumer left (the exit seals) actually needs. +// +// === The byte layout AppendSlot/GetSlotPlanes still use internally =========== // binding=2 mesh SSBO holds an array of CellClip, one per "slot": // struct CellClip { uint count; uint _p0; uint _p1; uint _p2; vec4 planes[8]; }; // std430 layout: count at byte 0, three pad uints at 4/8/12, planes[8] at 16 // (vec4 stride 16) → 144 bytes per slot. Slot 0 is RESERVED = no-clip (count 0). // -// In U.3 a ClipFrame is built via NoClip(): one slot (slot 0, count 0). -// Everything renders exactly as before. U.4 populates real slots from a -// PortalVisibilityFrame (one CellClip per visible cell), then points each -// renderer's per-instance slot buffer at the right slots. -// -// The one CONSUMER outside the mesh path that still reads a slot's packed -// bytes directly is PortalDepthMaskRenderer's exit-seal/punch-fan draw -// (S3 §8/§10 KEEP): it shares the SAME std140 144-byte layout -// (TerrainUboBytes) and binding (TerrainClipUboBinding = 2) the walk's own -// screen-space clip gate used to occupy before S3 chunk 4 fix round 2 (L3) -// deleted that gate outright — the terrain and sky shaders no longer declare -// any block at that binding. +// The one CONSUMER that still reads a slot's packed bytes directly is +// PortalDepthMaskRenderer's exit-seal/punch-fan draw (S3 §8/§10 KEEP): it +// shares the SAME std140 144-byte layout (TerrainUboBytes) and binding +// (TerrainClipUboBinding = 2) the walk's own screen-space clip gate used to +// occupy before S3 chunk 4 fix round 2 (L3) deleted that gate outright — the +// terrain and sky shaders no longer declare any block at that binding. // // Pure CPU byte-packing. The GL upload machinery this file used to carry // alongside the packing (a per-flight-slot region SSBO arena, @@ -40,9 +48,12 @@ using System.Runtime.InteropServices; namespace AcDream.App.Rendering; /// -/// Per-frame container for the mesh SSBO clip-region table (binding=2, one -/// CellClip per slot, slot 0 reserved no-clip). See the file header for -/// the exact std430 byte layout and for / +/// Per-frame CPU plane store (/ +/// ) the exit-seal/punch-fan KEEP clips read +/// through WalkFrameDriver.InteriorFloodViewClipPlanesAt. See the file +/// header for the internal std430 byte layout, for why / are untouched residue +/// (S3 review fix round 1, F5), and for / /// 's separate remaining purpose. /// Per-instance slot buffers (binding=3) are owned by each renderer, not here. /// @@ -84,7 +95,7 @@ public sealed class ClipFrame : IDisposable // ---- CPU-side state ------------------------------------------------------ - // Packed std430 bytes for clipRegions[]. Always holds at least slot 0. + // Packed std430 bytes, one CellClip per slot. Always holds at least slot 0. private byte[] _regionBytes; private int _slotCount; diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs index fe0fbace..46a01e39 100644 --- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -47,8 +47,23 @@ public enum TerrainClipMode /// One retail portal_view slice mapped to a GPU clip slot. The AABB is /// retained for diagnostics () only — no /// GPU pass reads it; there is no scissor consumer left in the walk. +/// +/// S3 review fix round 1 (F1): +/// distinguishes the "this walk view's own polygon collapsed" case (retail +/// polyClipFinish under a degenerate view leaves <3 vertices — +/// 0x59BDBC-0x59BDBF) from the pre-existing Slot == 0, Planes == +/// [] "plane overflow" case (a convex region needing >8 half-planes — +/// draws UNCLIPPED, over-include, never a scissor). Both states share +/// Slot == 0 and an empty array, so a consumer +/// MUST check this flag rather than infer intent from Planes.Length: +/// NothingVisible == true means DRAW NOTHING; +/// NothingVisible == false with empty means DRAW +/// UNCLIPPED. See 's +/// index-alignment doc comment for why every walk view must occupy exactly +/// one slice slot. /// -public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes); +public readonly record struct ClipViewSlice( + int Slot, Vector4 NdcAabb, Vector4[] Planes, bool NothingVisible = false); /// /// Populated clip buffers plus routing data consumed by the render walk. @@ -438,6 +453,25 @@ public static class ClipFrameAssembler /// Must run AFTER the walk's Collect and BEFORE /// PrepareClipFrame publishes the clip regions — appended slots /// join the same single publication. + /// + /// S3 review fix round 1 (F1, BLOCKING): keeps exactly ONE slice + /// per walk view, index-aligned with 's own + /// ViewCount. RetailFrameWalk.DrawBuilding pins + /// ActiveViewIndex = v over these SAME walk views (retail + /// building_view = portal_view_num @0x0059f3bf) and the punch + /// leaf () indexes + /// by that index. The + /// shared helper SKIPS a view whose + /// polygon collapses () — + /// correct for 's "empty regions are omitted + /// entirely" legacy policy, but wrong here: skipping would make the + /// slice array SHORTER than the view list, so every later view's fan + /// would read the WRONG view's planes (off by however many views + /// collapsed before it). A collapsed view instead gets its own slice + /// with set (slot 0, empty + /// planes, empty AABB) — the punch leaf draws nothing for that view + /// specifically, never a neighboring view's fan under the wrong clip. + /// /// public static void ReassembleOutsideViewFromWalk( ClipFrameAssembly assembly, @@ -497,13 +531,24 @@ public static class ClipFrameAssembler if (poly.MaxY > unionMaxY) unionMaxY = poly.MaxY; } - AppendOutsideSlice( + bool appended = AppendOutsideSlice( frame, poly, outsideSlicesList, ref outsideMaxPlaneCount, ref outsideHasScissorFallback, ref scissorFallbacks); + + if (!appended) + { + // F1: this walk view's own polygon collapsed (empty after + // projection, or ClipPlaneSet.IsNothingVisible — a line/point + // with no screen coverage). It still occupies slot v so the + // NEXT view's slice lands at index v+1, not v — see this + // method's own doc comment. + outsideSlicesList.Add( + new ClipViewSlice(0, default, System.Array.Empty(), NothingVisible: true)); + } } ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList); diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index c1f0fea3..4ff89f69 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -365,9 +365,10 @@ public sealed class PortalTunnelPresentation : IDisposable { UploadRetailLight(); // The dispatcher is shared with the world pass. Portal space is its - // own CreatureMode scene: it has no world-cell clip routing and no - // world point lights, only the distant light installed above. - _dispatcher.ClearClipRouting(); + // own CreatureMode scene: no world point lights, only the distant + // light installed above (S3 review fix round 1, F5: the former + // per-frame clip-routing reset call here is deleted with the routing + // state it reset — every instance is slot 0 regardless). _dispatcher.SetSceneLights(null); var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index 79d15f09..20e7a0dc 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -99,7 +99,21 @@ internal sealed partial class RetailPViewPassExecutor /// @0x0059bc90's far-Z punch through PortalDepthMaskRenderer, /// clipped by the pinned view's slice planes (retail /// building_view @0x0059f3bf). FW3.3: fans draw at the dat - /// aperture verbatim (the ShellDrawLiftZ retirement). + /// aperture verbatim (the ShellDrawLiftZ retirement). + /// + /// S3 review fix round 1 (F1, BLOCKING): indexes one-for-one with the + /// walk's own views ( keeps the + /// arrays index-aligned). An out-of-range index means the two arrays + /// desynchronized — drawing unclipped in that case would be a real + /// visibility bug (the fan could punch through walls no view actually + /// exposes), so this FAILS LOUD instead. A slice flagged means the pinned view's own + /// polygon collapsed (retail's polyClipFinish under a degenerate + /// view leaves <3 vertices → no fan, 0x59BDBC-0x59BDBF): this + /// draws NOTHING for it, matching retail. internal void DrawWalkPunchFan( RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly, @@ -111,10 +125,23 @@ internal sealed partial class RetailPViewPassExecutor Vector3[] vertices = worldPolygon.Vertices; if (vertices.Length < 3) return; + ReadOnlySpan slices = clipAssembly.OutsideViewSlices; - ReadOnlySpan planes = (uint)activeViewIndex < (uint)slices.Length - ? slices[activeViewIndex].Planes - : default; + if ((uint)activeViewIndex >= (uint)slices.Length) + { + throw new ArgumentOutOfRangeException( + nameof(activeViewIndex), + activeViewIndex, + $"punch fan pinned to walk view {activeViewIndex} but the " + + $"reassembled outside view only holds {slices.Length} " + + "index-aligned slice(s) — ReassembleOutsideViewFromWalk " + + "desynchronized from RetailFrameWalk.DrawBuilding's own " + + "view count (fail-loud rule; never draw unclipped)."); + } + + ClipViewSlice slice = slices[activeViewIndex]; + if (slice.NothingVisible) + return; Span world = stackalloc Vector3[32]; int count = Math.Min(vertices.Length, world.Length); @@ -123,7 +150,7 @@ internal sealed partial class RetailPViewPassExecutor _portalDepthMask.DrawDepthFan( world[..count], frame.ViewProjection, - planes, + slice.Planes, forceFarZ: true); } } diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 0ab6da15..a28a8be4 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -11,8 +11,8 @@ namespace AcDream.App.Rendering; // S3 chunk 4 fix round 2 (L2): RetailPViewFramebufferSize / IRetailPViewFramebufferSource // / SilkRetailPViewFramebufferSource are deleted — their only consumer was -// RhiWorldPassSurface.BeginScissor's NDC-to-pixel conversion, and BeginScissor -// itself is deleted in the same round (no producer of a narrowed scissor +// the RHI surface's NDC-to-pixel scissor conversion, and that scissor +// mechanism itself is deleted in the same round (no producer of a narrowed // rectangle remains anywhere in the walk). internal sealed class RetailPViewCellSource : IRetailPViewCellSource @@ -170,8 +170,8 @@ public RetailPViewPassExecutor( /// finishes (@0x00506396), not once per active landscape view. This call /// is UNCLIPPED and sets no scissor — S3 chunk 4 (§10.2) deleted the /// former per-outside-view-slice loop (the walk's own screen-space - /// terrain-clip writer, ClearClipRouting, and the old - /// DrawLandscapeSliceLate leaf, one call per active landscape + /// terrain-clip writer, its per-frame clip-routing reset call, and the + /// old DrawLandscapeSliceLate leaf, one call per active landscape /// view) that used to run before this /// call and re-submit the rain mesh once per doorway aperture; this is /// now the ONLY weather call site, matching retail's ONE unclipped @@ -223,7 +223,6 @@ public RetailPViewPassExecutor( if (!ShouldDrawWeatherOnce(frame.RenderSky, frame.RenderWeather, frame.PlayerCellId)) return; - DisableClipDistances(); _sky?.RenderWeather( frame.Camera, frame.CameraWorldPosition, @@ -270,8 +269,6 @@ public RetailPViewPassExecutor( // emitter owns one shadow in its OWN current cell, so this is a cell // lookup, not an owner union — a hidden/suspended owner's emitter // still draws here. - DisableClipDistances(); - if (_particles is not null && _particleRenderer is not null) { _particleRenderer.DrawForCell( @@ -281,9 +278,6 @@ public RetailPViewPassExecutor( cellId, clipSlot: 0); } - - _entities.ClearClipRouting(); - DisableClipDistances(); } public void ClearInteriorDepth() @@ -320,7 +314,6 @@ public RetailPViewPassExecutor( // The former once-per-OutsideView-slice submission with that slice's // hardware clip slot made effects vanish by view direction (zero // outside slices in view = zero submissions) — invented behavior. - DisableClipDistances(); _particleRenderer.DrawForOwners( frame.Camera, frame.CameraWorldPosition, @@ -342,7 +335,6 @@ public RetailPViewPassExecutor( if (_particles is null || _particleRenderer is null) return; - DisableClipDistances(); // Retail never clips cell particles to a portal view: the owner // cell's walls own occlusion via the depth test at the alpha flush. // CPhysicsObj::add_particle_shadow_to_cell (0x00514a70) draws an @@ -354,7 +346,6 @@ public RetailPViewPassExecutor( ParticleRenderPass.Scene, cellId, clipSlot: 0); - DisableClipDistances(); } public void EmitDiagnostics( @@ -444,17 +435,31 @@ public RetailPViewPassExecutor( // the interior depth clear still had a live scissor to end at that // point — a mechanism that no longer existed ANYWHERE by then (K4 had // already deleted the sky's own scissor bracket, the last production - // producer of a narrowed rectangle). IWorldPassSurface.BeginScissor/ - // EndScissor and their RhiWorldPassSurface bodies are deleted outright + // producer of a narrowed rectangle). The interface's own scissor + // begin/end pair and their RhiWorldPassSurface bodies are deleted outright // in this round; the interior depth clear no longer ends anything // because nothing narrows a rectangle any more — the pass encoder sets // the full-attachment scissor once, at pass begin // (VulkanGpuPassEncoder.cs:87), and it stays that way for the life of - // the pass. IWorldPassSurface.EnableClipDistances/DisableClipDistances - // stay: WorldScenePassExecutor (the separate flat-world path, out of - // this chunk's scope) still calls EnableClipDistances directly, and the + // the pass. + // + // S3 review fix round 1 (F3): round 2's own comment above claimed "the // KEEP clips (exit seals, punch fans) bracket their own draws with - // Enable/DisableClipDistances below. - - private void DisableClipDistances() => _surface.DisableClipDistances(); + // Enable/DisableClipDistances below" — FALSE. Neither + // DrawExitPortalMask/DrawPortalDepthWrite nor + // RetailPViewPassExecutor.WalkLeaf.cs's DrawWalkPunchFan ever called + // Enable/DisableClipDistances; every one of this file's six + // DisableClipDistances() call sites sat around the UNCLIPPED particle + // draws (DrawWeatherOnce, DrawLandscapeStaticParticles x2, + // DrawUnattachedSceneParticles, DrawCellParticles x2) — bodies that were + // already no-ops on the only backend + // (RhiWorldPassSurface.EnableClipDistances/DisableClipDistances, see + // WorldPassSurface.cs). A no-op call bracketing an unclipped draw is not + // a clip bracket at all; it asserted a mechanism this file never had. + // Deleted outright with this correction, along with the private + // DisableClipDistances() wrapper (a method that does nothing on the + // only backend is a false mechanism too). IWorldPassSurface. + // EnableClipDistances/DisableClipDistances stay on the INTERFACE — + // WorldScenePassExecutor (the separate flat-world path) still calls + // them — only this file's now-pointless use of them is gone. } diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 583ba3f5..290fee08 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -640,9 +640,9 @@ internal sealed class RetailPViewRenderer } // S3 chunk 4 (§10.2): the former per-outside-view-slice loop - // (the walk's own screen-space terrain-clip writer + ClearClipRouting - // + DrawLandscapeSliceLate, one call per active landscape view) is - // DELETED — retail draws the + // (the walk's own screen-space terrain-clip writer + its per-frame + // clip-routing reset call + DrawLandscapeSliceLate, one call per + // active landscape view) is DELETED — retail draws the // weather mesh and its rain particles ONCE, unclipped, after // LScape::draw's whole landblock loop (GameSky::Draw(sky,1) call site // @0x00506396 — the callee itself is @0x00506ff0), never once per diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert index cec1ba8c..7cf118a2 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert @@ -46,34 +46,17 @@ layout(std430, binding = 1) readonly buffer BatchBuffer { BatchData Batches[]; }; -// === Phase U.3: per-cell screen-space clip gate (gl_ClipDistance) ============= -// Two SSBOs add the clip mechanism without disturbing binding=0/1 above. -// -// binding=2 — SHARED per-frame clip regions, one CellClip per "slot". Uploaded -// ONCE per frame by ClipFrame.UploadShared (shared across WbDrawDispatcher + -// EnvCellRenderer). Slot 0 is RESERVED = no-clip (count 0 ⇒ every plane passes). -// -// binding=3 — PER-RENDERER per-instance slot index, parallel to the binding=0 -// instance buffer and indexed by the IDENTICAL per-instance index -// (gl_BaseInstanceARB + gl_InstanceID). instanceClipSlot[i] selects which -// CellClip region instance i is clipped against. Default all-zeros in U.3 ⇒ -// every instance maps to slot 0 ⇒ no clipping ⇒ identical render to pre-U.3. -// -// CellClip std430 layout (144 bytes/slot): a uint count + 3 pad uints (16 bytes) -// then vec4 planes[8] (8 × 16 = 128 bytes). vec4 array stride is 16 under std430. -// ClipFrame on the CPU side lays out the bytes to match exactly (verified by -// ClipFrameLayoutTests). A clip-space vertex is INSIDE iff dot(plane, gl_Position) -// >= 0 for every active plane (see ClipPlaneSet for the plane convention). -struct CellClip { - uint count; - uint _p0; - uint _p1; - uint _p2; - vec4 planes[8]; -}; -layout(std430, binding = 2) readonly buffer ClipRegionBuf { - CellClip clipRegions[]; -}; +// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate +// (Phase U.3's binding=2 CellClip region SSBO) is deleted — the CPU-side +// routing that could ever select a non-zero slot for an instance +// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every +// instance has always mapped to slot 0 (no-clip) in every shipped build; a +// shader-side clip test against a table that only ever holds the reserved +// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot +// index, parallel to the binding=0 instance buffer and indexed by the +// IDENTICAL per-instance index (gl_BaseInstanceARB + gl_InstanceID) — stays +// declared (the CPU side still writes it, always 0) but is no longer read +// here; the instance-buffer layout it occupies is S5's to revisit. layout(std430, binding = 3) readonly buffer ClipSlotBuf { uint instanceClipSlot[]; }; @@ -133,16 +116,6 @@ layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf { vec2 instanceSelectionLighting[]; }; -// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal -// alongside gl_Position. The array is sized 8 to match the CellClip plane budget -// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables -// GL_CLIP_DISTANCE0..7 once at startup; unused planes are set to +1.0 below so -// they pass everything (no clipping) when the slot's count < 8. -out gl_PerVertex { - vec4 gl_Position; - float gl_ClipDistance[8]; -}; - uniform mat4 uViewProjection; // Absolute transform prefix in the shared shadow/world pose arena. Every // parallel per-instance array remains local to this submission, so only the @@ -352,9 +325,9 @@ void main() { // Campaign VM VM6: weather-driven foliage sway. acdreamFoliageDisplace is // a no-op unless b.flags carries the cutout (0x2) or trunk (0x4) // classification bit — see foliage_wind.glsl. Applied before gl_Position - // so clip distances, lighting, and the fragment stage all see the - // displaced position; the four directional-shadow caster vertex shaders - // call the identical include so the shadow moves with the same vertex. + // so lighting and the fragment stage both see the displaced position; the + // four directional-shadow caster vertex shaders call the identical + // include so the shadow moves with the same vertex. worldPos.xyz = acdreamFoliageDisplace( worldPos.xyz, model[3].xyz, @@ -363,18 +336,6 @@ void main() { uAtmosphereWindAmplitude); gl_Position = uViewProjection * worldPos; - // Phase U.3: per-instance clip gate. instanceClipSlot is indexed by the - // SAME instanceIndex used for the binding=0 transform above, so the slot - // travels with the instance through the MDI BaseInstance offsets. Slot 0 - // (the U.3 default) has count 0 ⇒ the second loop sets all 8 distances to - // +1.0 ⇒ nothing is clipped. - uint _slot = instanceClipSlot[instanceIndex]; - CellClip _c = clipRegions[_slot]; - for (uint i = 0u; i < _c.count; ++i) - gl_ClipDistance[i] = dot(_c.planes[i], gl_Position); - for (uint i = _c.count; i < 8u; ++i) - gl_ClipDistance[i] = 1.0; - vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); vAmbientLocalLit = accumulateAmbientLocalLights( diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert index 9c8b8dad..6c56e6df 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert @@ -23,16 +23,17 @@ layout(std430, binding = 1) readonly buffer BatchBuffer { BatchData Batches[]; }; -struct CellClip { - uint count; - uint _p0; - uint _p1; - uint _p2; - vec4 planes[8]; -}; -layout(std430, binding = 2) readonly buffer ClipRegionBuf { - CellClip clipRegions[]; -}; +// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate +// (Phase U.3's binding=2 CellClip region SSBO) is deleted — the CPU-side +// routing that could ever select a non-zero slot for an instance +// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every +// instance has always mapped to slot 0 (no-clip) in every shipped build; a +// shader-side clip test against a table that only ever holds the reserved +// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot +// index, parallel to the binding=0 instance buffer and indexed by the +// IDENTICAL per-instance index — stays declared (the CPU side still writes +// it, always 0) but is no longer read here; the instance-buffer layout it +// occupies is S5's to revisit. layout(std430, binding = 3) readonly buffer ClipSlotBuf { uint instanceClipSlot[]; }; @@ -56,11 +57,6 @@ layout(std430, binding = 7) readonly buffer InstanceAlphaBuf { float instanceAlpha[]; }; -out gl_PerVertex { - vec4 gl_Position; - float gl_ClipDistance[8]; -}; - uniform mat4 uViewProjection; uniform int uDrawIDOffset; uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena @@ -84,13 +80,6 @@ void main() { gl_Position = uViewProjection * worldPos; vWorldPos = worldPos.xyz; - uint slot = instanceClipSlot[instanceIndex]; - CellClip clip = clipRegions[slot]; - for (uint i = 0u; i < clip.count; ++i) - gl_ClipDistance[i] = dot(clip.planes[i], gl_Position); - for (uint i = clip.count; i < 8u; ++i) - gl_ClipDistance[i] = 1.0; - // No distance term: VM2 found the fade only exists in // D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon // path) and only when the static noFadeDetail (0x00820e38, initialised diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index b7fd426c..b834886f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -42,34 +42,17 @@ layout(std430, binding = 1) readonly buffer BatchBuffer { BatchData Batches[]; }; -// === Phase U.3: per-cell screen-space clip gate (gl_ClipDistance) ============= -// Two SSBOs add the clip mechanism without disturbing binding=0/1 above. -// -// binding=2 — SHARED per-frame clip regions, one CellClip per "slot". Uploaded -// ONCE per frame by ClipFrame.UploadShared (shared across WbDrawDispatcher + -// EnvCellRenderer). Slot 0 is RESERVED = no-clip (count 0 ⇒ every plane passes). -// -// binding=3 — PER-RENDERER per-instance slot index, parallel to the binding=0 -// instance buffer and indexed by the IDENTICAL per-instance index -// (gl_BaseInstanceARB + gl_InstanceID). instanceClipSlot[i] selects which -// CellClip region instance i is clipped against. Default all-zeros in U.3 ⇒ -// every instance maps to slot 0 ⇒ no clipping ⇒ identical render to pre-U.3. -// -// CellClip std430 layout (144 bytes/slot): a uint count + 3 pad uints (16 bytes) -// then vec4 planes[8] (8 × 16 = 128 bytes). vec4 array stride is 16 under std430. -// ClipFrame on the CPU side lays out the bytes to match exactly (verified by -// ClipFrameLayoutTests). A clip-space vertex is INSIDE iff dot(plane, gl_Position) -// >= 0 for every active plane (see ClipPlaneSet for the plane convention). -struct CellClip { - uint count; - uint _p0; - uint _p1; - uint _p2; - vec4 planes[8]; -}; -layout(std430, binding = 2) readonly buffer ClipRegionBuf { - CellClip clipRegions[]; -}; +// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate +// (Phase U.3's binding=2 CellClip region SSBO) is deleted — the CPU-side +// routing that could ever select a non-zero slot for an instance +// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every +// instance has always mapped to slot 0 (no-clip) in every shipped build; a +// shader-side clip test against a table that only ever holds the reserved +// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot +// index, parallel to the binding=0 instance buffer and indexed by the +// IDENTICAL per-instance index (gl_BaseInstanceARB + gl_InstanceID) — stays +// declared (the CPU side still writes it, always 0) but is no longer read +// here; the instance-buffer layout it occupies is S5's to revisit. layout(std430, binding = 3) readonly buffer ClipSlotBuf { uint instanceClipSlot[]; }; @@ -129,16 +112,6 @@ layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf { vec2 instanceSelectionLighting[]; }; -// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal -// alongside gl_Position. The array is sized 8 to match the CellClip plane budget -// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables -// GL_CLIP_DISTANCE0..7 once at startup; unused planes are set to +1.0 below so -// they pass everything (no clipping) when the slot's count < 8. -out gl_PerVertex { - vec4 gl_Position; - float gl_ClipDistance[8]; -}; - uniform mat4 uViewProjection; // Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[]. @@ -311,18 +284,6 @@ void main() { vec4 worldPos = model * vec4(aPosition, 1.0); gl_Position = uViewProjection * worldPos; - // Phase U.3: per-instance clip gate. instanceClipSlot is indexed by the - // SAME instanceIndex used for the binding=0 transform above, so the slot - // travels with the instance through the MDI BaseInstance offsets. Slot 0 - // (the U.3 default) has count 0 ⇒ the second loop sets all 8 distances to - // +1.0 ⇒ nothing is clipped. - uint _slot = instanceClipSlot[instanceIndex]; - CellClip _c = clipRegions[_slot]; - for (uint i = 0u; i < _c.count; ++i) - gl_ClipDistance[i] = dot(_c.planes[i], gl_Position); - for (uint i = _c.count; i < 8u; ++i) - gl_ClipDistance[i] = 1.0; - vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights) diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert index 7df885dd..ca37a142 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.vert +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -14,24 +14,18 @@ layout(location = 5) in vec4 aColor; // (ACDREAM_TEXTURE_HANDLE, injected by // tools/ShaderCompiler/VulkanGlslPreamble.cs). layout(location = 6) in uint aTextureIndex; +// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate +// this attribute fed (the binding=2 CellClip region SSBO + the +// gl_ClipDistance write below) is deleted — the CPU-side routing that could +// ever select a non-zero slot for an instance (WbDrawDispatcher. +// the per-frame clip-routing arming call) had ZERO production callers, so every particle has always +// carried clip slot 0 (no-clip) in every shipped build; a shader-side clip +// test against a table that only ever holds the reserved no-clip slot clips +// nothing. The attribute itself stays declared (the CPU side still writes +// it, always 0) but is no longer read here; the vertex layout it occupies +// is S5's to revisit. layout(location = 7) in uint aClipSlot; -struct CellClip { - uint count; - uint _p0; - uint _p1; - uint _p2; - vec4 planes[8]; -}; -layout(std430, binding = 2) readonly buffer ClipRegionBuf { - CellClip clipRegions[]; -}; - -out gl_PerVertex { - vec4 gl_Position; - float gl_ClipDistance[8]; -}; - uniform mat4 uViewProjection; out vec2 vTex; @@ -52,9 +46,4 @@ void main() { // stage, which is the only form Vulkan can express. vTextureIndex = aTextureIndex; gl_Position = uViewProjection * vec4(world, 1.0); - CellClip clip = clipRegions[aClipSlot]; - for (uint i = 0u; i < clip.count; ++i) - gl_ClipDistance[i] = dot(clip.planes[i], gl_Position); - for (uint i = clip.count; i < 8u; ++i) - gl_ClipDistance[i] = 1.0; } diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert index 6d6542f1..4d40c2e6 100644 --- a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert +++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert @@ -5,24 +5,18 @@ layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aTexCoord; layout(location = 3) in mat4 aModel; layout(location = 7) in vec4 aColor; +// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate +// this attribute fed (the binding=2 CellClip region SSBO + the +// gl_ClipDistance write below) is deleted — the CPU-side routing that could +// ever select a non-zero slot for an instance (WbDrawDispatcher. +// the per-frame clip-routing arming call) had ZERO production callers, so every particle mesh has +// always carried clip slot 0 (no-clip) in every shipped build; a shader-side +// clip test against a table that only ever holds the reserved no-clip slot +// clips nothing. The attribute itself stays declared (the CPU side still +// writes it, always 0) but is no longer read here; the vertex layout it +// occupies is S5's to revisit. layout(location = 8) in uint aClipSlot; -struct CellClip { - uint count; - uint _p0; - uint _p1; - uint _p2; - vec4 planes[8]; -}; -layout(std430, binding = 2) readonly buffer ClipRegionBuf { - CellClip clipRegions[]; -}; - -out gl_PerVertex { - vec4 gl_Position; - float gl_ClipDistance[8]; -}; - uniform mat4 uViewProjection; out vec2 vTexCoord; @@ -32,9 +26,4 @@ void main() { vTexCoord = aTexCoord; vColor = aColor; gl_Position = uViewProjection * aModel * vec4(aPosition, 1.0); - CellClip clip = clipRegions[aClipSlot]; - for (uint i = 0u; i < clip.count; ++i) - gl_ClipDistance[i] = dot(clip.planes[i], gl_Position); - for (uint i = clip.count; i < 8u; ++i) - gl_ClipDistance[i] = 1.0; } diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv index 8ffa95f0..1fa7572d 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv index 03ed2c55..50650461 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv index a9833999..3ab0bbb5 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv index 9a52c2bf..f8f07394 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv index ec6aec3e..c2ef7d4a 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/particle_mesh.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 842ec7f5..d272f155 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -215,7 +215,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "c03ba560fbc2c7ce12e67326dcc79114f740999423fade53cd2d18804bf8f81a", + "sourceSha256": "e552edb7ef42e9e32812824c075310f2d99eb5826e527db5c22b54890ef02088", "compiled": true }, { @@ -231,7 +231,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "3b03a153a439aa54fcd0bcb575274c8e1da888b8ae85ee15fae020ccb1d3bf49", + "sourceSha256": "0c1eddb0fc892cb76ef4da47f727cec2516052d63247fa2682918bfd60b84fa8", "compiled": true }, { @@ -247,7 +247,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "2c37aa0fd1ee4af5ee1f6541b00e6a7226d4173988e748b1fb1c271b0a9b91b2", + "sourceSha256": "dba51ce23f2b621683fc78db73f608a0c000479b8e827cd3ecc8731d46c6ee64", "compiled": true }, { @@ -263,7 +263,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "2bdd7114223164916a87e7d2e6df8a21fdcbd1cad082fafb3102b8a0fd4a2a8b", + "sourceSha256": "f85089aaf92ee63d95f2e9feecd65d81e1aa942dceca5b62fa0747f9aae06f4c", "compiled": true }, { @@ -279,7 +279,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "582e9be4bca8bd1807d5a2152b211d1d4dd1ed54a6efe4240f52d402e628be75", + "sourceSha256": "14ef8815265fee45bccaf08dcf4f0adc3c1135305fe123cb9085a3a2880fe08a", "compiled": true }, { diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index c4d4844d..daa254ff 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -1723,7 +1723,28 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource // trailing per-frame weather OC (§11.6 H1), which has no EC // counterpart at all; see WalkTranscriptDump.PrintEnvCellShell's // own doc comment for the same citation. - WalkTranscriptDump.PrintEnvCellShell(cells[i]); + // + // S3 review fix round 1 (F4a): retail's breakpoint sits inside + // the setup_view/DrawEnvCell LOOP (PView::DrawCells + // @0x005a4ab1-0x005a4acc), which iterates the cell's live + // portal_view slices — @0x0059f170's own entry, above, fires + // once PER live view, not once per cell. Read the SliceCount + // directly off the route CaptureCellViewRoute just captured + // (_lookInTurns[viewRouteIndex]) rather than through + // InteriorFloodViewSliceCountAt's flood-index indirection: this + // loop also runs for a building's LOOK-IN flood + // (WalkDrawStage.LookInStatic), whose cells never populate + // InteriorFloodCells (that list is the interior ROOT flood + // only — see its own doc comment) — indexing it here would + // throw ArgumentOutOfRangeException on a real look-in turn. A + // cell with zero live views (an untracked/degenerate route) + // prints zero EC lines, matching setup_view never iterating an + // empty view list. The SHELL DRAW event below is unchanged — + // the frame stamp still dedupes it to one submission regardless + // of how many times this prints. + int liveViewCount = _lookInTurns[viewRouteIndex].SliceCount; + for (int view = 0; view < liveViewCount; view++) + WalkTranscriptDump.PrintEnvCellShell(cells[i]); if (_cellShellsDrawnThisFrame.Add(cells[i])) { MarkIfGrown(); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index 8154724e..dc4842ee 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -145,25 +145,18 @@ public sealed unsafe partial class EnvCellRenderer for (int i = 0; i < uniqueInstanceCount; i++) _gpuInstanceTransforms[i] = allInstances[i].Transform; - // Phase U.4: per-instance clip slots, laid out parallel to the transforms - // so instanceClipSlot[BaseInstance + gl_InstanceID] tracks Instances[]. + // S3 review fix round 1 (F5): per-instance clip slots, laid out + // parallel to the transforms so instanceClipSlot[BaseInstance + + // gl_InstanceID] tracks Instances[] — always slot 0 (no-clip). The + // former Phase U.4 cellId→slot routing this array carried (see + // EnvCellRenderer.cs's own doc comment) is deleted, not merely + // inactive: the arming method's only caller ever passed null, so + // this branch always ran in every shipped build. The vertex-layout input + // itself (instanceClipSlot[]) stays fed 0 — see mesh_modern.vert's + // own doc comment for why the shader no longer reads it either. if (_clipSlotData.Length < uniqueInstanceCount) _clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)]; - if (_cellIdToSlot is null - || AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim) - { - Array.Clear(_clipSlotData, 0, uniqueInstanceCount); - } - else - { - for (int i = 0; i < uniqueInstanceCount; i++) - { - _clipSlotData[i] = - _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot) - ? (uint)slot - : 0u; - } - } + Array.Clear(_clipSlotData, 0, uniqueInstanceCount); // Campaign VM VM1 follow-up: per-instance opacity multiplier, laid out // parallel to the transforms exactly like _clipSlotData above. EnvCell diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 271e00fe..f41083eb 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -214,23 +214,13 @@ public sealed partial class EnvCellRenderer : } } - // Phase U.4: per-frame cellId→CellClip-slot map for the cell shells. When - // non-null, RenderModernMDIInternal writes instanceClipSlot[i] = - // _cellIdToSlot[allInstances[i].CellId] so each cell's shell instances are - // gated to that cell's portal-clip region. When null (U.3 path), every - // instance maps to slot 0 (no-clip). A cell absent from the map writes slot 0 - // (no-clip) — but the caller's Render filter already restricts the draw to the - // map's keys, so that fallback should not fire in practice. - private IReadOnlyDictionary? _cellIdToSlot; - - /// - /// Phase U.4: install the per-frame cellId→slot map used to gate cell shells - /// to their portal-clip regions. Call once per frame BEFORE - /// . Pass null to revert to - /// the U.3 no-clip behavior (every shell instance → slot 0). - /// - public void SetClipRouting(IReadOnlyDictionary? cellIdToSlot) - => _cellIdToSlot = cellIdToSlot; + // S3 review fix round 1 (F5): the Phase U.4 per-frame cellId→CellClip-slot + // map this renderer's own arming method used to install is deleted — its + // only caller ever passed null (WorldScenePassExecutor's + // BeginFrame/AbortFrame), so RenderModernMDIInternal's instanceClipSlot + // write always took the "every instance maps to slot 0" branch in every + // shipped build. See RenderModernMDIInternal's own doc comment + // (EnvCellRenderer.Rhi.cs) for where that write now lives, unconditionally. /// /// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index 9b4375db..1cef8b9d 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -255,9 +255,11 @@ public sealed partial class WbDrawDispatcher /// _groups/_packedGroups or publishing selection itself (see /// this file's type doc comment). /// - /// A culled entity ( returns - /// Culled: true — not visible through the active clip route) - /// contributes nothing, matching every other classifier's cull gate. + /// S3 review fix round 1 (F5): + /// never culls (the clip-slot routing it used to gate on had zero + /// production callers, so it is deleted, not merely inactive) — the + /// cull check below is retained for shape parity with every other + /// classifier's cull gate but never actually fires. /// internal void ClassifyEntityForWalk( in RenderProjectionRecord projection, @@ -293,9 +295,7 @@ public sealed partial class WbDrawDispatcher tupleLandblockId, animated: liveDynamic); - (uint slot, bool culled) = ResolveSlotForFrame( - _clipRoutingActive, entity.ServerGuid, entity.ParentCell, - _cellIdToSlot, _outdoorSlot, _outdoorVisible); + (uint slot, bool culled) = ResolveSlotForFrame(); if (culled) return; @@ -826,9 +826,9 @@ public sealed partial class WbDrawDispatcher /// the caller's firstAdmission gate skips it) or the single /// RetailWholeMeshSlot sentinel, so looping was never anything but /// one iteration in practice. (the entity's OWN - /// classic clip-routing slot, always 0 while _clipRoutingActive is - /// never armed by the walk path — see ) - /// is therefore the ONE clip slot every surviving batch carries. + /// clip slot — always 0, see 's own doc + /// comment for why the routing that used to vary this is deleted) is + /// therefore the ONE clip slot every surviving batch carries. /// private void EmitClassifiedBatches( ObjectRenderData renderData, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index ba713392..cbefe579 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -570,27 +570,27 @@ public sealed partial class WbDrawDispatcher : IDisposable // GlWorldPassSurface still calls the setter unconditionally. private uint _sharedClipRegionSsbo; - // Phase U.4: per-frame clip-slot routing handed in via SetClipRouting before - // each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root / - // no portal frame), every instance maps to slot 0 (no-clip) and no instance is - // culled — identical to U.3. When active, each instance's slot is resolved by - // ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot; - // outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled). - private bool _clipRoutingActive; - private IReadOnlyDictionary? _cellIdToSlot; - private int _outdoorSlot; - private bool _outdoorVisible; + // S3 review fix round 1 (F5): Phase U.4's per-frame clip-slot routing + // fields and their install/reset methods are deleted outright — the ONE + // method that could ever arm routing has ZERO production callers, so + // the "when active" branch of the U.4 policy this comment used to + // describe NEVER RAN in any shipped build: every instance has always + // mapped to slot 0 (no-clip), unconditionally, on every path, matching + // the U.3 default this section header originally described as only the + // FALLBACK case. ResolveSlotForFrame below now says so directly instead + // of gating on a flag nothing ever set true. // Phase U.4: the clip slot of the entity currently being classified in Draw's // per-entity loop. Set once per entity (before ClassifyBatches / ApplyCacheHit), // read by the two matrix-append sites (AppendInstanceToGroup + ClassifyBatches) - // so every group's Slots[] stays in lockstep with its Matrices[]. Defaults to 0 - // (no-clip) on the U.3 / outdoor path. + // so every group's Slots[] stays in lockstep with its Matrices[]. Always 0 + // (no-clip) — see ResolveSlotForFrame's own doc comment. private uint _currentEntitySlot; - // Phase U.4: true when the current entity resolved to the CULL sentinel - // (cell not visible, or outdoor stab while no outdoors is visible). Persisted - // across the entity's tuples; the per-tuple body skips all instance emission. + // Phase U.4: whether the current entity was culled by clip-slot routing. + // Persisted across the entity's tuples; the per-tuple body skips all + // instance emission when true. Always false — see ResolveSlotForFrame's + // own doc comment. private bool _currentEntityCulled; // Per-frame scratch arrays — Tasks 9-10 fully wire these. @@ -855,186 +855,20 @@ public sealed partial class WbDrawDispatcher : IDisposable => _sharedClipRegionSsbo = sharedClipRegionSsbo; /// - /// Phase U.4: install the per-frame clip-slot routing for an INDOOR root. - /// Call once per frame BEFORE when the camera's root cell is - /// non-null; the next resolves each instance's binding=3 - /// clip slot via the U.4 policy (cell-owned entities to their cell slot, - /// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled). - /// Pair with on outdoor-root frames so the - /// dispatcher reverts to the U.3 no-clip-everything behavior. + /// S3 review fix round 1 (F5): the per-instance clip-slot decision for + /// one entity, returning the (Slot, Culled) pair the per-entity + /// loop body consumes. Every instance is slot 0 (no-clip) and nothing is + /// ever clip-culled — the U.4 "routing active" policy this used to gate + /// on (ResolveEntitySlot: indoor entities to their cell's slot, + /// outdoor entities to the OutsideView slot, everything else culled) is + /// DELETED, not merely disabled: the ONE method that could ever arm it + /// had ZERO production callers, so that branch never ran in any shipped + /// build. internal static (no parameters + /// needed any more) so it stays unit-testable — see + /// WbDrawDispatcherClipSlotTests' history for why the removed + /// per-branch tests existed and no longer apply. /// - /// cellId → CellClip slot. A cell absent from the map - /// is NOT visible → its cell-static instances are culled. - /// Slot for outdoor scenery / building shells while - /// indoors (the OutsideView slot, or 0 for no-clip over-include). - /// False ⇒ cull outdoor scenery / shells this frame - /// (the OutsideView is empty). - public void SetClipRouting(IReadOnlyDictionary cellIdToSlot, int outdoorSlot, bool outdoorVisible) - { - ArgumentNullException.ThrowIfNull(cellIdToSlot); - _clipRoutingActive = true; - _cellIdToSlot = cellIdToSlot; - _outdoorSlot = outdoorSlot; - _outdoorVisible = outdoorVisible; - } - - /// - /// Phase U.4: revert to U.3 behavior — every instance maps to slot 0 (no-clip), - /// nothing is culled by clip routing. Call on outdoor-root frames (camera - /// outdoors) and any frame without a portal-visibility result. - /// - public void ClearClipRouting() - { - _clipRoutingActive = false; - _cellIdToSlot = null; - _outdoorSlot = 0; - _outdoorVisible = false; - } - - // §4 flap [clip-route-disp] probe state (2026-06-10, throwaway): print-on-change - // signature + monotonic sequence + reusable histogram. See RenderingDiagnostics - // .ProbeClipRouteEnabled for the full probe contract. - private string? _lastClipRouteDispSig; - private long _clipRouteDispSeq; - private readonly SortedDictionary _clipRouteHist = new(); - - // §4 flap apparatus (2026-06-10): per-slot instance histogram as staged for binding=3. - // grp.Slots is laid out 1:1 with grp.Matrices (binding=0), so this IS the slot content - // the GPU reads per instance — if outdoor instances land on the wrong slot (or vanish - // into cullEnt) when the building flood merges, this line shows it directly. - private void EmitClipRouteDispatchProbe(int culledEntities) - { - _clipRouteHist.Clear(); - int total = 0; - foreach (var grp in _groups.Values) - { - var slots = grp.Slots; - for (int i = 0; i < slots.Count; i++) - { - _clipRouteHist.TryGetValue(slots[i], out int c); - _clipRouteHist[slots[i]] = c + 1; - total++; - } - } - - var sb = new System.Text.StringBuilder(128); - sb.Append(System.FormattableString.Invariant( - $"outdoorSlot={_outdoorSlot} outdoorVis={(_outdoorVisible ? 'Y' : 'n')} inst={total} cullEnt={culledEntities} slots={{")); - bool first = true; - foreach (var kv in _clipRouteHist) - { - if (!first) sb.Append(','); - first = false; - sb.Append(System.FormattableString.Invariant($"{kv.Key}:{kv.Value}")); - } - sb.Append('}'); - - string sig = sb.ToString(); - _clipRouteDispSeq++; - if (sig == _lastClipRouteDispSig) - return; - _lastClipRouteDispSig = sig; - Console.WriteLine($"[clip-route-disp] n={_clipRouteDispSeq} {sig}"); - } - - // Phase U.4 CULL sentinel returned by ResolveEntitySlot: the entity's instances - // are dropped entirely (not emitted into the binding=0 instance buffer NOR the - // binding=3 slot buffer), matching the existing frustum / visible-cell cull. - // Internal (not private) so the clip-slot unit tests can assert against it - // directly — see WbDrawDispatcherClipSlotTests. - internal const int ClipSlotCull = -1; - - /// - /// Phase U.4: resolve the clip slot for one entity per the slot/gate policy. - /// Returns to drop the entity's instances entirely. - /// - /// Indoor ParentCellId: the cell's slot, or CULL when hidden. - /// Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot - /// when , else CULL. - /// ServerGuid != 0 with ParentCellId == null: CULL while routing is active. - /// - /// Only called when _clipRoutingActive (indoor root). On the U.3 / outdoor - /// path every instance is slot 0 and nothing is culled — see - /// , which gates on that flag. - /// - /// INVARIANT: and the keys of - /// MUST live in the same FULL cell-id space - /// (lbMask | OtherCellId, e.g. 0xA9B40164). A bare-low-byte - /// ParentCellId (e.g. 0x64) would never match a full-id key and would - /// silently CULL every indoor stab — cf. the L.2e bare-low-byte finding in - /// CLAUDE.md where player CellId was tracked without its landblock prefix. - /// - /// - /// internal static + pure (reads no instance state) so the clip-slot - /// unit tests exercise every branch without a GL context. The caller hands in - /// the routing fields it would otherwise read from _cellIdToSlot etc. - /// - /// - internal static int ResolveEntitySlot( - uint serverGuid, - uint? parentCellId, - IReadOnlyDictionary cellIdToSlot, - int outdoorSlot, - bool outdoorVisible) - { - // Live-dynamic entities are not a global indoor overlay. When they - // have current cell ownership, route them through the same visible - // cell/OutsideView graph as every other object. Parentless live objects - // are unresolved indoors, so cull them while clip routing is active. - if (parentCellId is uint parentCell) - { - if (IsIndoorCellId(parentCell)) - { - if (!cellIdToSlot.ContainsKey(parentCell)) - return ClipSlotCull; - - return cellIdToSlot[parentCell]; - } - - return outdoorVisible ? outdoorSlot : ClipSlotCull; - } - - if (serverGuid != 0) - return ClipSlotCull; - - // Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to - // the OutsideView slot, or cull when nothing outdoors is visible. - return outdoorVisible ? outdoorSlot : ClipSlotCull; - } - - private static bool IsIndoorCellId(uint cellId) - { - uint low = cellId & 0xFFFFu; - return low >= 0x0100u && low != 0xFFFFu; - } - - /// - /// Phase U.4: the call-site clip-slot decision for one entity, returning the - /// (Slot, Culled) pair the per-entity loop body consumes. Wraps - /// with the - /// gate: when routing is INACTIVE (outdoor root / no portal frame), every entity - /// is slot 0 and nothing is clip-culled — the bit-identical-to-U.3 property, so - /// the resolver (and ) is bypassed entirely. - /// When active, a CULL sentinel maps to (0, culled=true) — the slot value - /// is never emitted for a culled entity. - /// internal static + pure so the whole policy (including the routing- - /// inactive branch) is unit-testable — see WbDrawDispatcherClipSlotTests. - /// - internal static (uint Slot, bool Culled) ResolveSlotForFrame( - bool clipRoutingActive, - uint serverGuid, - uint? parentCellId, - IReadOnlyDictionary? cellIdToSlot, - int outdoorSlot, - bool outdoorVisible) - { - if (!clipRoutingActive) - return (0u, false); - - int resolved = ResolveEntitySlot(serverGuid, parentCellId, cellIdToSlot!, outdoorSlot, outdoorVisible); - bool culled = resolved == ClipSlotCull; - return (culled ? 0u : (uint)resolved, culled); - } + internal static (uint Slot, bool Culled) ResolveSlotForFrame() => (0u, false); public static Matrix4x4 ComposePartWorldMatrix( Matrix4x4 entityWorld, @@ -1408,11 +1242,6 @@ public sealed partial class WbDrawDispatcher : IDisposable uint? populateEntityId = null; uint populateLandblockId = 0; - // §4 flap [clip-route-disp] probe (2026-06-10, throwaway): entities dropped by - // ResolveSlotForFrame's CULL sentinel this Draw. One increment per culled entity — - // cheap enough to count unconditionally; emission below is probe-gated. - int probeCulledEntities = 0; - // Tier 1 cache (#53) — fast-path one-shot tracker. The cache stores a // FLAT list of batches across all MeshRefs of an entity, so a single // ApplyCacheHit call already drew every batch. _walkScratch yields @@ -1506,16 +1335,12 @@ public sealed partial class WbDrawDispatcher : IDisposable } currentEntityIncomplete = false; - // Phase U.4: resolve this entity's clip slot ONCE per entity - // (constant across its tuples). On the U.3 / outdoor path - // (_clipRoutingActive false) every entity is slot 0, never culled. - // The whole decision (including the routing-active gate) lives in - // the pure ResolveSlotForFrame helper so it's unit-testable. - (_currentEntitySlot, _currentEntityCulled) = ResolveSlotForFrame( - _clipRoutingActive, entity.ServerGuid, entity.ParentCell, - _cellIdToSlot, _outdoorSlot, _outdoorVisible); - if (_currentEntityCulled) - probeCulledEntities++; + // S3 review fix round 1 (F5): resolve this entity's clip slot + // ONCE per entity (constant across its tuples) — always slot + // 0, never culled. See ResolveSlotForFrame's own doc comment + // for why the U.4 "routing active" branch this used to gate + // on is deleted rather than merely inactive. + (_currentEntitySlot, _currentEntityCulled) = ResolveSlotForFrame(); // Fix B: select this entity's up-to-8 point/spot lights ONCE (the set // is constant across the entity's parts/tuples), by the entity's @@ -1890,14 +1715,10 @@ public sealed partial class WbDrawDispatcher : IDisposable populateEntityId, populateLandblockId, _cache, _populateScratch, _populateSelectionScratch); - // §4 flap [clip-route-disp] probe (2026-06-10, throwaway): the per-slot instance - // histogram exactly as it will be uploaded to binding=3 (grp.Slots) plus the - // culled-entity count. Routed draws only (the landscape pass under DrawInside) so the - // unrouted per-cell bucket draws don't oscillate the print-on-change signature. - // Emitted BEFORE the MeshSourceReady / totalInstances early-outs so an - // all-culled frame still reports (inst=0). - if (RenderingDiagnostics.ProbeClipRouteEnabled && _clipRoutingActive) - EmitClipRouteDispatchProbe(probeCulledEntities); + // S3 review fix round 1 (F5): the §4 flap [clip-route-disp] probe + // (2026-06-10, throwaway) is deleted with the routing state it + // reported on — it could only ever print "cullEnt=0" now that + // ResolveSlotForFrame never culls anything. ExecuteClassifiedGroups( vp, @@ -3464,8 +3285,10 @@ public sealed partial class WbDrawDispatcher : IDisposable HashSet? visibleCellIds, EntitySet set) { - // No cell filter (outdoor root, or a bucket drawn unfiltered like live-dynamics / outdoor - // scenery) ⇒ every entity passes; clip-slot routing (ResolveEntitySlot) does the gating. + // No cell filter (outdoor root, or a bucket drawn unfiltered like live-dynamics / + // outdoor scenery) ⇒ every entity passes; no other gate narrows it further — S3 + // review fix round 1 (F5) deleted the clip-slot routing this comment used to cite + // (ResolveEntitySlot never had a production caller). if (visibleCellIds is null) return true; @@ -3476,7 +3299,7 @@ public sealed partial class WbDrawDispatcher : IDisposable // ParentCellId == null (outdoor scenery / building shell): NOT a member of any interior cell, // so it does NOT pass a cell-membership filter (R1: the bleed fix — was an unconditional // `return true`). When such entities must draw (through the doorway), the caller passes - // visibleCellIds: null and relies on ResolveEntitySlot's OutsideView routing instead. + // visibleCellIds: null instead. return false; } diff --git a/src/AcDream.App/Rendering/WorldPassSurface.cs b/src/AcDream.App/Rendering/WorldPassSurface.cs index dc28ba59..005e3402 100644 --- a/src/AcDream.App/Rendering/WorldPassSurface.cs +++ b/src/AcDream.App/Rendering/WorldPassSurface.cs @@ -30,16 +30,21 @@ internal interface IRenderFrameGlState internal interface IWorldPassSurface { /// - /// Publishes this frame's per-cell clip-region table, and routes it to the - /// renderers that read it. + /// Publishes this frame's per-cell clip-region table. /// /// S3 chunk 4 fix round 1 (K3) deleted the walk's per-frame terrain /// screen-space clip publish this used to also carry — its only writer /// was retired along with the per-outside-view-slice terrain/sky/weather /// clip loop — and fix round 2 (L3) deleted the terrain/sky shaders' own /// declaration of that clip block along with the section binder that used - /// to re-assert it, so this contract now publishes exactly one section, - /// the per-cell clip-region table. + /// to re-assert it, so this contract published exactly one section, the + /// per-cell clip-region table. S3 review fix round 1 (F5): that ONE + /// remaining section now has no shader reader left either (the mesh/ + /// particle vertex shaders' own per-cell fetch is deleted, per + /// 's own doc comment) — the publish + /// itself stays here as untouched residue this round (a CPU-side + /// byte-blob write nothing on the GPU consumes), named so a future round + /// can finish the deletion without re-discovering it. /// /// S3 landing hygiene (H2) deleted the GL-era reservation-count /// parameter this used to also take (how many distinct terrain clip @@ -56,19 +61,21 @@ internal interface IWorldPassSurface /// /// Vulkan activates every element the shader declares and has no /// enable, so this is a no-op there — and that is safe rather than a - /// divergence. Two shader families still write gl_ClipDistance in - /// the world pass: portal_depth.vert (the exit seals and punch fans, - /// planes handed per draw) and the mesh/particle vertex shaders + /// divergence. Only ONE shader family writes gl_ClipDistance in the + /// world pass now: portal_depth.vert (the exit seals and punch + /// fans, planes handed per draw through the TerrainClip UBO). S3 review + /// fix round 1 (F5) deleted the mesh/particle vertex shaders' /// (mesh_modern, mesh_atmospheric, mesh_detail, - /// particle, particle_mesh), which read the per-cell - /// clip-region table this contract publishes through - /// . The walk feeds every ordinary part and - /// every particle slot 0 (no clip), so in the walk path that table is - /// dormant; the sky and terrain shaders no longer declare any clip block - /// at all (S3 chunk 4 fix round 2, L3). Every writer sets 1.0 - /// ("keep everything") in each slot past its active count, so a frame - /// with no clip planes clips nothing on either backend (plan §5.5.14 - /// item 4). + /// particle, particle_mesh) own per-cell fetch and + /// gl_ClipDistance loop outright: the per-cell clip-region table + /// used to publish for them had no path + /// that could ever arm a non-zero slot for an instance (the ONE + /// WbDrawDispatcher API that could have armed it had ZERO production + /// callers), so that table was dormant in every path, not merely the walk's — + /// AD-17's correction is complete. Every writer left (portal_depth.vert + /// alone) sets 1.0 ("keep everything") in each slot past its + /// active count, so a frame with no clip planes clips nothing on either + /// backend (plan §5.5.14 item 4). /// void EnableClipDistances(); @@ -126,8 +133,8 @@ internal sealed class RhiWorldPassSurface : IWorldPassSurface public void ClearInteriorDepth() { - // S3 chunk 4 fix round 2 (L2): BeginScissor/EndScissor are deleted — - // no producer of a narrowed scissor rectangle remains anywhere in the + // S3 chunk 4 fix round 2 (L2): the interface's own scissor begin/end + // pair is deleted — no producer of a narrowed scissor rectangle remains anywhere in the // walk (K4 deleted the doorway scissor that used to bracket the sky; // fix round 1 already deleted the terrain/weather per-slice scissor // loop). VulkanGpuPassEncoder's constructor sets the full-attachment diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs index 2da09ff1..c758fe92 100644 --- a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs +++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs @@ -106,8 +106,6 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor { _visibleParticleOwners.Clear(); _clipFrame.Reset(); - _entities.ClearClipRouting(); - _environmentCells.SetClipRouting(null); } public void PrepareFlatWorldClip() => _surface.PrepareClipFrame(); @@ -279,9 +277,7 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor List? failures = null; TryAbort(_frameGlState.RestoreFrameDefaults); TryAbort(_clipFrame.Reset); - TryAbort(_entities.ClearClipRouting); TryAbort(_entities.AbortCurrentRenderSceneObserverFrame); - TryAbort(() => _environmentCells.SetClipRouting(null)); _visibleParticleOwners.Clear(); if (failures is { Count: > 0 }) throw new AggregateException("World scene pass abort failed.", failures); diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 6c2637fd..0a43478b 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -270,21 +270,13 @@ public static class RenderingDiagnostics ? ldm : 0; - /// - /// #176 stripe-hunt isolation (ACDREAM_CLIP_DEBUG=1) — throwaway - /// diagnostic. When true, the EnvCell SHELL pass maps every instance to clip - /// slot 0 (the reserved no-clip region) instead of its cell's portal-slice - /// region — i.e. shells draw WHOLE, retail's shape (retail never clips cell - /// geometry; aperture exactness comes from the seal/punch depth writes + - /// far→near order, PView::DrawCells 0x005a4840). Discriminator for the - /// camera-against-wall stripe/hatch pattern: a knife-edge slice region (eye - /// on a portal plane / inside a wall) yields a clip plane near-parallel to - /// large surfaces → interpolated gl_ClipDistance ≈ 0 across them → per-pixel - /// sign dither = the hatch. Stripes gone with this on = the shell trim is - /// the mechanism. Entity/dispatcher clip routing is NOT affected. - /// - public static bool ClipDebugNoShellTrim { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_CLIP_DEBUG") == "1"; + // S3 review fix round 1 (F5): ClipDebugNoShellTrim (ACDREAM_CLIP_DEBUG) + // is deleted — the #176 stripe-hunt isolation it toggled between "clip + // shells to their cell's portal-slice region" and "map every instance to + // slot 0" is moot now that EnvCellRenderer's per-cell clip routing is + // deleted outright (its arming method never had a live caller): shells + // always draw WHOLE, unconditionally, the same behavior this flag used + // to force. /// Parse ACDREAM_PROBE_SEAMDRAW: "1"/"true"/empty → the default #176 /// Facility Hub set; otherwise a comma-separated hex cell-id list (same forgiving diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs index 38f3b409..4c5f27a2 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs @@ -194,46 +194,23 @@ public class ClipFrameLayoutTests // AppendSlot_FromClipPlaneSet_AxisAlignedSquare_PacksFourPlanes pins // bit-exact GPU packing of ClipPlaneSet's own output — neither asks // whether a point ON an edge has ~0 distance under the planes actually - // read back through each KEEP item's real production accessor. These - // two tests close that gap, one per KEEP item (exit seals, punch fans). - - /// - /// KEEP item 1 — exit seals: RetailPViewPassExecutor.DrawExitPortalMask - /// reads its clip planes through WalkFrameDriver. - /// InteriorFloodViewClipPlanesAt, - /// the packed-byte round trip ClipFrameAssembler.Assemble builds - /// via . For a synthetic - /// quad "view" (a stand-in for one live portal view), every edge - /// midpoint has near-zero signed distance under the GPU-read-back - /// planes AND non-negative distance under every plane (retail's - /// polyClipFinish keeps the whole convex region, never clips a - /// point that sits ON the boundary) — proving the bytes - /// InteriorFloodViewClipPlanesAt hands the seal leaf really are - /// the CPU view polygon's own edge planes, not merely SOME bytes that - /// happen to round-trip. - /// - [Fact] - public void GetSlotPlanes_ExitSealPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView() - { - Vector2[] verts = - [ - new(-1f, -0.5f), new(1f, -0.5f), new(1f, 0.5f), new(-1f, 0.5f), - ]; - var cv = new CellView(); - cv.Add(new ViewPolygon(verts)); - ClipPlaneSet cps = ClipPlaneSet.From(cv); - Assert.Equal(4, cps.Count); - - // The EXACT production path: ClipFrameAssembler.Assemble packs the - // ClipPlaneSet into a slot; the seal leaf reads it back through - // ClipFrame.GetSlotPlanes (WalkFrameDriver.InteriorFloodViewClipPlanesAt). - var frame = ClipFrame.NoClip(); - int slot = frame.AppendSlot(cps); - ReadOnlySpan gpuPlanes = frame.GetSlotPlanes(checked((uint)slot)); - Assert.Equal(4, gpuPlanes.Length); - - AssertEveryEdgeMidpointLiesOnSomeGpuPlane(verts, gpuPlanes); - } + // read back through each KEEP item's real production accessor. + // + // KEEP item 1 — exit seals — moved to + // AcDream.App.Tests.Rendering.Walk.WalkFrameDriverClipSealTests + // (S3 review fix round 1, F2): the pin that used to live here drove + // ClipFrame.AppendSlot(ClipPlaneSet) directly through + // ClipFrameAssembler.Assemble's packed-byte round trip — an overload + // and a method with ZERO production callers (production interior roots + // never call Assemble; RetailPViewPassExecutor.DrawExitPortalMask + // reads its planes through WalkFrameDriver.InteriorFloodViewClipPlanesAt, + // which resolves through the driver's own CaptureViews/AppendClipSlot). + // The rewritten pin drives THAT real producer — it needs + // WalkFrameDriverTests' own DispatcherFixture/TestContext harness, so it + // lives alongside them rather than duplicating that machinery here. + // + // KEEP item 2 — punch fans stays below (it already drove the exact pair + // that runs: BeginWalkFrame + ReassembleOutsideViewFromWalk). /// /// KEEP item 2 — punch fans: RetailPViewPassExecutor.DrawWalkPunchFan diff --git a/tests/AcDream.App.Tests/Rendering/EntityClipTests.cs b/tests/AcDream.App.Tests/Rendering/EntityClipTests.cs index 8ad712aa..b051e846 100644 --- a/tests/AcDream.App.Tests/Rendering/EntityClipTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EntityClipTests.cs @@ -90,8 +90,9 @@ public sealed class EntityClipTests // R1 (bleed fix #78): an outdoor entity (ParentCellId == null) with a non-null cell filter // does NOT pass — it is not a member of any interior cell. (Was an unconditional return-true // bypass, the headline outdoor-scenery bleed.) When such entities must draw through the - // doorway, the caller passes visibleCellIds: null and the OutsideView clip-slot routing - // (ResolveEntitySlot) gates them instead. + // doorway, the caller passes visibleCellIds: null instead (S3 review fix round 1, F5: the + // OutsideView clip-slot routing this comment used to cite as the alternate gate never had a + // production caller and is deleted). var visibleCellIds = new HashSet { 0xA9B40170u }; var entity = Entity(parentCellId: null); diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 19e25259..f5f2bcd1 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -40,14 +40,30 @@ public sealed class VulkanShaderManifestTests ["debug_line.frag.spv"] = "02fc04880bc5eb74353566f914675244038125c71443964decdc28e8199264df", ["debug_line.vert.spv"] = "f9c6a9b575bb07a426fb6ade677bca96a7752ca6b120e8f6451363ba73b51140", ["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e", - ["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8", + // mesh_modern.vert re-pinned 2026-09-03 (S3 review fix round 1, + // F5): the binding=2 ClipRegionBuf (CellClip) SSBO, its + // gl_ClipDistance loop, and the gl_PerVertex redeclaration are + // deleted outright — the ONE WbDrawDispatcher API that could + // ever arm per-instance clip routing had ZERO production + // callers, so every instance has always mapped to slot + // 0 (no-clip) in every shipped build; the shader-side clip test + // clipped nothing. binding=3 instanceClipSlot[] stays declared, + // unread (fed 0; the instance-buffer layout is S5's). + ["mesh_modern.vert.spv"] = "f9ed4ee7140ccd136130559dbea68545f733f6022f52b085b6df4bcb787223c6", ["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b", // Re-pinned 2026-08-27: portal-view clip slots now travel with // deferred billboard particles, matching retail PortalList draws. - ["particle.vert.spv"] = "bf0f6b7b26a6b237e4abb2973b9959a38338868fd8304b3863f593ad56d61c35", + // Re-pinned again 2026-09-03 (S3 review fix round 1, F5): same + // deletion as mesh_modern.vert above — the binding=2 ClipRegionBuf + // fetch, gl_ClipDistance loop, and gl_PerVertex redeclaration are + // gone; the aClipSlot vertex attribute stays declared, unread. + ["particle.vert.spv"] = "95ce6ecf834930a92da5c5fe9aef513b38b5ba104704b98c1606af71fe17eaf3", ["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c", // Same contract for full-mesh particle geometry. - ["particle_mesh.vert.spv"] = "b5b3e0f583e00b78b56e297e60e5050013a26b3a74dbec64f6e5b286b751f0fa", + // Re-pinned again 2026-09-03 (S3 review fix round 1, F5): same + // ClipRegionBuf/gl_ClipDistance/gl_PerVertex deletion as + // particle.vert above. + ["particle_mesh.vert.spv"] = "043482b97c2ed036511692f89c75a0a6c298aba48cb519e5e3aff7fe7ba6371b", ["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc", // Re-pinned 2026-09-02 (Campaign OVERHAUL S1 hygiene): the b3b7d922 // investigation checkpoint removed the #117 mark-pass NDC bias and the diff --git a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs index 9d355c3d..28c5a79d 100644 --- a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs @@ -44,8 +44,11 @@ public sealed class ParticleBindlessInstanceTests // index rather than by a null handle, because Vulkan's descriptor array // cannot be asked whether an element was ever written. Assert.Contains("layout(location = 6) in uint aTextureIndex;", vertex); + // S3 review fix round 1 (F5): aClipSlot stays declared (the CPU side + // still writes it, always 0) but the per-cell clip-region SSBO fetch + // it used to index is deleted outright — WbDrawDispatcher's arming + // call for that routing had ZERO production callers. Assert.Contains("layout(location = 7) in uint aClipSlot;", vertex); - Assert.Contains("clipRegions[aClipSlot]", vertex); Assert.Contains("flat out uint vTextureIndex;", vertex); Assert.Contains("vTextureIndex = aTextureIndex;", vertex); Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment); diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 702c55ec..f2206913 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -122,8 +122,8 @@ public sealed class RetailPViewPassExecutorTests /// and Collect_InteriorRoot_...). This method now draws the /// weather MESH and the rain PARTICLES only — the former per-outside- /// view-slice loop that used to run before this call (the walk's own - /// screen-space terrain-clip writer + ClearClipRouting + the old - /// DrawLandscapeSliceLate leaf) is deleted outright (§10.2): retail + /// screen-space terrain-clip writer + its per-frame clip-routing reset + /// call + the old DrawLandscapeSliceLate leaf) is deleted outright (§10.2): retail /// draws the weather mesh and its /// rain particles ONCE, unclipped, never once per doorway aperture. /// MUTATION: re-inlining a @@ -155,8 +155,8 @@ public sealed class RetailPViewPassExecutorTests /// /// S3 chunk 4 (§10.2): the former per-outside-view-slice loop - /// (the walk's own screen-space terrain-clip writer + - /// ClearClipRouting + the old DrawLandscapeSliceLate leaf, + /// (the walk's own screen-space terrain-clip writer + its per-frame + /// clip-routing reset call + the old DrawLandscapeSliceLate leaf, /// one call per active landscape view) is deleted — DrawLandscapeDynamicsPhase now calls /// exactly once, /// conditional on diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverClipSealTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverClipSealTests.cs new file mode 100644 index 00000000..0405c7f0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverClipSealTests.cs @@ -0,0 +1,172 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Walk; + +namespace AcDream.App.Tests.Rendering.Walk; + +/// +/// S3 review fix round 1 (F2, major): the exit-seal CPU/GPU equivalence pin, +/// rewritten to drive the PRODUCER that actually runs. The prior pin (still +/// at ClipFrameLayoutTests.GetSlotPlanes_ExitSealPath_... before this +/// commit) drove ClipFrame.AppendSlot(ClipPlaneSet) directly — an +/// overload with ZERO production callers. Production seal planes come from +/// , which resolves +/// through the driver's private CaptureViews/AppendClipSlot (the +/// SAME pixel-space capture +/// runs during a real interior flood — RetailPViewPassExecutor. +/// DrawExitPortalMask reads it back through exactly this accessor). This +/// file drives that real path directly ( +/// + the cast, the same minimal-harness pattern +/// WalkFrameDriverTranscriptTests uses) — reusing 's own private fixture types via the shared +/// partial class. +/// +public sealed partial class WalkFrameDriverTests +{ + private static WalkScreenPoint[] NdcToPixelPoints(Vector2[] ndcVerts, float width, float height) + { + var points = new WalkScreenPoint[ndcVerts.Length]; + for (int i = 0; i < ndcVerts.Length; i++) + { + float px = (ndcVerts[i].X + 1f) * width / 2f; + float py = (1f - ndcVerts[i].Y) * height / 2f; + points[i] = new WalkScreenPoint(px, py, 0f, 1f); + } + return points; + } + + /// + /// KEEP item 1 — exit seals, rewritten through the real producer. + /// AppendClipSlot's per-edge plane formula (normalize + inward + /// perpendicular, winding-corrected) is IDENTICAL to 's own — two independent + /// implementations of the same retail idea — so an exact plane-for-plane + /// match against ClipPlaneSet.From's output proves the walk's own + /// capture reproduces the CPU polygon's true edge planes, not merely + /// bytes that happen to satisfy a looser geometric check. + /// MUTATION: flip the winding selection (ccw = area2 < 0f + /// instead of >= 0f) — every plane's sign inverts, failing the + /// exact-match assertion. Drop the normalize (Vector2.Normalize + /// removed from the per-edge formula) — the plane magnitude scales by + /// the (unnormalized) edge length, failing the exact-match assertion. + /// See + /// for the third mutation (deleting the AABB overflow branch). + /// + [Fact] + public void ExitSealPath_PlanesMatchClipPlaneSetsIndependentComputation_ThroughCaptureViewsAndAppendClipSlot() + { + using var fx = new DispatcherFixture(); + var ctx = new TestContext(); + using ClipFrame clipFrame = ClipFrame.NoClip(); + var driver = new WalkFrameDriver( + fx.Dispatcher, new RecordingLeafRenderer(new List()), new FakeWorldData(), clipFrame: clipFrame); + + const uint cellId = 0x100u; + var cell = new WalkCell { CellId = cellId }; + cell.PushView(); + + // A non-axis-aligned convex quad, CCW in NDC — distinct from the + // punch-fan KEEP-2 pin's own verts (ClipFrameLayoutTests). + Vector2[] verts = + [ + new(-0.3f, 0.5f), new(-0.6f, -0.2f), new(0.2f, -0.5f), new(0.6f, 0.3f), + ]; + Assert.True(WalkCopyView.Append( + cell.TopView, + NdcToPixelPoints(verts, ctx.ViewportWidth, ctx.ViewportHeight), + ctx.Rays, + ctx.WorldViewpoint)); + ctx.Cells[cellId] = cell; + + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + ((IWalkEventSink)driver).OnInteriorFloodDrawTurn(new[] { cellId }, outsideViewCount: 1); + driver.EndFrame(); + + Assert.Equal(1, driver.InteriorFloodViewSliceCountAt(0)); + ReadOnlySpan planes = driver.InteriorFloodViewClipPlanesAt(0, 0); + + Vector4[] expected = ClipPlaneSet.From(new ViewPolygon(verts)).PlaneArray; + Assert.Equal(expected.Length, planes.Length); + for (int i = 0; i < expected.Length; i++) + { + Assert.Equal(expected[i].X, planes[i].X, 4); + Assert.Equal(expected[i].Y, planes[i].Y, 4); + Assert.Equal(expected[i].Z, planes[i].Z, 4); + Assert.Equal(expected[i].W, planes[i].W, 4); + } + } + + /// + /// F2's first overflow case: a 9-vertex portal view (over = 8) exit-seals to exactly the 4 AABB + /// planes that CONTAIN every one of the 9 source vertices (over-include, + /// never under-include) — AppendClipSlot's conservative fallback + /// for a view too complex for the convex half-plane budget, rather than + /// falling back to slot 0 (which would erase the aperture entirely). + /// MUTATION: delete the if (ndc.Length > ClipFrame.MaxPlanes) + /// branch — the per-edge loop below stack-allocates exactly + /// (8) plane slots, so a 9-edge polygon + /// indexes past the end and throws, failing this test loudly. + /// + [Fact] + public void ExitSealPath_NineVertexView_UsesFourAabbPlanesContainingEveryVertex() + { + using var fx = new DispatcherFixture(); + var ctx = new TestContext(); + using ClipFrame clipFrame = ClipFrame.NoClip(); + var driver = new WalkFrameDriver( + fx.Dispatcher, new RecordingLeafRenderer(new List()), new FakeWorldData(), clipFrame: clipFrame); + + const uint cellId = 0x100u; + var cell = new WalkCell { CellId = cellId }; + cell.PushView(); + + const int n = 9; + var verts = new Vector2[n]; + for (int i = 0; i < n; i++) + { + float angle = i * MathF.Tau / n; + verts[i] = new Vector2(0.7f * MathF.Cos(angle), 0.7f * MathF.Sin(angle)); + } + Assert.True(WalkCopyView.Append( + cell.TopView, + NdcToPixelPoints(verts, ctx.ViewportWidth, ctx.ViewportHeight), + ctx.Rays, + ctx.WorldViewpoint)); + // No collinear-merge stole a vertex — a genuine 9-gon reached AppendClipSlot. + Assert.Equal(n, cell.TopView.View.Polys[0].VertexCount); + ctx.Cells[cellId] = cell; + + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + ((IWalkEventSink)driver).OnInteriorFloodDrawTurn(new[] { cellId }, outsideViewCount: 1); + driver.EndFrame(); + + ReadOnlySpan planes = driver.InteriorFloodViewClipPlanesAt(0, 0); + Assert.Equal(4, planes.Length); + + float minX = verts.Min(v => v.X), maxX = verts.Max(v => v.X); + float minY = verts.Min(v => v.Y), maxY = verts.Max(v => v.Y); + + // Over-include: every source vertex satisfies every plane. + foreach (Vector2 v in verts) + { + var clip = new Vector4(v.X, v.Y, 0f, 1f); + for (int p = 0; p < planes.Length; p++) + Assert.True(Vector4.Dot(planes[p], clip) >= -1e-3f); + } + + // Exactly the four axis-aligned NDC bounds, matching AppendClipSlot's own formula. + Vector4[] expected = + [ + new(1f, 0f, 0f, -minX), new(-1f, 0f, 0f, maxX), + new(0f, 1f, 0f, -minY), new(0f, -1f, 0f, maxY), + ]; + for (int i = 0; i < 4; i++) + { + Assert.Equal(expected[i].X, planes[i].X, 3); + Assert.Equal(expected[i].Y, planes[i].Y, 3); + Assert.Equal(expected[i].Z, planes[i].Z, 3); + Assert.Equal(expected[i].W, planes[i].W, 2); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs index 7fed1612..b69aad2c 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs @@ -83,6 +83,21 @@ public sealed class WalkTraceConformanceTests public void OnInteriorFloodDrawTurn(IReadOnlyList cells, int outsideViewCount) => WalkTraceReplayContext.AppendFloodTurns(Tokens, cells); + + /// S3 review fix round 1 (F4b): retail's trailing per-frame + /// weather turn (GameSky::Draw(sky,1) @0x00506ff0, fired by + /// RetailFrameWalk.DrawLandscape when ctx.WeatherGateOpen + /// holds) prints its own "OC" line + /// (WalkFrameDriver.OnWeatherTurn's production sibling) — + /// before this fix the interface's default no-op left the replay + /// side of the eight-kind signature silently blind to it, so + /// + /// had to drop the SAME line from the oracle side just to keep both + /// sides comparable (a pin that could never fail on the weather + /// turn's own placement/value). Recording it here the SAME way + /// records every other "OC" (identical token + /// shape) lets the oracle side compare literally instead. + public void OnWeatherTurn(uint viewerCellId) => Tokens.Add($"OC:{viewerCellId:x8}"); } private static DatCollection OpenDats() diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs index 53a7fe9b..588c8671 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceReplay.cs @@ -51,6 +51,15 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk public WalkTraceReplayContext(WalkOraclePose pose, Dictionary cells) { _cells = cells; + // S3 review fix round 1 (F4b): the SAME cell id the harness hands + // WalkFrame's own cameraCellId (every call site in + // WalkTraceConformanceTests passes pose.CellId) — production wires + // the identical invariant (RetailPViewFrameInput.ViewerCellId == + // WalkFrame's cameraCellId, see WalkFrameDriverTranscriptTests' + // own citation). Needed so a trailing weather "OC" this context's + // WeatherGateOpen now legitimately opens carries the SAME cell id + // retail's own capture recorded. + ViewerCellId = pose.CellId; WorldViewpoint = pose.Origin; var rotation = new Quaternion(pose.Q1, pose.Q2, pose.Q3, pose.Q0); // Basis convention RE-pinned 2026-08-30 (second pass): storage @@ -101,6 +110,26 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk public WalkPlane CyPlane { get; } public IWalkFrameContext CellContext => this; + /// S3 review fix round 1 (F4b): overrides + /// 's default 0 — see the + /// constructor's own doc comment for why this equals the harness's own + /// WalkFrame cameraCellId argument. + public uint ViewerCellId { get; } + + /// S3 review fix round 1 (F4b): overrides + /// 's default false with retail's + /// own gate — SmartBox::is_player_outside @0x00451e80, + /// (cellId & 0xFFFF) < 0x100 — so an outdoor-rooted + /// fixture's trailing weather turn fires during replay exactly where + /// retail's own capture recorded it, and an interior-rooted fixture's + /// never does (retail's own gate is unconditionally false whenever the + /// viewer's cell id has local part >= 0x100, regardless of whether an + /// exit view survives). This decomp-port context has no App-level + /// render-toggle concept to AND against — see the interface member's own + /// doc comment for why that is safe here (a pure walk conformance + /// harness assumes both toggles on, matching retail's default). + public bool WeatherGateOpen => (ViewerCellId & 0xFFFFu) < 0x100u; + public void SetActiveView(WalkPortalView views, int index) { WalkViewPoly poly = views.View.Polys[index]; @@ -200,20 +229,19 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk /// reason with LC/SC). Round 2 fixes this: the oracle side now reads its /// own CAPTURED EC/OC lines verbatim, in the order retail's /// cdb breakpoints actually recorded them — a real comparison against a - /// real trace, not a derivation compared to itself. The one exclusion is - /// the trailing per-frame weather OC (§11.6 H1): the LAST event - /// of the frame's raw list, when its cell id matches the frame's own "P" - /// line () AND that P cell itself looks - /// outdoor (local id < 0x100 — retail's own weather gate, - /// SmartBox::is_player_outside) — an interior root's OWN flood can - /// ALSO end the frame with a real OC matching the P cell (its nearest - /// cell's object-list turn draws last, and that nearest cell is often - /// the camera's own root cell: cathedral-arrival.walk's real trailing - /// "OC f4180108" at DI:f4180108/P-cell f4180108), so position and value - /// alone are not enough — it owns no DC and is not a walk turn at all - /// (see WalkOracleTraceTests for a dedicated - /// assertion that a COMPLETE outdoor frame's raw event list really does - /// end with it). + /// real trace, not a derivation compared to itself. + /// + /// + /// S3 review fix round 1 (F4b): round 2's own exclusion of the trailing + /// per-frame weather OC is GONE — 's Recorder now implements + /// IWalkEventSink.OnWeatherTurn (the interface's default was a + /// silent no-op), so the replay side records that exact "OC" line too, + /// at the exact point RetailFrameWalk.DrawLandscape fires it. Both + /// sides now carry the weather turn's own trailing OC literally — the + /// eight-kind signature pins its placement and value like every other + /// line, rather than the two sides mutually agreeing to stay silent + /// about it. /// /// public static string Signature8(IReadOnlyList tokens) => string.Join("|", tokens); @@ -254,30 +282,13 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk tokens.Add($"EC:{e.CellId!.Value:x8}"); break; case WalkOracleEventKind.ObjectCellTurn: - // §11.6 H1's trailing weather OC: the LAST event of the - // whole frame, matching the frame's own "P" cell — not a - // flood turn, excluded from the comparison (see this - // method's own class doc for the citation). The extra - // "P cell is itself an outdoor land cell" check matters: - // an INTERIOR root's OWN flood draws its NEAREST cell's - // object list LAST (PView::DrawCells's reversed loop, - // index 0 = nearest = last) — when that nearest cell IS - // the camera's own root cell, its real OC turn ALSO sits - // at the frame's last position AND shares the P cell's - // id (cathedral-arrival.walk: DI:f4180108, P cell - // f4180108, real trailing "OC f4180108" — position AND - // value alone would misclassify it). Retail's weather - // never draws for an interior-rooted P cell at all - // (SmartBox::is_player_outside is false whenever the - // viewer's own cell id has local part >= 0x100), so - // requiring the P cell itself look outdoor closes that - // hole without reopening G8's original one. - bool trailingWeatherOc = i == events.Count - 1 - && frame.Pose is not null - && e.CellId!.Value == frame.Pose.CellId - && (frame.Pose.CellId & 0xFFFFu) < 0x100u; - if (!trailingWeatherOc) - tokens.Add($"OC:{e.CellId!.Value:x8}"); + // S3 review fix round 1 (F4b): every ObjectCellTurn now + // reads literally, trailing weather OC included — see + // this method's own class doc for why the former + // position/value exclusion is gone (the replay side now + // records the same line through + // WalkTraceConformanceTests.Recorder.OnWeatherTurn). + tokens.Add($"OC:{e.CellId!.Value:x8}"); break; } } diff --git a/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs b/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs index 0b4dadc4..e1791cd1 100644 --- a/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WalkOutsideViewReassemblyTests.cs @@ -1,6 +1,10 @@ +using System.Linq; using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; using AcDream.App.Rendering.Walk; +using AcDream.App.Tests.Rendering.Gpu; using Xunit; namespace AcDream.App.Tests.Rendering; @@ -117,6 +121,269 @@ public class WalkOutsideViewReassemblyTests Assert.Equal(0, asm.OutdoorSlot); } + /// + /// S3 review fix round 1 (F1, BLOCKING): a two-view walk whose FIRST + /// polygon collapses (all three points exactly collinear — zero area, + /// well under ClipPlaneSet's MinPolygonArea) must still + /// produce a length-2 slice array, index-aligned with the walk's own + /// view count: slice[0] is the collapsed view (flagged + /// ), slice[1] is the SECOND + /// polygon's real edge planes — not the (nonexistent) first view's, and + /// not shifted down by one. Constructs the + /// directly (bypassing 's own pixel- + /// space collinearity filter, which is not the same test as + /// ClipPlaneSet's NDC-area gate) so the collapse is exact and + /// deterministic. + /// + [Fact] + public void FirstViewCollapses_SecondSurvives_SlicesStayIndexAligned() + { + ClipFrameAssembly asm = AssembledWithOldOutside(); + + var walkView = new WalkPortalView(); + // View 0: three EXACTLY collinear pixel points -> zero-area triangle, + // collinear before and after the affine pixel->NDC transform. + walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(100f, 100f) }); + walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(200f, 100f) }); + walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(300f, 100f) }); + walkView.View.Polys.Add(new WalkViewPoly(3, 0, 100f, 300f, 100f, 100f)); + + // View 1: a normal quad. + int secondBase = walkView.View.Vertices.Count; + Vector2[] secondQuad = + [ + new(600f, 400f), new(900f, 400f), new(900f, 650f), new(600f, 650f), + ]; + foreach (Vector2 pixel in secondQuad) + walkView.View.Vertices.Add(new WalkViewVertex { Point = pixel }); + walkView.View.Polys.Add(new WalkViewPoly(4, secondBase, 600f, 900f, 400f, 650f)); + + walkView.ViewCount = 2; + + ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, walkView, W, H); + + Assert.Equal(2, asm.OutsideViewSlices.Length); + + ClipViewSlice collapsed = asm.OutsideViewSlices[0]; + Assert.True(collapsed.NothingVisible); + Assert.Equal(0, collapsed.Slot); + Assert.Empty(collapsed.Planes); + Assert.Equal(default, collapsed.NdcAabb); + + ClipViewSlice survivor = asm.OutsideViewSlices[1]; + Assert.False(survivor.NothingVisible); + // The second polygon's own edge planes, converted from pixel space + // (px=600..900, py=400..650) into the assembler's standard NDC. + var expectedNdcVerts = new Vector2[secondQuad.Length]; + for (int i = 0; i < secondQuad.Length; i++) + { + expectedNdcVerts[i] = new Vector2( + secondQuad[i].X / W * 2f - 1f, + 1f - secondQuad[i].Y / H * 2f); + } + AssertEveryEdgeMidpointLiesOnSomeGpuPlane(expectedNdcVerts, survivor.Planes); + } + + /// + /// F1's punch-leaf half: RetailPViewPassExecutor.DrawWalkPunchFan + /// reading the SAME index-aligned array produced above. Uses a recording + /// surface so the assertion inspects the + /// actual GPU submission (or its absence), not just the CPU slice. + /// activeViewIndex=1 (the survivor) submits a fan with the survivor's + /// planes; activeViewIndex=0 (the collapsed view) submits NOTHING; + /// activeViewIndex=2 (out of range) throws (fail-loud rule). + /// + [Fact] + public void PunchLeaf_UsesIndexAlignedSlice_DrawsNothingForCollapsed_ThrowsOutOfRange() + { + ClipFrameAssembly asm = AssembledWithOldOutside(); + var walkView = new WalkPortalView(); + walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(100f, 100f) }); + walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(200f, 100f) }); + walkView.View.Vertices.Add(new WalkViewVertex { Point = new Vector2(300f, 100f) }); + walkView.View.Polys.Add(new WalkViewPoly(3, 0, 100f, 300f, 100f, 100f)); + int secondBase = walkView.View.Vertices.Count; + Vector2[] secondQuad = + [ + new(600f, 400f), new(900f, 400f), new(900f, 650f), new(600f, 650f), + ]; + foreach (Vector2 pixel in secondQuad) + walkView.View.Vertices.Add(new WalkViewVertex { Point = pixel }); + walkView.View.Polys.Add(new WalkViewPoly(4, secondBase, 600f, 900f, 400f, 650f)); + walkView.ViewCount = 2; + ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, walkView, W, H); + Assert.Equal(2, asm.OutsideViewSlices.Length); + + using var device = new RecordingGpuDevice(); + var frames = new GpuDeviceFrameLifetime(device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + using var portalDepthMask = new PortalDepthMaskRenderer(device, frames, scope); + + // DrawWalkPunchFan only reads _portalDepthMask, worldPolygon.Vertices, + // clipAssembly.OutsideViewSlices, and frame.ViewProjection — a bare + // (constructor-bypassed) executor with only that one field set + // exercises the real leaf without needing a full GL/DAT renderer + // graph. Same GetUninitializedObject pattern WorldRenderCompositionTests + // already uses for stubbing App-layer types. + var executor = (RetailPViewPassExecutor)System.Runtime.CompilerServices.RuntimeHelpers + .GetUninitializedObject(typeof(RetailPViewPassExecutor)); + typeof(RetailPViewPassExecutor) + .GetField("_portalDepthMask", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .SetValue(executor, portalDepthMask); + + var worldPolygon = new WalkPolygon + { + Vertices = new[] + { + new Vector3(-1f, -1f, 0f), new Vector3(1f, -1f, 0f), new Vector3(0f, 1f, 0f), + }, + }; + + RetailPViewFrameInput frame = new RetailPViewFrameInput().Reset( + rootCell: null!, + nearbyBuildingCells: null, + viewerEyePos: Vector3.Zero, + viewProjection: Matrix4x4.Identity, + cells: null!, + camera: null!, + cameraWorldPosition: Vector3.Zero, + frustum: null, + playerLandblockId: null, + animatedEntityIds: null, + renderCenterLbX: 0, + renderCenterLbY: 0, + renderRadius: 0, + landblockEntries: Array.Empty<(uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)>(), + renderSky: false, + renderWeather: false, + dayFraction: 0f, + activeDayGroup: null, + skyKeyframe: default, + environOverrideActive: false, + viewerCellId: 0, + playerCellId: 0, + playerViewPosition: Vector3.Zero, + cameraView: Matrix4x4.Identity, + cameraCellResolution: default); + + void RunDraw(int activeViewIndex) + { + frames.BeginFrame(); + portalDepthMask.BeginFrame(frameSlot: 0); + using (IGpuPassEncoder pass = frames.CurrentFrame!.BeginPass( + GpuPassDescription.BackbufferClear( + "s3-f1-punch-leaf", Vector4.Zero, sampleCount: 1))) + using (scope.Publish(pass)) + { + executor.DrawWalkPunchFan(frame, asm, worldPolygon, activeViewIndex); + } + frames.EndFrame(); + } + + // activeViewIndex = 0: the collapsed view. Draws nothing. + int drawsBefore = device.Calls.OfType().Count(); + RunDraw(0); + int drawsAfterCollapsed = device.Calls.OfType().Count(); + Assert.Equal(drawsBefore, drawsAfterCollapsed); + + // activeViewIndex = 1: the surviving view. Submits exactly one fan draw. + RunDraw(1); + int drawsAfterSurvivor = device.Calls.OfType().Count(); + Assert.Equal(drawsAfterCollapsed + 1, drawsAfterSurvivor); + + // activeViewIndex = 2: out of range. Fails loud instead of drawing unclipped. + var ex = Assert.Throws(() => + { + frames.BeginFrame(); + portalDepthMask.BeginFrame(frameSlot: 0); + using (IGpuPassEncoder pass = frames.CurrentFrame!.BeginPass( + GpuPassDescription.BackbufferClear( + "s3-f1-punch-leaf-oob", Vector4.Zero, sampleCount: 1))) + using (scope.Publish(pass)) + { + executor.DrawWalkPunchFan(frame, asm, worldPolygon, 2); + } + }); + Assert.Contains("activeViewIndex", ex.Message); + } + + /// Same CPU/GPU equivalence check as + /// ClipFrameLayoutTests.AssertEveryEdgeMidpointLiesOnSomeGpuPlane + /// (private there, duplicated here): every edge midpoint of the source + /// NDC polygon must be non-negative under every plane and ~zero under at + /// least one (its own edge's plane). + private static void AssertEveryEdgeMidpointLiesOnSomeGpuPlane( + Vector2[] verts, System.ReadOnlySpan gpuPlanes) + { + const float eps = 1e-4f; + for (int i = 0; i < verts.Length; i++) + { + Vector2 a = verts[i]; + Vector2 b = verts[(i + 1) % verts.Length]; + Vector2 mid = (a + b) / 2f; + var clip = new Vector4(mid.X, mid.Y, 0f, 1f); + + float minAbsDistance = float.PositiveInfinity; + foreach (Vector4 plane in gpuPlanes) + { + float distance = Vector4.Dot(plane, clip); + Assert.True( + distance >= -eps, + $"edge {i} midpoint ({mid.X},{mid.Y}) must be inside-or-on every " + + $"GPU plane; plane {plane} gave distance {distance}"); + minAbsDistance = MathF.Min(minAbsDistance, MathF.Abs(distance)); + } + Assert.True( + minAbsDistance < eps, + $"edge {i} midpoint ({mid.X},{mid.Y}) should lie ~on its OWN GPU plane; " + + $"the closest plane was only {minAbsDistance} away"); + } + } + + /// + /// S3 review fix round 1 (F2): the outside-view sibling of + /// WalkFrameDriverClipSealTests.ExitSealPath_NineVertexView_... — + /// a 9-vertex outside view is too complex for the <=8-plane budget + /// (), but unlike the exit-seal + /// path (which falls back to a conservative 4-plane AABB) the outside + /// view/punch-fan path has NO scissor consumer at all (S3 chunk 4 fix + /// round 2, L2) — it draws fully UNCLIPPED: + /// stays 0 with an EMPTY array, and + /// critically is FALSE — this + /// is the "draw unclipped" state F1 distinguishes from the "draw + /// nothing" collapsed-view state. + /// + [Fact] + public void NineVertexOutsideView_PunchSliceHasZeroPlanes_DrawsUnclipped_NotNothingVisible() + { + ClipFrameAssembly asm = AssembledWithOldOutside(); + + const int n = 9; + var verts = new Vector2[n]; + for (int i = 0; i < n; i++) + { + float angle = i * MathF.Tau / n; + verts[i] = new Vector2(0.6f * MathF.Cos(angle), 0.6f * MathF.Sin(angle)); + } + var pixelPoints = new WalkScreenPoint[n]; + for (int i = 0; i < n; i++) + { + pixelPoints[i] = new WalkScreenPoint( + (verts[i].X + 1f) * W / 2f, (1f - verts[i].Y) * H / 2f, 0f, 1f); + } + var walkView = new WalkPortalView(); + Assert.True(WalkCopyView.Append(walkView, pixelPoints, new StubRays(), Vector3.Zero)); + // No collinear-merge stole a vertex. + Assert.Equal(n, walkView.View.Polys[0].VertexCount); + + ClipFrameAssembler.ReassembleOutsideViewFromWalk(asm, walkView, W, H); + + ClipViewSlice slice = Assert.Single(asm.OutsideViewSlices); + Assert.False(slice.NothingVisible); + Assert.Equal(0, slice.Slot); + Assert.Empty(slice.Planes); + } + [Fact] public void TwoWalkViews_ProduceTwoSlicesWithDistinctSlots() { diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs index a596f2cc..b4833bb5 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs @@ -1,185 +1,28 @@ -using System.Collections.Generic; using AcDream.App.Rendering.Wb; using Xunit; namespace AcDream.App.Tests.Rendering.Wb; +/// +/// S3 review fix round 1 (F5): every test this file used to hold +/// (RawResolve_* against WbDrawDispatcher.ResolveEntitySlot, +/// ForFrame_RoutingActive_* against the active branch of +/// ResolveSlotForFrame) pinned the Phase U.4 per-instance clip-slot +/// routing policy — deleted outright because the ONE method that could ever +/// arm it had ZERO production callers, so that +/// policy never ran in any shipped build. ResolveEntitySlot and the +/// ClipSlotCull sentinel are deleted with it; the ONE surviving +/// behavior — every instance is slot 0, nothing is ever culled — is what +/// this file now pins. +/// public sealed class WbDrawDispatcherClipSlotTests { - private const uint VisibleCellA = 0xA9B4_0164u; - private const uint VisibleCellB = 0xA9B4_0165u; - private const uint NotVisibleCell = 0xA9B4_0999u; - private const uint OutdoorCell = 0xA9B4_0020u; - - private const int SlotA = 3; - private const int SlotB = 7; - private const int OutsideViewSlot = 11; - - private static IReadOnlyDictionary Routing() => new Dictionary - { - [VisibleCellA] = SlotA, - [VisibleCellB] = SlotB, - }; - [Fact] - public void RawResolve_LiveEntity_WithVisibleIndoorParent_GetsThatCellSlot() + public void ResolveSlotForFrame_AlwaysReturnsSlotZeroNeverCulled() { - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0x5000_000Au, parentCellId: VisibleCellA, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); + var r = WbDrawDispatcher.ResolveSlotForFrame(); - Assert.Equal(SlotA, slot); - } - - [Fact] - public void RawResolve_LiveEntity_WithHiddenIndoorParent_IsCulled() - { - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0x5000_000Au, parentCellId: NotVisibleCell, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); - } - - [Fact] - public void RawResolve_LiveEntity_WithOutdoorParent_UsesOutsideViewWhenVisible() - { - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0x5000_000Au, parentCellId: OutdoorCell, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal(OutsideViewSlot, slot); - } - - [Fact] - public void RawResolve_LiveEntity_WithParentNull_IsCulledWhenRoutingActive() - { - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0x5000_000Au, parentCellId: null, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); - } - - [Fact] - public void RawResolve_CellStatic_InVisibleSet_GetsThatCellSlot() - { - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0u, parentCellId: VisibleCellB, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal(SlotB, slot); - } - - [Fact] - public void RawResolve_CellStatic_NotInVisibleSet_IsCulled() - { - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0u, parentCellId: NotVisibleCell, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); - } - - [Fact] - public void RawResolve_OutdoorStab_OutdoorsVisible_GetsOutsideViewSlot() - { - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0u, parentCellId: null, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal(OutsideViewSlot, slot); - } - - [Fact] - public void RawResolve_OutdoorStab_OutdoorsNotVisible_IsCulled() - { - int slot = WbDrawDispatcher.ResolveEntitySlot( - serverGuid: 0u, parentCellId: null, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: false); - - Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot); - } - - [Fact] - public void ForFrame_RoutingInactive_EveryEntityIsSlot0AndNotCulled() - { - var live = WbDrawDispatcher.ResolveSlotForFrame( - clipRoutingActive: false, serverGuid: 0x5000_000Au, parentCellId: null, - cellIdToSlot: null, outdoorSlot: OutsideViewSlot, outdoorVisible: true); - Assert.Equal(0u, live.Slot); - Assert.False(live.Culled); - - var wouldCull = WbDrawDispatcher.ResolveSlotForFrame( - clipRoutingActive: false, serverGuid: 0u, parentCellId: NotVisibleCell, - cellIdToSlot: null, outdoorSlot: OutsideViewSlot, outdoorVisible: false); - Assert.Equal(0u, wouldCull.Slot); - Assert.False(wouldCull.Culled); - } - - [Fact] - public void ForFrame_RoutingActive_LiveEntityVisible_GetsCellSlotNotCulled() - { - var r = WbDrawDispatcher.ResolveSlotForFrame( - clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: VisibleCellA, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal((uint)SlotA, r.Slot); - Assert.False(r.Culled); - } - - [Fact] - public void ForFrame_RoutingActive_LiveEntityParentNull_Culled() - { - var r = WbDrawDispatcher.ResolveSlotForFrame( - clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: null, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.True(r.Culled); Assert.Equal(0u, r.Slot); - } - - [Fact] - public void ForFrame_RoutingActive_CellStaticVisible_GetsCellSlotNotCulled() - { - var r = WbDrawDispatcher.ResolveSlotForFrame( - clipRoutingActive: true, serverGuid: 0u, parentCellId: VisibleCellA, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal((uint)SlotA, r.Slot); Assert.False(r.Culled); } - - [Fact] - public void ForFrame_RoutingActive_CellStaticNotVisible_Culled() - { - var r = WbDrawDispatcher.ResolveSlotForFrame( - clipRoutingActive: true, serverGuid: 0u, parentCellId: NotVisibleCell, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.True(r.Culled); - Assert.Equal(0u, r.Slot); - } - - [Fact] - public void ForFrame_RoutingActive_OutdoorStabVisible_GetsOutsideViewSlot() - { - var r = WbDrawDispatcher.ResolveSlotForFrame( - clipRoutingActive: true, serverGuid: 0u, parentCellId: null, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true); - - Assert.Equal((uint)OutsideViewSlot, r.Slot); - Assert.False(r.Culled); - } - - [Fact] - public void ForFrame_RoutingActive_OutdoorStabNotVisible_Culled() - { - var r = WbDrawDispatcher.ResolveSlotForFrame( - clipRoutingActive: true, serverGuid: 0u, parentCellId: null, - cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: false); - - Assert.True(r.Culled); - Assert.Equal(0u, r.Slot); - } }