From be9b4c1f29a820fa12b958668c0931d52c17004f Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 3 Sep 2026 07:43:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20S3=20chunk=202=20=E2=80=94=20ga?= =?UTF-8?q?te=20the=20interior=20turn=20on=20the=20real=20outside-view=20c?= =?UTF-8?q?ount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RetailFrameWalk.DrawInside now passes _interiorPView.OutsideView.ViewCount into IWalkEventSink.OnInteriorFloodDrawTurn. WalkFrameDriver's own implementation reproduces PView::DrawCells @0x005a4840's exact gating (0x005a4852-0x005a49eb, all inside `if (outside_view.view_count > 0)`): the landscape flush (retail FlushAlphaList(0f) @0x005a4872 plus the pre-clear dynamics hook), the device-stamp advance @0x005a4886, a GATED depth clear (pc:432731-432732), and the exit-portal seals (pc:432785-432786) — all four skipped entirely when outsideViewCount == 0. The depth clear is gated on a new driver field, PortalsDrawnCount, which models retail's D3DPolyRender::portalsDrawnCount (uint16 @0x008719b4): read-then-zeroed at the interior root's own flood turn (@0x005a489c-0x005a489e), and re-armed at Replay by the count IWalkFrameLeafRenderer.DrawExitSeals now returns (the SAME portal enumeration RetailPViewPassExecutor.DrawPortalDepthWrite already performs — OtherCellId==0xFFFF, >=3 vertices). The field persists across frames (never cleared by BeginFrame/AbortFrame/EndFrame/Replay), reproducing retail's documented quirk: a fresh driver's first ov>0 frame never clears; every later ov>0 frame clears because the previous frame's own seals armed the counter. _skyDrawnThisFrame — the proxy for outside_view.view_count != 0 that used to gate the stamp re-arm — is deleted; its "second Landscape turn in one frame" fail-loud guard moves to a frame-scoped counter (_landscapeTurnsThisFrame). RetailPViewRenderer.ClearWalkInteriorDepth splits into FlushWalkLandscape (pre-clear dynamics + FlushLandscapeAlpha) and ClearWalkInteriorDepth (the Z clear only), both wired through the new IWalkFrameLeafRenderer.FlushLandscape leaf and the WalkLeaf production adapter. Tests: flipped the ov==0 pin to expect no landscape-flush/clear/seals at all (T1); added the two-frame first-frame-no-clear / armed-clear pin plus a no-exit-portal-never-clears pin (T2); added a look-in-neither-arms- nor-consumes-the-counter pin (T3); added RetailFrameWalk's two-PView draw_landscape wiring pin and a WalkPView.ConstructView reset pin (T4); updated every direct OnInteriorFloodDrawTurn caller to pass the outsideViewCount it models (T5). The four per-category leaf-contract pins (whole-once shell, Boolean sphere admission, portal-polygon-only clip, local-player repeated submission) already existed and needed no additions (B4). Co-Authored-By: Claude Fable 5.1 --- .../RetailPViewPassExecutor.WalkLeaf.cs | 27 +- .../Rendering/RetailPViewPassExecutor.cs | 27 +- .../Rendering/RetailPViewRenderer.cs | 50 ++- .../Rendering/Walk/RetailFrameWalk.cs | 25 +- src/AcDream.App/Rendering/Walk/WalkEvents.cs | 44 +-- .../Rendering/Walk/WalkFrameDriver.cs | 227 +++++++++---- .../Rendering/Walk/RetailFrameWalkTests.cs | 21 ++ .../Rendering/Walk/WalkFrameDriverTests.cs | 299 ++++++++++++++++-- .../Rendering/Walk/WalkPViewFloodTests.cs | 40 +++ 9 files changed, 607 insertions(+), 153 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index 239ec7fb..6c048447 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -191,9 +191,13 @@ internal sealed partial class RetailPViewPassExecutor /// with forceFarZ, clipped by the pinned view's slice planes /// (retail building_view @0x0059f3bf); FW3.3 draws fans at the dat /// aperture verbatim (the ShellDrawLiftZ retirement). -/// / → -/// caller-supplied actions (the renderer owns the pass scope and the -/// root-flood seal iteration; the adapter only provides the turns). +/// // +/// → caller-supplied delegates (the renderer +/// owns the pass scope, the pre-clear dynamics phase, and the root-flood +/// seal iteration; the adapter only provides the turns). +/// returns the submitted seal-polygon count so +/// the driver can re-arm its persistent PortalsDrawnCount (S3 §8.2 +/// B2). /// FlushLandscapeAlpha, retail's /// flush-all FlushAlphaList(0f) @0x0059f30b. /// @@ -203,8 +207,9 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer private RetailPViewPassExecutor _passes = null!; private RetailPViewFrameInput _frame = null!; private ClipFrameAssembly _clipAssembly = null!; + private Action _flushLandscape = null!; private Action _clearInteriorDepth = null!; - private Action _drawExitSeals = null!; + private Func _drawExitSeals = null!; private readonly HashSet _singleCellScratch = new(); private readonly List _singleCellListScratch = new(); @@ -212,9 +217,10 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer RetailPViewPassExecutor passes, RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly, + Action flushLandscape, Action clearInteriorDepth, - Action drawExitSeals) - => Reset(passes, frame, clipAssembly, clearInteriorDepth, drawExitSeals); + Func drawExitSeals) + => Reset(passes, frame, clipAssembly, flushLandscape, clearInteriorDepth, drawExitSeals); /// /// FW6 allocation closeout: bind the retained leaf and its retained @@ -225,13 +231,16 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer RetailPViewPassExecutor passes, RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly, + Action flushLandscape, Action clearInteriorDepth, - Action drawExitSeals) + Func drawExitSeals) { _passes = passes ?? throw new ArgumentNullException(nameof(passes)); _frame = frame ?? throw new ArgumentNullException(nameof(frame)); _clipAssembly = clipAssembly ?? throw new ArgumentNullException(nameof(clipAssembly)); + _flushLandscape = flushLandscape + ?? throw new ArgumentNullException(nameof(flushLandscape)); _clearInteriorDepth = clearInteriorDepth ?? throw new ArgumentNullException(nameof(clearInteriorDepth)); _drawExitSeals = drawExitSeals @@ -272,6 +281,8 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer } } + public void FlushLandscape() => _flushLandscape(); + public void ClearInteriorDepth() => _clearInteriorDepth(); public void DrawStaticParticles(uint cellId) => @@ -280,7 +291,7 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer public void DrawCellParticles(uint cellId) => _passes.DrawCellParticles(_frame, cellId); - public void DrawExitSeals() => _drawExitSeals(); + public int DrawExitSeals() => _drawExitSeals(); public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) => _passes.DrawWalkPunchFan(_frame, _clipAssembly, worldPolygon, activeViewIndex); diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 45cf149f..40e42c26 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -300,7 +300,11 @@ public RetailPViewPassExecutor( _surface.ClearInteriorDepth(); } - public void DrawExitPortalMask( + /// Returns the number of exit-seal fan polygons actually + /// submitted (S3 §8.2 B2) — the SAME enumeration that draws them, so the + /// caller's persistent seal counter always matches what reached the GPU + /// this turn. + public int DrawExitPortalMask( RetailPViewFrameInput frame, uint cellId, ReadOnlySpan clipPlanes) => @@ -376,29 +380,34 @@ public RetailPViewPassExecutor( frame.PlayerViewPosition, frame.CameraCellResolution); - private void DrawPortalDepthWrite( + /// Retail D3DPolyRender::DrawPortalPolyInternal + /// @0x0059BC90. Main interior roots stamp true depth (seal); outdoor and + /// look-in apertures stamp far depth (punch). The renderer owns that + /// choice. Returns the number of portals actually submitted — S3 §8.2 B2: + /// each portal with OtherCellId == 0xFFFF and >=3 vertices — + /// the same enumeration WalkFrameDriver.OnInteriorFloodDrawTurn + /// counts through DrawExitSeals's return value. + private int DrawPortalDepthWrite( uint cellId, ReadOnlySpan clipPlanes, RetailPViewFrameInput frame, bool forceFarZ, int? onlyPortalIndex = null) { - // Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90. - // Main interior roots stamp true depth (seal); outdoor and look-in - // apertures stamp far depth (punch). The renderer owns that choice. if (_portalDepthMask is null) - return; + return 0; if (!forceFarZ && AcDream.Core.Rendering.RenderingDiagnostics .ProbeCathedralSkipFloatingStairSeals && cellId is 0xF4180107u or 0xF4180112u) { - return; + return 0; } LoadedCell? cell = frame.Cells.Find(cellId); if (cell is null) - return; + return 0; + int submitted = 0; Span world = stackalloc Vector3[32]; for (int index = 0; index < cell.Portals.Count; index++) { @@ -435,7 +444,9 @@ public RetailPViewPassExecutor( frame.ViewProjection, clipPlanes, forceFarZ); + submitted++; } + return submitted; } private bool BeginDoorwayScissor(Vector4 ndcAabb) => diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 93c221bb..ed6be9cb 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -46,8 +46,9 @@ internal sealed class RetailPViewRenderer private Walk.WalkFrameDriver? _walkFrameDriverScratch; private Walk.WalkProductionFrameContext? _walkFrameContextScratch; private WalkProductionLeafRenderer? _walkLeafRendererScratch; + private readonly Action _walkFlushLandscapeAction; private readonly Action _walkClearInteriorDepthAction; - private readonly Action _walkDrawExitSealsAction; + private readonly Func _walkDrawExitSealsFunc; private RetailPViewPassExecutor? _activeWalkPasses; private RetailPViewFrameInput? _activeWalkFrame; private ClipFrameAssembly? _activeWalkClipAssembly; @@ -84,8 +85,9 @@ internal sealed class RetailPViewRenderer _walkWorldData = new Walk.WalkProductionWorldData( _walkBuildings, shadows ?? throw new ArgumentNullException(nameof(shadows))); + _walkFlushLandscapeAction = FlushWalkLandscape; _walkClearInteriorDepthAction = ClearWalkInteriorDepth; - _walkDrawExitSealsAction = DrawWalkExitSeals; + _walkDrawExitSealsFunc = DrawWalkExitSeals; } // T2 (BR-4): retail has NO distance constant on the flood-admission chain @@ -219,8 +221,9 @@ internal sealed class RetailPViewRenderer walkExecutor, ctx, clipAssembly, + _walkFlushLandscapeAction, _walkClearInteriorDepthAction, - _walkDrawExitSealsAction); + _walkDrawExitSealsFunc); } else { @@ -228,8 +231,9 @@ internal sealed class RetailPViewRenderer walkExecutor, ctx, clipAssembly, + _walkFlushLandscapeAction, _walkClearInteriorDepthAction, - _walkDrawExitSealsAction); + _walkDrawExitSealsFunc); } if (_walkFrameDriverScratch is null) @@ -496,7 +500,15 @@ internal sealed class RetailPViewRenderer private readonly Walk.RetailFrameWalk _frameWalk = new(); - private void ClearWalkInteriorDepth() + /// S3 chunk 2 (§8.2 B3): the flush half of the former combined + /// ClearWalkInteriorDepth — retail's D3DPolyRender::FlushAlphaList(0f) + /// @0x005a4872 plus the pre-clear dynamics phase (FW3 visual-gate fix: + /// retail draws outside objects inside LScape::draw, before the + /// clear+seals). Called by the driver's LandscapeFlush leaf, + /// unconditionally whenever an interior root's own flood has a + /// surviving exit view (ov>0) — see + /// . + private void FlushWalkLandscape() { RetailPViewPassExecutor passes = _activeWalkPasses ?? throw new InvalidOperationException( @@ -507,10 +519,28 @@ internal sealed class RetailPViewRenderer // LScape::draw, before the clear+seals. _walkPreClearDynamics?.Invoke(); passes.FlushLandscapeAlpha(); + } + + /// S3 chunk 2 (§8.2 B3): the Z-clear half of the former combined + /// ClearWalkInteriorDepthPView::DrawCells's gated full + /// depth clear (pc:432731-432732). The driver only calls this leaf when + /// its persistent PortalsDrawnCount was nonzero at the + /// read-then-zero decision (S3 §8.1 R4) — see + /// . + private void ClearWalkInteriorDepth() + { + RetailPViewPassExecutor passes = _activeWalkPasses + ?? throw new InvalidOperationException( + "The retained walk leaf has no active pass binding."); + passes.ClearInteriorDepth(); } - private void DrawWalkExitSeals() + /// Returns the total exit-seal fan count submitted this turn + /// (S3 §8.2 B2), so the driver can re-arm its persistent + /// PortalsDrawnCount for the NEXT ov>0 interior-root + /// flood's clear decision. + private int DrawWalkExitSeals() { RetailPViewFrameInput frame = _activeWalkFrame ?? throw new InvalidOperationException( @@ -522,7 +552,7 @@ internal sealed class RetailPViewRenderer ?? throw new InvalidOperationException( "The retained walk leaf has no active driver binding."); - DrawWalkExitPortalMasks(frame, passes, driver); + return DrawWalkExitPortalMasks(frame, passes, driver); } private void ClearWalkFrameBindings() @@ -630,11 +660,12 @@ internal sealed class RetailPViewRenderer /// portal is stamped once per exact walk-owned view captured for that /// flood cell, matching retail's CEnvCell::setup_view loop. The /// legacy visibility assembly has no production role here. - private void DrawWalkExitPortalMasks( + private int DrawWalkExitPortalMasks( RetailPViewFrameInput ctx, RetailPViewPassExecutor passes, Walk.WalkFrameDriver driver) { + int submitted = 0; List floodCells = driver.InteriorFloodCells; for (int i = floodCells.Count - 1; i >= 0; i--) { @@ -642,12 +673,13 @@ internal sealed class RetailPViewRenderer int sliceCount = driver.InteriorFloodViewSliceCountAt(i); for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++) { - passes.DrawExitPortalMask( + submitted += passes.DrawExitPortalMask( ctx, cellId, driver.InteriorFloodViewClipPlanesAt(i, sliceIndex)); } } + return submitted; } private static RenderFrameDiagnosticCounts WalkDiagnosticCounts( diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs index 621db244..621c3d94 100644 --- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -109,12 +109,14 @@ public sealed class RetailFrameWalk /// /// call below fires at breakpoint-ENTRY order (matching the FW0 oracle /// traces, whose breakpoint sat at DrawCells entry — before - /// retail has drawn anything), but retail itself draws - /// LScape::draw FIRST (pc:432719, only when exit views survived), - /// then a gated full depth clear (pc:432731-432732), then the exit- - /// portal seals (pc:432785-432786), and ONLY THEN the flood's own cells - /// far-to-near. - /// fires at that later point (see its own doc comment). + /// retail has drawn anything), but retail itself draws the landscape + /// flush/stamp/gated-clear/seal turn (S3 §8.1 R3, all four strictly + /// inside if (outside_view.view_count > 0)) and ONLY THEN the + /// flood's own cells far-to-near. + /// fires at that + /// later point, carrying the SAME outside_view.view_count the DC + /// event recorded, since retail's real gate reads it there (see that + /// method's own doc comment). public void DrawInside( WalkCell cell, WalkLandscape landscape, IRetailFrameWalkContext ctx, IWalkEventSink sink) @@ -128,14 +130,15 @@ public sealed class RetailFrameWalk _interiorPView.ConstructView(cell, 0xFFFF, ctx.CellContext); uint[] floodCells = EmitDrawCells(_interiorPView, sink); - if (_interiorPView.OutsideView.ViewCount > 0) + int outsideViewCount = _interiorPView.OutsideView.ViewCount; + if (outsideViewCount > 0) DrawLandscape(landscape, _interiorPView.OutsideView, ctx, sink); // Additive (Campaign FW3.2b-1): see this method's own doc comment — - // the flood's actual cell-drawing turn, unconditional of whether a - // landscape turn just ran (ov==0 skips straight here from the DC - // event above). - sink.OnInteriorFloodDrawTurn(floodCells); + // the flood's actual cell-drawing turn. S3 §8.1 R3: outsideViewCount + // also gates the sink's own landscape-flush/stamp/clear/seal turn + // (ov==0 skips straight to the flood's own cells). + sink.OnInteriorFloodDrawTurn(floodCells, outsideViewCount); RemoveViews(cell.StabList, ctx); cell.PopView(); diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs index b57a1185..6fac78f5 100644 --- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs +++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs @@ -169,23 +169,31 @@ public interface IWalkEventSink /// @0x005a4840 actually DRAWS the root flood's own cells — NOT where the /// call for the /// SAME flood fires (that one sits at breakpoint-ENTRY order, matching - /// the FW0 oracle traces; it only RECORDS the flood list). Retail's own - /// order inside DrawCells is: LScape::draw FIRST - /// (pc:432719, only when exit views survived — see - /// and - /// ), then a full depth clear - /// (pc:432731-432732), then the exit-portal seals (pc:432785-432786) — - /// BOTH unconditional for an interior root's - /// own flood, whether or not a landscape turn just ran — and ONLY THEN - /// the flood's cells in two reverse passes: every EnvCell shell first, - /// then every cell object list (the same PView::DrawCells - /// discipline used by a building's look-in). This hook fires at that later point, so - /// this is where a driver should actually draw . - /// Building look-in floods are UNAFFECTED — retail calls - /// DrawCells re-entrantly there with ov==0 and no - /// landscape/clear/seal step, so their - /// call still fires at the actual draw point (a - /// driver may keep drawing those immediately, as before). Default no-op. + /// the FW0 oracle traces; it only RECORDS the flood list). is the SAME outside_view.view_count + /// the DC event's own carries — + /// forwarded again here because retail's actual draw-time gate reads it + /// at THIS later point, not at the earlier record-only DC event. + /// Retail's own order inside DrawCells (0x005a4852-0x005a49eb, + /// S3 §8.1 R3) is: LScape::draw (pc:432719), then + /// FlushAlphaList(0f) @0x005a4872 plus the pre-clear dynamics + /// hook, then the device-stamp advance @0x005a4886, then a gated full + /// depth clear (pc:432731-432732, gated on the persistent + /// portalsDrawnCount counter read-then-zeroed at + /// @0x005a489c-0x005a489e — R4), then the exit-portal seals + /// (pc:432785-432786) — ALL FOUR sit strictly INSIDE + /// if (outside_view.view_count > 0); when + /// is 0 none of them run at all — + /// and ONLY THEN the flood's cells draw in two reverse passes: every + /// EnvCell shell first, then every cell object list (the same + /// PView::DrawCells discipline used by a building's look-in). + /// This hook fires at that later point, so this is where a driver + /// should actually draw . Building look-in + /// floods are UNAFFECTED — retail calls DrawCells re-entrantly + /// there with ov==0 and no landscape/clear/seal step, so their + /// call still + /// fires at the actual draw point (a driver may keep drawing those + /// immediately, as before). Default no-op. /// - void OnInteriorFloodDrawTurn(IReadOnlyList cells) { } + void OnInteriorFloodDrawTurn(IReadOnlyList cells, int outsideViewCount) { } } diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 4fc01d80..07ee2acf 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -142,30 +142,51 @@ internal interface IWalkFrameLeafRenderer /// (pc:432731-432732) between the outside stage and the interior root's /// own flood — production maps this to IWorldPassScope.ClearInteriorDepth /// (see that interface's own member of the same name in - /// RetailPViewRenderer.cs, staged there on OutsideViewSlices.Length - /// > 0 — an ACKNOWLEDGED approximation of retail's true - /// portalsDrawnCount gate per that file's own comment). This walk - /// driver instead fires unconditionally for every interior root (per the - /// 2026-08-30 decomp correction: the coordinator's directive supersedes - /// the packed path's staged gate — reconcile the two if a firmer - /// portalsDrawnCount reading ever lands). Only called for an - /// INTERIOR root, never outdoors (retail has no depth clear there — + /// RetailPViewRenderer.cs). The driver calls this leaf ONLY when + /// was nonzero at the + /// read-then-zero decision point (S3 §8.1 R4: retail's + /// portalsDrawnCount, read-then-zeroed @0x005a489c-0x005a489e — + /// forceClear never writes in the pseudo-C, so the clear fires + /// iff the counter was nonzero). Only called for an INTERIOR root, never + /// outdoors (retail has no depth clear there — /// portalsDrawnCount never applies to LScape::draw's own - /// top-level walk). + /// top-level walk) and never for a building look-in (R1: those call + /// DrawCells re-entrantly with ov==0, which never reaches + /// this leaf at all). void ClearInteriorDepth(); /// The exit-portal seals (pc:432785-432786) — re-stamping every /// outside-leading portal's TRUE depth right after - /// , so the aperture the clear just wiped - /// stays occluded by the world beyond it rather than by whatever draws - /// next. Production maps this to the existing seal-fan machinery - /// (RetailPViewRenderer.DrawExitPortalMask/ + /// (when it ran) so the aperture the + /// clear just wiped stays occluded by the world beyond it rather than by + /// whatever draws next. Production maps this to the existing seal-fan + /// machinery (RetailPViewRenderer.DrawExitPortalMask/ /// PortalDepthMaskRenderer) — this driver only provides the TURN; - /// the real per-portal fan geometry is FW3.2b-2's job. Only called for an - /// INTERIOR root's own flood, never for a building look-in (those call - /// DrawCells re-entrantly with no clear/seal step) and never - /// outdoors. - void DrawExitSeals(); + /// the real per-portal fan geometry is FW3.2b-2's job. Returns the + /// number of seal polygons actually submitted this turn (S3 §8.1 R4/§8.2 + /// B2: retail's D3DPolyRender::portalsDrawnCount @0x008719b4 + /// increments once per DrawPortalPolyInternal call with its + /// second argument FALSE — exactly the exit-seal calls, never punch + /// fans) — the driver adds the returned count to + /// , which the NEXT + /// ov>0 interior-root flood's + /// decision reads. Only called for an INTERIOR root's own flood, never + /// for a building look-in (those call DrawCells re-entrantly with + /// no clear/seal step) and never outdoors. + int DrawExitSeals(); + + /// Retail D3DPolyRender::FlushAlphaList(0f) @0x005a4872 + /// plus the pre-clear dynamics hook (RetailPViewRenderer's + /// FlushWalkLandscape) — the first action inside + /// if (outside_view.view_count > 0) at the interior root's own + /// flood turn (S3 §8.1 R3), strictly before the device-stamp advance and + /// the gated depth clear. Distinct from (a + /// BUILDING turn's own alpha barrier, @0x0059f30b) even though both map + /// to the same underlying flush call — kept as a separate leaf member so + /// a driver trace can tell the two turns apart. Only called for an + /// INTERIOR root's own flood with a surviving exit view (ov>0), + /// never outdoors and never for a building look-in. + void FlushLandscape(); /// DrawPortalPolyInternal @0x0059bc90's depth-only far-Z /// punch fan — pass 1 of the building portal walk. @@ -261,6 +282,9 @@ internal enum WalkFrameEventKind : byte /// at Collect time (the context that supplies it does not outlive Collect). AlphaBarrier, + /// . + LandscapeFlush, + /// . ClearInteriorDepth, @@ -398,6 +422,9 @@ internal readonly struct WalkFrameEvent internal static WalkFrameEvent CellParticles(uint cellId) => new(WalkFrameEventKind.CellParticles, 0, cellId, 0f, null); + internal static WalkFrameEvent LandscapeFlush() => + new(WalkFrameEventKind.LandscapeFlush, 0, 0, 0f, null); + internal static WalkFrameEvent ClearInteriorDepth() => new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null); @@ -446,30 +473,34 @@ internal readonly struct WalkFrameEvent /// /// The one mark rule that reproduces the whole frame script: /// before EVERY leaf-renderer event (, -/// TerrainSlice, CellShell, ClearInteriorDepth, -/// ExitSeals, PunchFan) and before every -/// event, Collect records a -/// if the stream grew since the -/// last one (a no-op otherwise — "empty segments submit nothing"); a -/// building's own shell content is APPENDED (not marked) the moment -/// fires, so it only gets a -/// mark ahead of whatever non-stream event comes next (the next building's -/// alpha barrier, or the final mark at 's prepare step). -/// This single rule, combined with retail's two reverse flood passes (ALL -/// shells, then ALL contents), +/// TerrainSlice, CellShell, LandscapeFlush, +/// ClearInteriorDepth, ExitSeals, PunchFan) and before +/// every event, Collect +/// records a if the stream grew +/// since the last one (a no-op otherwise — "empty segments submit +/// nothing"); a building's own shell content is APPENDED (not marked) the +/// moment fires, so it only +/// gets a mark ahead of whatever non-stream event comes next (the next +/// building's alpha barrier, or the final mark at 's +/// prepare step). This single rule, combined with retail's two reverse +/// flood passes (ALL shells, then ALL contents), /// retail's own building order (alpha barrier → portal pass → shell — see /// 's doc comment), and retail's -/// own interior-root DRAW order (landscape → clear → seals → the flood's own -/// cells — see 's doc -/// comment; this is NOT the order the walk's EVENTS fire in, which is -/// breakpoint-entry order matching the FW0 oracle traces), is what produces -/// every ordering constraint the plan's frame script names: [far shell] … -/// [near shell] [far contents] … [near contents], [alpha barrier] [punch -/// fan(s) + look-in flood(s), each following the SAME reverse two-pass -/// discipline] [building shell content mark], [landscape (if exit views -/// survived)] [interior depth clear] [exit-portal seals] [the interior -/// root's own flood cells], and a final mark at Replay's prepare step. No -/// special-casing per turn kind is needed beyond that. +/// own interior-root DRAW order (landscape → flush/stamp/[gated clear]/seals +/// → the flood's own cells, the middle four steps ALL gated on +/// outside_view.view_count > 0 — see +/// 's doc comment; this +/// is NOT the order the walk's EVENTS fire in, which is breakpoint-entry +/// order matching the FW0 oracle traces), is what produces every ordering +/// constraint the plan's frame script names: [far shell] … [near shell] +/// [far contents] … [near contents], [alpha barrier] [punch fan(s) + +/// look-in flood(s), each following the SAME reverse two-pass discipline] +/// [building shell content mark], [landscape (if exit views survived)] +/// [landscape flush] [gated interior depth clear] [exit-portal seals] [the +/// interior root's own flood cells] — the last four only when +/// outside_view.view_count > 0 (S3 §8.1 R3) — and a final mark at +/// Replay's prepare step. No special-casing per turn kind is needed beyond +/// that. /// /// Retail anchors: SmartBox::RenderNormalMode @0x00453aa0 (the /// root already ports), @@ -522,6 +553,21 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource // Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn). private readonly HashSet _cellParticleTurnsDrawnThisFrame = new(); + /// S3 chunk 2 (§8.2 B2): retail's D3DPolyRender::portalsDrawnCount + /// (uint16 @0x008719b4) — retained ACROSS frames (this driver is itself + /// retained by RetailPViewRenderer), never cleared by + /// /// + /// . Read-then-zeroed at every ov>0 + /// interior-root flood's clear decision ( + /// implementation, S3 §8.1 R4); incremented at by + /// the count returns + /// for THIS frame's own exit-seal turn, which the NEXT ov>0 + /// frame's read will see. A driver whose interior floods never reach an + /// exit portal keeps this at zero forever — the gated clear never fires + /// for it, matching retail exactly (no seals submitted, no depth ever + /// needed clearing). + internal int PortalsDrawnCount; + IReadOnlyList IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; /// The set form of , for drawn-once @@ -594,7 +640,15 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private IWalkBuildingFrameContext? _ctx; private Matrix4x4 _viewProjection; private Vector3 _cameraWorldPosition; - private bool _skyDrawnThisFrame; + + /// Frame-scoped fail-loud guard (replaces the deleted + /// _skyDrawnThisFrame proxy — S3 chunk 2): counts this frame's + /// turns so a second one throws + /// (see ) rather than double-drawing + /// sky/terrain. Retail's own gate — the interior-root flood's + /// landscape/flush/stamp/clear/seal turn — now reads the real + /// outside_view.view_count the sink receives, not this counter. + private int _landscapeTurnsThisFrame; private WalkDrawStage? _currentDcStage; private bool _readyToReplay; private int _cellViewRouteIndex; @@ -638,7 +692,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _ctx = null; _viewProjection = default; _cameraWorldPosition = default; - _skyDrawnThisFrame = false; + _landscapeTurnsThisFrame = 0; _currentDcStage = null; _readyToReplay = false; _stream.Reset(); @@ -774,7 +828,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _ctx = ctx; _viewProjection = viewProjection; _cameraWorldPosition = cameraWorldPosition; - _skyDrawnThisFrame = false; + _landscapeTurnsThisFrame = 0; _currentDcStage = null; _readyToReplay = false; _stream.Reset(); @@ -893,11 +947,20 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource case WalkFrameEventKind.AlphaBarrier: _leafRenderer.AlphaBarrier(); break; + case WalkFrameEventKind.LandscapeFlush: + _leafRenderer.FlushLandscape(); + break; case WalkFrameEventKind.ClearInteriorDepth: _leafRenderer.ClearInteriorDepth(); break; case WalkFrameEventKind.ExitSeals: - _leafRenderer.DrawExitSeals(); + // S3 §8.2 B2: the driver — not Collect — owns adding + // the leaf's returned submitted-fan count to the + // persistent PortalsDrawnCount, since the real + // enumeration (and therefore the real count) only + // exists once the production leaf actually runs, at + // Replay. + PortalsDrawnCount += _leafRenderer.DrawExitSeals(); break; case WalkFrameEventKind.StaticParticles: // Retail CPhysicsObj::add_particle_shadow_to_cell @@ -1019,6 +1082,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource case WalkFrameEventKind.AlphaBarrier: order.Append('>').Append(i).Append(":AB"); break; + case WalkFrameEventKind.LandscapeFlush: + order.Append('>').Append(i).Append(":LF"); + break; case WalkFrameEventKind.ClearInteriorDepth: order.Append('>').Append(i).Append(":CLEAR"); break; @@ -1233,39 +1299,62 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource WalkFrameEvent.PunchFan(TransformToWorld(polygon, worldTransform), activeViewIndex)); } - void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList cells) + void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList cells, int outsideViewCount) { ArgumentNullException.ThrowIfNull(cells); RequireOpenFrame(); - // PView::DrawCells @0x005a4840 advances m_nFrameStamp at 0x005a4886 - // after LScape::draw + FlushAlphaList and before the depth clear. Its - // drawn-part AND DrawEnvCell dedupe is therefore per render stamp, not - // per presented frame: content admitted during the landscape must - // remain eligible for the interior-cell repaint after the clear. In - // Collect, every landscape candidate has been classified by this point - // and no interior-root candidate has, so re-arming both CPU-side stamp - // mirrors here is the exact boundary. Without the shell re-arm, color - // from a pre-clear building look-in survives while the root repaint is - // incorrectly suppressed, producing wall-textured bleed slabs. - MarkIfGrown(); - if (_skyDrawnThisFrame) + // PView::DrawCells @0x005a4840 (S3 §8.1 R3): the landscape flush, the + // device-stamp advance, the gated depth clear, and the exit-portal + // seals ALL sit strictly inside `if (outside_view.view_count > 0)` + // (0x005a4852-0x005a49eb) — outsideViewCount==0 skips straight to + // the flood's own cells below with none of the four having run. + if (outsideViewCount > 0) { + // D3DPolyRender::FlushAlphaList(0f) @0x005a4872, plus the + // pre-clear dynamics hook RetailPViewRenderer.FlushWalkLandscape + // bundles — the first action inside the gate. + MarkIfGrown(); + _events.Add(WalkFrameEvent.LandscapeFlush()); + + // m_nFrameStamp += 1 @0x005a4886. Its drawn-part AND DrawEnvCell + // dedupe is per render stamp, not per presented frame: content + // admitted during the landscape must remain eligible for the + // interior-cell repaint after the clear. In Collect, every + // landscape candidate has been classified by this point and no + // interior-root candidate has, so re-arming both CPU-side stamp + // mirrors here is the exact boundary. Without the shell re-arm, + // color from a pre-clear building look-in survives while the + // root repaint is incorrectly suppressed, producing + // wall-textured bleed slabs. _dispatcher.AdvanceWalkPartPassStamp(); _cellShellsDrawnThisFrame.Clear(); _cellParticleTurnsDrawnThisFrame.Clear(); + + // The gated full depth clear (pc:432731-432732): retail's + // portalsDrawnCount (D3DPolyRender::portalsDrawnCount, + // uint16 @0x008719b4) is read-then-zeroed HERE + // (@0x005a489c-0x005a489e; R4) — forceClear never writes in the + // pseudo-C, so the clear fires iff the counter was nonzero. A + // fresh driver's very first ov>0 frame therefore draws NO clear + // (portalsDrawnCount starts at 0); every later ov>0 frame clears + // because the PREVIOUS frame's own exit seals armed the counter + // at Replay (see PortalsDrawnCount's own doc comment). + int armed = PortalsDrawnCount; + PortalsDrawnCount = 0; + if (armed != 0) + { + MarkIfGrown(); + _events.Add(WalkFrameEvent.ClearInteriorDepth()); + } + + // The exit-portal seals (pc:432785-432786) — this turn's + // submitted fan count re-arms PortalsDrawnCount at Replay (B2), + // read by the NEXT ov>0 frame's decision above. + MarkIfGrown(); + _events.Add(WalkFrameEvent.ExitSeals()); } - // PView::DrawCells @0x005a4840: the gated full depth clear - // (pc:432731-432732) then the exit-portal seals (pc:432785-432786) — - // both unconditional for an interior root's own flood, whether or - // not a landscape turn just ran (see this driver's type doc - // comment). - _events.Add(WalkFrameEvent.ClearInteriorDepth()); - - MarkIfGrown(); - _events.Add(WalkFrameEvent.ExitSeals()); - // FW4 slice 2: retain the ordered flood for the seal draw (the // DrawExitSeals leaf runs at Replay, when Collect has long filled // this) — see the property's own doc comment. @@ -1292,7 +1381,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource + "surviving exit views, both at least 1). A zero/negative count is a " + "walk/driver desync (Campaign FW fail-loud rule)."); } - if (_skyDrawnThisFrame) + if (_landscapeTurnsThisFrame != 0) { throw new InvalidOperationException( "A second Landscape turn fired in one frame — RetailFrameWalk.WalkFrame/" @@ -1305,7 +1394,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource MarkIfGrown(); _events.Add(WalkFrameEvent.Sky()); - _skyDrawnThisFrame = true; + _landscapeTurnsThisFrame++; // FW4 slice 6 (correcting slice 1's per-view fan): retail's // LScape::draw draws the terrain blocks ONCE per landscape turn — // the active views feed only the block-level visibility union diff --git a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs index a969c34f..681e011a 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs @@ -180,6 +180,27 @@ public sealed class RetailFrameWalkTests Assert.Equal("DI:a9b40150|DC:ov=1:a9b40150|LS", recorder.Signature()); } + // ── T4 / R1 (S3 chunk 2): RenderDeviceD3D::Init @0x0059efb0 constructs + // TWO PViews — indoor_pview = PView(…, 1) [draw_landscape=true] and + // outdoor_pview = PView(…, 0) [draw_landscape=false]. A building + // look-in's own DrawCells always runs through the OUTDOOR pview + // (RetailFrameWalk.DrawBuilding passes _outdoorPView into + // WalkBuildingPortals.DrawPortal), so it can never raise an outside + // view regardless of what portals the reached cell has — the mechanism + // itself (draw_landscape gating outside_view population) is pinned at + // the WalkPView level by + // WalkPViewFloodTests.Exit_portal_raises_the_outside_view_only_when_landscape_is_drawn; + // this test pins the ASSEMBLY fact that RetailFrameWalk wires the two + // PView instances with the correct flags. + [Fact] + public void RetailFrameWalk_WiresTheOutdoorPViewWithDrawLandscapeFalse_AndTheInteriorPViewTrue() + { + var walk = new RetailFrameWalk(); + + Assert.False(walk.OutdoorPView.DrawLandscape); + Assert.True(walk.InteriorPView.DrawLandscape); + } + [Fact] public void Outdoor_camera_cell_roots_the_landscape_walk() { diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index b9012066..87a6c906 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -48,6 +48,16 @@ public sealed class WalkFrameDriverTests public readonly List Shells = new(); public readonly List AlphaPendingAtBarrier = new(); + /// S3 chunk 2: the exit-seal polygon count this fake + /// reports back to the driver (B2 — + /// returns the submitted count so the driver can re-arm its + /// persistent PortalsDrawnCount for the NEXT ov>0 + /// frame's clear decision, R4). Defaults to 1 so a test that + /// doesn't care about the re-arm mechanism sees an ordinary + /// "some seal fired" outcome; a test proving "flood with no exit + /// portal never clears" sets this to 0. + public int SealPolygonsSubmitted = 1; + public void DrawSky() => log.Add("SKY"); public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}"); @@ -58,9 +68,15 @@ public sealed class WalkFrameDriverTests log.Add($"SHELL:{cellId:x8}"); } + public void FlushLandscape() => log.Add("LFLUSH"); + public void ClearInteriorDepth() => log.Add("CLEAR"); - public void DrawExitSeals() => log.Add("SEALS"); + public int DrawExitSeals() + { + log.Add("SEALS"); + return SealPolygonsSubmitted; + } public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) { @@ -189,19 +205,24 @@ public sealed class WalkFrameDriverTests // PView::DrawCells' exact two reverse loops: ALL shells far-to-near, // then ALL object cells far-to-near. ───────────────────────────────── - // ── Deliverable (2026-08-30 decomp correction): PView::DrawCells - // @0x005a4840's actual DRAW order for an interior root's OWN flood is - // NOT the order its DrawInside/DrawCells EVENTS fire in (breakpoint- - // entry order, matching the FW0 oracle traces) — retail draws - // LScape::draw FIRST (pc:432719, only when exit views survived), then - // the depth clear (pc:432731-432732), then the exit-portal seals - // (pc:432785-432786), and ONLY THEN the flood's own cells far-to-near. - // This case has a surviving exit view (ov=1): DC records the flood list - // (no draw), the landscape turn runs (flush no-op, sky, terrain), THEN - // clear, seals, then all shells and all contents in reverse order. ─── + // ── Deliverable (S3 chunk 2, superseding the 2026-08-30 decomp + // correction): PView::DrawCells @0x005a4840's actual DRAW order for an + // interior root's OWN flood is NOT the order its DrawInside/DrawCells + // EVENTS fire in (breakpoint-entry order, matching the FW0 oracle + // traces) — retail draws LScape::draw FIRST (pc:432719, only when exit + // views survived), then the landscape flush + device-stamp advance, + // then a GATED depth clear (pc:432731-432732 — R4: read-then-zero + // portalsDrawnCount @0x005a489c-0x005a489e, clear iff nonzero), then + // the exit-portal seals (pc:432785-432786), and ONLY THEN the flood's + // own cells far-to-near. This case has a surviving exit view (ov=1): DC + // records the flood list (no draw), the landscape turn runs (sky, + // terrain), THEN flush, THEN — since this is a FRESH driver's very + // first ov>0 flood, so portalsDrawnCount starts at 0 (R4's + // "first-frame no-clear quirk") — the clear is SKIPPED, then seals, + // then all shells and all contents in reverse order. ────────────────── [Fact] - public void RunFrame_InteriorFloodWithExitView_DrawsLandscapeThenClearSealsThenFloodCells() + public void RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells() { using var fx = new DispatcherFixture(); var log = new List(); @@ -271,12 +292,19 @@ public sealed class WalkFrameDriverTests Assert.Equal( new[] { - "SKY", "TERRAIN:0", "CLEAR", "SEALS", + // No CLEAR: R4's first-frame no-clear quirk — this driver's + // PortalsDrawnCount starts at 0, so the read-then-zero + // decision sees "not armed" even though ov=1. + "SKY", "TERRAIN:0", "LFLUSH", "SEALS", "SHELL:00000101", "SHELL:00000100", "FLUSH:1:CellStatic", "CELL-PARTICLES:00000101", "FLUSH:1:CellStatic", "CELL-PARTICLES:00000100", }, log); + Assert.DoesNotContain("CLEAR", log); + // This frame's own seals (RecordingLeafRenderer's default + // SealPolygonsSubmitted) armed the counter for a NEXT ov>0 frame. + Assert.Equal(1, driver.PortalsDrawnCount); List mdiCalls = [.. fx.Device.Calls.OfType()]; @@ -346,7 +374,9 @@ public sealed class WalkFrameDriverTests ctx.ViewportHeight); sink.OnLandscapeViews(landscapeViews); sink.OnLandscapeCellTurn(outdoorCellId); - sink.OnInteriorFloodDrawTurn([interiorCellId]); + // A landscape turn ran (activeViewCount: 1 above), so this models + // retail's ov>0 case. + sink.OnInteriorFloodDrawTurn([interiorCellId], outsideViewCount: 1); driver.EndFrame(); driver.Replay(draw.Frame, draw.Pass); @@ -359,13 +389,15 @@ public sealed class WalkFrameDriverTests || entry == "FLUSH:1:CellStatic")); } - // ── Deliverable: the ov==0 interior case — no exit view survives, so - // DrawInside never runs the landscape turn at all; retail's clear+seals - // still run unconditionally for the interior root's own flood, straight - // after the (draw-nothing) DC event. ─────────────────────────────────── + // ── Deliverable (T1, flipped for S3 chunk 2): the ov==0 interior case — + // no exit view survives, so DrawInside never runs the landscape turn, + // and PView::DrawCells' whole landscape-flush/stamp/clear/seal turn + // (S3 §8.1 R3: all four gated on outside_view.view_count > 0) never + // runs either — straight to the flood's own cells after the + // (draw-nothing) DC event. ────────────────────────────────────────── [Fact] - public void RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeButStillClearsAndSeals() + public void RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeAndNeverFlushesClearsOrSeals() { using var fx = new DispatcherFixture(); var log = new List(); @@ -416,15 +448,18 @@ public sealed class WalkFrameDriverTests ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero); // No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape - // at all — but CLEAR/SEALS still fire unconditionally. + // at all. T1 (flipped): LFLUSH/CLEAR/SEALS and the device-stamp + // advance are ALSO absent now — S3 §8.1 R3 gates all four on + // outside_view.view_count > 0, and it is 0 here. Assert.Equal( new[] { - "CLEAR", "SEALS", "SHELL:00000101", "SHELL:00000100", + "SHELL:00000101", "SHELL:00000100", "FLUSH:1:CellStatic", "CELL-PARTICLES:00000101", "FLUSH:1:CellStatic", "CELL-PARTICLES:00000100", }, log); + Assert.Equal(0, driver.PortalsDrawnCount); List mdiCalls = [.. fx.Device.Calls.OfType()]; @@ -434,6 +469,179 @@ public sealed class WalkFrameDriverTests Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount)); } + // ── T2 (S3 chunk 2, R4): a fresh driver's first ov>0 interior flood + // skips the gated clear (PortalsDrawnCount starts at 0); that same + // frame's own exit seals arm the counter at Replay, so the driver's + // SECOND ov>0 flood — even with a different flood cell, since the + // counter is driver-scoped, not cell-scoped — sees it nonzero and + // clears. ──────────────────────────────────────────────────────────── + + [Fact] + public void OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears() + { + using var fx = new DispatcherFixture(); + var log = new List(); + var leaf = new RecordingLeafRenderer(log); + var ctx = new TestContext(); + const uint cellId = 0xF4180200u; + var cell = new WalkCell { CellId = cellId }; + cell.PushView(); + WalkCopyView.AppendFullViewportQuad( + cell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + ctx.Cells[cellId] = cell; + + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData()); + IWalkEventSink sink = driver; + + using DrawScope draw = fx.BeginDraw(); + + // R4: PortalsDrawnCount starts at 0 for a fresh driver — the + // 0x005a489c-0x005a489e read-then-zero decision sees "not armed" on + // this driver's very first ov>0 interior-root flood, so the gated + // depth clear (pc:432731-432732) is skipped even though a landscape + // turn just ran. + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + sink.Emit(WalkEvent.Landscape(activeViewCount: 1)); + var views1 = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + views1, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + sink.OnLandscapeViews(views1); + sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + + Assert.Equal( + new[] + { + "SKY", "TERRAIN:0", "LFLUSH", "SEALS", + "SHELL:f4180200", "CELL-PARTICLES:f4180200", + }, + log); + Assert.DoesNotContain("CLEAR", log); + Assert.Equal(1, driver.PortalsDrawnCount); + log.Clear(); + + // Frame 1's exit seals reported SealPolygonsSubmitted (default 1) at + // Replay, arming PortalsDrawnCount for THIS frame's read-then-zero. + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + sink.Emit(WalkEvent.Landscape(activeViewCount: 1)); + var views2 = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + views2, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + sink.OnLandscapeViews(views2); + sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + + Assert.Equal( + new[] + { + "SKY", "TERRAIN:0", "LFLUSH", "CLEAR", "SEALS", + "SHELL:f4180200", "CELL-PARTICLES:f4180200", + }, + log); + } + + // ── T2 (S3 chunk 2, R4): a driver whose flood never reaches an exit + // portal (DrawExitSeals reports 0 submitted every turn) never clears, + // across any number of ov>0 frames — retail's counter is fed ONLY by + // actually-submitted seal fans. ───────────────────────────────────── + + [Fact] + public void OnInteriorFloodDrawTurn_FloodWithNoExitPortal_NeverClearsAcrossFrames() + { + using var fx = new DispatcherFixture(); + var log = new List(); + var leaf = new RecordingLeafRenderer(log) { SealPolygonsSubmitted = 0 }; + var ctx = new TestContext(); + const uint cellId = 0xF4180201u; + var cell = new WalkCell { CellId = cellId }; + cell.PushView(); + WalkCopyView.AppendFullViewportQuad( + cell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + ctx.Cells[cellId] = cell; + + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData()); + IWalkEventSink sink = driver; + + using DrawScope draw = fx.BeginDraw(); + for (int frame = 0; frame < 3; frame++) + { + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + sink.Emit(WalkEvent.Landscape(activeViewCount: 1)); + var views = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + views, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + sink.OnLandscapeViews(views); + sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + } + + Assert.DoesNotContain("CLEAR", log); + Assert.Equal(3, log.Count(entry => entry == "SEALS")); + Assert.Equal(0, driver.PortalsDrawnCount); + } + + // ── T3 (S3 chunk 2, R1): a building look-in's own DrawCells re-enters + // with ov==0 unconditionally and neither ARMS nor CONSUMES the + // persistent PortalsDrawnCount counter — retail calls DrawCells + // re-entrantly there with no clear/seal step at all. ──────────────── + + [Fact] + public void LookInDrawCells_NeitherArmsNorConsumesThePortalsDrawnCounter() + { + using var fx = new DispatcherFixture(); + var log = new List(); + var leaf = new RecordingLeafRenderer(log); + var ctx = new TestContext(); + const uint rootCellId = 0xF4180301u; + const uint lookInCellId = 0xF4180302u; + var rootCell = new WalkCell { CellId = rootCellId }; + rootCell.PushView(); + WalkCopyView.AppendFullViewportQuad( + rootCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + ctx.Cells[rootCellId] = rootCell; + var lookInCell = new WalkCell { CellId = lookInCellId }; + lookInCell.PushView(); + WalkCopyView.AppendFullViewportQuad( + lookInCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + ctx.Cells[lookInCellId] = lookInCell; + + var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData()); + IWalkEventSink sink = driver; + + using DrawScope draw = fx.BeginDraw(); + + // Arm PortalsDrawnCount with one throwaway ov>0 interior-root flood. + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + sink.Emit(WalkEvent.Landscape(activeViewCount: 1)); + var rootViews = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + rootViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + sink.OnLandscapeViews(rootViews); + sink.OnInteriorFloodDrawTurn([rootCellId], outsideViewCount: 1); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + Assert.Equal(1, driver.PortalsDrawnCount); + log.Clear(); + + // A building look-in's own DrawCells re-enters with ov==0 + // unconditionally (R1) — no LFLUSH/clear/seal step at all ("DrawCells + // re-entrantly there with no clear/seal step"), so it must leave the + // already-armed counter alone. + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + sink.OnBuildingTurn(new WalkBuilding()); + sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellId])); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + + Assert.DoesNotContain("LFLUSH", log); + Assert.DoesNotContain("CLEAR", log); + Assert.DoesNotContain("SEALS", log); + Assert.Equal(1, driver.PortalsDrawnCount); + } + // ── Deliverable: a building turn's alpha barrier precedes its portal // pass (retail RenderDeviceD3D::DrawBuilding @0x0059f2a0: // FlushAlphaList(0f) -> CPhysicsPart::Draw(parts,1) [the portal walk] @@ -599,15 +807,22 @@ public sealed class WalkFrameDriverTests using DrawScope draw = fx.BeginDraw(); driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); - sink.OnInteriorFloodDrawTurn([cellId]); - sink.OnInteriorFloodDrawTurn([cellId]); + // Neither call models a surviving exit view (ov==0 both times — no + // WalkEvent.Landscape turn ran in this synthetic double-entry + // scenario), so per S3 §8.1 R3 the device-stamp advance never fires + // from either call: this isolates the frame-stamp shell dedup from + // the landscape-flush/clear/seal gate entirely. + sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 0); + sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 0); driver.EndFrame(); driver.Replay(draw.Frame, draw.Pass); Assert.Equal([cellId], leaf.Shells); Assert.Equal(1, log.Count(entry => entry == "SHELL:f4180112")); - Assert.Equal(2, log.Count(entry => entry == "CLEAR")); - Assert.Equal(2, log.Count(entry => entry == "SEALS")); + Assert.Equal(0, log.Count(entry => entry == "LFLUSH")); + Assert.Equal(0, log.Count(entry => entry == "CLEAR")); + Assert.Equal(0, log.Count(entry => entry == "SEALS")); + Assert.Equal(0, driver.PortalsDrawnCount); } // Campaign OVERHAUL S2 chunk 6 pin — the portal-haze bug this chunk @@ -639,7 +854,8 @@ public sealed class WalkFrameDriverTests using DrawScope draw = fx.BeginDraw(); driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); - sink.OnInteriorFloodDrawTurn([arrivalCellId]); + // No landscape turn modeled here — ov==0. + sink.OnInteriorFloodDrawTurn([arrivalCellId], outsideViewCount: 0); driver.EndFrame(); driver.Replay(draw.Frame, draw.Pass); @@ -651,7 +867,6 @@ public sealed class WalkFrameDriverTests { using var fx = new DispatcherFixture(); var log = new List(); - var leaf = new RecordingLeafRenderer(log); var ctx = new TestContext(); const uint cellId = 0xF4180112u; var cell = new WalkCell { CellId = cellId }; @@ -666,11 +881,32 @@ public sealed class WalkFrameDriverTests var driver = new WalkFrameDriver( fx.Dispatcher, - leaf, + new RecordingLeafRenderer(new List()), new FakeWorldData()); IWalkEventSink sink = driver; using DrawScope draw = fx.BeginDraw(); + + // R4 (the "first-frame no-clear quirk", pinned explicitly by + // RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClear...): + // PortalsDrawnCount starts at 0 for a fresh driver, so ITS first + // ov>0 flood would skip the clear. Prime the counter with one + // throwaway ov>0 flood (this driver's own exit seals arm it) so the + // documented scenario below models a STEADY-STATE interior frame, + // where the clear actually fires. + driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + sink.Emit(WalkEvent.Landscape(activeViewCount: 1)); + var primerViews = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + primerViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + sink.OnLandscapeViews(primerViews); + sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1); + driver.EndFrame(); + driver.Replay(draw.Frame, draw.Pass); + Assert.Equal(1, driver.PortalsDrawnCount); + + var leaf = new RecordingLeafRenderer(log); + driver.RebindFrame(leaf, clipFrame: null); driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); // LScape::draw has begun. A building look-in reached this cell before @@ -689,13 +925,16 @@ public sealed class WalkFrameDriverTests // The same shell must draw again after the retail stamp increment and // full depth clear; otherwise the pre-clear color survives unpaired - // with depth and bleeds through the root's walls. - sink.OnInteriorFloodDrawTurn([cellId]); + // with depth and bleeds through the root's walls. ov=1 (the + // landscape turn above ran), and PortalsDrawnCount is armed from + // the primer frame's own seals, so the clear actually fires here. + sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1); driver.EndFrame(); driver.Replay(draw.Frame, draw.Pass); Assert.Equal([cellId, cellId], leaf.Shells); Assert.Equal(2, log.Count(entry => entry == "SHELL:f4180112")); + Assert.Contains("CLEAR", log); Assert.True( log.IndexOf("SHELL:f4180112") < log.IndexOf("CLEAR"), "The look-in shell must precede the interior clear."); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs index 18f4e79f..70e4be3d 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkPViewFloodTests.cs @@ -177,6 +177,46 @@ public sealed class WalkPViewFloodTests Assert.Equal(1, far.TopView.ViewCount); } + // ── T4 / R2 (S3 chunk 2): PView::ConstructView @0x005a57b0 resets + // outside_view.view_count = 0, master_timestamp++, cell_todo_num = 0, + // and cell_draw_num = 0 BEFORE InitCell — a second flood on the SAME + // pview instance must not accumulate the first flood's draw list or + // outside view. ────────────────────────────────────────────────────── + + [Fact] + public void ConstructView_CalledTwiceOnTheSameInstance_ResetsCellDrawListAndOutsideViewEachTime() + { + var ctx = new TestContext(); + WalkCell first = Cell(ctx, 0x200, + (new WalkCellPortal { OtherCellId = 0xFFFFFFFF, PolygonIndex = 0, PortalSide = 0, OtherPortalId = -1 }, + Quad(-2f))); + WalkCell second = Cell(ctx, 0x300, + (new WalkCellPortal { OtherCellId = 0x301, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0 }, + Quad(-2f))); + Cell(ctx, 0x301, + (new WalkCellPortal { OtherCellId = 0x300, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0 }, + Quad(-2f))); + + var pview = new WalkPView(); + WalkCopyView.AppendFullViewportQuad( + first.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + pview.ConstructView(first, 0xFFFF, ctx); + Assert.Equal(new[] { 0x200u }, pview.CellDrawList.Select(c => c.CellId)); + Assert.Equal(1, pview.OutsideView.ViewCount); + int timestampAfterFirst = WalkPView.MasterTimestampForDiagnostics; + + WalkCopyView.AppendFullViewportQuad( + second.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight); + pview.ConstructView(second, 0xFFFF, ctx); + + // The second flood's own cells only — NOT the first flood's 0x200 + // still sitting in CellDrawList, and the exit-facing portal from the + // first flood does not leave OutsideView.ViewCount stuck at 1. + Assert.Equal(new[] { 0x300u, 0x301u }, pview.CellDrawList.Select(c => c.CellId)); + Assert.Equal(0, pview.OutsideView.ViewCount); + Assert.True(WalkPView.MasterTimestampForDiagnostics > timestampAfterFirst); + } + [Fact] public void Unloaded_neighbor_is_silently_skipped() {