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
+ /// ClearWalkInteriorDepth — PView::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()
{