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)); }