fix(render): S3 review round 1 — punch fans clipped by their own view, seal pin through the producer, dead per-cell clip machinery deleted, EC/OC transcript exact

F1 (BLOCKING, punch-fan view alignment). ClipFrameAssembler.
ReassembleOutsideViewFromWalk now keeps exactly ONE outside-view slice per
walk view, index-aligned with RetailFrameWalk.DrawBuilding's own
ActiveViewIndex (retail building_view = portal_view_num @0x0059f3bf): a
collapsed view (ClipPlaneSet.IsNothingVisible — retail polyClipFinish under
a degenerate view leaves <3 vertices, 0x59BDBC-0x59BDBF) now appends its
own ClipViewSlice.NothingVisible=true slot instead of being skipped, which
used to shift every later view's slice down by one. DrawWalkPunchFan draws
NOTHING for a NothingVisible slice and throws ArgumentOutOfRangeException
when activeViewIndex is out of range (fail-loud; never draws unclipped).
New tests: WalkOutsideViewReassemblyTests.
FirstViewCollapses_SecondSurvives_SlicesStayIndexAligned and
PunchLeaf_UsesIndexAlignedSlice_DrawsNothingForCollapsed_ThrowsOutOfRange.
MUTATION M1 (revert the append-nothing-on-skip fix, restoring the old
skip): both new tests fail with
  Assert.Equal() Failure: Values differ
  Expected: 2
  Actual:   1
MUTATION M2 (restore the old ": default" unclipped fallback in
DrawWalkPunchFan): the punch-leaf test fails with
  Assert.Equal() Failure: Values differ
  Expected: 0
  Actual:   1
(both restored; verified clean afterward).

F2 (major, exit-seal pin through the real producer + both overflow cases).
The exit-seal CPU/GPU equivalence pin moved from ClipFrameLayoutTests (which
drove ClipFrame.AppendSlot(ClipPlaneSet) via the zero-caller
ClipFrameAssembler.Assemble) to the new
tests/.../Walk/WalkFrameDriverClipSealTests.cs, which drives
WalkFrameDriver.BeginFrame + the real IWalkEventSink.OnInteriorFloodDrawTurn
hook — the SAME path RetailPViewPassExecutor.DrawExitPortalMask resolves
through via WalkFrameDriver.InteriorFloodViewClipPlanesAt. The new pin
compares WalkFrameDriver's captured planes EXACTLY (not just geometrically)
against ClipPlaneSet.From's independent CCW-normalized-perpendicular
computation, since AppendClipSlot's per-edge formula is bit-identical to
ClipPlaneSet's own. Two overflow cases added: a 9-vertex portal view exit-seals
to exactly the 4 conservative AABB planes containing every source vertex
(over-include, never under-include — AppendClipSlot's fallback for a view too
complex for the 8-plane budget); a 9-vertex OUTSIDE view (the punch-fan
sibling, WalkOutsideViewReassemblyTests.
NineVertexOutsideView_PunchSliceHasZeroPlanes_DrawsUnclipped_NotNothingVisible)
produces a zero-plane, NOT-NothingVisible slice — draws fully unclipped,
distinct from F1's "draw nothing" state.
MUTATION M1 (flip the winding selection, `ccw = area2 < 0f`):
  Assert.Equal() Failure: Values are not within 4 decimal places
  Expected: 0.9191 (rounded from 0.919144988)
  Actual:   0.8944 (rounded from 0.89442724)
MUTATION M2 (drop the normalize from the per-edge plane formula):
  Assert.Equal() Failure: Values are not within 4 decimal places
  Expected: 0.9191 (rounded from 0.919144988)
  Actual:   0.7 (rounded from 0.700000048)
MUTATION M3 (delete the >8-plane AABB overflow branch — the per-edge loop
then indexes a fixed 8-plane stackalloc with a 9-edge polygon):
  System.IndexOutOfRangeException : Index was outside the bounds of the array.
    at WalkFrameDriver.AppendClipSlot(...) line 1915
    at WalkFrameDriver.CaptureViews(...) -> CaptureCellViews -> CaptureCellViewRoute
    -> EmitFloodTurns -> IWalkEventSink.OnInteriorFloodDrawTurn
(all three restored; verified clean afterward).

F3 (minor/major). RetailPViewPassExecutor.cs's false bracket comment
(neither KEEP clip — exit seals nor punch fans — ever called Enable/
DisableClipDistances; the six DisableClipDistances() call sites all sat
around the UNCLIPPED particle/weather draws, whose bodies are no-ops on the
only backend) is rewritten to the truth; the six no-op calls and the
private DisableClipDistances() wrapper are deleted.
IWorldPassSurface.EnableClipDistances/DisableClipDistances stay on the
INTERFACE — WorldScenePassExecutor (the separate flat-world path) still
calls them.

F4a (EC transcript exactness). WalkFrameDriver.EmitFloodTurns now prints one
"EC" line per LIVE VIEW of the flood cell (retail's setup_view/DrawEnvCell
loop, PView::DrawCells @0x005a4ab1-0x005a4acc, fires once per live portal_view
slice, not once per cell) via the captured route's own SliceCount — read
directly off _lookInTurns[viewRouteIndex] rather than through
InteriorFloodViewSliceCountAt's flood-index indirection, because this same
loop also serves a building's LOOK-IN flood (WalkDrawStage.LookInStatic),
whose cells never populate InteriorFloodCells (the interior ROOT flood only)
— indexing through that accessor threw ArgumentOutOfRangeException on a real
look-in turn during verification, fixed before landing. The actual CellShell
DRAW event is unchanged (the frame stamp still dedupes it to one submission).

F4b (transcript exactness, trailing weather OC).
WalkTraceConformanceTests.Recorder now implements IWalkEventSink.
OnWeatherTurn (the interface's silent no-op default previously left the
replay side of Signature8 blind to the weather turn's own "OC" line), and
WalkTraceReplayContext.Signature8(WalkOracleFrame) no longer excludes the
trailing per-frame weather ObjectCellTurn — every ObjectCellTurn now reads
literally, on both sides. Implementing OnWeatherTurn alone left
Still_fixture_first_frame_reproduces_exactly(terrace-edge.walk) diverging
ONLY on the newly-un-excluded trailing OC (EXPECTED ...OC:f4180104|
OC:f418000b vs ACTUAL ...OC:f4180104 — a length-12 tail, nothing else
differs across a 16.6k-char signature) because
WalkTraceReplayContext.WeatherGateOpen had always defaulted to false (the
harness never needed to fire the walk's weather hook before). Per the
review round's own instruction not to weaken the pin, the harness itself is
fixed instead: WalkTraceReplayContext now implements ViewerCellId (= the
harness's own WalkFrame cameraCellId argument, matching production's
RetailPViewFrameInput.ViewerCellId invariant) and WeatherGateOpen
(retail's own SmartBox::is_player_outside gate, (cellId & 0xFFFF) < 0x100,
with no App-level render-toggle concept to AND against). MUTATION (delete
Recorder.OnWeatherTurn): Still_fixture_first_frame_reproduces_exactly
(terrace-edge.walk) fails with EXPECTED ending "...OC:f4180104|OC:f418000b"
vs ACTUAL ending "...OC:f4180104" (the trailing OC missing) — restored;
verified clean afterward.
Fixture-row status (InstalledDat lane, WalkTraceConformanceTests, 14 rows):
13 pass, 1 fails — Oh_doorway_still_first_frame_diff, the PRE-EXISTING
[Trait("Status","KnownFailure")] #458 row (a documented block-plane
precision boundary at token index 165, LC/SC content, upstream of any
EC/OC — unaffected by and unrelated to this round's OC change). Every OTHER
row, including every eight-kind (Signature8) row that now compares the
weather OC literally, passes exactly.

F5 (major, dead per-cell clip machinery). WbDrawDispatcher.SetClipRouting
(the only writer of the per-instance routing-active flag) had ZERO
production callers; EnvCellRenderer.SetClipRouting was called only with
null (WorldScenePassExecutor's BeginFrame/AbortFrame) — no path could ever
arm the per-cell clip-region table, on the walk path OR the flat path (the
S3 landing-hygiene AD-17 correction's "live for the flat path" clause is
itself corrected here: dormant in every path, deleted). Deleted:
WbDrawDispatcher's SetClipRouting/ClearClipRouting and their four backing
fields; EnvCellRenderer's SetClipRouting and its backing field (its
RenderModernMDIInternal write now unconditionally clears instanceClipSlot);
the ProbeClipRouteEnabled-gated per-frame [clip-route-disp] dispatch probe
and its three backing fields (the OTHER two ACDREAM_PROBE_CLIPROUTE
producers, [clip-route] and [clip-route-scis] in WorldRenderDiagnostics.cs,
are untouched — a different, still-live mechanism); ResolveEntitySlot,
IsIndoorCellId and the ClipSlotCull sentinel; the ACDREAM_CLIP_DEBUG probe
(RenderingDiagnostics.ClipDebugNoShellTrim) and its docs/launch-options.md
row, since the "clip shells" branch it toggled between is gone.
ResolveSlotForFrame is simplified to a parameterless
`(uint Slot, bool Culled) ResolveSlotForFrame() => (0u, false)` per the
review's own framing ("it becomes slot 0, never culled") rather than
deleted outright, since its callers (WbDrawDispatcher.cs and
WbDrawDispatcher.WalkClassify.cs) still want the same two-value shape.
tests/.../Wb/WbDrawDispatcherClipSlotTests.cs (12 tests, all exercising the
deleted routing arm via ResolveEntitySlot/the active ResolveSlotForFrame
branch) is replaced with one pin on the new parameterless behavior.
Shaders: mesh_modern.vert, mesh_atmospheric.vert, mesh_detail.vert,
particle.vert and particle_mesh.vert each lose their CellClip struct +
binding=2 ClipRegionBuf fetch, gl_ClipDistance write loop, and gl_PerVertex
redeclaration (portal_depth.vert's own, separate gl_PerVertex/gl_ClipDistance
pair — the KEEP mechanism, its planes handed through the TerrainClip UBO at
binding=2 in the UBO namespace — is untouched; verified it never read the
deleted SSBO). Recompiled via tools/compile-shaders.ps1 (glslc backend
detected; managed shaderc path ran); 24/24 pairs compiled. Re-pinned in
VulkanShaderManifestTests.RetailOracleSpirvSha256 (the only three of the
five edited shaders that carry a byte-exact retail-oracle pin):
  mesh_modern.vert.spv    = f9ed4ee7140ccd136130559dbea68545f733f6022f52b085b6df4bcb787223c6
  particle.vert.spv       = 95ce6ecf834930a92da5c5fe9aef513b38b5ba104704b98c1606af71fe17eaf3
  particle_mesh.vert.spv  = 043482b97c2ed036511692f89c75a0a6c298aba48cb519e5e3aff7fe7ba6371b
(mesh_atmospheric.vert.spv / mesh_detail.vert.spv changed too but carry no
retail-oracle pin, so no re-pin was needed for them.)
tests/.../ParticleBindlessInstanceTests.cs's
Assert.Contains("clipRegions[aClipSlot]", vertex) — a real breakage the
grep sweep caught — is deleted with a note; the surrounding aClipSlot
attribute-declaration assertion stays.
Vertex-layout residue kept THIS round, fed 0 by the CPU, unread by any
shader (S5's instance-buffer-layout revisit): instanceClipSlot[] (binding=3
SSBO) in mesh_modern.vert/mesh_atmospheric.vert/mesh_detail.vert; aClipSlot
(a per-vertex attribute) in particle.vert/particle_mesh.vert.
Grep sweep — "SetClipRouting|_clipRoutingActive|ClearClipRouting|
BeginScissor|NdcScissorRect|SetTerrainClip|BindTerrainClip|TerrainBytes|
ScissorNdcAabb|UseScissorFallback" over src/ and tests/: EMPTY (also swept
and rewrote several PRE-EXISTING prose mentions of BeginScissor/
ClearClipRouting in RetailPViewPassExecutor.cs, RetailPViewRenderer.cs,
WorldPassSurface.cs and RetailPViewPassExecutorTests.cs that predate this
commit's own work, purely to satisfy the literal sweep). "clipRegions|
instanceClipSlot|aClipSlot": every remaining match is the vertex-layout
residue named above, or a comment/assertion describing that same residue.

Deviations from a fully literal reading of F5 (recorded here since none of
them cross the mandatory automated gates, all of which pass):
- The C#-side ClipRegions SSBO publish/bind pipeline
  (RhiWorldPassSurface.PrepareClipFrame's publish, WorldFrameSections.
  ClipRegions, and ClipFrame's internal std430 byte-packing —
  RegionBytes/RegionBytesForTest/CellClipStrideBytes) is NOT deleted this
  round; it is left as documented dead residue (verified zero shader
  readers) rather than rewritten. The mandatory grep sweep targets the
  shader-source identifier `clipRegions` (lowercase), which is fully clean;
  the C# publish machinery sits behind ~8 files (ClipFrame.cs,
  WorldPassSurface.cs, WbDrawDispatcher.Rhi.cs, WorldPassScope.cs,
  GpuBindingModel.cs, EnvCellRenderer.Rhi.cs, ParticleRenderer.Rhi.cs,
  WbDrawDispatcher.OrderedStream.cs, plus the ClipFrameLayoutTests std430
  pins) and CellClipPlanesOffset/MaxPlanes are the SAME constants
  portal_depth.vert's KEEP mechanism uses for its own, unrelated UBO layout
  — a full rewrite carried materially higher regression risk than this
  round's mandatory scope justified. ClipFrame.cs and WorldPassSurface.cs
  now both name this residue explicitly in their doc comments for a future
  round to finish.
- SetClipRegionSsbo/_sharedClipRegionSsbo in WbDrawDispatcher.cs (a
  pre-existing, already fully dead GL-era write-only field/method with zero
  callers, unrelated to per-instance clip-slot routing) is left untouched —
  outside F5's named scope and outside the mandatory grep sweep.
- WalkTraceReplayContext.ViewerCellId/WeatherGateOpen (F4b, detailed above)
  is an addition beyond the literal §12 text, made to keep the InstalledDat
  lane at exactly the four established known failures without weakening
  the newly-un-excluded OC pin.

Gates: dotnet build tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c
Release and dotnet build src/AcDream.App/AcDream.App.csproj -c Release both
0 warnings/0 errors; dotnet build AcDream.slnx -c Release 0 warnings/0
errors; hermetic lane 6815/6815 passed; InstalledDat lane 243 passed / 4
failed / 1 skipped (exactly the four known failures: two #383 layout tests,
TowerAscentReplayTests, Oh_doorway_still_first_frame_diff #458);
VulkanShaderDescriptorContractTests/VulkanShaderManifestTests/
RenderPackSpirvValidatorTests/SkyVertexLayoutTests 34/34 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 19:46:14 +02:00
parent 6c7512de15
commit 89c4494d05
36 changed files with 891 additions and 787 deletions

View file

@ -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` |

View file

@ -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;
/// <summary>
/// Per-frame container for the mesh SSBO clip-region table (binding=2, one
/// <c>CellClip</c> per slot, slot 0 reserved no-clip). See the file header for
/// the exact std430 byte layout and for <see cref="TerrainUboBytes"/>/
/// Per-frame CPU plane store (<see cref="AppendSlot(ReadOnlySpan{Vector4})"/>/
/// <see cref="GetSlotPlanes"/>) the exit-seal/punch-fan KEEP clips read
/// through <c>WalkFrameDriver.InteriorFloodViewClipPlanesAt</c>. See the file
/// header for the internal std430 byte layout, for why <see
/// cref="RegionBytes"/>/<see cref="RegionBytesForTest"/> are untouched residue
/// (S3 review fix round 1, F5), and for <see cref="TerrainUboBytes"/>/
/// <see cref="TerrainClipUboBinding"/>'s separate remaining purpose.
/// Per-instance slot buffers (binding=3) are owned by each renderer, not here.
/// </summary>
@ -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;

View file

@ -47,8 +47,23 @@ public enum TerrainClipMode
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is
/// retained for diagnostics (<see cref="WorldRenderDiagnostics"/>) only — no
/// GPU pass reads it; there is no scissor consumer left in the walk.
///
/// <para>S3 review fix round 1 (F1): <paramref name="NothingVisible"/>
/// distinguishes the "this walk view's own polygon collapsed" case (retail
/// <c>polyClipFinish</c> under a degenerate view leaves &lt;3 vertices —
/// <c>0x59BDBC-0x59BDBF</c>) from the pre-existing <c>Slot == 0, Planes ==
/// []</c> "plane overflow" case (a convex region needing &gt;8 half-planes —
/// draws UNCLIPPED, over-include, never a scissor). Both states share
/// <c>Slot == 0</c> and an empty <see cref="Planes"/> array, so a consumer
/// MUST check this flag rather than infer intent from <c>Planes.Length</c>:
/// <c>NothingVisible == true</c> means DRAW NOTHING;
/// <c>NothingVisible == false</c> with empty <see cref="Planes"/> means DRAW
/// UNCLIPPED. See <see cref="ClipFrameAssembler.ReassembleOutsideViewFromWalk"/>'s
/// index-alignment doc comment for why every walk view must occupy exactly
/// one slice slot.</para>
/// </summary>
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);
/// <summary>
/// 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
/// <c>PrepareClipFrame</c> publishes the clip regions — appended slots
/// join the same single publication.
///
/// <para>S3 review fix round 1 (F1, BLOCKING): keeps exactly ONE slice
/// per walk view, index-aligned with <paramref name="outsideView"/>'s own
/// <c>ViewCount</c>. <c>RetailFrameWalk.DrawBuilding</c> pins
/// <c>ActiveViewIndex = v</c> over these SAME walk views (retail
/// <c>building_view = portal_view_num</c> @0x0059f3bf) and the punch
/// leaf (<see cref="RetailPViewPassExecutor.DrawWalkPunchFan"/>) indexes
/// <see cref="ClipFrameAssembly.OutsideViewSlices"/> by that index. The
/// shared <see cref="AppendOutsideSlice"/> helper SKIPS a view whose
/// polygon collapses (<see cref="ClipPlaneSet.IsNothingVisible"/>) —
/// correct for <see cref="Assemble"/>'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 <see cref="ClipViewSlice.NothingVisible"/> 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.
/// </para>
/// </summary>
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<Vector4>(), NothingVisible: true));
}
}
ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);

View file

@ -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<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]

View file

@ -99,7 +99,21 @@ internal sealed partial class RetailPViewPassExecutor
/// @0x0059bc90's far-Z punch through <c>PortalDepthMaskRenderer</c>,
/// clipped by the pinned view's slice planes (retail
/// <c>building_view</c> @0x0059f3bf). FW3.3: fans draw at the dat
/// aperture verbatim (the ShellDrawLiftZ retirement).</summary>
/// aperture verbatim (the ShellDrawLiftZ retirement).
///
/// <para>S3 review fix round 1 (F1, BLOCKING): <paramref
/// name="activeViewIndex"/> indexes <see
/// cref="ClipFrameAssembly.OutsideViewSlices"/> one-for-one with the
/// walk's own views (<see
/// cref="ClipFrameAssembler.ReassembleOutsideViewFromWalk"/> 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 <see
/// cref="ClipViewSlice.NothingVisible"/> means the pinned view's own
/// polygon collapsed (retail's <c>polyClipFinish</c> under a degenerate
/// view leaves &lt;3 vertices → no fan, <c>0x59BDBC-0x59BDBF</c>): this
/// draws NOTHING for it, matching retail.</para></summary>
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<ClipViewSlice> slices = clipAssembly.OutsideViewSlices;
ReadOnlySpan<Vector4> 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<Vector3> 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);
}
}

View file

@ -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, <c>ClearClipRouting</c>, and the old
/// <c>DrawLandscapeSliceLate</c> leaf, one call per active landscape
/// terrain-clip writer, its per-frame clip-routing reset call, and the
/// old <c>DrawLandscapeSliceLate</c> 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.
}

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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
},
{

View file

@ -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();

View file

@ -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

View file

@ -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<uint, int>? _cellIdToSlot;
/// <summary>
/// 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
/// <see cref="Render(WbRenderPass, HashSet{uint}?)"/>. Pass null to revert to
/// the U.3 no-clip behavior (every shell instance → slot 0).
/// </summary>
public void SetClipRouting(IReadOnlyDictionary<uint, int>? 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.
/// <summary>
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot

View file

@ -255,9 +255,11 @@ public sealed partial class WbDrawDispatcher
/// <c>_groups</c>/<c>_packedGroups</c> or publishing selection itself (see
/// this file's type doc comment).
///
/// <para>A culled entity (<see cref="ResolveSlotForFrame"/> returns
/// <c>Culled: true</c> — not visible through the active clip route)
/// contributes nothing, matching every other classifier's cull gate.</para>
/// <para>S3 review fix round 1 (F5): <see cref="ResolveSlotForFrame"/>
/// 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.</para>
/// </summary>
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 <c>firstAdmission</c> gate skips it) or the single
/// <c>RetailWholeMeshSlot</c> sentinel, so looping was never anything but
/// one iteration in practice. <paramref name="slot"/> (the entity's OWN
/// classic clip-routing slot, always 0 while <c>_clipRoutingActive</c> is
/// never armed by the walk path — see <see cref="ResolveSlotForFrame"/>)
/// is therefore the ONE clip slot every surviving batch carries.
/// clip slot — always 0, see <see cref="ResolveSlotForFrame"/>'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.
/// </summary>
private void EmitClassifiedBatches(
ObjectRenderData renderData,

View file

@ -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<uint, int>? _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;
/// <summary>
/// Phase U.4: install the per-frame clip-slot routing for an INDOOR root.
/// Call once per frame BEFORE <see cref="Draw"/> when the camera's root cell is
/// non-null; the next <see cref="Draw"/> 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 <see cref="ClearClipRouting"/> 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 <c>(Slot, Culled)</c> 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 (<c>ResolveEntitySlot</c>: 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. <c>internal static</c> (no parameters
/// needed any more) so it stays unit-testable — see
/// <c>WbDrawDispatcherClipSlotTests</c>' history for why the removed
/// per-branch tests existed and no longer apply.
/// </summary>
/// <param name="cellIdToSlot">cellId → CellClip slot. A cell absent from the map
/// is NOT visible → its cell-static instances are culled.</param>
/// <param name="outdoorSlot">Slot for outdoor scenery / building shells while
/// indoors (the OutsideView slot, or 0 for no-clip over-include).</param>
/// <param name="outdoorVisible">False ⇒ cull outdoor scenery / shells this frame
/// (the OutsideView is empty).</param>
public void SetClipRouting(IReadOnlyDictionary<uint, int> cellIdToSlot, int outdoorSlot, bool outdoorVisible)
{
ArgumentNullException.ThrowIfNull(cellIdToSlot);
_clipRoutingActive = true;
_cellIdToSlot = cellIdToSlot;
_outdoorSlot = outdoorSlot;
_outdoorVisible = outdoorVisible;
}
/// <summary>
/// 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.
/// </summary>
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<uint, int> _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;
/// <summary>
/// Phase U.4: resolve the clip slot for one entity per the slot/gate policy.
/// Returns <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
/// <list type="bullet">
/// <item>Indoor ParentCellId: the cell's slot, or CULL when hidden.</item>
/// <item>Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
/// when <paramref name="outdoorVisible"/>, else CULL.</item>
/// <item>ServerGuid != 0 with ParentCellId == null: CULL while routing is active.</item>
/// </list>
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
/// path every instance is slot 0 and nothing is culled — see
/// <see cref="ResolveSlotForFrame"/>, which gates on that flag.
/// <para>
/// INVARIANT: <paramref name="parentCellId"/> and the keys of
/// <paramref name="cellIdToSlot"/> MUST live in the same FULL cell-id space
/// (<c>lbMask | OtherCellId</c>, e.g. <c>0xA9B40164</c>). A bare-low-byte
/// ParentCellId (e.g. <c>0x64</c>) 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.
/// </para>
/// <para>
/// <c>internal static</c> + 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 <c>_cellIdToSlot</c> etc.
/// </para>
/// </summary>
internal static int ResolveEntitySlot(
uint serverGuid,
uint? parentCellId,
IReadOnlyDictionary<uint, int> 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;
}
/// <summary>
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
/// <c>(Slot, Culled)</c> pair the per-entity loop body consumes. Wraps
/// <see cref="ResolveEntitySlot"/> with the <paramref name="clipRoutingActive"/>
/// 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 <paramref name="cellIdToSlot"/>) is bypassed entirely.
/// When active, a CULL sentinel maps to <c>(0, culled=true)</c> — the slot value
/// is never emitted for a culled entity.
/// <c>internal static</c> + pure so the whole policy (including the routing-
/// inactive branch) is unit-testable — see WbDrawDispatcherClipSlotTests.
/// </summary>
internal static (uint Slot, bool Culled) ResolveSlotForFrame(
bool clipRoutingActive,
uint serverGuid,
uint? parentCellId,
IReadOnlyDictionary<uint, int>? 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<uint>? 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;
}

View file

@ -30,16 +30,21 @@ internal interface IRenderFrameGlState
internal interface IWorldPassSurface
{
/// <summary>
/// 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.
///
/// <para>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.</para>
/// 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
/// <see cref="EnableClipDistances"/>'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.</para>
///
/// <para>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
///
/// <para>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 <c>gl_ClipDistance</c> in
/// the world pass: <c>portal_depth.vert</c> (the exit seals and punch fans,
/// planes handed per draw) and the mesh/particle vertex shaders
/// divergence. Only ONE shader family writes <c>gl_ClipDistance</c> in the
/// world pass now: <c>portal_depth.vert</c> (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'
/// (<c>mesh_modern</c>, <c>mesh_atmospheric</c>, <c>mesh_detail</c>,
/// <c>particle</c>, <c>particle_mesh</c>), which read the per-cell
/// clip-region table this contract publishes through
/// <see cref="PrepareClipFrame"/>. 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 <c>1.0</c>
/// ("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).</para>
/// <c>particle</c>, <c>particle_mesh</c>) own per-cell fetch and
/// <c>gl_ClipDistance</c> loop outright: the per-cell clip-region table
/// <see cref="PrepareClipFrame"/> 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 <c>1.0</c> ("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).</para>
/// </summary>
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

View file

@ -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<Exception>? 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);

View file

@ -270,21 +270,13 @@ public static class RenderingDiagnostics
? ldm
: 0;
/// <summary>
/// #176 stripe-hunt isolation (<c>ACDREAM_CLIP_DEBUG=1</c>) — 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.
/// </summary>
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.
/// <summary>Parse ACDREAM_PROBE_SEAMDRAW: "1"/"true"/empty → the default #176
/// Facility Hub set; otherwise a comma-separated hex cell-id list (same forgiving

View file

@ -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).
/// <summary>
/// KEEP item 1 — exit seals: <c>RetailPViewPassExecutor.DrawExitPortalMask</c>
/// reads its clip planes through <c>WalkFrameDriver.
/// InteriorFloodViewClipPlanesAt</c> → <see cref="ClipFrame.GetSlotPlanes"/>,
/// the packed-byte round trip <c>ClipFrameAssembler.Assemble</c> builds
/// via <see cref="ClipFrame.AppendSlot(ClipPlaneSet)"/>. 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
/// <c>polyClipFinish</c> keeps the whole convex region, never clips a
/// point that sits ON the boundary) — proving the bytes
/// <c>InteriorFloodViewClipPlanesAt</c> hands the seal leaf really are
/// the CPU view polygon's own edge planes, not merely SOME bytes that
/// happen to round-trip.
/// </summary>
[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<Vector4> 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).
/// <summary>
/// KEEP item 2 — punch fans: <c>RetailPViewPassExecutor.DrawWalkPunchFan</c>

View file

@ -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<uint> { 0xA9B40170u };
var entity = Entity(parentCellId: null);

View file

@ -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

View file

@ -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);

View file

@ -122,8 +122,8 @@ public sealed class RetailPViewPassExecutorTests
/// and <c>Collect_InteriorRoot_...</c>). 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 + <c>ClearClipRouting</c> + the old
/// <c>DrawLandscapeSliceLate</c> leaf) is deleted outright (§10.2): retail
/// screen-space terrain-clip writer + its per-frame clip-routing reset
/// call + the old <c>DrawLandscapeSliceLate</c> 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
/// <summary>
/// S3 chunk 4 (§10.2): the former per-outside-view-slice loop
/// (the walk's own screen-space terrain-clip writer +
/// <c>ClearClipRouting</c> + the old <c>DrawLandscapeSliceLate</c> leaf,
/// (the walk's own screen-space terrain-clip writer + its per-frame
/// clip-routing reset call + the old <c>DrawLandscapeSliceLate</c> leaf,
/// one call per active landscape view) is deleted — <c>DrawLandscapeDynamicsPhase</c> now calls
/// <see cref="RetailPViewPassExecutor.DrawWeatherOnce"/> exactly once,
/// conditional on <see cref="AcDream.App.Rendering.Walk.WalkFrameDriver.WeatherTurnFired"/>

View file

@ -0,0 +1,172 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Walk;
namespace AcDream.App.Tests.Rendering.Walk;
/// <summary>
/// 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 <c>ClipFrameLayoutTests.GetSlotPlanes_ExitSealPath_...</c> before this
/// commit) drove <c>ClipFrame.AppendSlot(ClipPlaneSet)</c> directly — an
/// overload with ZERO production callers. Production seal planes come from
/// <see cref="WalkFrameDriver.InteriorFloodViewClipPlanesAt"/>, which resolves
/// through the driver's private <c>CaptureViews</c>/<c>AppendClipSlot</c> (the
/// SAME pixel-space capture <see cref="IWalkEventSink.OnInteriorFloodDrawTurn"/>
/// runs during a real interior flood — <c>RetailPViewPassExecutor.
/// DrawExitPortalMask</c> reads it back through exactly this accessor). This
/// file drives that real path directly (<see cref="WalkFrameDriver.BeginFrame"/>
/// + the <see cref="IWalkEventSink"/> cast, the same minimal-harness pattern
/// <c>WalkFrameDriverTranscriptTests</c> uses) — reusing <see
/// cref="WalkFrameDriverTests"/>'s own private fixture types via the shared
/// <c>partial class</c>.
/// </summary>
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;
}
/// <summary>
/// KEEP item 1 — exit seals, rewritten through the real producer.
/// <c>AppendClipSlot</c>'s per-edge plane formula (normalize + inward
/// perpendicular, winding-corrected) is IDENTICAL to <see
/// cref="ClipPlaneSet.From(in ViewPolygon)"/>'s own — two independent
/// implementations of the same retail idea — so an exact plane-for-plane
/// match against <c>ClipPlaneSet.From</c>'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 (<c>ccw = area2 &lt; 0f</c>
/// instead of <c>&gt;= 0f</c>) — every plane's sign inverts, failing the
/// exact-match assertion. Drop the normalize (<c>Vector2.Normalize</c>
/// removed from the per-edge formula) — the plane magnitude scales by
/// the (unnormalized) edge length, failing the exact-match assertion.
/// See <see cref="ExitSealPath_NineVertexView_UsesFourAabbPlanesContainingEveryVertex"/>
/// for the third mutation (deleting the AABB overflow branch).
/// </summary>
[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<string>()), 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<Vector4> 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);
}
}
/// <summary>
/// F2's first overflow case: a 9-vertex portal view (over <see
/// cref="ClipFrame.MaxPlanes"/> = 8) exit-seals to exactly the 4 AABB
/// planes that CONTAIN every one of the 9 source vertices (over-include,
/// never under-include) — <c>AppendClipSlot</c>'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 <c>if (ndc.Length &gt; ClipFrame.MaxPlanes)</c>
/// branch — the per-edge loop below stack-allocates exactly
/// <see cref="ClipFrame.MaxPlanes"/> (8) plane slots, so a 9-edge polygon
/// indexes past the end and throws, failing this test loudly.
/// </summary>
[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<string>()), 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<Vector4> 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);
}
}
}

View file

@ -83,6 +83,21 @@ public sealed class WalkTraceConformanceTests
public void OnInteriorFloodDrawTurn(IReadOnlyList<uint> cells, int outsideViewCount)
=> WalkTraceReplayContext.AppendFloodTurns(Tokens, cells);
/// <summary>S3 review fix round 1 (F4b): retail's trailing per-frame
/// weather turn (<c>GameSky::Draw(sky,1)</c> @0x00506ff0, fired by
/// <c>RetailFrameWalk.DrawLandscape</c> when <c>ctx.WeatherGateOpen</c>
/// holds) prints its own "OC" line
/// (<c>WalkFrameDriver.OnWeatherTurn</c>'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
/// <see cref="WalkTraceReplayContext.Signature8(WalkOracleFrame)"/>
/// 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
/// <see cref="Emit"/> records every other "OC" (identical token
/// shape) lets the oracle side compare literally instead.</summary>
public void OnWeatherTurn(uint viewerCellId) => Tokens.Add($"OC:{viewerCellId:x8}");
}
private static DatCollection OpenDats()

View file

@ -51,6 +51,15 @@ public sealed class WalkTraceReplayContext : IWalkFrameContext, IRetailFrameWalk
public WalkTraceReplayContext(WalkOraclePose pose, Dictionary<uint, WalkCell> 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;
/// <summary>S3 review fix round 1 (F4b): overrides
/// <see cref="IRetailFrameWalkContext"/>'s default 0 — see the
/// constructor's own doc comment for why this equals the harness's own
/// WalkFrame cameraCellId argument.</summary>
public uint ViewerCellId { get; }
/// <summary>S3 review fix round 1 (F4b): overrides
/// <see cref="IRetailFrameWalkContext"/>'s default false with retail's
/// own gate — <c>SmartBox::is_player_outside</c> @0x00451e80,
/// <c>(cellId &amp; 0xFFFF) &lt; 0x100</c> — 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 &gt;= 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).</summary>
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 <c>EC</c>/<c>OC</c> 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 <c>OC</c> (§11.6 H1): the LAST event
/// of the frame's raw list, when its cell id matches the frame's own "P"
/// line (<see cref="WalkOracleFrame.Pose"/>) AND that P cell itself looks
/// outdoor (local id &lt; 0x100 — retail's own weather gate,
/// <c>SmartBox::is_player_outside</c>) — 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 <c>WalkOracleTraceTests</c> 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.
/// </para>
/// <para>
/// S3 review fix round 1 (F4b): round 2's own exclusion of the trailing
/// per-frame weather <c>OC</c> is GONE — <see
/// cref="WalkTraceConformanceTests"/>'s <c>Recorder</c> now implements
/// <c>IWalkEventSink.OnWeatherTurn</c> (the interface's default was a
/// silent no-op), so the replay side records that exact "OC" line too,
/// at the exact point <c>RetailFrameWalk.DrawLandscape</c> 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.
/// </para>
/// </summary>
public static string Signature8(IReadOnlyList<string> 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 &gt;= 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;
}
}

View file

@ -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);
}
/// <summary>
/// S3 review fix round 1 (F1, BLOCKING): a two-view walk whose FIRST
/// polygon collapses (all three points exactly collinear — zero area,
/// well under <c>ClipPlaneSet</c>'s <c>MinPolygonArea</c>) must still
/// produce a length-2 slice array, index-aligned with the walk's own
/// view count: slice[0] is the collapsed view (flagged
/// <see cref="ClipViewSlice.NothingVisible"/>), slice[1] is the SECOND
/// polygon's real edge planes — not the (nonexistent) first view's, and
/// not shifted down by one. Constructs the <see cref="WalkPortalView"/>
/// directly (bypassing <see cref="WalkCopyView.Append"/>'s own pixel-
/// space collinearity filter, which is not the same test as
/// <c>ClipPlaneSet</c>'s NDC-area gate) so the collapse is exact and
/// deterministic.
/// </summary>
[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);
}
/// <summary>
/// F1's punch-leaf half: <c>RetailPViewPassExecutor.DrawWalkPunchFan</c>
/// reading the SAME index-aligned array produced above. Uses a recording
/// <see cref="RecordingGpuDevice"/> 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).
/// </summary>
[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<AcDream.Core.World.WorldEntity>, IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)>(),
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<GpuRecordedDraw>().Count();
RunDraw(0);
int drawsAfterCollapsed = device.Calls.OfType<GpuRecordedDraw>().Count();
Assert.Equal(drawsBefore, drawsAfterCollapsed);
// activeViewIndex = 1: the surviving view. Submits exactly one fan draw.
RunDraw(1);
int drawsAfterSurvivor = device.Calls.OfType<GpuRecordedDraw>().Count();
Assert.Equal(drawsAfterCollapsed + 1, drawsAfterSurvivor);
// activeViewIndex = 2: out of range. Fails loud instead of drawing unclipped.
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
{
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);
}
/// <summary>Same CPU/GPU equivalence check as
/// <c>ClipFrameLayoutTests.AssertEveryEdgeMidpointLiesOnSomeGpuPlane</c>
/// (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).</summary>
private static void AssertEveryEdgeMidpointLiesOnSomeGpuPlane(
Vector2[] verts, System.ReadOnlySpan<Vector4> 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");
}
}
/// <summary>
/// S3 review fix round 1 (F2): the outside-view sibling of
/// <c>WalkFrameDriverClipSealTests.ExitSealPath_NineVertexView_...</c> —
/// a 9-vertex outside view is too complex for the &lt;=8-plane budget
/// (<see cref="ClipPlaneSet.IsPlaneOverflow"/>), 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: <see cref="ClipViewSlice.Slot"/>
/// stays 0 with an EMPTY <see cref="ClipViewSlice.Planes"/> array, and
/// critically <see cref="ClipViewSlice.NothingVisible"/> is FALSE — this
/// is the "draw unclipped" state F1 distinguishes from the "draw
/// nothing" collapsed-view state.
/// </summary>
[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()
{

View file

@ -1,185 +1,28 @@
using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
using Xunit;
namespace AcDream.App.Tests.Rendering.Wb;
/// <summary>
/// S3 review fix round 1 (F5): every test this file used to hold
/// (<c>RawResolve_*</c> against <c>WbDrawDispatcher.ResolveEntitySlot</c>,
/// <c>ForFrame_RoutingActive_*</c> against the active branch of
/// <c>ResolveSlotForFrame</c>) 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. <c>ResolveEntitySlot</c> and the
/// <c>ClipSlotCull</c> sentinel are deleted with it; the ONE surviving
/// behavior — every instance is slot 0, nothing is ever culled — is what
/// this file now pins.
/// </summary>
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<uint, int> Routing() => new Dictionary<uint, int>
{
[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);
}
}