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>
239 lines
8 KiB
C#
239 lines
8 KiB
C#
using System.Numerics;
|
|
using System.Reflection;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.Core.Vfx;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public sealed class WorldRenderDiagnosticsTests
|
|
{
|
|
[Fact]
|
|
public void GlTripwire_DisabledPerformsNoGlRead()
|
|
{
|
|
var gl = new RecordingGlStateReader();
|
|
var log = new RecordingLog();
|
|
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
|
|
|
diagnostics.EmitGlStateTripwireIfChanged(enabled: false);
|
|
|
|
Assert.Equal(0, gl.StateCaptureCount);
|
|
Assert.Empty(log.Messages);
|
|
}
|
|
|
|
[Fact]
|
|
public void GlTripwire_LogsOnlyChangesAndReportsStableFrameCount()
|
|
{
|
|
var gl = new RecordingGlStateReader
|
|
{
|
|
State = State(depthFunction: 0x201),
|
|
};
|
|
var log = new RecordingLog();
|
|
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
|
|
|
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
|
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
|
gl.State = State(depthFunction: 0x203);
|
|
diagnostics.EmitGlStateTripwireIfChanged(enabled: true);
|
|
|
|
Assert.Equal(3, gl.StateCaptureCount);
|
|
Assert.Collection(
|
|
log.Messages,
|
|
first =>
|
|
{
|
|
Assert.StartsWith("[gl-state] frame=1 stable=0", first);
|
|
Assert.Contains("dfunc=0x201", first);
|
|
},
|
|
changed =>
|
|
{
|
|
Assert.StartsWith("[gl-state] frame=3 stable=1", changed);
|
|
Assert.Contains("dfunc=0x203", changed);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void ScissorProbe_SequenceAdvancesAcrossSuppressedDuplicates()
|
|
{
|
|
var gl = new RecordingGlStateReader
|
|
{
|
|
Scissor = new RenderGlScissorSnapshot(
|
|
true,
|
|
new IntRenderRectangle(1, 2, 3, 4)),
|
|
};
|
|
var log = new RecordingLog();
|
|
var diagnostics = new WorldRenderDiagnostics(gl, log);
|
|
|
|
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
|
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
|
gl.Scissor = new RenderGlScissorSnapshot(
|
|
true,
|
|
new IntRenderRectangle(5, 6, 7, 8));
|
|
diagnostics.EmitClipRouteScissorProbe(true, true, new Vector4(-1, -1, 1, 1));
|
|
|
|
Assert.Equal(3, gl.ScissorCaptureCount);
|
|
Assert.Collection(
|
|
log.Messages,
|
|
first => Assert.StartsWith("[clip-route-scis] n=1", first),
|
|
changed => Assert.StartsWith("[clip-route-scis] n=3", changed));
|
|
}
|
|
|
|
[Fact]
|
|
public void Owner_RetainsNoBorrowedFrameProductsOrBroadRuntimeOwners()
|
|
{
|
|
FieldInfo[] fields = typeof(WorldRenderDiagnostics).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
|
|
Type[] forbidden =
|
|
[
|
|
typeof(GameWindow),
|
|
typeof(PortalVisibilityFrame),
|
|
typeof(ClipFrameAssembly),
|
|
typeof(InteriorEntityPartition.Result),
|
|
typeof(AcDream.Core.Physics.PhysicsEngine),
|
|
typeof(CameraController),
|
|
typeof(CellVisibility),
|
|
];
|
|
foreach (Type type in forbidden)
|
|
Assert.DoesNotContain(fields, field => field.FieldType == type);
|
|
Assert.DoesNotContain(
|
|
fields,
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
}
|
|
|
|
// Campaign V slice V11 deleted SilkRenderGlStateReader, the raw-GL
|
|
// IRenderGlStateReader implementation this test pinned (it was never
|
|
// constructed in production — NullRenderGlStateReader.Instance is the
|
|
// sole surviving implementer), so the "consume the entering GL error
|
|
// before other state reads" source-order tripwire it protected has
|
|
// nothing left to pin.
|
|
|
|
[Fact]
|
|
public void TerrainDiagnostics_RetryFailedPublicationWithoutLosingSamples()
|
|
{
|
|
var log = new ThrowOnceLog();
|
|
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
|
var facts = new TerrainRenderDiagnosticFacts(VisibleSlots: 3, Draws: 3, LoadedSlots: 5, CapacitySlots: 8);
|
|
|
|
diagnostics.BeginTerrainDraw();
|
|
diagnostics.EndTerrainDraw();
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
diagnostics.PublishTerrainDiagnostics(facts));
|
|
|
|
diagnostics.BeginTerrainDraw();
|
|
diagnostics.EndTerrainDraw();
|
|
diagnostics.PublishTerrainDiagnostics(facts);
|
|
|
|
Assert.Single(log.Messages);
|
|
Assert.Contains("draws=3/frame", log.Messages[0]);
|
|
Assert.Contains("visible=3", log.Messages[0]);
|
|
Assert.Contains("loaded=5", log.Messages[0]);
|
|
Assert.Contains("capacity=8", log.Messages[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderSignature_LogsOnlyChangesAndCarriesStableCount()
|
|
{
|
|
var log = new RecordingLog();
|
|
var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log);
|
|
|
|
EmitMinimalSignature(diagnostics, "flat");
|
|
EmitMinimalSignature(diagnostics, "flat");
|
|
EmitMinimalSignature(diagnostics, "pview");
|
|
|
|
Assert.Collection(
|
|
log.Messages,
|
|
first => Assert.StartsWith("[render-sig] frame=1 stable=0 branch=flat", first),
|
|
changed => Assert.StartsWith("[render-sig] frame=3 stable=1 branch=pview", changed));
|
|
}
|
|
|
|
private static RenderGlStateSnapshot State(int depthFunction) => new(
|
|
DepthTest: true,
|
|
DepthWrite: true,
|
|
DepthFunction: depthFunction,
|
|
Blend: false,
|
|
BlendSource: 1,
|
|
BlendDestination: 0,
|
|
CullFace: true,
|
|
CullMode: 0x405,
|
|
FrontFace: 0x900,
|
|
Scissor: false,
|
|
ScissorBox: new IntRenderRectangle(0, 0, 1280, 720),
|
|
Viewport: new IntRenderRectangle(0, 0, 1280, 720),
|
|
DrawFramebuffer: 0,
|
|
AlphaToCoverage: false,
|
|
Stencil: false,
|
|
ClipBits: 0,
|
|
Error: 0);
|
|
|
|
private sealed class RecordingGlStateReader : IRenderGlStateReader
|
|
{
|
|
public RenderGlStateSnapshot State { get; set; } = WorldRenderDiagnosticsTests.State(0x201);
|
|
public RenderGlScissorSnapshot Scissor { get; set; }
|
|
public int StateCaptureCount { get; private set; }
|
|
public int ScissorCaptureCount { get; private set; }
|
|
|
|
public RenderGlStateSnapshot CaptureState()
|
|
{
|
|
StateCaptureCount++;
|
|
return State;
|
|
}
|
|
|
|
public RenderGlScissorSnapshot CaptureScissor()
|
|
{
|
|
ScissorCaptureCount++;
|
|
return Scissor;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingLog : IRenderFrameDiagnosticLog
|
|
{
|
|
public List<string> Messages { get; } = [];
|
|
|
|
public void WriteLine(string message) => Messages.Add(message);
|
|
}
|
|
|
|
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
|
|
{
|
|
private bool _throw = true;
|
|
public List<string> Messages { get; } = [];
|
|
|
|
public void WriteLine(string message)
|
|
{
|
|
if (_throw)
|
|
{
|
|
_throw = false;
|
|
throw new InvalidOperationException("diagnostic sink failed");
|
|
}
|
|
|
|
Messages.Add(message);
|
|
}
|
|
}
|
|
|
|
private static void EmitMinimalSignature(
|
|
WorldRenderDiagnostics diagnostics,
|
|
string branch) =>
|
|
diagnostics.EmitRenderSignatureIfChanged(
|
|
enabled: true,
|
|
branch: branch,
|
|
clipRoot: null,
|
|
viewerRoot: null,
|
|
playerRoot: null,
|
|
viewerCellId: 0,
|
|
playerCellId: 0,
|
|
playerIndoorGate: false,
|
|
cameraInsideCell: false,
|
|
renderSkyGate: false,
|
|
drawSkyThisFrame: false,
|
|
terrainDrawn: false,
|
|
terrainClipMode: TerrainClipMode.Skip,
|
|
skyDrawn: false,
|
|
depthClear: false,
|
|
outdoorSceneryDrawn: false,
|
|
liveDynamicDrawnCount: 0,
|
|
sceneParticles: "none",
|
|
visibleCells: null,
|
|
clipAssembly: null,
|
|
drawableCells: null,
|
|
partition: null,
|
|
cameraPosition: Vector3.Zero,
|
|
playerPosition: Vector3.Zero);
|
|
}
|