diff --git a/docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md b/docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md
index 488c3a94..7ee07515 100644
--- a/docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md
+++ b/docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md
@@ -311,10 +311,11 @@ weather/sky post-pass
| Cell object draw consumes the cell's exact stable far-to-near `shadow_part_list`. | The driver materializes per-cell record buckets, classifies/batches them, and uses a scope-level ordered stream plus separate alpha collection. | OH3/OH7 must replace reconstructed membership/order with exact part entries and exact per-cell alpha barriers. |
| Building is alpha drain -> portal pass -> shell, then cell objects. | `RetailFrameWalk.DrawBuilding` and the driver's building events model this order. | Mechanism broadly matches, but its correctness depends on the sibling OH1 built-mesh/view and depth-lifecycle findings. |
-The architecture SSOT currently says that the walk records one retail-ordered
-stream including landscape cells, but the implementation and its own comments
-admit that terrain is one whole pre-stage. That is an architecture/code
-contradiction, not a documented adaptation. The same architecture section
+The architecture SSOT says that the walk records one retail-ordered stream
+including landscape cells; until S3 chunk 3 the implementation drew terrain as
+one whole pre-stage (an architecture/code contradiction, RESOLVED 2026-09-03:
+`RetailFrameWalk.DrawLandscape` now emits one land-cell terrain turn per
+admitted cell before that cell's own building/object turn). The same architecture section
also states that every admitted portal slice GPU-clips the mesh; the dirty
built-mesh candidate says the opposite. The sibling built-mesh evidence must
arbitrate that claim before the architecture text or production path is
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index 03cac3f6..c78c934b 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -588,14 +588,21 @@ internal sealed class RetailPViewRenderer
// filter) is gone — the walk's own per-cell LandCell turns are now
// the sole terrain-visibility authority, so there is no plumbing
// left to feed here.
- driver.Replay(frame, encoder);
- // S3 chunk 3 fix round 1 (F3): "the end of the walk replay" —
- // exactly where the deleted whole-stage terrain leaf's own
- // Begin()/Complete() bracket used to close. Pushes this frame's ONE
- // accumulated terrain-timing sample (every DrawWalkLandCellBatch
- // call between here and the last CompleteWalkTerrainFrame) and
- // publishes the periodic [TERRAIN-DIAG] line if the cadence is due.
- passes.CompleteWalkTerrainFrame();
+ try
+ {
+ driver.Replay(frame, encoder);
+ }
+ finally
+ {
+ // S3 chunk 3 fix round 1 (F3): "the end of the walk replay" —
+ // exactly where the deleted whole-stage terrain leaf's own
+ // Begin()/Complete() bracket used to close. Pushes this frame's
+ // ONE accumulated terrain-timing sample (every DrawWalkLandCellBatch
+ // call since the last CompleteWalkTerrainFrame) and publishes the
+ // periodic [TERRAIN-DIAG] line if the cadence is due. In a finally
+ // so a throwing Replay cannot leak its ticks into the next frame.
+ passes.CompleteWalkTerrainFrame();
+ }
// Landscape-stage static-owner particles (candles, the cathedral
// falls) submit AT THEIR OWN WALK TURNS inside Replay
diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
index fe0dcf8e..e10b5148 100644
--- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
@@ -282,15 +282,16 @@ internal enum WalkFrameEventKind : byte
/// .
Sky,
- /// S3 chunk 3:
- /// for ONE admitted land cell — is
- /// the owning landblock id, packs
- /// (sideCellCount << 8) | cellIndex (side ∈ {1,2,4,8},
- /// cellIndex < side², both fit comfortably below the 8-bit shift).
- /// merges a run of consecutive same-landblock
- /// entries into ONE
- /// call (B2's order-preserving batching) rather than replaying them
- /// one at a time.
+ /// —
+ /// one admitted land cell's terrain (retail DrawLandCell
+ /// @0x0059f120): is the landblock,
+ /// packs side << 8 | cellIndex.
+ /// does NOT draw it immediately: it appends the
+ /// cell to ONE pending terrain batch that may span landblocks and flushes
+ /// that batch only before a leaf that will submit GPU work (S3 chunk 3
+ /// fix round 1, §9.6 F2) — a particle turn whose cell has no renderable
+ /// emitter submits nothing and keeps the batch open. The GPU submission
+ /// order is therefore identical to replaying each cell alone.
LandCell,
/// —
@@ -741,6 +742,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
+ _pendingTerrainBatch.Clear();
_alphaSubmitMark = 0;
VisitedCells.Clear();
LookInCellTurns.Clear();
@@ -877,6 +879,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
+ _pendingTerrainBatch.Clear();
_alphaSubmitMark = 0;
VisitedCells.Clear();
LookInCellTurns.Clear();
@@ -968,12 +971,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
cursor = end;
break;
case WalkFrameEventKind.AlphaSubmitMark:
- // Not a flush point (F2): an AlphaSubmitMark event is
- // always recorded alongside — immediately after — a
- // StreamMark from the SAME MarkIfGrown/MarkAlphaIfGrown
- // pairing (every Collect-side call site pairs them),
- // so the StreamMark case above already flushed any
- // pending batch by the time this one runs.
+ // Not a flush point (F2): this arm only ENQUEUES the
+ // translucent instances into the CPU alpha list
+ // (SubmitWalkAlphaInstance); nothing reaches the GPU
+ // until the list drains at an AlphaBarrier /
+ // LandscapeFlush leaf or the pass end — each of which
+ // flushes the pending terrain batch first, so the
+ // drained alpha still lands after every preceding
+ // land cell's terrain, exactly as when unbatched.
int alphaEnd = e.IntArg;
for (; alphaCursor < alphaEnd; alphaCursor++)
{
@@ -1064,6 +1069,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_events.Clear();
_markPositions.Clear();
_alphaSubmissions.Clear();
+ _pendingTerrainBatch.Clear();
_alphaSubmitMark = 0;
_readyToReplay = false;
_ctx = null;
diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs
index f4d11640..d8c66b44 100644
--- a/src/AcDream.Core/Vfx/ParticleSystem.cs
+++ b/src/AcDream.Core/Vfx/ParticleSystem.cs
@@ -606,17 +606,6 @@ public sealed class ParticleSystem : IParticleSystem
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
}
- ///
- /// Copies the renderable emitters whose
- /// is , retaining spawn order. Retail
- /// CPhysicsObj::add_particle_shadow_to_cell (0x00514a70) gives an
- /// emitter exactly one shadow in its own current cell, independent of its
- /// attached owner's registry membership — a hidden/suspended owner's
- /// emitter is still enumerated here as long as it remains renderable
- /// (presentation-visible and view-eligible). No per-call allocation after
- /// warmup: the bucket's own sorted handle list is copied through the
- /// retained buffer.
- ///
///
/// S3 chunk 3 fix round 1 (F2): whether has any
/// renderable emitter for , WITHOUT copying
@@ -635,6 +624,17 @@ public sealed class ParticleSystem : IParticleSystem
&& bucket.FirstRenderableHandle != 0;
}
+ ///
+ /// Copies the renderable emitters whose
+ /// is , retaining spawn order. Retail
+ /// CPhysicsObj::add_particle_shadow_to_cell (0x00514a70) gives an
+ /// emitter exactly one shadow in its own current cell, independent of its
+ /// attached owner's registry membership — a hidden/suspended owner's
+ /// emitter is still enumerated here as long as it remains renderable
+ /// (presentation-visible and view-eligible). No per-call allocation after
+ /// warmup: the bucket's own sorted handle list is copied through the
+ /// retained buffer.
+ ///
public void CopyRenderableEmittersInCell(
ParticleRenderPass renderPass,
uint cellId,
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
index 033bfe35..2e7c933a 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
@@ -1360,7 +1360,12 @@ public sealed class WalkFrameDriverTests
using var fx = new DispatcherFixture();
var log = new List();
var leaf = new RecordingLeafRenderer(log);
+ // Every covered bucket but ONE reports no renderable emitter, so the
+ // block's object turns submit nothing (no flush) except bucket
+ // 0xF4180001, whose particle turn is a real submission — it must land
+ // AFTER the coarse cell's own terrain (retail DrawLandCell → DrawSortCell).
leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
+ leaf.CellsWithoutEmitters.Remove(0xF4180001u);
var ctx = new TestContext();
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
var walk = new RetailFrameWalk();
@@ -1378,7 +1383,7 @@ public sealed class WalkFrameDriverTests
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
- Assert.Equal(new[] { "SKY", "LANDCELL:f4180000:1:0" }, log);
+ Assert.Equal(new[] { "SKY", "LANDCELL:f4180000:1:0", "PARTICLES:f4180001" }, log);
Assert.Equal(new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF4180000u, 1, 0) },
Assert.Single(leaf.LandCellBatches));
}