diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
index f6fb066a..767431dd 100644
--- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
+++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs
@@ -93,7 +93,18 @@ public sealed class RetailFrameWalk
}
/// PView::DrawInside + the event half of
- /// DrawCells. The geometry/object passes emit no walk events.
+ /// DrawCells. The geometry/object passes emit no walk events.
+ /// PView::DrawCells @0x005a4840's actual DRAW order for the root
+ /// flood is NOT the order its events fire in: the
+ ///
+ /// 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).
public void DrawInside(
WalkCell cell, WalkLandscape landscape,
IRetailFrameWalkContext ctx, IWalkEventSink sink)
@@ -106,10 +117,16 @@ public sealed class RetailFrameWalk
ctx.ViewportWidth, ctx.ViewportHeight);
_interiorPView.ConstructView(cell, 0xFFFF, ctx.CellContext);
- EmitDrawCells(_interiorPView, sink);
+ uint[] floodCells = EmitDrawCells(_interiorPView, sink);
if (_interiorPView.OutsideView.ViewCount > 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);
+
RemoveViews(cell.StabList, ctx);
cell.PopView();
}
@@ -213,12 +230,16 @@ public sealed class RetailFrameWalk
sink.OnBuildingShellTurn(building);
}
- private void EmitDrawCells(WalkPView pview, IWalkEventSink sink)
+ /// Returns the extracted cell-id array so
+ /// can hand the SAME flood list to OnInteriorFloodDrawTurn later,
+ /// without re-walking pview.CellDrawList a second time.
+ private uint[] EmitDrawCells(WalkPView pview, IWalkEventSink sink)
{
uint[] cells = new uint[pview.CellDrawList.Count];
for (int i = 0; i < cells.Length; i++)
cells[i] = pview.CellDrawList[i].CellId;
sink.Emit(WalkEvent.DrawCells(pview.OutsideView.ViewCount, cells));
+ return cells;
}
private void AddViews(uint[] stabList, IRetailFrameWalkContext ctx)
diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs
index f510d93f..854710a9 100644
--- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs
@@ -53,7 +53,7 @@ public readonly record struct WalkEvent(
/// production sink will additionally receive the ordered draw stream.
///
/// Campaign FW3.2b-1 additive seam: also
-/// calls the four richer, default-no-op members below at turns the
+/// calls the five richer, default-no-op members below at turns the
/// vocabulary-only stream cannot express (a visited
/// landscape cell with no building emits no at all;
/// carries only a cell id, not the
@@ -125,4 +125,29 @@ public interface IWalkEventSink
/// no-op.
///
void OnPunchGeometry(WalkBuilding building, WalkPolygon polygon) { }
+
+ ///
+ /// Fires once per interior root, at the point PView::DrawCells
+ /// @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 far-to-near (shell then contents per cell, same
+ /// discipline as 's per-cell contents
+ /// and 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) { }
}
diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
index a6bf5d06..6815f0c7 100644
--- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
@@ -63,7 +63,8 @@ internal interface IWalkFrameWorldData
/// Campaign FW stage FW3.2b-1: the leaf GPU-adjacent actions
/// calls at walk turns that have no
/// submission path YET (sky, terrain, an
-/// EnvCell shell, a portal punch fan) or that aren't a draw at all (the
+/// EnvCell shell, a portal punch fan, the interior depth clear, the exit-
+/// portal seals) or that aren't a draw at all (the
/// barrier). Kept as its own seam — rather
/// than folding these into directly — so the
/// FW3.2b-1 headless referee suite can wire a fake and prove turn ORDER
@@ -109,6 +110,35 @@ internal interface IWalkFrameLeafRenderer
/// this same shell-then-contents order).
void DrawCellShell(uint cellId);
+ /// PView::DrawCells @0x005a4840's gated full depth clear
+ /// (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 —
+ /// portalsDrawnCount never applies to LScape::draw's own
+ /// top-level walk).
+ 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/
+ /// 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();
+
/// DrawPortalPolyInternal @0x0059bc90's depth-only far-Z
/// punch fan — pass 1 of the building portal walk.
/// is already transformed building-local
@@ -172,28 +202,38 @@ internal interface IWalkFrameDriverTrace
///
/// The one flush rule that reproduces the whole frame script:
/// before EVERY leaf-renderer call (,
-/// DrawTerrainSlice, DrawCellShell, DrawPunchFan) and
-/// before every call, the
-/// driver flushes the accumulated opaque stream (a no-op when the stream is
-/// empty — "empty segments submit nothing"); a building's own shell content
-/// is APPENDED (not flushed) the moment
+/// DrawTerrainSlice, DrawCellShell, ClearInteriorDepth,
+/// DrawExitSeals, DrawPunchFan) and before every
+/// call, the driver
+/// flushes the accumulated opaque stream (a no-op when the stream is empty —
+/// "empty segments submit nothing"); a building's own shell content is
+/// APPENDED (not flushed) the moment
/// fires, so it flushes only at whatever non-stream action comes next (the
/// next building's alpha barrier, or end of frame). This single rule,
-/// combined with "shell before contents" per cell and retail's own building
+/// combined with "shell before contents" per cell, retail's own building
/// order (alpha barrier → portal pass → shell — see
-/// 's doc comment), is what
-/// produces every ordering constraint the plan's frame script names: [cell1
-/// shell] [cell1 contents flush] [cell2 shell] …, [alpha barrier] [punch fan(s)
-/// + look-in flood(s), each following the SAME shell-then-contents per-cell
-/// discipline] [building shell content flush], and the final end-of-frame
-/// flush. No special-casing per turn kind is needed beyond that.
+/// '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: [cell1 shell]
+/// [cell1 contents flush] [cell2 shell] …, [alpha barrier] [punch fan(s) +
+/// look-in flood(s), each following the SAME shell-then-contents per-cell
+/// discipline] [building shell content flush], [landscape (if exit views
+/// survived)] [interior depth clear] [exit-portal seals] [the interior
+/// root's own flood cells], and the final end-of-frame flush. No special-
+/// casing per turn kind is needed beyond that.
///
/// Retail anchors: SmartBox::RenderNormalMode @0x00453aa0 (the
/// root already ports),
/// RenderDeviceD3D::DrawSortCell @0x0059f140 (building-before-
/// DrawObjCell per landscape cell), PView::DrawCells @0x005a4840
/// (DrawEnvCell @0x005a4abe before DrawObjCellForDummies
-/// @0x005a4b0d per flooded cell), RenderDeviceD3D::DrawBuilding
+/// @0x005a4b0d per flooded cell; LScape::draw pc:432719, the depth
+/// clear pc:432731-432732, and the exit-portal seals pc:432785-432786 —
+/// ALL strictly before the flood's own cells, though the event marking
+/// DrawCells entry fires before all three), RenderDeviceD3D::DrawBuilding
/// @0x0059f2a0 (the part->gfxobj[deg_level]!=0 gate @0x0059f2d3
/// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345).
///
@@ -395,6 +435,26 @@ internal sealed class WalkFrameDriver : IWalkEventSink
_leafRenderer.DrawPunchFan(TransformToWorld(polygon, worldTransform));
}
+ void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList cells)
+ {
+ ArgumentNullException.ThrowIfNull(cells);
+ RequireOpenFrame();
+
+ // 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).
+ FlushIfNonEmpty();
+ _leafRenderer.ClearInteriorDepth();
+
+ FlushIfNonEmpty();
+ _leafRenderer.DrawExitSeals();
+
+ for (int i = 0; i < cells.Count; i++)
+ EmitCellTurn(WalkDrawStage.CellStatic, cells[i]);
+ }
+
// ------------------------------------------------------------------
// Turn handlers
// ------------------------------------------------------------------
@@ -432,6 +492,22 @@ internal sealed class WalkFrameDriver : IWalkEventSink
+ "(the interior root's own flood) or after a building's look-in portal pass.");
}
+ if (stage == WalkDrawStage.CellStatic)
+ {
+ // The interior root's OWN flood: this DC event fired at
+ // breakpoint-ENTRY order (matching the FW0 oracle traces), not
+ // retail's actual draw point — RECORD only. RetailFrameWalk.
+ // DrawInside hands the SAME cell list to
+ // OnInteriorFloodDrawTurn later, at the point retail really
+ // draws it (after the landscape turn, or immediately when no
+ // exit views survived) — see that method's doc comment.
+ return;
+ }
+
+ // Any other stage (LookInStatic) is a building's look-in flood:
+ // retail calls DrawCells re-entrantly there with no landscape/clear/
+ // seal step, so its DC event already fires at the real draw point —
+ // draw immediately, unchanged from before this correction.
for (int i = 0; i < cells.Count; i++)
EmitCellTurn(stage, cells[i]);
}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
index fc59b311..c6bdfdba 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
@@ -49,6 +49,10 @@ public sealed class WalkFrameDriverTests
public void DrawCellShell(uint cellId) => log.Add($"SHELL:{cellId:x8}");
+ public void ClearInteriorDepth() => log.Add("CLEAR");
+
+ public void DrawExitSeals() => log.Add("SEALS");
+
public void DrawPunchFan(WalkPolygon worldPolygon)
{
Punches.Add(worldPolygon);
@@ -154,8 +158,19 @@ public sealed class WalkFrameDriverTests
// the NEXT cell's shell needs the stream clear (never before, never
// batched across cells within this stage's turn-by-turn discipline). ──
+ // ── 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, and the two flood cells shell-then-contents. ──────────
+
[Fact]
- public void RunFrame_InteriorTwoCellFlood_EmitsShellThenContentsPerCellWithAFlushBetween()
+ public void RunFrame_InteriorFloodWithExitView_DrawsLandscapeThenClearSealsThenFloodCells()
{
using var fx = new DispatcherFixture();
var log = new List();
@@ -166,6 +181,94 @@ public sealed class WalkFrameDriverTests
InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
MakeBatch(0x08100002u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
+ var ctx = new TestContext();
+ var cell1 = new WalkCell
+ {
+ CellId = 0x100,
+ StabList = [0x101u],
+ Portals =
+ [
+ new WalkCellPortal
+ {
+ OtherCellId = 0x101, PolygonIndex = 0, PortalSide = 0, OtherPortalId = 0,
+ },
+ // The exit portal (retail's "world beyond the door") — this
+ // is what raises ov to 1 and makes DrawInside run the
+ // landscape turn before clear+seals+the flood cells.
+ new WalkCellPortal
+ {
+ OtherCellId = 0xFFFFFFFF, PolygonIndex = 1, PortalSide = 0, OtherPortalId = -1,
+ },
+ ],
+ PortalPolygons = [Quad(-2f), Quad(-3f)],
+ };
+ var cell2 = new WalkCell
+ {
+ CellId = 0x101,
+ Portals = [new WalkCellPortal
+ {
+ OtherCellId = 0x100, PolygonIndex = 0, PortalSide = 1, OtherPortalId = 0,
+ }],
+ PortalPolygons = [Quad(-2f)],
+ };
+ ctx.Cells[cell1.CellId] = cell1;
+ ctx.Cells[cell2.CellId] = cell2;
+
+ var worldData = new FakeWorldData();
+ worldData.CellStaticsByCell[0x100] = new WalkFrameStaticRecords(
+ [MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)])], 0x8C04u);
+ worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
+ [MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)])], 0x8C04u);
+
+ var leaf = new RecordingLeafRenderer(log);
+ var trace = new RecordingTrace(log);
+ var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace);
+ var walk = new RetailFrameWalk();
+ // A minimal, no-op landscape (1x1 window, the one slot unpublished)
+ // — matches RetailFrameWalkTests' own exit-view fixture. LScape::draw
+ // still runs its full turn (sky/terrain) against it; there's simply
+ // nothing published to iterate blocks/cells/buildings for.
+ var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
+
+ using DrawScope draw = fx.BeginDraw();
+ driver.RunFrame(
+ walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape,
+ ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero,
+ activeTerrainSliceCount: 1);
+
+ Assert.Equal(
+ new[]
+ {
+ "SKY", "TERRAIN:0", "CLEAR", "SEALS",
+ "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic",
+ },
+ log);
+
+ List mdiCalls =
+ [.. fx.Device.Calls.OfType()];
+ Assert.Equal(2, mdiCalls.Count);
+ Assert.All(mdiCalls, c => Assert.Equal(1u, c.DrawCount));
+ // Nothing dropped: every populated record reached exactly one indirect draw.
+ Assert.Equal(2, mdiCalls.Sum(c => (int)c.DrawCount));
+ }
+
+ // ── 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. ───────────────────────────────────
+
+ [Fact]
+ public void RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeButStillClearsAndSeals()
+ {
+ using var fx = new DispatcherFixture();
+ var log = new List();
+ const ulong gfxObjA = 0x0200_0003UL;
+ const ulong gfxObjB = 0x0200_0004UL;
+ InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
+ MakeBatch(0x08100003u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
+ InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
+ MakeBatch(0x08100004u, TranslucencyKind.Opaque, 3, 4, 3, 2)));
+
var ctx = new TestContext();
var cell1 = new WalkCell
{
@@ -206,8 +309,10 @@ public sealed class WalkFrameDriverTests
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero,
activeTerrainSliceCount: 0);
+ // No SKY/TERRAIN — ov==0 means DrawInside never calls DrawLandscape
+ // at all — but CLEAR/SEALS still fire unconditionally.
Assert.Equal(
- new[] { "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic" },
+ new[] { "CLEAR", "SEALS", "SHELL:00000100", "FLUSH:1:CellStatic", "SHELL:00000101", "FLUSH:1:CellStatic" },
log);
List mdiCalls =