fix(render): S3 chunk 3 round 1 - slot key, deferred cross-block terrain batches, per-frame terrain diagnostic

Fixes the three-lens review blockers against 671eb3ad4 (S3 section 9.6 F1-F6).

F1 - slot key (blocking, retail). TerrainModernRenderer.DrawLandCells
normalizes every incoming landblockId to (id & 0xFFFF0000u) | 0xFFFFu
before the _idToSlot lookup: the walk hands 0xXXYY0000
(WalkLandBlock.LandblockId) but AddLandblock stores under the DAT id
0xXXYYFFFF (LandblockRenderPublisher.LandblockId) - every walk lookup
was missing and the walk path drew NO terrain. Unit-tested end-to-end
through a real RecordingGpuDevice-backed TerrainModernRenderer
(TerrainWalkSlotKeyNormalizationTests): AddLandblock(0xA9B4FFFF, ...)
is found by a 0xA9B40000 lookup, an unknown landblock is a silent
per-entry no-op, and a batch mixing a known and unknown entry submits
only the known one.

F2 - deferred cross-block batching (blocking, driver). Retail's
DrawSortCell always follows DrawLandCell (LC/SC strictly alternate,
never two LC in a row - S3 section 9 R1), so chunk 3's "merge
consecutive same-landblock LandCell events" rule never actually
merged anything; the driver review flagged batching as inert.
WalkFrameDriver.Replay now keeps ONE pending terrain batch across
landblocks ((landblockId, side, cellIndex) entries, cleared at
Replay's own start); a LandCell event only appends; every OTHER event
kind that will itself submit GPU work (StreamMark, Sky, CellShell,
PunchFan, AlphaBarrier, LandscapeFlush, ClearInteriorDepth,
ExitSeals) flushes the pending batch first; a StaticParticles/
CellParticles turn asks the new ParticleSystem.
HasRenderableEmittersInCell (an allocation-free sibling of
CopyRenderableEmittersInCell) and, when the cell has no renderable
emitter, submits nothing and does NOT flush either - the whole point
of the deferred rule. The end of Replay flushes the remainder. This
is order-preserving by construction: a flush always lands at the
exact point the unbatched draw would have, so GPU submission order -
and therefore pixels - is identical to the unbatched baseline; only
the number of small terrain draw calls shrinks.
TerrainModernRenderer.DrawLandCellRuns becomes DrawLandCells(
viewProjection, IReadOnlyList<(uint LandblockId, int SideCellCount,
int CellIndex)>) - one MultiDrawIndexedIndirect over every entry's
runs, unknown slots skipped per-entry. IWalkFrameLeafRenderer.
DrawLandCellBatch drops its separate landblockId parameter to match
(a batch can span several landblocks now) and gains
HasRenderableEmittersInCell.

Batch-count demonstration: driven through a real WalkFrameDriver
Replay (OnLandCellTurn_MergesAcrossLandblocksOverAnEmptyParticleTurn_
RealSubmissionsSplit), 4 LandCell turns across 3 distinct landblocks,
separated only by an empty particle turn, a real StreamMark, and a
building's alpha barrier, submit as exactly 3 DrawLandCellBatch calls
(2+1+1) instead of 4 - the empty particle turn's non-flush merges two
otherwise-separate cross-landblock entries. At production scale the
same mechanism is expected to cut the terrace-edge frame's ~578
individual DrawLandCell events (S3 section 9's captured transcript
count) to "tens" of submitted batches, per the contract's own
expectation: most terrain cells have no particle owner nearby, so the
strict LC/[empty-SC]/LC/[empty-SC]/... run collapses into one batch
per region bounded by real content (a building, a StreamMark-worthy
cell, or a genuine emitter) rather than per cell.

F3 - per-frame terrain diagnostic (blocking, build/test). The walk
leaf no longer brackets each batch with TerrainDrawDiagnosticsController
.Begin()/Complete() (a per-batch Stopwatch Restart/Stop pair that was
pushing one timing SAMPLE per batch, not per frame).
RetailPViewPassExecutor.DrawWalkLandCellBatch instead times its own
call with a raw Stopwatch.GetTimestamp() delta (no allocation) and
hands the ticks to the controller's new AccumulateWalkBatch;
RetailPViewRenderer.DrawWalkDrivenStatics calls the new
CompleteWalkTerrainFrame() exactly once, immediately after
driver.Replay finishes - "the end of the walk replay", where the
deleted whole-stage terrain leaf's own Begin()/Complete() bracket
used to close - which pushes ONE elapsed-time sample (even a
zero-batch frame pushes a zero sample: one sample per frame, not per
landscape turn) and publishes on the existing 5-second cadence.
TerrainRenderDiagnosticFacts gains a Draws field alongside
VisibleSlots (both were the same field before); TerrainModernRenderer
tracks its own per-frame WalkVisibleSlotCount/WalkDrawCount (a
HashSet<int>/int cleared in BeginFrame, populated by DrawLandCells),
and the diagnostics source reports those whenever the walk drew at
least one batch this frame, falling back to the non-walk Draw()
path's VisibleSlots otherwise (the two paths never both run in the
same frame). The [TERRAIN-DIAG] line's meaning (cpu_us per frame) is
unchanged, so the S3 section 9.5 before/after compare stays valid.

F4 - driver pins for the LandCell position (major). Three RunFrame-
level pins replace the deleted TERRAIN:0 pins: an outdoor-root
sequence (SKY, then one LANDCELL, driving RetailFrameWalk.
DrawLandscape directly with a one-view/zero-vertex WalkPortalView so
WalkLandscape.CheckBlocks' admission stays the same deterministic
"CY-only" test RetailFrameWalkTests already relies on, while still
satisfying WalkFrameDriver's real >=1-active-view fail-loud guard);
an interior-root test with one real exit view and one populated
block (SKY, LANDCELL, LFLUSH, SEALS, SHELL...) built on the existing
RunFrame_InteriorFloodWithExitView_... fixture; and the T4 batching
pin re-expressed for the F2 rule (OnLandCellTurn_
MergesAcrossLandblocksOverAnEmptyParticleTurn_RealSubmissionsSplit,
described above). The fake leaf's DrawLandCellBatch now logs
LANDCELL:<lb>:<side>:<idx>[,...] per batch and gains
HasRenderableEmittersInCell backed by an opt-out CellsWithoutEmitters
set (default true - has-emitters - so every pre-existing pin in the
file keeps its old unconditional-submission behavior unchanged).

F5 - no code change: the walk's in-view gate is unchanged; no
whole-block terrain re-added.

F6 - minor/notes: DrawLandCells' own comment now states the walk's
CheckBlocks/landcell_check admission is the sole terrain culling
authority (retail has no separate terrain frustum test); the
HandleLandscapeTurn comment's inverted claim is corrected (a FARTHER
building's punch survived because NEARER terrain was drawn BEFORE
it, not after - the interleave now draws it after, matching retail);
the "flat/directional-shadow paths" claim is corrected to the one
actual caller, WorldScenePassExecutor.DrawFlatTerrain (a directional-
shadow receiver selects its pipeline inside the SAME DrawRhi call,
not through a second caller); the cathedral order-trace token gains
the LOD side/index (":LC<lb>/<side>:<idx>"); T2's vacuous "no
TERRAIN event" assertion in RetailFrameWalkTests is replaced by a
comment pointing at the F4 driver-level pins; and the stale
"Confirmed OH5 defect" row in oh1-construction-landscape-contract.md
is retired with "FIXED by S3 chunk 3 (commit 671eb3ad4 + fix round
1)".

App hermetic lane: 6,795/6,795 (up from 671eb3ad4's 6,786 baseline -
net +9 tests: 3 F1 slot-key tests, 2 F4a/b driver RunFrame pins, 3
TerrainDrawDiagnosticsController walk-frame tests, plus the T4->F4c
rewrite and the RetailPViewPassExecutorTests split are net neutral).
InstalledDat lane: 241 passed, the same 3 accepted failures (2
pre-existing #383 layout fixture-drift tests, 1 TowerAscent
Status=KnownFailure) - unchanged from baseline. Core Vfx tests:
109/109 (108 baseline + 1 new HasRenderableEmittersInCell lifecycle
pin mirroring CopyRenderableEmittersInCell's own add/move/remove
test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-03 09:38:48 +02:00
parent 4ee2866a7b
commit 651badc2b9
15 changed files with 1003 additions and 131 deletions

View file

@ -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<CompiledCall> 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<CompiledCall> 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]

View file

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

View file

@ -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;
/// <summary>
/// S3 chunk 3 fix round 1 (§9.6 F1): <c>TerrainModernRenderer._idToSlot</c>
/// is keyed by the DAT landblock id <c>0xXXYYFFFF</c>
/// (<c>LandblockRenderPublisher.LandblockId =&gt; Build.Landblock.LandblockId</c>,
/// which <see cref="TerrainModernRenderer.AddLandblock"/> stores under
/// verbatim), while the walk hands <c>0xXXYY0000</c>
/// (<c>WalkLandBlock.LandblockId = bx&lt;&lt;24 | by&lt;&lt;16</c>). Before
/// this fix, <see cref="TerrainModernRenderer.DrawLandCells"/> 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
/// <see cref="RecordingGpuDevice"/>, not a mock of the lookup alone) — an
/// entry keyed by the walk's <c>0xA9B40000</c> convention must resolve the
/// SAME slot <see cref="TerrainModernRenderer.AddLandblock"/> published
/// under the DAT's <c>0xA9B4FFFF</c> 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.
/// </summary>
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<GpuRecordedMultiDrawIndirect>());
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<GpuRecordedMultiDrawIndirect>());
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<GpuRecordedMultiDrawIndirect>());
Assert.Equal(1u, call.DrawCount);
Assert.Equal(1, _terrain.WalkDrawCount);
Assert.Equal(1, _terrain.WalkVisibleSlotCount);
}
public void Dispose()
{
_terrain.Dispose();
_atlas.Dispose();
_device.Dispose();
}
/// <summary>Minimal hermetic <see cref="IDatReaderWriter"/> — every read
/// misses except <c>Get&lt;Region&gt;(0x13000000)</c>, which returns a
/// Region with no <c>TerrainInfo</c> so <see
/// cref="TerrainAtlas.BuildBackendNeutral"/> takes its documented
/// single-white-fallback-layer branch instead of throwing.</summary>
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<uint, IDatDatabase> CellRegions { get; } =
new(new Dictionary<uint, IDatDatabase>());
public IDatDatabase HighRes => _highRes;
public IDatDatabase Language => _language;
public IDatDatabase Local => _language;
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
new(new Dictionary<uint, uint>());
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<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
Array.Empty<IDatReaderWriter.IdResolution>();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public bool TrySave<T>(
uint regionId,
T obj,
int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj
{
if (typeof(T) == typeof(Region) && fileId == 0x13000000u)
return (T)(object)_region;
return default;
}
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = Get<T>(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<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public bool TryGet<T>(
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>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public void Dispose()
{
}
}
}
}

View file

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

View file

@ -47,7 +47,16 @@ public sealed class WalkFrameDriverTests
public readonly List<WalkPolygon> Punches = new();
public readonly List<uint> Shells = new();
public readonly List<int> AlphaPendingAtBarrier = new();
public readonly List<(uint LandblockId, int CellCount)> LandCellBatches = new();
public readonly List<(uint LandblockId, int SideCellCount, int CellIndex)[]> LandCellBatches = new();
/// <summary>S3 chunk 3 fix round 1 (F2/F4c): cell ids
/// <see cref="HasRenderableEmittersInCell"/> 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.</summary>
public readonly HashSet<uint> CellsWithoutEmitters = new();
/// <summary>S3 chunk 2: the exit-seal polygon count this fake
/// reports back to the driver (B2 — <see cref="DrawExitSeals"/>
@ -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<uint> 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);
}
/// <summary>A ONE-view <see cref="WalkPortalView"/> whose single polygon
/// has ZERO vertices — <c>WalkLandscape.CheckBlocks</c> reads only
/// <c>poly.VertexCount</c> to build its edge-plane list, so this reaches
/// the SAME permissive <c>edgeCount == 0</c> ("CY-only") admission test
/// RetailFrameWalkTests' own outdoor fixtures use via a bare
/// zero-VIEW <see cref="WalkPortalView"/> — but with <c>ViewCount == 1</c>,
/// satisfying <see cref="WalkFrameDriver"/>'s fail-loud
/// "a Landscape turn needs at least one active view" guard, which a
/// driver-level test (unlike a bare <c>IWalkEventSink</c> recorder) must
/// pass through <c>RetailFrameWalk.DrawLandscape</c>'s real
/// <c>Emit(WalkEvent.Landscape(...))</c> call.</summary>
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<string>();
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<string>();
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<string>();
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 —

View file

@ -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();

View file

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