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() {