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 8b20b2dc..488c3a94 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
@@ -307,7 +307,7 @@ weather/sky post-pass
| Outside cells reached by the static bbox transit remain in the CELLARRAY. | Dirty `CellTransit` removed an outdoor prune globally, including collision callers. | Correct retail fact, wrong ownership boundary risk. OH3 must port the exact bbox route without mutating unrelated sphere/collision routes. |
| Dynamics and attached children inherit the root's exact ordered CELLARRAY and publish exact per-part entries. | `WalkProductionWorldData` buckets whole `RenderProjectionRecord`s by `ShadowObjectRegistry.GetOwnerCells`, resolving equipped children by a parent callback. The draw classifier later tries to restore per-part stamps. | Membership is reconstructed from the collision registry, loses the canonical `CShadowPart` transaction/order, and creates a second model. |
| `LScape::draw` runs once and visibility unions all views. | `RetailFrameWalk.DrawLandscape` correctly installs one view set and walks blocks/cells once. | Keep. |
-| Per landscape cell: terrain -> building -> ordinary objects. | `WalkFrameDriver.HandleLandscapeTurn` emits one whole `TerrainSlice(0)` immediately after sky; all terrain is submitted before `RetailFrameWalk` later emits building/object cell turns. The source comment explicitly calls this an intra-stage simplification. | Confirmed OH5 defect. It can expose background/behind-wall content because retail's depth/painter interleave no longer exists. |
+| Per landscape cell: terrain -> building -> ordinary objects. | `RetailFrameWalk.DrawLandscape` now emits one `OnLandCellTurn` per admitted land cell BEFORE that cell's own building/object turn (`RenderDeviceD3D::DrawBlock`'s real per-cell interleave), and `WalkFrameDriver.Replay` submits each cell's terrain through a deferred, order-preserving batch rather than one whole pre-stage draw. | FIXED by S3 chunk 3 (commit 671eb3ad4 + fix round 1) — the whole-stage `TerrainSlice(0)`/`DrawTerrainSlice` this row described is deleted outright; row retired. |
| 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. |
diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs
index bb673bab..54f3fdf7 100644
--- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs
+++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Walk;
@@ -74,23 +75,48 @@ internal sealed partial class RetailPViewPassExecutor
/// S3 chunk 3 (§9.2 B1): the walk's per-land-cell terrain
/// turn — retail RenderDeviceD3D::DrawLandCell @0x0059f120,
- /// batched (S3 §9.2 B2). UNCLIPPED for BOTH root kinds — retail never
+ /// batched (S3 §9.2 B2, fix round 1 F2 — a batch may span several
+ /// landblocks). UNCLIPPED for BOTH root kinds — retail never
/// view-clips terrain (LScape::draw draws whole blocks; the
/// walk's own CellInView admission already decided which cells
/// reach here, and the exit-seal/interior-repaint turn owns aperture
/// exactness afterward, S3 §9.1 R4/R5), so unlike the whole-stage
/// turn this superseded, there is no scissor, no terrain clip, and no
- /// per-slice loop — one call per (already order-merged) batch.
+ /// per-slice loop — one call per (already order-merged) batch.
+ ///
+ /// S3 chunk 3 fix round 1 (F3): times THIS ONE batch with a raw
+ /// Stopwatch.GetTimestamp() delta (no allocation, no per-batch
+ /// stopwatch Restart/Stop) and accumulates it into
+ /// —
+ /// pushes the
+ /// frame's single resulting sample once, after the whole walk replay
+ /// finishes.
internal void DrawWalkLandCellBatch(
RetailPViewFrameInput frame,
- uint landblockId,
- IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
+ IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
{
- _terrainDiagnostics.Begin();
- _terrain?.DrawLandCellRuns(landblockId, frame.ViewProjection, cells);
- _terrainDiagnostics.Complete();
+ long start = Stopwatch.GetTimestamp();
+ _terrain?.DrawLandCells(frame.ViewProjection, cells);
+ _terrainDiagnostics.AccumulateWalkBatch(Stopwatch.GetTimestamp() - start);
}
+ /// S3 chunk 3 fix round 1 (F3): pushes THIS FRAME'S single
+ /// accumulated terrain-timing sample (from every
+ /// call this frame) and publishes
+ /// the periodic diagnostic if the cadence is due — the walk path's
+ /// analogue of the deleted whole-stage DrawWalkTerrainSlice
+ /// leaf's own Begin()/Complete() bracket. Called exactly once, at the
+ /// end of the walk replay ().
+ internal void CompleteWalkTerrainFrame() => _terrainDiagnostics.CompleteWalkFrame();
+
+ /// S3 chunk 3 fix round 1 (F2): the production
+ /// IWalkFrameLeafRenderer.HasRenderableEmittersInCell wiring —
+ /// both and
+ /// draw through
+ /// ParticleRenderPass.Scene, so this asks that SAME pass.
+ internal bool HasWalkRenderableEmittersInCell(uint cellId) =>
+ _particles?.HasRenderableEmittersInCell(ParticleRenderPass.Scene, cellId) ?? false;
+
/// The walk's punch-fan turn — DrawPortalPolyInternal
/// @0x0059bc90's far-Z punch through PortalDepthMaskRenderer,
/// clipped by the pinned view's slice planes (retail
@@ -209,8 +235,11 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void DrawSky() => _passes.DrawWalkSky(_frame, _clipAssembly);
public void DrawLandCellBatch(
- uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells) =>
- _passes.DrawWalkLandCellBatch(_frame, landblockId, cells);
+ IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) =>
+ _passes.DrawWalkLandCellBatch(_frame, cells);
+
+ public bool HasRenderableEmittersInCell(uint cellId) =>
+ _passes.HasWalkRenderableEmittersInCell(cellId);
public void DrawCellShell(uint cellId)
{
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index 11de8e88..03cac3f6 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -589,6 +589,13 @@ internal sealed class RetailPViewRenderer
// 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();
// Landscape-stage static-owner particles (candles, the cathedral
// falls) submit AT THEIR OWN WALK TURNS inside Replay
diff --git a/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs b/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs
index ca253710..f43f76a6 100644
--- a/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs
+++ b/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs
@@ -56,10 +56,22 @@ internal sealed class RuntimeFramePipelineDiagnosticFactsSource :
_world = world ?? throw new ArgumentNullException(nameof(world));
}
- public TerrainRenderDiagnosticFacts CaptureTerrain() => new(
- _terrain?.VisibleSlots ?? 0,
- _terrain?.LoadedSlots ?? 0,
- _terrain?.CapacitySlots ?? 0);
+ public TerrainRenderDiagnosticFacts CaptureTerrain()
+ {
+ // S3 chunk 3 fix round 1 (F3): only ONE of the two terrain paths
+ // draws in a given frame (the walk path never calls Draw() any
+ // more — S3 §9.2 B3). Report the walk path's own per-frame counters
+ // whenever it submitted at least one batch this frame; otherwise
+ // fall back to the non-walk Draw() path's own per-call VisibleSlots
+ // (its "draws" has always equalled its visible-slot count — ONE
+ // MultiDrawIndexedIndirect spanning every visible slot).
+ int walkDraws = _terrain?.WalkDrawCount ?? 0;
+ if (walkDraws > 0)
+ return new(_terrain!.WalkVisibleSlotCount, walkDraws, _terrain.LoadedSlots, _terrain.CapacitySlots);
+
+ int visibleSlots = _terrain?.VisibleSlots ?? 0;
+ return new(visibleSlots, visibleSlots, _terrain?.LoadedSlots ?? 0, _terrain?.CapacitySlots ?? 0);
+ }
public FramePipelineDiagnosticFacts CaptureFrame() => new(
_presentation?.Diagnostics ?? default,
@@ -88,6 +100,10 @@ internal sealed class TerrainDrawDiagnosticsController
private readonly IRenderFrameDiagnosticLog _log;
private long _lastPublicationMilliseconds;
+ // S3 chunk 3 fix round 1 (F3): the walk path's per-BATCH tick
+ // accumulator — see AccumulateWalkBatch/CompleteWalkFrame.
+ private long _walkAccumulatedTicks;
+
public TerrainDrawDiagnosticsController(
bool enabled,
WorldRenderDiagnostics world,
@@ -107,6 +123,40 @@ internal sealed class TerrainDrawDiagnosticsController
internal void Complete(long nowMilliseconds)
{
_world.EndTerrainDraw();
+ PublishIfDue(nowMilliseconds);
+ }
+
+ /// S3 chunk 3 fix round 1 (F3): the leaf calls this once per
+ /// land-cell batch, with that ONE batch's own elapsed
+ /// Stopwatch.GetTimestamp() delta (no allocation, no per-batch
+ /// Stopwatch Restart/Stop — /
+ /// stay reserved for the non-walk fallback path's own single bracketed
+ /// call). Accumulates into this frame's running total; converts and pushes it as ONE sample.
+ public void AccumulateWalkBatch(long elapsedTicks) => _walkAccumulatedTicks += elapsedTicks;
+
+ /// S3 chunk 3 fix round 1 (F3): call once, after finishes — the walk path's
+ /// analogue of for the deleted whole-stage
+ /// terrain draw's own Begin()/Complete() bracket (superseded by the S3
+ /// chunk 3 per-cell interleave). Converts this frame's accumulated
+ /// per-batch tick total into ONE elapsed-time sample (a frame that
+ /// submitted zero batches still pushes a zero sample — "one sample per
+ /// frame", not "one sample per landscape turn") and resets the
+ /// accumulator for the next frame.
+ public void CompleteWalkFrame() => CompleteWalkFrame(_enabled ? Environment.TickCount64 : 0L);
+
+ internal void CompleteWalkFrame(long nowMilliseconds)
+ {
+ double ticksToHundredthsMicroseconds = 1_000_000.0 * 100.0 / Stopwatch.Frequency;
+ _world.PushTerrainSample(
+ (long)(_walkAccumulatedTicks * ticksToHundredthsMicroseconds));
+ _walkAccumulatedTicks = 0;
+ PublishIfDue(nowMilliseconds);
+ }
+
+ private void PublishIfDue(long nowMilliseconds)
+ {
if (!_enabled
|| nowMilliseconds - _lastPublicationMilliseconds <= PublicationIntervalMilliseconds)
{
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
index 33f6ed34..900b79c1 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
@@ -71,15 +71,43 @@ public sealed partial class TerrainModernRenderer : IDisposable
private readonly HashSet _walkVisibleLandblocks = new();
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty();
- // S3 chunk 3 (§9.2 B1): DrawLandCellRuns' reusable index-run scratch —
- // repopulated per call by AppendCellIndexRuns, one (Start, Count) pair
- // per contiguous run (side 8: one run/cell; side 1: one run total).
+ // S3 chunk 3 (§9.2 B1): DrawLandCells' reusable per-entry index-run
+ // scratch — repopulated per cell by AppendCellIndexRuns, one
+ // (Start, Count) pair per contiguous run relative to THAT entry's own
+ // slot (side 8: one run/cell; side 1: one run total). Converted to
+ // absolute (FirstIndex, Count) pairs in _batchRunScratch below because a
+ // fix-round-1 batch (F2) can span several landblocks/slots.
private readonly List<(int Start, int Count)> _cellRunScratch = new();
+ // S3 chunk 3 fix round 1 (F2): DrawLandCells' reusable ABSOLUTE run
+ // scratch — one (FirstIndex, Count) pair per contiguous index run across
+ // every entry of one batch, in entry order, cleared per call.
+ private readonly List<(uint FirstIndex, int Count)> _batchRunScratch = new();
+
+ // S3 chunk 3 fix round 1 (F3): the walk path's own per-FRAME submission
+ // stats — cleared in BeginFrame, populated by DrawLandCells. Distinct
+ // from _visibleSlots (Draw()'s own per-call list, the non-walk fallback
+ // path) because the walk submits many small batches across a frame
+ // rather than one Draw() call; TerrainDrawDiagnosticsController reports
+ // whichever path actually ran this publication window.
+ private readonly HashSet _walkSlotsThisFrame = new();
+ private int _walkDrawsThisFrame;
+
// Diag.
public int LoadedSlots => _alloc.LoadedCount;
public int VisibleSlots => _visibleSlots.Count;
public int CapacitySlots => _alloc.Capacity;
+
+ /// S3 chunk 3 fix round 1 (F3): distinct slots actually submitted THIS FRAME (cleared at
+ /// ) — the walk path's own per-frame
+ /// "VisibleSlots" answer, since the walk never calls .
+ internal int WalkVisibleSlotCount => _walkSlotsThisFrame.Count;
+
+ /// S3 chunk 3 fix round 1 (F3): the number of batches (indirect draws) submitted THIS FRAME.
+ internal int WalkDrawCount => _walkDrawsThisFrame;
+
///
/// Outdoor landcells admitted by the current landscape view. The set is
/// accumulated across doorway landscape slices and consumed after the
@@ -103,6 +131,10 @@ public sealed partial class TerrainModernRenderer : IDisposable
_retirementLedger.RetryPendingPublications();
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
+ // S3 chunk 3 fix round 1 (F3): reset the walk path's per-frame
+ // submission stats — one presented frame, one answer.
+ _walkSlotsThisFrame.Clear();
+ _walkDrawsThisFrame = 0;
}
///
@@ -294,54 +326,100 @@ public sealed partial class TerrainModernRenderer : IDisposable
}
///
- /// S3 chunk 3 (§9.2 B1/B2): the walk's per-land-cell terrain draw —
- /// retail RenderDeviceD3D::DrawLandCell @0x0059f120, batched.
- /// Submits 's index runs for every
- /// entry of as ONE indirect draw, UNCLIPPED:
+ /// S3 chunk 3 (§9.2 B1/B2), fix round 1 (F1/F2): the walk's per-land-cell
+ /// terrain draw — retail RenderDeviceD3D::DrawLandCell
+ /// @0x0059f120, batched. Submits 's
+ /// index runs for every entry of as ONE
+ /// indirect draw spanning however many DISTINCT landblocks the batch
+ /// covers (fix round 1's F2: the terrain index buffer is one buffer with
+ /// a per-slot FirstIndex offset, so a batch is never required to
+ /// stay within one landblock —
+ /// only splits it at a genuine intervening GPU submission), UNCLIPPED:
/// retail never view-clips terrain — the walk's own per-cell
/// CellInView admission (RetailFrameWalk.DrawLandscape's
/// DrawLandCell gate) already decided which cells reach here, so
- /// there is no frustum test and no inViewLandcells filter here,
- /// unlike the non-walk entry point this supersedes
- /// for the walk path only ( keeps serving non-walk
- /// callers, e.g. the flat/directional-shadow paths). A landblockId with
- /// no uploaded slot (a streaming-timing race between the walk's own
- /// block graph and this renderer's landblock upload) is a silent
- /// no-op — the walk's visited-cell bookkeeping is the timing authority,
- /// not this call.
+ /// there is no frustum test and no inViewLandcells filter here.
+ /// Fix round 1 F6: this is not a missing culling step — retail has no
+ /// separate terrain frustum test beyond LScape::draw_check_blocks/
+ /// landcell_check (ported as WalkLandscape.CheckBlocks),
+ /// so the walk's own admission IS the sole culling authority for
+ /// terrain, matching retail exactly. Unlike the non-walk entry point this supersedes
+ /// for the walk path only ( keeps serving its one
+ /// remaining non-walk caller — fix round 1 F6 corrects the prior "flat/
+ /// directional-shadow paths" wording: the only caller left is the flat-
+ /// terrain fallback, WorldScenePassExecutor.DrawFlatTerrain; a
+ /// directional-shadow-receiving surface selects its receiver pipeline
+ /// inside the SAME DrawRhi call this makes, not through a second
+ /// caller).
+ ///
+ /// F1 — the slot key. carries each
+ /// entry's landblock id in the WALK's own bx<<24 | by<<16
+ /// encoding (WalkLandBlock.LandblockId, low word 0x0000), while
+ /// is keyed by the DAT landblock id
+ /// (LandblockRenderPublisher.LandblockId, low word 0xFFFF — the
+ /// same convention /
+ /// already use and must keep using, since those callers already pass the
+ /// DAT id). Normalized HERE, at this one walk entry point, rather than at
+ /// every walk call site.
+ ///
+ /// An entry whose normalized id has no uploaded slot (a
+ /// streaming-timing race between the walk's own block graph and this
+ /// renderer's landblock upload, OR a genuinely unknown block) is a
+ /// silent per-entry no-op — the walk's visited-cell bookkeeping is the
+ /// timing authority, not this call; the other entries in the same batch
+ /// still submit.
///
- public void DrawLandCellRuns(
- uint landblockId,
+ public void DrawLandCells(
Matrix4x4 viewProjection,
- IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
+ IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
{
ArgumentNullException.ThrowIfNull(cells);
if (cells.Count == 0) return;
- if (!_idToSlot.TryGetValue(landblockId, out int slot)) return;
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
- _cellRunScratch.Clear();
+ _batchRunScratch.Clear();
for (int i = 0; i < cells.Count; i++)
- AppendCellIndexRuns(cells[i].SideCellCount, cells[i].CellIndex, _cellRunScratch);
- int commandCount = _cellRunScratch.Count;
+ {
+ (uint landblockId, int sideCellCount, int cellIndex) = cells[i];
+ // F1: the walk hands 0xXXYY0000 (WalkLandBlock.LandblockId);
+ // AddLandblock stores under the DAT id 0xXXYYFFFF
+ // (LandblockRenderPublisher.LandblockId). Normalize the lookup,
+ // not the storage — every non-walk caller still stores/removes
+ // under the DAT id unchanged.
+ uint slotKey = (landblockId & 0xFFFF0000u) | 0xFFFFu;
+ if (!_idToSlot.TryGetValue(slotKey, out int slot))
+ continue;
+ uint baseFirstIndex = (uint)(slot * IndicesPerLandblock);
+
+ _cellRunScratch.Clear();
+ AppendCellIndexRuns(sideCellCount, cellIndex, _cellRunScratch);
+ for (int r = 0; r < _cellRunScratch.Count; r++)
+ {
+ (int start, int count) = _cellRunScratch[r];
+ _batchRunScratch.Add((baseFirstIndex + (uint)start, count));
+ }
+ _walkSlotsThisFrame.Add(slot);
+ }
+
+ int commandCount = _batchRunScratch.Count;
if (commandCount == 0) return;
if (_deicScratch.Length < commandCount)
_deicScratch = new DrawElementsIndirectCommand[Math.Max(commandCount, 64)];
- uint baseFirstIndex = (uint)(slot * IndicesPerLandblock);
for (int i = 0; i < commandCount; i++)
{
- (int start, int count) = _cellRunScratch[i];
+ (uint firstIndex, int count) = _batchRunScratch[i];
_deicScratch[i] = new DrawElementsIndirectCommand
{
Count = (uint)count,
InstanceCount = 1u,
- FirstIndex = baseFirstIndex + (uint)start,
+ FirstIndex = firstIndex,
BaseVertex = 0, // baked into indices on upload
BaseInstance = 0,
};
}
+ _walkDrawsThisFrame++;
DrawRhi(viewProjection, commandCount);
}
diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
index 4de980e8..fe0dcf8e 100644
--- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
@@ -102,24 +102,40 @@ internal interface IWalkFrameLeafRenderer
/// calls this exactly once per frame's Landscape turn).
void DrawSky();
- /// S3 chunk 3 (§9.2 B1/B2): retail RenderDeviceD3D::
- /// DrawLandCell @0x0059f120 — one landblock's terrain at one or
- /// more admitted LOD cells, submitted together as ONE indirect draw.
- /// merges consecutive same-slot
- /// events with no intervening
- /// event before calling this once (order-preserving batching — any
- /// other event, e.g. a building's alpha barrier or the SAME cell's own
- /// object-list turn, splits the batch), so a single-cell call is the
- /// common case whenever a cell's own /
- /// stream mark interposes. carries (side,
- /// cellIndex) pairs in event (draw) order — the R3 index-run arithmetic
- /// (contiguous runs inside the landblock's 384-index slot,
+ /// S3 chunk 3 (§9.2 B1/B2), fix round 1 (F2): retail
+ /// RenderDeviceD3D::DrawLandCell @0x0059f120 — one or more
+ /// admitted LOD cells, possibly spanning several landblocks, submitted
+ /// together as ONE indirect draw.
+ /// keeps a PENDING batch that
+ /// events append to and flushes (calls this once) immediately before any
+ /// OTHER event that will itself submit GPU work — see that method's own
+ /// doc comment for the exact flush points, including the F2 particle-turn
+ /// exception (a genuinely empty / turn neither submits nor flushes). This is
+ /// order-preserving by construction: the batch's GPU submission point is
+ /// always the SAME point the unbatched terrain draws would have occupied
+ /// (immediately before the next real submission), so pixel output is
+ /// identical to submitting each entry as its own draw. carries (landblockId, side, cellIndex) triples in
+ /// event (draw) order — the R3 index-run arithmetic (contiguous runs
+ /// inside each entry's own landblock's 384-index slot,
/// TerrainModernRenderer.AppendCellIndexRuns) runs per entry.
/// Draws UNCLIPPED for both root kinds: retail never view-clips terrain
/// (LScape::draw draws whole blocks; the exit-seal/interior-
/// repaint turn owns aperture exactness afterward, S3 §9.1 R4/R5).
void DrawLandCellBatch(
- uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells);
+ IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells);
+
+ /// S3 chunk 3 fix round 1 (F2): whether has any renderable emitter right now — production
+ /// wires this to ParticleSystem.HasRenderableEmittersInCell with
+ /// the SAME ParticleRenderPass.Scene pass both and
+ /// already draw through. asks this
+ /// BEFORE flushing the pending terrain batch (or calling either particle
+ /// leaf) so a genuinely empty particle turn submits nothing and does not
+ /// force an otherwise-unneeded terrain flush.
+ bool HasRenderableEmittersInCell(uint cellId);
/// One committed cell's EnvCell shell —
/// PView::DrawCells's DrawEnvCell @0x005a4abe. Retail first
@@ -563,11 +579,13 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private readonly List _floodViewRouteScratch = new();
private WalkPlane _lookInCyPlane;
- /// S3 chunk 3 (§9.2 B2): 's reusable
- /// batch-merge scratch — the (side, cellIndex) pairs of a run of
- /// consecutive same-landblock
- /// events, cleared and repopulated at each batch boundary.
- private readonly List<(int SideCellCount, int CellIndex)> _landCellBatchScratch = new();
+ /// S3 chunk 3 (§9.2 B2), fix round 1 (F2): 's
+ /// PENDING terrain batch — every
+ /// event since the last flush, across however many landblocks, in event
+ /// order. Cleared at the start of each call ("frame
+ /// start") and by every flush; NOT cleared between events, since it is
+ /// the whole point of the deferred-batching rule.
+ private readonly List<(uint LandblockId, int SideCellCount, int CellIndex)> _pendingTerrainBatch = new();
private readonly HashSet _cellShellsDrawnThisFrame = new();
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
@@ -929,6 +947,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
if (_stream.Count > 0)
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
+ // S3 chunk 3 fix round 1 (F2): cleared at the start of THIS
+ // replay ("frame start") — a batch never survives across frames.
+ _pendingTerrainBatch.Clear();
+
int cursor = 0;
int alphaCursor = 0;
for (int i = 0; i < _events.Count; i++)
@@ -937,6 +959,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
switch (e.Kind)
{
case WalkFrameEventKind.StreamMark:
+ FlushPendingTerrainBatch();
int end = e.IntArg;
int count = end - cursor;
if (_trace is not null)
@@ -945,6 +968,12 @@ 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.
int alphaEnd = e.IntArg;
for (; alphaCursor < alphaEnd; alphaCursor++)
{
@@ -957,52 +986,39 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
}
break;
case WalkFrameEventKind.Sky:
+ FlushPendingTerrainBatch();
_leafRenderer.DrawSky();
break;
case WalkFrameEventKind.LandCell:
- {
- // S3 chunk 3 (§9.2 B2): order-preserving batching —
- // merge this run of CONSECUTIVE same-landblock
- // LandCell events into ONE DrawLandCellBatch call.
- // Any other event kind sits between two runs in
- // _events (Collect recorded it there in retail's
- // own order), so this lookahead naturally splits the
- // batch there without inspecting event kinds itself.
- uint landblockId = e.CellId;
- _landCellBatchScratch.Clear();
- _landCellBatchScratch.Add((e.IntArg >> 8, e.IntArg & 0xFF));
- int j = i + 1;
- while (j < _events.Count)
- {
- WalkFrameEvent next = _events[j];
- if (next.Kind != WalkFrameEventKind.LandCell
- || next.CellId != landblockId)
- {
- break;
- }
- _landCellBatchScratch.Add((next.IntArg >> 8, next.IntArg & 0xFF));
- j++;
- }
- _leafRenderer.DrawLandCellBatch(landblockId, _landCellBatchScratch);
- i = j - 1;
+ // S3 chunk 3 fix round 1 (F2): append-only — no
+ // lookahead, no same-landblock restriction, no
+ // flush. The batch may span several landblocks; it
+ // flushes only when a later event actually needs the
+ // GPU (or at Replay's own end).
+ _pendingTerrainBatch.Add((e.CellId, e.IntArg >> 8, e.IntArg & 0xFF));
break;
- }
case WalkFrameEventKind.CellShell:
+ FlushPendingTerrainBatch();
_leafRenderer.DrawCellShell(e.CellId);
break;
case WalkFrameEventKind.PunchFan:
+ FlushPendingTerrainBatch();
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
break;
case WalkFrameEventKind.AlphaBarrier:
+ FlushPendingTerrainBatch();
_leafRenderer.AlphaBarrier();
break;
case WalkFrameEventKind.LandscapeFlush:
+ FlushPendingTerrainBatch();
_leafRenderer.FlushLandscape();
break;
case WalkFrameEventKind.ClearInteriorDepth:
+ FlushPendingTerrainBatch();
_leafRenderer.ClearInteriorDepth();
break;
case WalkFrameEventKind.ExitSeals:
+ FlushPendingTerrainBatch();
// S3 §8.2 B2: the driver — not Collect — owns adding
// the leaf's returned submitted-fan count to the
// persistent PortalsDrawnCount, since the real
@@ -1012,18 +1028,35 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
PortalsDrawnCount += _leafRenderer.DrawExitSeals();
break;
case WalkFrameEventKind.StaticParticles:
- // Retail CPhysicsObj::add_particle_shadow_to_cell
+ // S3 chunk 3 fix round 1 (F2): a genuinely empty
+ // particle turn submits nothing — and, since it
+ // submits nothing, must NOT flush the pending
+ // terrain batch either (the batch is order-preserving
+ // only because a skipped turn changes nothing about
+ // what reaches the GPU, at all). Retail
+ // CPhysicsObj::add_particle_shadow_to_cell
// (0x00514a70): an emitter owns one shadow in its OWN
// current cell, drawn at that cell's object turn
// regardless of any owner's registry membership —
// this is a cell lookup, not an owner union.
- _leafRenderer.DrawStaticParticles(e.CellId);
+ if (_leafRenderer.HasRenderableEmittersInCell(e.CellId))
+ {
+ FlushPendingTerrainBatch();
+ _leafRenderer.DrawStaticParticles(e.CellId);
+ }
break;
case WalkFrameEventKind.CellParticles:
- _leafRenderer.DrawCellParticles(e.CellId);
+ if (_leafRenderer.HasRenderableEmittersInCell(e.CellId))
+ {
+ FlushPendingTerrainBatch();
+ _leafRenderer.DrawCellParticles(e.CellId);
+ }
break;
}
}
+ // S3 chunk 3 fix round 1 (F2): the end of Replay flushes the
+ // remainder — a batch never crosses into the next frame.
+ FlushPendingTerrainBatch();
}
finally
{
@@ -1037,6 +1070,21 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
}
}
+ /// S3 chunk 3 fix round 1 (F2): submits the pending terrain
+ /// batch as ONE
+ /// call, if it holds anything, and clears it. Called immediately before
+ /// every OTHER event kind that will itself submit GPU work — see
+ /// 's own doc comment for the full flush-point
+ /// list — so the batch's actual submission point is always the exact
+ /// point the unbatched per-cell draws would have occupied.
+ private void FlushPendingTerrainBatch()
+ {
+ if (_pendingTerrainBatch.Count == 0)
+ return;
+ _leafRenderer.DrawLandCellBatch(_pendingTerrainBatch);
+ _pendingTerrainBatch.Clear();
+ }
+
///
/// Output-only Campaign FW cathedral trace. The event list described here
/// is the same list immediately executes, so this
@@ -1109,7 +1157,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
order.Append('>').Append(i).Append(":SKY");
break;
case WalkFrameEventKind.LandCell:
- order.Append('>').Append(i).Append(":LC").Append(e.CellId.ToString("x8"));
+ // S3 chunk 3 fix round 1 (F6): the trace token now
+ // carries the LOD side/index too — ":LC/:".
+ order.Append('>').Append(i).Append(":LC")
+ .Append(e.CellId.ToString("x8"))
+ .Append('/').Append(e.IntArg >> 8)
+ .Append(':').Append(e.IntArg & 0xFF);
break;
case WalkFrameEventKind.CellShell when IsCathedralCell(e.CellId):
order.Append('>').Append(i).Append(":S")
@@ -1474,11 +1527,15 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
// interleave now emits one WalkFrameEventKind.LandCell turn per
// admitted land cell (IWalkEventSink.OnLandCellTurn, fired from
// RetailFrameWalk.DrawLandscape's own per-cell loop), not a single
- // TerrainSlice(0) here. Drawing terrain BEFORE every building would
- // let a nearer building's far-Z punch survive under farther terrain
- // that draws afterward and overwrite it — the doorway-behind-a-hill
- // fragment bug the interleave removes by ORDER alone. The walk's
- // active views still own only the punch fans and dynamics
+ // TerrainSlice(0) here. S3 chunk 3 fix round 1 (F6) corrects the
+ // direction of this note: drawing ALL terrain BEFORE every building
+ // let a FARTHER building's far-Z punch survive, because the NEARER
+ // terrain that retail draws AFTER that punch (and which overwrites
+ // it) was instead drawn BEFORE it here — nothing later covered the
+ // punched depth, so a doorway behind a hill punched straight through
+ // it. The interleave fixes this by ORDER alone: nearer terrain now
+ // draws AFTER the farther building's punch, exactly like retail. The
+ // walk's active views still own only the punch fans and dynamics
// apertures; terrain itself is UNCLIPPED for both root kinds.
}
diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
index 9cda7cb2..c0dddf62 100644
--- a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
+++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs
@@ -33,6 +33,7 @@ internal readonly record struct RenderGlScissorSnapshot(
internal readonly record struct TerrainRenderDiagnosticFacts(
int VisibleSlots,
+ int Draws,
int LoadedSlots,
int CapacitySlots);
@@ -85,6 +86,15 @@ internal sealed class WorldRenderDiagnostics
(long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0));
}
+ /// S3 chunk 3 fix round 1 (F3): the walk path's per-frame
+ /// analogue of — pushes ONE precomputed
+ /// elapsed-time sample (the sum of every land-cell batch's own
+ /// Stopwatch.GetTimestamp() delta this frame, converted by the caller)
+ /// instead of stopping this owner's own stopwatch, since the walk path
+ /// times many small batches per frame rather than one bracketed call.
+ public void PushTerrainSample(long elapsedHundredthsMicroseconds) =>
+ _terrainSamples.PushHundredthsMicroseconds(elapsedHundredthsMicroseconds);
+
public void PublishTerrainDiagnostics(TerrainRenderDiagnosticFacts facts)
{
RollingTimingPercentiles timing = _terrainSamples.Snapshot();
@@ -93,7 +103,7 @@ internal sealed class WorldRenderDiagnostics
string budget = medianMicroseconds > 1000.0 ? " BUDGET_OVER" : string.Empty;
_log.WriteLine(
$"[TERRAIN-DIAG]{budget} cpu_us={medianMicroseconds:F2}m/"
- + $"{p95Microseconds:F2}p95 draws={facts.VisibleSlots}/frame "
+ + $"{p95Microseconds:F2}p95 draws={facts.Draws}/frame "
+ $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} "
+ $"capacity={facts.CapacitySlots}");
}
diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs
index c6508acc..f4d11640 100644
--- a/src/AcDream.Core/Vfx/ParticleSystem.cs
+++ b/src/AcDream.Core/Vfx/ParticleSystem.cs
@@ -617,6 +617,24 @@ public sealed class ParticleSystem : IParticleSystem
/// 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
+ /// the bucket's handle list — 's
+ /// allocation-free sibling for a Replay-time gate
+ /// (WalkFrameDriver.Replay's StaticParticles/CellParticles
+ /// arms) that must not force a pending terrain-batch flush for a
+ /// genuinely empty particle turn. A bucket's FirstRenderableHandle
+ /// is 0 exactly when it holds no renderable handle (handles start at 1),
+ /// so this is a single dictionary lookup plus a field read.
+ ///
+ public bool HasRenderableEmittersInCell(ParticleRenderPass renderPass, uint cellId)
+ {
+ int passIndex = RenderPassIndex(renderPass);
+ return _cellHandlesByPass[passIndex].TryGetValue(cellId, out OwnerEmitterBucket? bucket)
+ && bucket.FirstRenderableHandle != 0;
+ }
+
public void CopyRenderableEmittersInCell(
ParticleRenderPass renderPass,
uint cellId,
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index aa361e8e..6e37b7f4 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -27,27 +27,58 @@ public sealed class RetailPViewPassExecutorTests
}
[Fact]
- public void Concrete_executor_brackets_terrain_diagnostics()
+ public void Concrete_executor_accumulates_walk_terrain_batch_timing()
{
+ // S3 chunk 3 fix round 1 (F3): the walk leaf no longer brackets
+ // itself with Begin()/Complete() (that stopwatch-restart pair would
+ // push one timing SAMPLE per batch, not one per frame) — it times
+ // itself with a raw Stopwatch.GetTimestamp() delta and hands the
+ // elapsed ticks to AccumulateWalkBatch, which only accumulates.
MethodInfo landscape = typeof(RetailPViewPassExecutor).GetMethod(
"DrawWalkLandCellBatch",
BindingFlags.Instance | BindingFlags.NonPublic)!;
IReadOnlyList landscapeCalls = CompiledCallGraph.Read(landscape);
- int diagnosticsBegin = RequiredCallIndex(
- landscapeCalls,
- typeof(TerrainDrawDiagnosticsController),
- nameof(TerrainDrawDiagnosticsController.Begin));
int terrainDraw = RequiredCallIndex(
landscapeCalls,
typeof(TerrainModernRenderer),
- nameof(TerrainModernRenderer.DrawLandCellRuns));
- int diagnosticsComplete = RequiredCallIndex(
+ nameof(TerrainModernRenderer.DrawLandCells));
+ int accumulate = RequiredCallIndex(
landscapeCalls,
typeof(TerrainDrawDiagnosticsController),
- nameof(TerrainDrawDiagnosticsController.Complete));
+ nameof(TerrainDrawDiagnosticsController.AccumulateWalkBatch));
- Assert.True(diagnosticsBegin < terrainDraw);
- Assert.True(terrainDraw < diagnosticsComplete);
+ Assert.True(terrainDraw < accumulate);
+ Assert.DoesNotContain(
+ landscapeCalls,
+ call => call.Target.DeclaringType == typeof(TerrainDrawDiagnosticsController)
+ && call.Target.Name == nameof(TerrainDrawDiagnosticsController.Begin));
+ Assert.DoesNotContain(
+ landscapeCalls,
+ call => call.Target.DeclaringType == typeof(TerrainDrawDiagnosticsController)
+ && call.Target.Name == nameof(TerrainDrawDiagnosticsController.Complete));
+ }
+
+ [Fact]
+ public void Concrete_executor_pushes_the_walk_terrain_frame_sample_at_replay_end()
+ {
+ // S3 chunk 3 fix round 1 (F3): DrawWalkDrivenStatics is the ONE call
+ // site of driver.Replay in production — CompleteWalkTerrainFrame
+ // must run immediately after it, so the frame's sample is pushed
+ // exactly once, at "the end of the walk replay".
+ MethodInfo drawWalkDrivenStatics = typeof(RetailPViewRenderer).GetMethod(
+ "DrawWalkDrivenStatics",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ IReadOnlyList calls = CompiledCallGraph.Read(drawWalkDrivenStatics);
+ int replay = RequiredCallIndex(
+ calls,
+ typeof(AcDream.App.Rendering.Walk.WalkFrameDriver),
+ nameof(AcDream.App.Rendering.Walk.WalkFrameDriver.Replay));
+ int completeWalkFrame = RequiredCallIndex(
+ calls,
+ typeof(RetailPViewPassExecutor),
+ nameof(RetailPViewPassExecutor.CompleteWalkTerrainFrame));
+
+ Assert.True(replay < completeWalkFrame);
}
[Fact]
diff --git a/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs
index e15516a7..4368283a 100644
--- a/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/TerrainDrawDiagnosticsControllerTests.cs
@@ -87,6 +87,72 @@ public sealed class TerrainDrawDiagnosticsControllerTests
Assert.Equal(4, log.Messages.Count);
}
+ // ── S3 chunk 3 fix round 1 (F3): the walk path's own per-frame timing
+ // bracket — AccumulateWalkBatch/CompleteWalkFrame replace the deleted
+ // whole-stage terrain leaf's Begin()/Complete() bracket for the walk
+ // path only; Begin()/Complete() stay reserved for the non-walk
+ // fallback path (TerrainDrawDiagnosticsControllerTests above pin
+ // those unchanged). ──────────────────────────────────────────────────
+
+ [Fact]
+ public void CompleteWalkFrame_PublishesOnTheSameCadenceAsComplete()
+ {
+ var log = new RecordingLog();
+ var facts = new RecordingFacts();
+ var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
+ var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
+
+ controller.AccumulateWalkBatch(1_000);
+ controller.AccumulateWalkBatch(2_000);
+ controller.CompleteWalkFrame(10_000);
+
+ Assert.Equal(1, facts.TerrainCaptureCount);
+ Assert.Equal(1, facts.FrameCaptureCount);
+ Assert.Equal(2, log.Messages.Count);
+ Assert.StartsWith("[TERRAIN-DIAG]", log.Messages[0]);
+ }
+
+ [Fact]
+ public void CompleteWalkFrame_WithNoAccumulatedBatchesStillPublishesOnCadence()
+ {
+ // F3: "a frame that submitted zero batches still pushes a zero
+ // sample — one sample per frame, not one sample per landscape
+ // turn" — CompleteWalkFrame's own cadence/publish behavior does
+ // not depend on AccumulateWalkBatch ever having been called.
+ var log = new RecordingLog();
+ var facts = new RecordingFacts();
+ var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
+ var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
+
+ controller.CompleteWalkFrame(10_000);
+
+ Assert.Equal(1, facts.TerrainCaptureCount);
+ Assert.Equal(2, log.Messages.Count);
+ }
+
+ [Fact]
+ public void CompleteWalkFrame_ResetsTheAccumulatorAcrossFrames()
+ {
+ var log = new RecordingLog();
+ var facts = new RecordingFacts();
+ var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
+ var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
+
+ controller.AccumulateWalkBatch(5_000);
+ controller.CompleteWalkFrame(10_000);
+ // A second frame's own cadence-gated publish (still inside the
+ // interval) proves the first frame's ticks were not left to bleed
+ // into a value that could only be observed via the [TERRAIN-DIAG]
+ // line's cpu_us field — the accumulator itself is private, so this
+ // asserts indirectly: a second CompleteWalkFrame with ZERO new
+ // batches, once the cadence is due again, does not throw and still
+ // reports a fresh (not doubled) sample.
+ controller.CompleteWalkFrame(15_001);
+
+ Assert.Equal(2, facts.TerrainCaptureCount);
+ Assert.Equal(4, log.Messages.Count);
+ }
+
private sealed class RecordingFacts : IFramePipelineDiagnosticFactsSource
{
public int TerrainCaptureCount { get; private set; }
@@ -95,7 +161,7 @@ public sealed class TerrainDrawDiagnosticsControllerTests
public TerrainRenderDiagnosticFacts CaptureTerrain()
{
TerrainCaptureCount++;
- return new TerrainRenderDiagnosticFacts(1, 2, 3);
+ return new TerrainRenderDiagnosticFacts(VisibleSlots: 1, Draws: 1, LoadedSlots: 2, CapacitySlots: 3);
}
public FramePipelineDiagnosticFacts CaptureFrame()
diff --git a/tests/AcDream.App.Tests/Rendering/TerrainWalkSlotKeyNormalizationTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainWalkSlotKeyNormalizationTests.cs
new file mode 100644
index 00000000..d6485fef
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/TerrainWalkSlotKeyNormalizationTests.cs
@@ -0,0 +1,272 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Gpu.Vk;
+using AcDream.App.Tests.Rendering.Gpu;
+using AcDream.Content;
+using AcDream.Core.Terrain;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Lib.IO;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// S3 chunk 3 fix round 1 (§9.6 F1): TerrainModernRenderer._idToSlot
+/// is keyed by the DAT landblock id 0xXXYYFFFF
+/// (LandblockRenderPublisher.LandblockId => Build.Landblock.LandblockId,
+/// which stores under
+/// verbatim), while the walk hands 0xXXYY0000
+/// (WalkLandBlock.LandblockId = bx<<24 | by<<16). Before
+/// this fix, normalized
+/// nothing, so every walk-path lookup missed and the walk drew NO terrain.
+/// This pins the fix end-to-end through the REAL RHI submission path (a
+/// , not a mock of the lookup alone) — an
+/// entry keyed by the walk's 0xA9B40000 convention must resolve the
+/// SAME slot published
+/// under the DAT's 0xA9B4FFFF convention, and an entry for a
+/// genuinely unknown landblock must be a silent per-entry no-op rather than
+/// a thrown exception or a spurious draw.
+///
+public sealed class TerrainWalkSlotKeyNormalizationTests : IDisposable
+{
+ private readonly RecordingGpuDevice _device = new();
+ private readonly GpuDeviceFrameLifetime _frameLifetime;
+ private readonly VulkanWorldPassScope _scope = new(sampleCount: 1);
+ private readonly TerrainAtlas _atlas;
+ private readonly TerrainModernRenderer _terrain;
+
+ public TerrainWalkSlotKeyNormalizationTests()
+ {
+ _frameLifetime = new GpuDeviceFrameLifetime(_device);
+ // A Region with no TerrainInfo takes BuildBackendNeutral's own
+ // documented single-white-fallback-layer branch — no installed DAT
+ // needed, and this suite stays hermetic.
+ _atlas = TerrainAtlas.BuildBackendNeutral(_device, new EmptyRegionDats());
+ _terrain = new TerrainModernRenderer(_device, _frameLifetime, _scope, _atlas, _device.Retirement);
+ }
+
+ private DrawScope BeginDraw()
+ {
+ _frameLifetime.BeginFrame();
+ IGpuFrame frame = _frameLifetime.CurrentFrame!;
+ IGpuPassEncoder pass = frame.BeginPass(
+ GpuPassDescription.BackbufferClear(
+ "s3-chunk3-fix-round-1-f1", Vector4.Zero, sampleCount: 1));
+ IDisposable publication = _scope.Publish(pass);
+ _device.Clear();
+ return new DrawScope(frame, pass, publication);
+ }
+
+ private readonly struct DrawScope(
+ IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication) : IDisposable
+ {
+ public IGpuFrame Frame { get; } = frame;
+ public IGpuPassEncoder Pass { get; } = pass;
+
+ public void Dispose() => publication.Dispose();
+ }
+
+ private static LandblockMeshData MakeFullSizeMesh()
+ {
+ var vertices = new TerrainVertex[LandblockMesh.VerticesPerLandblock];
+ var indices = new uint[LandblockMesh.VerticesPerLandblock];
+ for (int i = 0; i < indices.Length; i++)
+ indices[i] = (uint)i;
+ return new LandblockMeshData(vertices, indices);
+ }
+
+ [Fact]
+ public void DrawLandCells_ResolvesTheDatSlotFromTheWalksLowWordZeroLandblockId()
+ {
+ // Stored under the DAT id (LandblockRenderPublisher's own
+ // convention, low word 0xFFFF).
+ _terrain.AddLandblock(0xA9B4FFFFu, MakeFullSizeMesh(), Vector3.Zero);
+
+ using DrawScope draw = BeginDraw();
+ _terrain.BeginFrame(frameSlot: 0);
+ // The walk's own convention (WalkLandBlock.LandblockId), low word
+ // 0x0000 — F1's normalization must still find the slot above.
+ _terrain.DrawLandCells(
+ Matrix4x4.Identity,
+ new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xA9B40000u, 8, 0) });
+
+ GpuRecordedMultiDrawIndirect call = Assert.Single(
+ _device.Calls.OfType());
+ Assert.Equal(1u, call.DrawCount);
+ }
+
+ [Fact]
+ public void DrawLandCells_UnknownLandblockIsASilentNoOp()
+ {
+ using DrawScope draw = BeginDraw();
+ _terrain.BeginFrame(frameSlot: 0);
+
+ _terrain.DrawLandCells(
+ Matrix4x4.Identity,
+ new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xDEAD0000u, 8, 0) });
+
+ Assert.Empty(_device.Calls.OfType());
+ Assert.Equal(0, _terrain.WalkDrawCount);
+ Assert.Equal(0, _terrain.WalkVisibleSlotCount);
+ }
+
+ [Fact]
+ public void DrawLandCells_KnownAndUnknownLandblocksInOneBatch_SubmitsOnlyTheKnownEntry()
+ {
+ _terrain.AddLandblock(0xA9B4FFFFu, MakeFullSizeMesh(), Vector3.Zero);
+
+ using DrawScope draw = BeginDraw();
+ _terrain.BeginFrame(frameSlot: 0);
+ _terrain.DrawLandCells(
+ Matrix4x4.Identity,
+ new (uint LandblockId, int SideCellCount, int CellIndex)[]
+ {
+ (0xDEAD0000u, 8, 0), (0xA9B40000u, 8, 1),
+ });
+
+ GpuRecordedMultiDrawIndirect call = Assert.Single(
+ _device.Calls.OfType());
+ Assert.Equal(1u, call.DrawCount);
+ Assert.Equal(1, _terrain.WalkDrawCount);
+ Assert.Equal(1, _terrain.WalkVisibleSlotCount);
+ }
+
+ public void Dispose()
+ {
+ _terrain.Dispose();
+ _atlas.Dispose();
+ _device.Dispose();
+ }
+
+ /// Minimal hermetic — every read
+ /// misses except Get<Region>(0x13000000), which returns a
+ /// Region with no TerrainInfo so takes its documented
+ /// single-white-fallback-layer branch instead of throwing.
+ private sealed class EmptyRegionDats : IDatReaderWriter
+ {
+ private readonly Region _region = new();
+ private readonly StubDatabase _portal = new();
+ private readonly StubDatabase _highRes = new();
+ private readonly StubDatabase _language = new();
+ private readonly StubDatabase _cell = new();
+
+ public string SourceDirectory => string.Empty;
+
+ public IDatDatabase Portal => _portal;
+
+ public IDatDatabase Cell => _cell;
+
+ public ReadOnlyDictionary CellRegions { get; } =
+ new(new Dictionary());
+
+ public IDatDatabase HighRes => _highRes;
+
+ public IDatDatabase Language => _language;
+
+ public IDatDatabase Local => _language;
+
+ public ReadOnlyDictionary RegionFileMap { get; } =
+ new(new Dictionary());
+
+ public int PortalIteration => 0;
+
+ public int CellIteration => 0;
+
+ public int HighResIteration => 0;
+
+ public int LanguageIteration => 0;
+
+ public bool TryGetFileBytes(
+ uint regionId,
+ uint fileId,
+ ref byte[] bytes,
+ out int bytesRead)
+ {
+ bytesRead = 0;
+ return false;
+ }
+
+ public IEnumerable GetAllIdsOfType() where T : IDBObj =>
+ Array.Empty();
+
+ public IEnumerable ResolveId(uint id) =>
+ Array.Empty();
+
+ public bool TrySave(T obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public bool TrySave(
+ uint regionId,
+ T obj,
+ int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ [return: MaybeNull]
+ public T Get(uint fileId) where T : IDBObj
+ {
+ if (typeof(T) == typeof(Region) && fileId == 0x13000000u)
+ return (T)(object)_region;
+ return default;
+ }
+
+ public bool TryGet(
+ uint fileId,
+ [MaybeNullWhen(false)] out T value) where T : IDBObj
+ {
+ value = Get(fileId);
+ return value is not null;
+ }
+
+ public void Dispose()
+ {
+ }
+
+ private sealed class StubDatabase : IDatDatabase
+ {
+ public DatDatabase Db => throw new NotSupportedException();
+
+ public int Iteration => 0;
+
+ public IEnumerable GetAllIdsOfType() where T : IDBObj =>
+ Array.Empty();
+
+ public bool TryGet(
+ uint fileId,
+ [MaybeNullWhen(false)] out T value) where T : IDBObj
+ {
+ value = default;
+ return false;
+ }
+
+ public bool TryGetFileBytes(
+ uint fileId,
+ [MaybeNullWhen(false)] out byte[] value)
+ {
+ value = null;
+ return false;
+ }
+
+ public bool TryGetFileBytes(
+ uint fileId,
+ ref byte[] bytes,
+ out int bytesRead)
+ {
+ bytesRead = 0;
+ return false;
+ }
+
+ public bool TrySave(T obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public void Dispose()
+ {
+ }
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs
index 68f5a896..d82a5b50 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs
@@ -244,8 +244,14 @@ public sealed class RetailFrameWalkTests
walk.DrawLandscape(landscape, new WalkPortalView(), ctx, recorder);
Assert.Equal("LS", recorder.Combined[0]);
- Assert.DoesNotContain(
- recorder.Combined, e => e.StartsWith("TERRAIN", StringComparison.Ordinal));
+ // S3 chunk 3 fix round 1 (F6): the "no TERRAIN event" assertion this
+ // line used to make was vacuous — this Recorder's own vocabulary
+ // (Combined) never had a "TERRAIN"-prefixed entry to begin with, so
+ // the assertion could never fail. WalkFrameEventKind has no
+ // TerrainSlice case any more (chunk 3 deleted it outright), and the
+ // driver-level pins in WalkFrameDriverTests (F4: the outdoor-root,
+ // interior-root, and cross-landblock-batching RunFrame tests) are
+ // what actually prove no whole-stage terrain draw survives.
const uint farCellId = 0x22220001u;
int farLcIndex = recorder.Combined.IndexOf($"LC:{farCellId:x8}");
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
index cd001a5c..033bfe35 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
@@ -47,7 +47,16 @@ public sealed class WalkFrameDriverTests
public readonly List Punches = new();
public readonly List Shells = new();
public readonly List AlphaPendingAtBarrier = new();
- public readonly List<(uint LandblockId, int CellCount)> LandCellBatches = new();
+ public readonly List<(uint LandblockId, int SideCellCount, int CellIndex)[]> LandCellBatches = new();
+
+ /// S3 chunk 3 fix round 1 (F2/F4c): cell ids
+ /// reports as having NO
+ /// renderable emitter. Empty by default so EVERY pre-existing pin in
+ /// this file keeps its old "every StaticParticles/CellParticles turn
+ /// submits unconditionally" behavior unchanged; a test proving the
+ /// new "genuinely empty turn submits nothing and does not flush"
+ /// rule opts specific cells OUT via this set.
+ public readonly HashSet CellsWithoutEmitters = new();
/// S3 chunk 2: the exit-seal polygon count this fake
/// reports back to the driver (B2 —
@@ -62,12 +71,15 @@ public sealed class WalkFrameDriverTests
public void DrawSky() => log.Add("SKY");
public void DrawLandCellBatch(
- uint landblockId, IReadOnlyList<(int SideCellCount, int CellIndex)> cells)
+ IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
{
- LandCellBatches.Add((landblockId, cells.Count));
- log.Add($"LANDCELL:{landblockId:x8}:{cells.Count}");
+ LandCellBatches.Add(cells.ToArray());
+ log.Add("LANDCELL:" + string.Join(
+ ',', cells.Select(c => $"{c.LandblockId:x8}:{c.SideCellCount}:{c.CellIndex}")));
}
+ public bool HasRenderableEmittersInCell(uint cellId) => !CellsWithoutEmitters.Contains(cellId);
+
public void DrawCellShell(uint cellId)
{
Shells.Add(cellId);
@@ -1301,50 +1313,246 @@ public sealed class WalkFrameDriverTests
Assert.Equal(1, fx.AlphaQueue.PendingCount);
}
- // ── T4 (S3 chunk 3 §9.3): order-preserving batching — consecutive
- // same-landblock LandCell turns with NO intervening event merge into
- // ONE DrawLandCellBatch call; any other event (here, a building's own
- // alpha barrier) splits the batch; a later run of the SAME landblock
- // after a split is its own new batch; a different landblock never
- // merges with a prior one even when adjacent. ────────────────────────
+ // ── F4(a) (S3 chunk 3 fix round 1 §9.6): an outdoor-root sequence — the
+ // LandCell terrain turn precedes its own cell's object-list turn, and
+ // (since nothing else intervenes) the whole frame's terrain stays one
+ // PENDING batch until Replay's own end. Drives RetailFrameWalk.
+ // DrawLandscape directly with a ZERO-view WalkPortalView — the SAME
+ // deterministic "CY-only" admission technique RetailFrameWalkTests'
+ // own outdoor tests use (a real WalkFrame root's 1-view default quad
+ // depends on the production ray-caster's screen geometry, which this
+ // suite's synthetic Caster does not model faithfully enough to predict
+ // block/cell admission from). A side=1 block's own object-list turn
+ // (WalkFrameDriver.OnLandscapeCellTurn's coarse-cell expansion) fires a
+ // StaticParticles turn for every one of the underlying 64 owner
+ // buckets; marking them all "no emitters" keeps this test's sequence
+ // to exactly SKY + one LANDCELL, the same way a genuinely empty turn
+ // stays silent (F2). ────────────────────────────────────────────────
+
+ private static IEnumerable CoarseLandscapeBuckets(uint landblockPrefix)
+ {
+ for (int x = 0; x < 8; x++)
+ for (int y = 0; y < 8; y++)
+ yield return landblockPrefix | (uint)(x * 8 + y + 1);
+ }
+
+ /// A ONE-view whose single polygon
+ /// has ZERO vertices — WalkLandscape.CheckBlocks reads only
+ /// poly.VertexCount to build its edge-plane list, so this reaches
+ /// the SAME permissive edgeCount == 0 ("CY-only") admission test
+ /// RetailFrameWalkTests' own outdoor fixtures use via a bare
+ /// zero-VIEW — but with ViewCount == 1,
+ /// satisfying 's fail-loud
+ /// "a Landscape turn needs at least one active view" guard, which a
+ /// driver-level test (unlike a bare IWalkEventSink recorder) must
+ /// pass through RetailFrameWalk.DrawLandscape's real
+ /// Emit(WalkEvent.Landscape(...)) call.
+ private static WalkPortalView OneDegenerateView()
+ {
+ var view = new WalkPortalView { ViewCount = 1 };
+ view.View.Polys.Add(new WalkViewPoly(0, 0, 0, 0, 0, 0));
+ return view;
+ }
[Fact]
- public void OnLandCellTurn_ConsecutiveSameSlotEventsMergeIntoOneBatch_AnyOtherEventSplits()
+ public void OutdoorRoot_LandCellPrecedesItsOwnCellsObjectTurn_ThenFlushesAtReplaysEnd()
{
using var fx = new DispatcherFixture();
var log = new List();
var leaf = new RecordingLeafRenderer(log);
+ leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
var ctx = new TestContext();
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
+ var walk = new RetailFrameWalk();
+ var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
+ var block = new WalkLandBlock
+ {
+ LandblockId = 0xF4180000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
+ };
+ block.EnsureCellArrays();
+ landscape.Blocks[0] = block;
+
+ using DrawScope draw = fx.BeginDraw();
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
+ walk.DrawLandscape(landscape, OneDegenerateView(), ctx, driver);
+ driver.EndFrame();
+ driver.Replay(draw.Frame, draw.Pass);
+
+ Assert.Equal(new[] { "SKY", "LANDCELL:f4180000:1:0" }, log);
+ Assert.Equal(new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF4180000u, 1, 0) },
+ Assert.Single(leaf.LandCellBatches));
+ }
+
+ // ── F4(b) (S3 chunk 3 fix round 1 §9.6): an interior root with one
+ // surviving exit view — the SAME fixture as
+ // RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
+ // above, with one populated land block added to the (previously
+ // unpublished) landscape. Sequence: SKY, LANDCELL…, LFLUSH, SEALS,
+ // SHELL… — the LandCell terrain turn(s) drawn through the interior
+ // root's own exit view precede the landscape-flush/seal/flood-cell
+ // turns, exactly like the outdoor case above. ────────────────────────
+
+ [Fact]
+ public void InteriorRootWithExitView_DrawsLandCellThroughTheExitViewBeforeFlushSealsAndFlood()
+ {
+ using var fx = new DispatcherFixture();
+ var log = new List();
+ const ulong gfxObjA = 0x0200_0025UL;
+ const ulong gfxObjB = 0x0200_0026UL;
+ InjectRenderData(fx.Manager, gfxObjA, MakeFlatMesh(
+ MakeBatch(0x08100025u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
+ InjectRenderData(fx.Manager, gfxObjB, MakeFlatMesh(
+ MakeBatch(0x08100026u, 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 drives the landscape (and now
+ // its own LandCell turns) 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(
+ new[] { MakeRecord(101, 0, Vector3.Zero, [new MeshRef((uint)gfxObjA, Matrix4x4.Identity)]) }, 0x8C04u);
+ worldData.CellStaticsByCell[0x101] = new WalkFrameStaticRecords(
+ new[] { MakeRecord(102, 0, Vector3.Zero, [new MeshRef((uint)gfxObjB, Matrix4x4.Identity)]) }, 0x8C04u);
+
+ var leaf = new RecordingLeafRenderer(log);
+ leaf.CellsWithoutEmitters.UnionWith(CoarseLandscapeBuckets(0xF4180000u));
+ var trace = new RecordingTrace(log);
+ using ClipFrame clipFrame = ClipFrame.NoClip();
+ var driver = new WalkFrameDriver(
+ fx.Dispatcher, leaf, worldData, trace, clipFrame);
+ var walk = new RetailFrameWalk();
+ // The same 1x1 landscape RunFrame_InteriorFloodWithExitView... uses,
+ // but with a real block published in its one slot so the exit view
+ // has something to admit.
+ var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
+ var block = new WalkLandBlock
+ {
+ LandblockId = 0xF4180000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
+ };
+ block.EnsureCellArrays();
+ landscape.Blocks[0] = block;
+
+ 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);
+
+ Assert.Equal(
+ new[]
+ {
+ "SKY", "LANDCELL:f4180000:1:0", "LFLUSH", "SEALS",
+ "SHELL:00000101", "SHELL:00000100",
+ "FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
+ "FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
+ },
+ log);
+ }
+
+ // ── T4 (S3 chunk 3 §9.3), re-expressed for fix round 1's F2 rule
+ // (§9.6 F4c): the batch is now a single PENDING list Replay keeps
+ // across the WHOLE frame, not a same-landblock lookahead merge — two
+ // LandCell turns of DIFFERENT landblocks with only an empty particle
+ // turn between them still merge into ONE DrawLandCellBatch call; a
+ // StreamMark (a real cell with statics) OR a building's own alpha
+ // barrier between two LandCell turns splits the batch; the fake leaf's
+ // HasRenderableEmittersInCell reports "no emitters" for one cell (via
+ // CellsWithoutEmitters) and "has emitters" (the default) for another,
+ // so both arms of the F2 particle-turn gate are covered in one test. ──
+
+ [Fact]
+ public void OnLandCellTurn_MergesAcrossLandblocksOverAnEmptyParticleTurn_RealSubmissionsSplit()
+ {
+ using var fx = new DispatcherFixture();
+ const ulong gfxObj = 0x0200_0024UL;
+ InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
+ MakeBatch(0x08100024u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
+
+ var worldData = new FakeWorldData();
+ worldData.OutdoorStaticsByCell[0xBBBB0002u] = new WalkFrameStaticRecords(
+ new[] { MakeRecord(310, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) },
+ 0xBBBBu);
+
+ var log = new List();
+ var leaf = new RecordingLeafRenderer(log);
+ // The 0xAAAA0001 particle turn has no world-data records AND is
+ // marked without emitters -> a genuinely empty turn (F2's other
+ // arm: 0xBBBB0002 below keeps the default "has emitters").
+ leaf.CellsWithoutEmitters.Add(0xAAAA0001u);
+ var ctx = new TestContext();
+ var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
IWalkEventSink sink = driver;
using DrawScope draw = fx.BeginDraw();
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
- sink.OnLandCellTurn(0xF4180000u, 8, 0);
- sink.OnLandCellTurn(0xF4180000u, 8, 1); // same slot, nothing between -> merges
- sink.OnBuildingTurn(new WalkBuilding()); // intervening event splits the batch
- sink.OnLandCellTurn(0xF4180000u, 8, 2); // same slot as before the split, but a NEW batch
- sink.OnLandCellTurn(0xF3180000u, 8, 0); // a different slot never merges
+ sink.OnLandscapeViews(new WalkPortalView());
+
+ sink.OnLandCellTurn(0xF4180000u, 8, 0); // landblock A
+ sink.OnLandscapeCellTurn(0xAAAA0001u); // empty particle turn: no emitters -> no submit, no flush
+ sink.OnLandCellTurn(0xF3180000u, 8, 0); // landblock B, DIFFERENT -> still merges (F2)
+
+ sink.OnLandscapeCellTurn(0xBBBB0002u); // real content -> StreamMark splits; has emitters -> submits
+
+ sink.OnLandCellTurn(0xF3180000u, 8, 1); // new batch, started after the StreamMark split
+
+ sink.OnBuildingTurn(new WalkBuilding()); // the building's own alpha barrier splits again
+
+ sink.OnLandCellTurn(0xF2180000u, 8, 0); // final batch, flushed at Replay's own end
driver.EndFrame();
driver.Replay(draw.Frame, draw.Pass);
Assert.Equal(
new[]
{
- "LANDCELL:f4180000:2",
+ "LANDCELL:f4180000:8:0,f3180000:8:0",
+ "PARTICLES:bbbb0002",
+ "LANDCELL:f3180000:8:1",
"ALPHA",
- "LANDCELL:f4180000:1",
- "LANDCELL:f3180000:1",
+ "LANDCELL:f2180000:8:0",
},
log);
+ Assert.DoesNotContain("PARTICLES:aaaa0001", log);
+ Assert.Equal(3, leaf.LandCellBatches.Count);
Assert.Equal(
- new[]
+ new (uint LandblockId, int SideCellCount, int CellIndex)[]
{
- (0xF4180000u, 2),
- (0xF4180000u, 1),
- (0xF3180000u, 1),
+ (0xF4180000u, 8, 0), (0xF3180000u, 8, 0),
},
- leaf.LandCellBatches);
+ leaf.LandCellBatches[0]);
+ Assert.Equal(
+ new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF3180000u, 8, 1) },
+ leaf.LandCellBatches[1]);
+ Assert.Equal(
+ new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF2180000u, 8, 0) },
+ leaf.LandCellBatches[2]);
}
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —
diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
index e49bf0ab..48effcec 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs
@@ -111,7 +111,7 @@ public sealed class WorldRenderDiagnosticsTests
{
var log = new ThrowOnceLog();
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
- var facts = new TerrainRenderDiagnosticFacts(3, 5, 8);
+ var facts = new TerrainRenderDiagnosticFacts(VisibleSlots: 3, Draws: 3, LoadedSlots: 5, CapacitySlots: 8);
diagnostics.BeginTerrainDraw();
diagnostics.EndTerrainDraw();
diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
index efbbb7e8..738c506b 100644
--- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
+++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs
@@ -882,6 +882,46 @@ public sealed class ParticleSystemTests
Assert.Empty(destination);
}
+ // S3 chunk 3 fix round 1 (§9.6 F2): HasRenderableEmittersInCell is
+ // CopyRenderableEmittersInCell's allocation-free sibling —
+ // WalkFrameDriver.Replay's StaticParticles/CellParticles gate. Same
+ // add/move/remove lifecycle as CellIndex_AddRemoveMove... above, but
+ // asserted as a bool answer instead of a copied list.
+
+ [Fact]
+ public void HasRenderableEmittersInCell_AddRemoveMove_TracksTheSameLifecycleAsCopy()
+ {
+ var sys = MakeSystem();
+ var desc = new EmitterDesc
+ {
+ DatId = 0x32000092u,
+ Type = ParticleType.Still,
+ MaxParticles = 1,
+ };
+
+ // A cell with no bucket at all (never touched) reports false, not a
+ // KeyNotFoundException or similar.
+ Assert.False(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u));
+
+ int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 503u);
+ sys.UpdateEmitterOwnerCell(handle, 0x0102_0001u);
+ var visible = new HashSet { 0x0102_0001u, 0x0102_0002u };
+ sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
+
+ // Add: renderable in its own cell, nowhere else.
+ Assert.True(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u));
+ Assert.False(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u));
+
+ // Move: the handle relocates from the old cell's bucket to the new one.
+ sys.UpdateEmitterOwnerCell(handle, 0x0102_0002u);
+ Assert.False(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0001u));
+ Assert.True(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u));
+
+ // Remove: a hard stop clears the cell bucket too.
+ sys.StopEmitter(handle, fadeOut: false);
+ Assert.False(sys.HasRenderableEmittersInCell(ParticleRenderPass.Scene, 0x0102_0002u));
+ }
+
[Fact]
public void CellIndex_MultipleEmittersInOneCell_EnumerateInSpawnOrder()
{