diff --git a/src/AcDream.App/Rendering/ClipFrame.cs b/src/AcDream.App/Rendering/ClipFrame.cs index 5e890cda..a447d097 100644 --- a/src/AcDream.App/Rendering/ClipFrame.cs +++ b/src/AcDream.App/Rendering/ClipFrame.cs @@ -25,11 +25,15 @@ // Pure CPU byte-packing. The GL upload machinery this file used to carry // alongside the packing (a per-flight-slot region SSBO + terrain UBO arena, // reservation/upload-once bookkeeping, and their disposal) was deleted at -// Campaign V slice V11: the RHI arm (see RhiWorldPassSurface.PrepareClipFrame / -// SetTerrainClip in WorldPassSurface.cs) reads RegionBytes/TerrainBytes below and -// copies them into a frame ring allocation instead, so nothing here owns a GPU -// resource anymore. The byte layout is asserted by ClipFrameLayoutTests so a -// silent std430/std140 drift can't reach the GPU. +// Campaign V slice V11: the RHI arm (see RhiWorldPassSurface.PrepareClipFrame +// in WorldPassSurface.cs) reads RegionBytes below and copies it into a frame +// ring allocation instead, so nothing here owns a GPU resource anymore. S3 +// chunk 4 fix round 1 (K3) deleted SetTerrainClip (its only writer) — the +// terrain UBO half of PrepareClipFrame's publication is gone with it; +// WorldFrameSectionBinding.BindTerrainClip's existing zeroed-ring fallback +// binds TerrainBytes's permanent all-zero state instead. The byte layout is +// asserted by ClipFrameLayoutTests so a silent std430/std140 drift can't +// reach the GPU. using System; using System.Numerics; using System.Runtime.InteropServices; @@ -226,20 +230,18 @@ public sealed class ClipFrame : IDisposable count * sizeof(float) * 4)); } - /// - /// Set the terrain OutsideView clip region (the single region the terrain - /// shader gates against). length 0 ungates terrain - /// (count 0). U.3 callers never touch this — leaves it - /// at count 0. U.4 calls it with the OutsideView planes. - /// - public void SetTerrainClip(ReadOnlySpan planes) - { - int count = Math.Min(planes.Length, MaxPlanes); - Array.Clear(_terrainBytes); - WriteInt(_terrainBytes, 0, count); - for (int i = 0; i < count; i++) - WriteVec4(_terrainBytes, CellClipPlanesOffset + i * 16, planes[i]); - } + // S3 chunk 4 fix round 1 (K3): SetTerrainClip (the terrain OutsideView + // writer) is deleted — no production caller has written a non-empty + // terrain clip since S3 chunk 4's original round deleted the walk's + // per-outside-view-slice sky/terrain-clip loop, and fix round 1's K4 + // deletes the sky loop's own now-dead SetTerrainClip wrapper too. Every + // KEEP clip (exit seals, punch fans) already read their planes through + // GetSlotPlanes above / ClipViewSlice.Planes directly, never through + // this terrain-specific slot. _terrainBytes therefore stays at its + // NoClip/Reset default (count 0, all zero) for the life of a frame — + // see WorldPassSurface.cs's PrepareClipFrame for how the TerrainClip + // UBO section still gets bound as that all-zero disabled block even + // with nothing left to publish it. /// /// No-op: this container owns no GPU resource of its own on the RHI arm — diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index f8f41497..79d15f09 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -11,65 +11,43 @@ namespace AcDream.App.Rendering; /// methods — the cutover changes ORDER only. internal sealed partial class RetailPViewPassExecutor { - /// The walk's single sky turn: retail draws GameSky once - /// inside LScape::draw, clipped by the active views; looping - /// today's per-slice scissor + terrain-clip block reproduces today's - /// pixels while the driver sees ONE turn. - internal void DrawWalkSky( - RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly) + /// The walk's single sky turn — retail draws GameSky exactly + /// once per frame, UNCLIPPED, for both root kinds: + /// LScape::draw @0x00506330 calls GameSky::Draw(sky,0) + /// @0x0050633c once, before the block loop, with no view clip of any + /// kind (view-culled landscape content — terrain, the sky dome, the + /// weather pass alike — is never GPU-clipped to a portal view; S3 + /// chunk 3's terrain turn and chunk 1's weather turn already draw this + /// way). + /// + /// S3 chunk 4 fix round 1 (K4): this used to loop the OUTDOOR case + /// once per active landscape view under a doorway scissor + the terrain + /// clip block (FW4 slice 6 had already retired that loop for the + /// INTERIOR case, on the same reasoning this now applies uniformly) — + /// an interior root's exit-portal aperture exactness comes from the + /// depth clear, the exit seals, and the interior repaint (FW4 slice 6's + /// own argument), not a GPU clip on the sky dome; the same is true + /// outdoors, where there is no aperture at all to bound. The former + /// per-slice scissor left stale sky color wherever the authored + /// exit-portal bands were narrower than the real opening. + internal void DrawWalkSky(RetailPViewFrameInput frame) { if (!frame.RenderSky) return; - // FW4 slice 6: an INTERIOR root draws the sky ONCE, unclipped — - // the same LScape::draw rule as the terrain turn below (view-culled - // landscape content is never view-clipped; the clear + seals + - // interior repaint own aperture exactness). The former per-slice - // scissor left stale sky color wherever the authored exit-portal - // bands were narrower than the real opening. - if (!frame.RootCell.IsOutdoorNode) + _sky?.RenderSky( + frame.Camera, + frame.CameraWorldPosition, + frame.DayFraction, + frame.ActiveDayGroup, + frame.SkyKeyframe, + frame.EnvironOverrideActive); + if (_particles is not null && _particleRenderer is not null) { - _sky?.RenderSky( + _particleRenderer.Draw( frame.Camera, frame.CameraWorldPosition, - frame.DayFraction, - frame.ActiveDayGroup, - frame.SkyKeyframe, - frame.EnvironOverrideActive); - if (_particles is not null && _particleRenderer is not null) - { - _particleRenderer.Draw( - frame.Camera, - frame.CameraWorldPosition, - ParticleRenderPass.SkyPreScene); - } - return; - } - - ReadOnlySpan slices = clipAssembly.OutsideViewSlices; - for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++) - { - ClipViewSlice slice = slices[sliceIndex]; - bool scissor = BeginDoorwayScissor(slice.NdcAabb); - _surface.BindTerrainClip(); - EnableClipDistances(); - _sky?.RenderSky( - frame.Camera, - frame.CameraWorldPosition, - frame.DayFraction, - frame.ActiveDayGroup, - frame.SkyKeyframe, - frame.EnvironOverrideActive); - DisableClipDistances(); - if (_particles is not null && _particleRenderer is not null) - { - _particleRenderer.Draw( - frame.Camera, - frame.CameraWorldPosition, - ParticleRenderPass.SkyPreScene); - } - if (scissor) - _surface.EndScissor(); + ParticleRenderPass.SkyPreScene); } } @@ -160,11 +138,10 @@ internal sealed partial class RetailPViewPassExecutor /// ORDER, never the leaf mechanics: /// /// -/// → the sky block of DrawLandscapeSlice, -/// looped over the active slices internally (retail draws GameSky once -/// inside LScape::draw clipped by the active views; the per-slice -/// scissor+clip here reproduces today's pixels while the driver still sees -/// ONE sky turn). +/// → S3 chunk 4 fix round 1's single unclipped +/// sky draw (retail's GameSky::Draw(sky,0) inside LScape::draw, +/// before the block loop) — one call, no scissor, no clip, for both root +/// kinds. /// → S3 chunk 3's per-land-cell /// terrain turn, UNCLIPPED for both root kinds (retail never view-clips /// terrain) — no scissor, no terrain clip, one call per order-merged @@ -232,7 +209,7 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer ?? throw new ArgumentNullException(nameof(drawExitSeals)); } - public void DrawSky() => _passes.DrawWalkSky(_frame, _clipAssembly); + public void DrawSky() => _passes.DrawWalkSky(_frame); public void DrawLandCellBatch( IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) => diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index dd62c895..100a8116 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -214,6 +214,23 @@ public RetailPViewPassExecutor( /// the end of the walk for an outdoor root) instead of wherever the GPU /// draw happens to run at Replay. /// + /// + /// S3 chunk 4 fix round 1 (K2): the caller () now gates + /// this call on WalkFrameDriver.WeatherTurnFired — the walk's own + /// record of "a Landscape turn ran with the weather gate open" — rather + /// than re-deriving the gate independently, so this method's own + /// check is a + /// provably redundant safety net (the caller reads the same + /// 's toggles the flag was computed from). + /// + /// + /// S3 chunk 4 fix round 1 (K8): the weather MESH draws before the rain + /// PARTICLE emitters below purely as this method's own call order — + /// retail's single GameSky::Draw(sky,1) call imposes no ordering + /// between acdream's two substitutes (the mesh and the particle + /// system), so this order is a bookkeeping choice, not a retail fact. + /// /// public void DrawWeatherOnce(RetailPViewFrameInput frame) { @@ -430,10 +447,16 @@ public RetailPViewPassExecutor( return submitted; } - private bool BeginDoorwayScissor(Vector4 ndcAabb) => - _surface.BeginScissor(ndcAabb); - - private void EnableClipDistances() => _surface.EnableClipDistances(); + // S3 chunk 4 fix round 1 (K4): BeginDoorwayScissor and the + // EnableClipDistances wrapper are deleted — their only caller was + // DrawWalkSky's per-outside-view-slice loop (RetailPViewPassExecutor. + // WalkLeaf.cs), itself deleted by the same fix (retail draws the sky + // ONCE, unclipped, exactly like the terrain and the weather — see + // DrawWalkSky's own doc comment). _surface.BeginScissor/EndScissor and + // IWorldPassSurface.EnableClipDistances stay: RhiWorldPassSurface. + // ClearInteriorDepth still ends an active scissor, and + // WorldScenePassExecutor (the separate flat-world path, out of this + // chunk's scope) still calls EnableClipDistances directly. private void DisableClipDistances() => _surface.DisableClipDistances(); } diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 6421abab..cbde681f 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -451,7 +451,8 @@ internal sealed class RetailPViewRenderer DrawLandscapeDynamicsPhase( ctx, passes, - clipAssembly); + clipAssembly, + walkDriver); }; try { @@ -461,7 +462,8 @@ internal sealed class RetailPViewRenderer DrawLandscapeDynamicsPhase( ctx, passes, - clipAssembly); + clipAssembly, + walkDriver); } } finally @@ -624,16 +626,17 @@ internal sealed class RetailPViewRenderer private void DrawLandscapeDynamicsPhase( RetailPViewFrameInput ctx, RetailPViewPassExecutor passes, - ClipFrameAssembly clipAssembly) + ClipFrameAssembly clipAssembly, + Walk.WalkFrameDriver walkDriver) { - if (clipAssembly.OutsideViewSlices.Length == 0) - return; - - // Ownerless OUTDOOR-cell emitters — now unconditional: the old - // hasBuildingLookIns gate only existed to sequence this submission - // around the OLD static barrier drains, which no longer run here - // (the walk owns its own alpha barriers — WalkFrameDriver.OnBuildingTurn). - passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); + if (clipAssembly.OutsideViewSlices.Length != 0) + { + // Ownerless OUTDOOR-cell emitters — now unconditional: the old + // hasBuildingLookIns gate only existed to sequence this submission + // around the OLD static barrier drains, which no longer run here + // (the walk owns its own alpha barriers — WalkFrameDriver.OnBuildingTurn). + passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true); + } // S3 chunk 4 (§10.2): the former per-outside-view-slice loop // (SetTerrainClip + ClearClipRouting + DrawLandscapeSliceLate, one @@ -648,7 +651,19 @@ internal sealed class RetailPViewRenderer // emitter. The OC transcript print no longer lives here — it moved // to WalkFrameDriver.OnWeatherTurn, fired by RetailFrameWalk. // DrawLandscape at Collect time (S3 chunk 4, O3). - passes.DrawWeatherOnce(ctx); + // + // S3 chunk 4 fix round 1 (K2): gated on walkDriver.WeatherTurnFired + // — the SAME predicate that gated the Collect-time "OC" transcript + // print (WalkFrameDriver.OnWeatherTurn, fired only when a Landscape + // turn actually ran with ctx.WeatherGateOpen open) — instead of + // independently re-deriving "clipAssembly.OutsideViewSlices.Length + // > 0", which could diverge from the print's gate on an interior + // root whose landscape turn ran but whose reassembled outside-view + // slices ended up empty (the transcript would then report a weather + // turn this call never actually drew). Gate closed -> neither the + // print nor this draw fires; gate open -> both do. + if (walkDriver.WeatherTurnFired) + passes.DrawWeatherOnce(ctx); } /// Campaign FW4 slice 2 — the walk-flood seal draw. Retail's diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 81ef1453..c4d4844d 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -597,6 +597,23 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private readonly List _floodViewRouteScratch = new(); private WalkPlane _lookInCyPlane; + /// S3 chunk 4 fix round 1 (K2): set exactly when fires this frame — i.e. a + /// Landscape turn actually ran (retail's RetailFrameWalk.DrawLandscape + /// only reaches that call for an outdoor root, or an interior root whose + /// flood still has a surviving exit view) AND ctx.WeatherGateOpen + /// held. This is "the walk's own predicate" the S3 chunk-4 fix round 1 + /// unifies the transcript print and the Replay-time weather draw + /// on — before this, the print's own gate (ctx.WeatherGateOpen + /// alone) and the draw's gate (clipAssembly.OutsideViewSlices.Length + /// > 0, re-derived independently at Replay) could diverge on an + /// interior root whose landscape turn ran but whose reassembled outside- + /// view slices ended up empty, letting the transcript report a weather + /// turn the frame never actually drew. Reset every Collect + /// (/); read at Replay + /// by . + internal bool WeatherTurnFired { get; private set; } + /// S3 chunk 3 (§9.2 B2), fix round 1 (F2), round 2 (F10): /// 's PENDING terrain batch — every /// event since the last flush, @@ -693,6 +710,25 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource return routeIndex; } + /// Test-only accessor (S3 chunk 4 fix round 1, K7): the packed + /// GPU clip slot one look-in route's slice was assigned by 's call to — the + /// SAME value resolves + /// through _clipFrame.GetSlotPlanes(slice.ClipSlot) for the root- + /// flood case. Restores the "a real look-in slice never reuses the + /// reserved no-clip slot 0" pin the deleted VisibleClipSlotsInLookInTurn + /// used to prove. indexes _lookInTurns + /// directly, matching 's own + /// parameter (not the flood-cell indirection goes through). + internal uint LookInSliceClipSlotAt(int routeIndex, int sliceOffset = 0) + { + WalkLookInTurn turn = _lookInTurns[routeIndex]; + if ((uint)sliceOffset >= (uint)turn.SliceCount) + throw new ArgumentOutOfRangeException(nameof(sliceOffset)); + return _lookInSlices[turn.SliceStart + sliceOffset].ClipSlot; + } + internal List VisitedBuildings { get; } = new(); internal HashSet VisitedLandscapeCellIds { get; } = new(); @@ -764,6 +800,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _landscapeTurnsThisFrame = 0; _currentDcStage = null; _readyToReplay = false; + WeatherTurnFired = false; _stream.Reset(); _events.Clear(); _markPositions.Clear(); @@ -917,6 +954,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _landscapeTurnsThisFrame = 0; _currentDcStage = null; _readyToReplay = false; + WeatherTurnFired = false; _stream.Reset(); _events.Clear(); _markPositions.Clear(); @@ -1550,20 +1588,25 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource /// S3 chunk 4 (O3): retail's GameSky::Draw(sky,1) /// @0x00506ff0 weather pass — - /// only calls this when its own gate already held, so this handler is - /// print-only, matching 's own - /// shape (no stream side effect; the flag gate wraps the entire body). - /// The GPU draw stays where chunk 1 round 2 put it — , called directly - /// from at - /// Replay time — this hook exists ONLY to move the "OC" transcript print - /// to retail's real Collect-time turn. + /// only calls this when its own gate already held, so this handler + /// records unconditionally (S3 chunk 4 + /// fix round 1, K2 — the one predicate the Replay-time draw now reads, + /// so a landscape turn that ran with the weather gate open can never be + /// under-reported to the draw side) and is otherwise print-only, + /// matching 's own shape (no + /// stream side effect). The GPU draw stays where chunk 1 round 2 put + /// it — , called + /// directly from + /// at Replay time, now gated on THIS flag instead of re-deriving its + /// own independent condition. void IWalkEventSink.OnWeatherTurn(uint viewerCellId) { + RequireOpenFrame(); + WeatherTurnFired = true; + if (!AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled) return; - RequireOpenFrame(); WalkTranscriptDump.PrintObjectCellTurn(viewerCellId); } diff --git a/src/AcDream.App/Rendering/WorldPassSurface.cs b/src/AcDream.App/Rendering/WorldPassSurface.cs index 6b17adca..5ac23b23 100644 --- a/src/AcDream.App/Rendering/WorldPassSurface.cs +++ b/src/AcDream.App/Rendering/WorldPassSurface.cs @@ -29,8 +29,8 @@ internal interface IRenderFrameGlState internal interface IWorldPassSurface { /// - /// Publishes this frame's per-cell clip-region table and terrain clip block, - /// and routes both to the renderers that read them. + /// Publishes this frame's per-cell clip-region table, and routes it to the + /// renderers that read it. /// /// is how many distinct terrain /// clip blocks the frame will issue. GL reserves that many arena records @@ -38,12 +38,21 @@ internal interface IWorldPassSurface /// slice can still reference it is the hazard the reservation exists for. The /// RHI arm ignores it: a ring allocation is distinct memory by construction /// and lives until the frame retires. + /// + /// S3 chunk 4 fix round 1 (K3): this no longer also publishes the + /// terrain clip block — ClipFrame.SetTerrainClip, its only + /// writer, is deleted, so stays at + /// its permanent NoClip/Reset default (count 0, all zero) for the life of + /// a frame. The terrain/sky shaders still declare the TerrainClip + /// UBO (binding=2), so 's + /// existing zeroed-ring fallback (used whenever nothing published the + /// section) now binds that all-zero disabled block on every frame instead + /// of only when something raced the publish — the observable result is + /// identical either way, since a zeroed publish and a zeroed fallback are + /// the same bytes. /// void PrepareClipFrame(int terrainUploadCount); - /// Replaces the terrain clip planes and republishes the block. - void SetTerrainClip(ReadOnlySpan planes); - /// /// Re-asserts the terrain clip block at its binding. /// @@ -122,13 +131,12 @@ internal sealed class RhiWorldPassSurface : IWorldPassSurface _scope.Sections.ClipRegions = Publish( _clipFrame.RegionBytes, GpuRingUsage.Storage); - PublishTerrainClip(); - } - - public void SetTerrainClip(ReadOnlySpan planes) - { - _clipFrame.SetTerrainClip(planes); - PublishTerrainClip(); + // S3 chunk 4 fix round 1 (K3): the terrain-clip publish half is + // deleted — SetTerrainClip (its only writer) is gone, so publishing + // ClipFrame.TerrainBytes here would only ever republish permanent + // zero bytes. WorldFrameSectionBinding.BindTerrainClip's existing + // zeroed-ring fallback (for when nothing published the section) + // binds that same all-zero disabled block instead. } /// No-op: there is no ambient binding to re-assert. See the interface. @@ -176,11 +184,6 @@ internal sealed class RhiWorldPassSurface : IWorldPassSurface _scope.ClearInteriorDepth(); } - private void PublishTerrainClip() => - _scope.Sections.TerrainClip = Publish( - _clipFrame.TerrainBytes, - GpuRingUsage.Uniform); - private GpuBufferSection Publish(ReadOnlySpan data, GpuRingUsage usage) { IGpuFrame frame = _frames.CurrentFrame diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs index 72a7f709..dca1080a 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs @@ -180,20 +180,11 @@ public class ClipFrameLayoutTests AssertPlaneAt(bytes, baseOff + ClipFrame.CellClipPlanesOffset + i * 16, cps.Planes[i]); } - [Fact] - public void SetTerrainClip_WritesCountAndPlanes_AtStd140Offsets() - { - var frame = ClipFrame.NoClip(); - var p0 = new Vector4(0.3f, -0.4f, 0f, 0.1f); - var p1 = new Vector4(-0.6f, 0.8f, 0f, -0.2f); - - frame.SetTerrainClip(new[] { p0, p1 }); - - var t = frame.TerrainBytesForTest; - Assert.Equal(2, ReadInt(t, 0)); // int count at offset 0 - AssertPlaneAt(t, ClipFrame.CellClipPlanesOffset + 0, p0); // planes start at 16 under std140 too - AssertPlaneAt(t, ClipFrame.CellClipPlanesOffset + 16, p1); - } + // 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 bytes, int offset, Vector4 expected) { @@ -255,15 +246,21 @@ public class ClipFrameLayoutTests /// KEEP item 2 — punch fans: RetailPViewPassExecutor.DrawWalkPunchFan /// reads its clip planes through clipAssembly.OutsideViewSlices /// [activeViewIndex].Planes's - /// Planes field, which ClipFrameAssembler.Assemble sets - /// DIRECTLY to cps.PlaneArray (no packed-byte round trip at - /// all). This pin is therefore the more fundamental of the two: it - /// proves 's own output is - /// geometrically correct for the polygon it was built from — the - /// property BOTH keep items ultimately depend on. Same synthetic-view - /// method as the exit-seal pin, a different (non-axis-aligned) - /// synthetic polygon so the two pins are not testing the identical - /// input. + /// Planes field. S3 chunk 4 fix round 1 (K6): this pin now builds + /// that slice through the REAL production assembly — + /// ClipFrameAssembler.Assemble's own + /// outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), + /// planes)) line, the exact construction + /// ReassembleOutsideViewFromWalk (the walk's real interior-root + /// producer) shares — instead of hand-constructing a + /// directly from '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 ClipViewSlice 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. /// [Fact] public void ClipViewSlicePlanes_PunchFanPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView() @@ -272,16 +269,19 @@ public class ClipFrameLayoutTests [ new(0f, 0.6f), new(-0.6f, -0.4f), new(0.5f, -0.5f), new(0.7f, 0.2f), ]; - var cv = new CellView(); - cv.Add(new ViewPolygon(verts)); - ClipPlaneSet cps = ClipPlaneSet.From(cv); - Assert.True(cps.Count >= 3); - // ClipFrameAssembler.Assemble: `planes = cps.PlaneArray; slices.Add( - // new ClipViewSlice(slot, AabbOf(poly), planes));` — the SAME array - // reference DrawWalkPunchFan reads through + // 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 slice = new ClipViewSlice(0, default, cps.PlaneArray); + 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); } diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 17b752c7..b793bfac 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -157,11 +157,15 @@ public sealed class RetailPViewPassExecutorTests /// DrawLandscapeSliceLate leaf, one call per active landscape /// view) is deleted — DrawLandscapeDynamicsPhase now calls /// exactly once, - /// unconditionally, with no loop of any kind around it (a deleted - /// symbol cannot be re-introduced without a compile error, so this pin - /// only needs to rule out a NEW multi-call path). MUTATION: adding a - /// second call site (e.g. reintroducing a per-slice loop around a new - /// leaf) makes Assert.Single fail. + /// unconditionally, with no loop of any kind around it. This + /// Assert.Single alone proved insufficient at fix round 1 (K1): + /// it counts DISTINCT call-site offsets, so it stays green even with a + /// foreach wrapped around the one call site (the exact round-1 + /// regression this file's review caught) — see + /// + /// for the pin that actually rules that out. Kept as a cheap first-line + /// check: MUTATION: adding a second, textually distinct call site (e.g. + /// a duplicated call, not a loop) makes Assert.Single fail. /// [Fact] public void DrawLandscapeDynamicsPhase_CallsDrawWeatherOnceExactlyOnce() @@ -177,6 +181,47 @@ public sealed class RetailPViewPassExecutorTests && call.Target.Name == nameof(RetailPViewPassExecutor.DrawWeatherOnce)); } + /// + /// S3 chunk 4 fix round 1 (K1, blocking): the real loop-shape pin. + /// 's + /// Assert.Single over call-site offsets still passes when the ONE + /// call site sits inside a foreach — an IL-offset ORDER pin has + /// now failed three times to be discriminating for this exact class of + /// regression, so this asks the LOOP-SHAPE question directly: does any + /// BACKWARD branch (a branch whose target offset is lower than its own + /// offset — the shape every C# loop compiles to, whether + /// for/foreach/while) enclose the + /// DrawWeatherOnce call's own IL offset? A call sitting strictly + /// between a backward branch's target and its own offset is inside that + /// loop's body and can run more than once per method invocation; a call + /// outside every backward branch's span cannot. MUTATION: wrap the call + /// in foreach (var slice in clipAssembly.OutsideViewSlices) — + /// the compiled foreach emits a backward branch (the + /// condition-check jump back to the loop body) whose span now contains + /// the call's offset, so this test fails; restore the single + /// unconditional call to make it pass again. + /// + [Fact] + public void DrawLandscapeDynamicsPhase_DrawWeatherOnceCallSiteHasNoEnclosingBackwardBranch() + { + MethodInfo method = typeof(RetailPViewRenderer).GetMethod( + "DrawLandscapeDynamicsPhase", + BindingFlags.Instance | BindingFlags.NonPublic)!; + IReadOnlyList calls = CompiledCallGraph.Read(method); + int callIndex = RequiredCallIndex( + calls, + typeof(RetailPViewPassExecutor), + nameof(RetailPViewPassExecutor.DrawWeatherOnce)); + int callOffset = calls[callIndex].Offset; + + IReadOnlyList branches = CompiledCallGraph.ReadBranches(method); + Assert.DoesNotContain( + branches, + branch => branch.TargetOffset < branch.Offset + && branch.TargetOffset <= callOffset + && callOffset < branch.Offset); + } + /// /// S3 chunk 1 fix round 2 (§11.6 H1): is diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 56cefa41..78afdc76 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -791,6 +791,13 @@ public sealed partial class WalkFrameDriverTests 0, in insideCone, 0.1f)); Assert.False(driver.SphereVisibleInLookInTurn( 0, new Vector3(10_000f, 0f, 10f), 0.1f)); + // S3 chunk 4 fix round 1 (K7): restores the pin the deleted + // VisibleClipSlotsInLookInTurn used to prove — a real look-in slice + // is packed into its own GPU clip slot, never left on the reserved + // no-clip slot 0 (the exit-seal KEEP reads planes through the SAME + // slot mechanism, _clipFrame.GetSlotPlanes(slice.ClipSlot)). + uint clipSlot = driver.LookInSliceClipSlotAt(0); + Assert.NotEqual(0u, clipSlot); Assert.Equal(2, clipFrame.SlotCount); // The punch polygon reached the leaf renderer in WORLD space: the diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs index a2370ef9..9ce4e67d 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs @@ -711,4 +711,79 @@ public sealed partial class WalkFrameDriverTests RenderingDiagnostics.DumpWalkTranscriptEnabled = previous; } } + + /// + /// Campaign OVERHAUL S3 chunk 4 fix round 1 (K2): proves the print's own + /// gate and the Replay-time draw's gate are now the SAME predicate — + /// — instead of two + /// independently re-derived conditions that could diverge. Before this + /// fix, the print fired on ctx.WeatherGateOpen alone while the + /// Replay-time draw additionally required + /// clipAssembly.OutsideViewSlices.Length > 0, re-derived + /// separately at Replay — on an interior root whose landscape turn ran + /// with the gate open but whose reassembled outside-view slices ended + /// up empty, the transcript could report a weather turn the frame never + /// actually drew. is exactly the flag + /// RetailPViewRenderer.DrawLandscapeDynamicsPhase now gates + /// DrawWeatherOnce on, so this test's flag assertion doubles as + /// the draw-side pin: gate closed -> the flag stays false (so + /// DrawWeatherOnce would not fire either) AND no "OC" line + /// prints; gate open -> the flag becomes true (so + /// DrawWeatherOnce would fire) AND exactly one "OC" line prints. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Collect_WeatherTurnFiredMatchesThePrintedOcLineExactly(bool weatherGateOpen) + { + bool previous = RenderingDiagnostics.DumpWalkTranscriptEnabled; + RenderingDiagnostics.DumpWalkTranscriptEnabled = true; + TextWriter originalOut = Console.Out; + var capture = new StringWriter(); + try + { + Console.SetOut(capture); + + using var fx = new DispatcherFixture(); + const uint viewerCellId = 0xF4180003u; // low word < 0x100 -> outdoor root + var ctx = new TestContext + { + WeatherGateOpen = weatherGateOpen, + ViewerCellId = viewerCellId, + }; + var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] }; + var leaf = new RecordingLeafRenderer(new List()); + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData()); + var walk = new RetailFrameWalk(); + + driver.Collect( + walk, viewerCellId, null, landscape, ctx, Matrix4x4.Identity, Vector3.Zero); + + // The one predicate the Replay-time draw now reads instead of + // re-deriving its own condition — checked before the second + // Collect call resets it for the next frame. + Assert.Equal(weatherGateOpen, driver.WeatherTurnFired); + + // A second Collect call — WalkOracleTrace.Parse (like every + // real capture) discards the final in-progress frame. + driver.Collect( + walk, viewerCellId, null, landscape, ctx, Matrix4x4.Identity, Vector3.Zero); + + Console.Out.Flush(); + string[] lines = capture.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.TrimEnd('\r')) + .ToArray(); + + IReadOnlyList frames = WalkOracleTrace.Parse(lines); + WalkOracleFrame frame = Assert.Single(frames); + bool printed = frame.Events.Any(e => e.Kind == WalkOracleEventKind.ObjectCellTurn); + Assert.Equal(weatherGateOpen, printed); + } + finally + { + Console.SetOut(originalOut); + RenderingDiagnostics.DumpWalkTranscriptEnabled = previous; + } + } }