acdream/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs
Erik ff607a1e04 fix(render): S3 chunk 4 round 1 — loop-shape weather pin, one weather gate, dead terrain-clip stack deleted, sky drawn once
Campaign OVERHAUL S3 chunk 4 fix round 1 (docs/research/2026-09-01-overhaul/s3-walk-ownership-map.md
§10.4), on top of 6ba4b0b87. K1, K2, K3, K4, K6, K7, K8 (K5 was the lead's own plan-doc note, already
done).

K1 (blocking) — the Assert.Single pin over DrawLandscapeDynamicsPhase's DrawWeatherOnce call site
stayed green even wrapped in a foreach (it counts distinct call-site offsets, not loop shape). Added
a real LOOP-SHAPE pin reusing CompiledCallGraph.ReadBranches: asserts no backward branch (target
offset < its own offset) spans the DrawWeatherOnce call's IL offset — the shape every C#
for/foreach/while loop compiles to.

K2 (minor) — the Collect-time "OC" print fired on ctx.WeatherGateOpen alone while the Replay-time
draw independently re-derived clipAssembly.OutsideViewSlices.Length > 0, which could diverge on an
interior root whose landscape turn ran but whose reassembled outside-view slices ended up empty.
WalkFrameDriver now owns one WeatherTurnFired flag, set unconditionally inside OnWeatherTurn (so it
tracks "did the print's own gate hold" regardless of the transcript flag) and reset every
BeginFrame/AbortFrame; RetailPViewRenderer.DrawLandscapeDynamicsPhase gates DrawWeatherOnce on it
instead of re-deriving its own condition.

K3 (minor) — ClipFrame.SetTerrainClip (the terrain OutsideView writer) had no remaining caller after
chunk 4's original round and K4 below; deleted along with IWorldPassSurface.SetTerrainClip,
RhiWorldPassSurface.SetTerrainClip, the now-orphaned PublishTerrainClip helper, and PrepareClipFrame's
terrain-clip publish call. The terrain/sky shaders still declare the TerrainClip UBO (verified:
terrain_modern.vert, terrain_atmospheric.vert, sky.vert all read uTerrainClipCount/uTerrainClipPlanes),
so the section binding itself (WorldFrameSectionBinding.BindTerrainClip) stays — its existing
zeroed-ring fallback (for when nothing published the section) now binds that same all-zero disabled
block on every frame, identical bytes to the old permanent NoClip/Reset default.

K4 (major) — DrawWalkSky still looped the OUTDOOR case once per active landscape view under a doorway
scissor + BindTerrainClip + EnableClipDistances (the INTERIOR case already drew once unclipped, FW4
slice 6). Retail draws GameSky::Draw(sky,0) ONCE, unconditionally, before LScape::draw's block loop,
for both root kinds. Collapsed DrawWalkSky to one unconditional, unclipped draw; deleted
BeginDoorwayScissor and the RetailPViewPassExecutor.EnableClipDistances wrapper (both lost their only
caller). _surface.BeginScissor/EndScissor and IWorldPassSurface.EnableClipDistances stay:
RhiWorldPassSurface.ClearInteriorDepth still ends an active scissor, and WorldScenePassExecutor (the
separate flat-world path) still calls EnableClipDistances directly.

K6 (minor) — the punch-fan CPU/GPU equivalence pin hand-built a ClipViewSlice from ClipPlaneSet.From's
raw output, which could pass even if ClipFrameAssembler.Assemble's own packing/array-construction
diverged from that output. Rewrote it to build a real PortalVisibilityFrame and run it through
ClipFrameAssembler.Assemble, reading the planes back through assembly.OutsideViewSlices[0].Planes —
the exact outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes)) path
ReassembleOutsideViewFromWalk (the walk's real interior-root producer) shares.

K7 (note) — restored the "a real look-in slice never reuses the reserved no-clip slot 0" assertion
the deleted VisibleClipSlotsInLookInTurn used to prove, via a new internal test-only accessor
(WalkFrameDriver.LookInSliceClipSlotAt) reading the same _lookInSlices storage
InteriorFloodViewClipPlanesAt resolves through _clipFrame.GetSlotPlanes(slice.ClipSlot).

K8 (note) — documented in DrawWeatherOnce's own comment that the weather mesh drawing before the rain
particles is this method's own call-order choice; retail's single GameSky::Draw(sky,1) imposes no
order between acdream's two substitutes.

Every new/changed pin's mutation was hand-verified this session (temporarily applied, ran the
specific test, confirmed the exact failing assertion, then reverted — see the parent task's structured
report for the four failing-assertion texts).

No register row added or removed — every change here deletes an acdream-only rule or repairs a pin;
none introduces a new deviation.

Full solution build: 0 warnings/0 errors. App hermetic (Lane!=InstalledDat/PreparedPackage/Live/
Manual/Timing/Windows/Linux/SystemFont & Purpose!=Diagnostic & Status!=KnownFailure): 6831/6831.
InstalledDat: 244 pass/1 skip/4 known — identical to 6ba4b0b87's own baseline (2x #383 layout tests,
TowerAscent, and the pre-existing #458 WalkLandscape.CheckBlocks block-visibility divergence,
unrelated to and untouched by this round).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:18:27 +02:00

326 lines
14 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Phase U.3: CPU-side proof that <see cref="ClipFrame"/> packs the shared clip
/// data in the EXACT std430 (mesh SSBO) / std140 (terrain UBO) byte layout the
/// shaders read. A silent layout drift here would mis-clip at U.4 with no build
/// error — these tests are the gate that catches it.
///
/// Layout under test (mesh CellClip, std430):
/// offset 0 : uint count
/// offset 4 : uint _p0 (pad)
/// offset 8 : uint _p1 (pad)
/// offset 12 : uint _p2 (pad)
/// offset 16 : vec4 planes[0] (16-byte vec4 stride)
/// ...
/// offset 16 + i*16 : vec4 planes[i]
/// stride 144 bytes per slot.
/// Terrain UBO (std140): int count at 0 (padded to 16), vec4 planes[8] at 16.
/// </summary>
public class ClipFrameLayoutTests
{
private static float ReadFloat(System.ReadOnlySpan<byte> b, int offset)
=> System.BitConverter.ToSingle(b.Slice(offset, 4));
private static uint ReadUInt(System.ReadOnlySpan<byte> b, int offset)
=> System.BitConverter.ToUInt32(b.Slice(offset, 4));
private static int ReadInt(System.ReadOnlySpan<byte> b, int offset)
=> System.BitConverter.ToInt32(b.Slice(offset, 4));
[Fact]
public void LayoutConstants_MatchShaderStruct()
{
// CellClip: 16 (count + 3 pad uints) + 8*16 (vec4 planes) = 144.
Assert.Equal(144, ClipFrame.CellClipStrideBytes);
Assert.Equal(16, ClipFrame.CellClipPlanesOffset);
Assert.Equal(8, ClipFrame.MaxPlanes);
Assert.Equal(144, ClipFrame.TerrainUboBytes);
// Binding contract: mesh clip regions on SSBO binding=2, terrain on UBO binding=2.
// The mesh side's binding index moved off ClipFrame at Campaign V slice
// V11 — the RHI arm addresses it through GpuBindingModel.StorageClipRegions
// instead of a raw GL binding constant (see ClipFrame's BeginFrame doc
// comment); the terrain UBO binding is still genuinely shared, so it stays.
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
}
[Fact]
public void NoClip_HasExactlyOneSlot_AllZeros_Count0()
{
var frame = ClipFrame.NoClip();
Assert.Equal(1, frame.SlotCount);
var bytes = frame.RegionBytesForTest;
Assert.Equal(ClipFrame.CellClipStrideBytes, bytes.Length); // 144 — exactly one slot
// count == 0 ⇒ shader passes every plane (no-clip).
Assert.Equal(0u, ReadUInt(bytes, 0));
// Every byte of the reserved no-clip slot is zero.
foreach (var b in bytes)
Assert.Equal(0, b);
}
[Fact]
public void NoClip_TerrainBytes_Count0_AllZeros()
{
var frame = ClipFrame.NoClip();
var t = frame.TerrainBytesForTest;
Assert.Equal(ClipFrame.TerrainUboBytes, t.Length);
Assert.Equal(0, ReadInt(t, 0)); // count 0 ⇒ terrain ungated
foreach (var b in t)
Assert.Equal(0, b);
}
[Fact]
public void AppendSlot_WritesCountAndPlanes_AtStd430Offsets()
{
var frame = ClipFrame.NoClip();
// Three distinct planes so each lands at a verifiable offset.
var p0 = new Vector4(1f, 0f, 0f, 0.5f);
var p1 = new Vector4(0f, 1f, 0f, 0.25f);
var p2 = new Vector4(-1f, 0f, 0f, -0.75f);
int slot = frame.AppendSlot(new[] { p0, p1, p2 });
Assert.Equal(1, slot); // slot 0 is the reserved no-clip; this is slot 1
Assert.Equal(2, frame.SlotCount);
var bytes = frame.RegionBytesForTest;
Assert.Equal(2 * ClipFrame.CellClipStrideBytes, bytes.Length); // two slots now
int baseOff = slot * ClipFrame.CellClipStrideBytes; // 144
// count == 3 at offset 0 of the slot; the 3 pad uints stay zero.
Assert.Equal(3u, ReadUInt(bytes, baseOff + 0));
Assert.Equal(0u, ReadUInt(bytes, baseOff + 4));
Assert.Equal(0u, ReadUInt(bytes, baseOff + 8));
Assert.Equal(0u, ReadUInt(bytes, baseOff + 12));
// planes[0..2] at offset 16, 32, 48 (vec4 stride 16).
AssertPlaneAt(bytes, baseOff + 16, p0);
AssertPlaneAt(bytes, baseOff + 32, p1);
AssertPlaneAt(bytes, baseOff + 48, p2);
// Slot 0 (the reserved no-clip) is untouched: still count 0.
Assert.Equal(0u, ReadUInt(bytes, 0));
}
[Fact]
public void GetSlotPlanes_BorrowsTheExactPackedClipRegion()
{
using ClipFrame frame = ClipFrame.NoClip();
Vector4[] expected =
[
new(1f, 2f, 3f, 4f),
new(-5f, 6f, -7f, 8f),
];
int slot = frame.AppendSlot(expected);
ReadOnlySpan<Vector4> actual = frame.GetSlotPlanes(checked((uint)slot));
Assert.Equal(expected.Length, actual.Length);
Assert.Equal(expected[0], actual[0]);
Assert.Equal(expected[1], actual[1]);
Assert.Equal(0, frame.GetSlotPlanes(0).Length);
}
[Fact]
public void AppendSlot_EmptyPlaneList_PacksNoClipSlot_Count0()
{
var frame = ClipFrame.NoClip();
int slot = frame.AppendSlot(System.ReadOnlySpan<Vector4>.Empty);
Assert.Equal(1, slot);
var bytes = frame.RegionBytesForTest;
Assert.Equal(0u, ReadUInt(bytes, slot * ClipFrame.CellClipStrideBytes)); // count 0
}
[Fact]
public void AppendSlot_ClampsToEightPlanes()
{
var frame = ClipFrame.NoClip();
var planes = new Vector4[12];
for (int i = 0; i < planes.Length; i++)
planes[i] = new Vector4(i, 0f, 0f, 0f);
int slot = frame.AppendSlot(planes);
var bytes = frame.RegionBytesForTest;
// Only MaxPlanes (8) are recorded in the count.
Assert.Equal((uint)ClipFrame.MaxPlanes, ReadUInt(bytes, slot * ClipFrame.CellClipStrideBytes));
}
[Fact]
public void AppendSlot_FromClipPlaneSet_AxisAlignedSquare_PacksFourPlanes()
{
// A unit square in NDC → ClipPlaneSet with 4 convex planes.
var cv = new CellView();
cv.Add(new ViewPolygon(new[]
{
new Vector2(-0.5f, -0.5f), new Vector2(0.5f, -0.5f),
new Vector2(0.5f, 0.5f), new Vector2(-0.5f, 0.5f),
}));
var cps = ClipPlaneSet.From(cv);
Assert.Equal(4, cps.Count);
var frame = ClipFrame.NoClip();
int slot = frame.AppendSlot(cps);
var bytes = frame.RegionBytesForTest;
int baseOff = slot * ClipFrame.CellClipStrideBytes;
Assert.Equal(4u, ReadUInt(bytes, baseOff + 0));
// Each packed plane must match the ClipPlaneSet's plane bit-for-bit.
for (int i = 0; i < 4; i++)
AssertPlaneAt(bytes, baseOff + ClipFrame.CellClipPlanesOffset + i * 16, cps.Planes[i]);
}
// S3 chunk 4 fix round 1 (K3): SetTerrainClip_WritesCountAndPlanes_AtStd140Offsets
// is deleted along with ClipFrame.SetTerrainClip itself (no production
// writer remains). NoClip_TerrainBytes_Count0_AllZeros above still pins
// the permanent all-zero state SetTerrainClip used to be the only way
// to move off of.
private static void AssertPlaneAt(System.ReadOnlySpan<byte> bytes, int offset, Vector4 expected)
{
Assert.Equal(expected.X, ReadFloat(bytes, offset + 0), 6);
Assert.Equal(expected.Y, ReadFloat(bytes, offset + 4), 6);
Assert.Equal(expected.Z, ReadFloat(bytes, offset + 8), 6);
Assert.Equal(expected.W, ReadFloat(bytes, offset + 12), 6);
}
// ── S3 chunk 4 (§10.2 KEEP): CPU/GPU clip-plane equivalence pins ────────
//
// Grepped first (per the chunk 4 contract): ClipPlaneSetTests.cs pins
// sign correctness (inside >= 0, just-outside < 0 on SOME plane) and
// 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);
}
/// <summary>
/// KEEP item 2 — punch fans: <c>RetailPViewPassExecutor.DrawWalkPunchFan</c>
/// reads its clip planes through <c>clipAssembly.OutsideViewSlices
/// [activeViewIndex].Planes</c> — <see cref="ClipViewSlice"/>'s
/// <c>Planes</c> field. S3 chunk 4 fix round 1 (K6): this pin now builds
/// that slice through the REAL production assembly —
/// <c>ClipFrameAssembler.Assemble</c>'s own
/// <c>outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly),
/// planes))</c> line, the exact construction
/// <c>ReassembleOutsideViewFromWalk</c> (the walk's real interior-root
/// producer) shares — instead of hand-constructing a
/// <see cref="ClipViewSlice"/> directly from <see
/// cref="ClipPlaneSet.From(CellView)"/>'s raw output: a hand-built slice
/// could pass even if Assemble's own packing/array-construction diverged
/// from that raw output, which is exactly the gap a prior round's
/// three-lens review found (a hand-built <c>ClipViewSlice</c> is not
/// proof the production path builds the same one). Same synthetic-view
/// helper as the exit-seal pin, a different (non-axis-aligned) synthetic
/// polygon so the two pins are not testing the identical input.
/// </summary>
[Fact]
public void ClipViewSlicePlanes_PunchFanPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView()
{
Vector2[] verts =
[
new(0f, 0.6f), new(-0.6f, -0.4f), new(0.5f, -0.5f), new(0.7f, 0.2f),
];
// The EXACT production assembly path: ClipFrameAssembler.Assemble
// packs the outside_view polygon into a slot and constructs the
// ClipViewSlice DrawWalkPunchFan reads back through
// clipAssembly.OutsideViewSlices[activeViewIndex].Planes.
var pvFrame = new PortalVisibilityFrame();
pvFrame.OutsideView.Add(new ViewPolygon(verts));
var frame = ClipFrame.NoClip();
ClipFrameAssembly assembly = ClipFrameAssembler.Assemble(frame, pvFrame);
ClipViewSlice slice = Assert.Single(assembly.OutsideViewSlices);
Assert.True(slice.Planes.Length >= 3);
AssertEveryEdgeMidpointLiesOnSomeGpuPlane(verts, slice.Planes);
}
/// <summary>
/// CPU/GPU equivalence: a point on a convex polygon's edge must sit
/// (a) non-negative under EVERY plane (still inside-or-on the region —
/// no plane clips it away) and (b) within <paramref name="verts"/>'s
/// own floating-point tolerance of ZERO under at least one plane (that
/// edge's own plane). MUTATION: perturbing any one GPU plane's offset
/// or normal after it left <see cref="ClipPlaneSet.From(CellView)"/>
/// (a packing/binding bug) breaks either (a) for a nearby edge or (b)
/// for its own edge, failing the corresponding assertion below.
/// </summary>
private static void AssertEveryEdgeMidpointLiesOnSomeGpuPlane(
Vector2[] verts, 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");
}
}
}