acdream/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
Erik 89f1e2676f feat(render): S4 chunk 2 — retail's two-list alpha FIFO cutover
Replaces the single scope-global distance-sorted RetailAlphaQueue with
retail's own two independent FIFO lists (CLIP/ALPHA, capacity 3000 each,
D3DPolyRender::AddMeshToAlphaList's exact append-only/capacity-drop
behavior — Ghidra-verified 2026-09-04), routed by a new
RetailAlphaMeshRouter porting DrawMesh's five-row immediate/delayed
branch table and ConstructMesh's subset-mask formula as pure functions,
and drained at retail's four normal-world FlushAlphaList sites
(DrawBuilding/DrawBlock/PView::DrawCells/RenderNormalMode) under the
exact Ghidra-verified no-op predicate (both counts strictly below
threshold*3000). A new WalkFrameEventKind.SortCellExit /
IWalkEventSink.OnSortCellExit / IWalkFrameLeafRenderer.FlushSortCellExit
fires once per admitted land-block cell for DrawBlock's 0.75f valve,
pinned by a dedicated far/near ordering test in RetailFrameWalkTests.cs.

WbDrawDispatcher's two submit sites and ParticleRenderer's one route
through the router; since none of the three ever draws during the Sky
leaf, installs a detail surface, or sets MultiPassAlpha, rows 1/2/4/5
are provably unreachable there and the call sites assert loudly rather
than building unexercisable immediate-draw plumbing. FlushFartherThan,
RetailAlphaOrdering.ComputeViewerDistance, and every viewerDistance
argument on the submit path are deleted.

Scope note (packet s4-depth-alpha-packet.md §10): C4 (routing EnvCell's
transparent shell batches through the shared queue) was not attempted —
EnvCell draws one per-cell MultiDrawIndexedIndirect call with no
per-subset deferred-replay abstraction, and building one without visual
verification (no graphical client in this worktree) was judged out of
this bounded chunk's scope. AP-34 is therefore retired and replaced by
two narrower rows rather than deleted outright: AP-236 (the carried-
forward EnvCell-immediate residual) and AP-237 (a newly identified gap:
TranslucencyKind.AlphaBlend can arise from either retail's Alpha/
Translucent bits, mask 0x02/ALPHA, or the Translucent+ClipMap "cloud"
override, mask 0x08/CLIP — GroupKey doesn't retain the raw bit to tell
them apart, so the router always picks ALPHA; only known example is
cloud GfxObj 0x01004C35). Both are compositing-order-only divergences,
never blend/visual ones.

Mutation checks (each applied, confirmed failing, then reverted):
- FIFO drain order reversed -> 5 RetailAlphaQueueTests fail (order).
- FlushAlphaList `<` -> `<=` -> boundary/scratch tests fail (2250 case
  reads drained=0 instead of 2250).
- Capacity check loosened (3000 -> 6000) -> overflow-drop test fails
  (TryAppend returns true, PendingCount reads 3001).
- IsFirstForList forced true -> flag test fails once inspected on the
  pre-flush two-entry snapshot (the post-flush single-survivor version
  of this test was vacuous and rewritten).
- Router row 3 condition inverted -> both the hand-traced Theory (6
  cases) and the 160-cell independent-truth-table brute force fail (20
  mismatches).
- SortCellExit emitted before OnLandscapeCellTurn instead of after ->
  RetailFrameWalkTests ordering pin fails ("SCX must immediately follow
  its own cell's SC").
- Prepare-per-list instead of prepare-once-combined -> the CLIP/ALPHA
  boundary batching test throws (index out of range).

Gates: Release build 0 warnings/0 errors. Hermetic lane (Lane!=Installed
Dat&...&Status!=KnownFailure) 6855/6855 passed. InstalledDat lane 249
passed / 10 failed — exactly the 4 pre-existing failures (#383 x2
LayoutImporter, TowerAscent KnownFailure, #458 Oh_doorway_still
KnownFailure) plus 6 NEW KnownFailure Facts
(AlphaFlushTranscript_*_MatchesRetailFrame2, one per capture) extending
this gate from PM/PC to AM/FL: the flush SITE+THRESHOLD sequence matches
the capture exactly for all six poses (including zero SortCellExit
drains in every capture, confirming the 0.75 valve is inert at these
scene complexities in both retail and this replay); the drained-COUNT-
per-list dimension diverges because this hermetic harness (matching the
existing PM/PC gate's own EmptyAlphaDepthWorldData design) carries no
live GfxObj/particle content, so every observed count reads (0,0)
against retail's real per-frame volume — both sequences quoted in full
per pose in the packet's new §10. Shader classes (VulkanShaderDescriptor
ContractTests/VulkanShaderManifestTests/RenderPackSpirvValidatorTests)
32/32 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 11:52:40 +02:00

507 lines
21 KiB
C#

using System.Reflection;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// S4-c2: <see cref="RetailAlphaQueue"/>'s two-list FIFO rewrite. Every test
/// below has a mutation check recorded in its own doc comment (or the S4-c2
/// commit body) proving it fails without the change it pins.
/// </summary>
public sealed class RetailAlphaQueueTests
{
/// <summary>Mutation check: reverting <c>TryAppend</c>/<c>Flush</c> to the
/// old distance-sorted single queue (submitting these same three entries
/// through the old <c>Submit(source, token, viewerDistance)</c> API with
/// distances 30/10/5 — far to near) drains far-to-near
/// (<c>alpha:0, alpha:1, alpha:2</c>), NOT append order — this assertion
/// fails against that old behavior.</summary>
[Fact]
public void Flush_FifoBeatsReversedDistanceInOneCell()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
// "Reversed distance": entry 0 is submitted first but would be
// farthest under the deleted distance model; entry 2 nearest.
// FIFO means append order alone decides replay order now.
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 0, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 1, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 2, false));
queue.EndFrame();
Assert.Equal(new[] { "alpha:0", "alpha:1", "alpha:2" }, log);
}
/// <summary>Mutation check: sorting entries by any key (even a stable
/// one) before draining, instead of never sorting at all, cannot be
/// distinguished from FIFO for a single source's own append order — so
/// this test interleaves TWO sources with equal claim to "first" and
/// pins that neither source's internal order nor a materialGroup-style
/// regrouping can reorder them: object and particle entries must stay in
/// the exact submission interleave.</summary>
[Fact]
public void Flush_EqualPrioritySourcesPreserveSubmissionInterleave()
{
var log = new List<string>();
var objects = new RecordingSource("object", log);
var particles = new RecordingSource("particle", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, objects, 7, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, particles, 4, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, objects, 8, false));
queue.EndFrame();
Assert.Equal(new[] { "object:7", "particle:4", "object:8" }, log);
}
/// <summary>Two "cells" (two BeginFrame/EndFrame scopes) where a
/// GLOBAL distance sort across both scopes would disagree with per-scope
/// traversal order: cell A submits a "far" entry then a "near" one, cell
/// B (a later scope) submits a "very near" entry. A global sort by
/// distance would put cell B's very-near entry ahead of BOTH of cell A's
/// entries; per-scope FIFO traversal (what this test pins) keeps cell A
/// entirely before cell B regardless of any distance value that would
/// have been attached. Mutation check: sorting the combined per-scope
/// output by a synthetic "distance" derived from token order (as the old
/// queue's radix sort effectively encoded via ViewerDistance) would
/// still pass this test since FIFO happens to coincide with ascending
/// token order here — so the real proof is
/// <see cref="Flush_FifoBeatsReversedDistanceInOneCell"/> above, which
/// this test complements by proving traversal never leaks across scope
/// boundaries.</summary>
[Fact]
public void Flush_TwoScopesNeverInterleaveRegardlessOfGlobalOrder()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 1, false)); // "far"
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 2, false)); // "near"
queue.EndFrame();
queue.BeginFrame();
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 9, false)); // "very near"
queue.EndFrame();
Assert.Equal(new[] { "alpha:1", "alpha:2", "alpha:9" }, log);
}
/// <summary>Particle, object, and (conceptually) transparent-cell
/// content overlapping in one scope: three distinct sources interleave
/// in submission order. Mutation check: grouping by source (drawing all
/// of one source's entries before any of another's, e.g. "prepare
/// completely per source then draw all its batches") instead of walking
/// the combined append order would produce
/// <c>object:1,object:2,particle:1,cell:1</c> — this assertion fails
/// against that grouping.</summary>
[Fact]
public void Flush_ParticleObjectAndCellSourcesOverlapInSubmissionOrder()
{
var log = new List<string>();
var objects = new RecordingSource("object", log);
var particles = new RecordingSource("particle", log);
var cellShells = new RecordingSource("cell", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, objects, 1, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, particles, 1, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, cellShells, 1, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, objects, 2, false));
queue.EndFrame();
Assert.Equal(new[] { "object:1", "particle:1", "cell:1", "object:2" }, log);
}
/// <summary>Retail <c>RenderDeviceD3D::DrawBuilding</c>'s own
/// <c>FlushAlphaList(0f)</c>. Mutation check: passing any nonzero
/// threshold here (e.g. leaving the old hardcoded 0f-only <c>Flush()</c>
/// signature but silently routing DrawBuilding through the 0.75f valve
/// instead) would make this single low-count entry a no-op — the
/// assertion that it drained would fail.</summary>
[Fact]
public void Flush_DrawBuildingSiteAtZeroThresholdAlwaysDrains()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 1, false));
queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
Assert.Equal(new[] { "alpha:1" }, log);
Assert.Equal(0, queue.PendingCount);
Assert.True(queue.IsCollecting);
queue.EndFrame();
}
/// <summary>Pre-clear partial flush (frame stays open, keeping later
/// content) then the final end-of-frame flush drains the rest. Mutation
/// check: an <c>EndFrame</c> that forgets to flush at all (or a
/// <c>Flush</c> that clears <see cref="RetailAlphaQueue.IsCollecting"/>)
/// would leave <c>alpha:2</c> undrained or the frame permanently open —
/// both assertions below fail against that bug.</summary>
[Fact]
public void Flush_PreClearThenFinalFlushBothDrainInOrder()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 1, false));
queue.Flush(RetailAlphaFlushSite.LandscapeFlush, 0f);
Assert.True(queue.IsCollecting);
Assert.Equal(0, queue.PendingCount);
Assert.Equal(new[] { "alpha:1" }, log);
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 2, false));
queue.EndFrame();
Assert.False(queue.IsCollecting);
Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
Assert.Equal(2, source.ResetCount);
}
/// <summary>
/// Ghidra-verified 2026-09-04 boundary
/// (<c>D3DPolyRender::FlushAlphaList</c> @0x0059d2e0): the early return
/// fires only when BOTH counts are STRICTLY below <c>threshold * 3000</c>.
/// At exactly 2250 (0.75 * 3000) the ALPHA count is NOT strictly less
/// than 2250, so the drain proceeds. Mutation check: using
/// <c>&lt;=</c> instead of <c>&lt;</c> for the no-op comparison makes
/// this exact-2250 case a no-op — the drained-count assertion (2250, not
/// 0) fails against that mutation.</summary>
[Fact]
public void Flush_SortCellExitValveDrainsExactlyAtTwoThousandTwoHundredFifty()
{
var log = new List<string>();
var source = new CountingSource();
var queue = new RetailAlphaQueue();
queue.BeginFrame();
for (int i = 0; i < 2250; i++)
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, i, false));
queue.Flush(RetailAlphaFlushSite.SortCellExit, 0.75f);
Assert.Equal(0, queue.PendingCount);
Assert.Equal(2250, source.LastDrawCount);
Assert.Equal(1, source.ResetCount);
queue.AbortFrame();
}
/// <summary>The complement of the boundary test above: one entry BELOW
/// 2250 in both lists is a true no-op (both lists left exactly as they
/// were). Mutation check: a valve that drains "at or above 2249" (an
/// off-by-one on the threshold constant, not just the comparison
/// operator) would drain here too — the assertion that the entry is
/// STILL pending and nothing was drawn fails against that mutation.</summary>
[Fact]
public void Flush_SortCellExitValveIsANoOpOneBelowTheBoundary()
{
var log = new List<string>();
var source = new CountingSource();
var queue = new RetailAlphaQueue();
queue.BeginFrame();
for (int i = 0; i < 2249; i++)
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, i, false));
queue.Flush(RetailAlphaFlushSite.SortCellExit, 0.75f);
Assert.Equal(2249, queue.PendingCount);
Assert.Equal(0, source.PrepareCount);
Assert.Equal(0, source.ResetCount);
queue.AbortFrame();
}
/// <summary>Alternating CLIP/ALPHA appends from ONE source must still
/// batch as one contiguous run per Vulkan draw call across the
/// CLIP-then-ALPHA boundary (retail draws CLIP fully, then ALPHA fully —
/// nothing about a shared source spanning that boundary changes visual
/// order, since CLIP entries always precede all ALPHA entries anyway).
/// A second source's single CLIP entry, interposed between the first
/// source's CLIP and ALPHA entries, must split that run into two
/// batches. Mutation check: preparing/drawing CLIP and ALPHA as two
/// fully independent per-list passes (never combining a source's tokens
/// across both lists into one prepare call) would call
/// <c>PrepareAlphaDraws</c> twice for the shared source instead of once —
/// <c>PrepareCount</c> asserted at 1 fails against that mutation.</summary>
[Fact]
public void Flush_BatchesAdjacentSameSourceEntriesAcrossTheClipAlphaBoundary()
{
var log = new List<string>();
var shared = new RecordingSource("shared", log);
var other = new RecordingSource("other", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
Assert.True(queue.TryAppend(RetailAlphaList.Clip, shared, 100, false));
Assert.True(queue.TryAppend(RetailAlphaList.Clip, other, 200, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, shared, 300, false));
queue.EndFrame();
// Drain order: CLIP fully (shared:100, other:200) then ALPHA fully
// (shared:300) — shared's ALPHA entry is adjacent to other's CLIP
// entry in the combined sequence, so shared gets TWO batches (its
// own CLIP run of one, then its ALPHA run of one) while other gets
// one.
Assert.Equal(new[] { "shared:100", "other:200", "shared:300" }, log);
Assert.Equal(new[] { 1, 1 }, shared.BatchSizes);
Assert.Equal(new[] { 1 }, other.BatchSizes);
Assert.Equal(1, shared.PrepareCount);
Assert.Equal(1, other.PrepareCount);
}
/// <summary>
/// <c>D3DPolyRender::AddMeshToAlphaList</c> @0x0059c230 (Ghidra-verified
/// 2026-09-04): append returns <see langword="false"/> once the target
/// list already holds <see cref="RetailAlphaQueue.ListCapacity"/> (3000)
/// entries; the subset is DROPPED, no recovery. Mutation check: growing
/// the backing list instead of rejecting the 3001st append would make
/// <c>TryAppend</c> return <see langword="true"/> and
/// <c>PendingCount</c> read 3001 — both assertions fail against that
/// mutation.</summary>
[Fact]
public void TryAppend_CapacityOverflowDropsTheSubsetWithoutRecovery()
{
var source = new CountingSource();
var queue = new RetailAlphaQueue();
queue.BeginFrame();
for (int i = 0; i < RetailAlphaQueue.ListCapacity; i++)
Assert.True(queue.TryAppend(RetailAlphaList.Clip, source, i, false));
bool overflowed = queue.TryAppend(RetailAlphaList.Clip, source, 3000, false);
Assert.False(overflowed);
Assert.Equal(RetailAlphaQueue.ListCapacity, queue.ClipCount);
Assert.Equal(RetailAlphaQueue.ListCapacity, queue.PendingCount);
queue.Flush(RetailAlphaFlushSite.RenderNormalMode, 0f);
// The dropped 3001st token (3000) never reaches the source at all —
// only the 3000 accepted entries drew.
Assert.Equal(RetailAlphaQueue.ListCapacity, source.LastDrawCount);
}
/// <summary>The ALPHA list has its own independent capacity — filling
/// CLIP to capacity must not affect ALPHA appends.</summary>
[Fact]
public void TryAppend_ClipAndAlphaCapacitiesAreIndependent()
{
var source = new CountingSource();
var queue = new RetailAlphaQueue();
queue.BeginFrame();
for (int i = 0; i < RetailAlphaQueue.ListCapacity; i++)
Assert.True(queue.TryAppend(RetailAlphaList.Clip, source, i, false));
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 9999, false));
Assert.Equal(1, queue.AlphaCount);
queue.AbortFrame();
}
/// <summary>The first entry appended to a list after it was last drained
/// is flagged <c>IsFirstForList</c>; later entries in the same
/// uninterrupted run are not. Mutation check: always setting the flag
/// true (or always false) fails this exact sequence assertion.</summary>
[Fact]
public void TryAppend_FlagsOnlyTheFirstEntrySinceTheLastDrain()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
FieldInfo alphaField = typeof(RetailAlphaQueue).GetField(
"_alpha", BindingFlags.NonPublic | BindingFlags.Instance)!;
queue.BeginFrame();
queue.TryAppend(RetailAlphaList.Alpha, source, 1, false);
queue.TryAppend(RetailAlphaList.Alpha, source, 2, false);
// Inspect BEFORE flushing: two entries in the SAME list snapshot,
// discriminating true (first) from false (second) — checking only
// the post-flush single-survivor list (as an earlier draft of this
// test did) is vacuous, since a one-element list is trivially
// "first" whether or not the flag logic is correct.
var beforeFlush = (List<RetailAlphaEntry>)alphaField.GetValue(queue)!;
Assert.Equal(2, beforeFlush.Count);
Assert.True(beforeFlush[0].IsFirstForList);
Assert.False(beforeFlush[1].IsFirstForList);
queue.Flush(RetailAlphaFlushSite.RenderNormalMode, 0f);
queue.TryAppend(RetailAlphaList.Alpha, source, 3, false);
var afterDrain = (List<RetailAlphaEntry>)alphaField.GetValue(queue)!;
Assert.Single(afterDrain);
Assert.True(afterDrain[0].IsFirstForList);
queue.AbortFrame();
}
[Fact]
public void RetainedScratchConvergesAfterAOneScopeSpike()
{
const int budgetBytes = 128 * 1024;
var source = new CountingSource();
var queue = new RetailAlphaQueue(budgetBytes);
queue.BeginFrame();
for (int i = 0; i < 8_192; i++)
queue.TryAppend(i % 2 == 0 ? RetailAlphaList.Clip : RetailAlphaList.Alpha, source, i, false);
queue.EndFrame();
Assert.True(queue.RetainedScratchBytes > budgetBytes);
for (int i = 0; i < 3; i++)
{
queue.BeginFrame();
queue.EndFrame();
}
Assert.True(queue.RetainedScratchBytes <= budgetBytes);
Assert.False(queue.IsCollecting);
Assert.Equal(0, queue.PendingCount);
}
[Fact]
public void AbortFrame_DiscardsPayloadAndAllowsTheNextFrameToRender()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.TryAppend(RetailAlphaList.Alpha, source, 1, false);
queue.AbortFrame();
Assert.False(queue.IsCollecting);
Assert.Equal(0, queue.PendingCount);
Assert.Empty(log);
Assert.Equal(1, source.ResetCount);
queue.BeginFrame();
queue.TryAppend(RetailAlphaList.Alpha, source, 2, false);
queue.EndFrame();
Assert.Equal(new[] { "alpha:2" }, log);
Assert.Equal(2, source.ResetCount);
}
[Fact]
public void EndFrame_DrawAndResetFailuresPreserveThePrimaryFailureAndClearTheFrame()
{
var drawSource = new FailureSource("draw failed", "first reset failed");
var secondSource = new FailureSource(null, null);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.TryAppend(RetailAlphaList.Alpha, drawSource, 1, false);
queue.TryAppend(RetailAlphaList.Alpha, secondSource, 2, false);
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
Assert.Collection(
failure.InnerExceptions,
error => Assert.Equal("draw failed", error.Message),
error => Assert.Equal("first reset failed", error.Message));
Assert.Equal(1, drawSource.ResetCount);
Assert.Equal(1, secondSource.ResetCount);
Assert.Equal(0, queue.PendingCount);
Assert.False(queue.IsCollecting);
}
[Fact]
public void EndFrame_MultipleResetFailuresAttemptEverySourceAndClearTheFrame()
{
var first = new FailureSource(null, "first reset failed");
var second = new FailureSource(null, "second reset failed");
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.TryAppend(RetailAlphaList.Alpha, first, 1, false);
queue.TryAppend(RetailAlphaList.Alpha, second, 2, false);
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
Assert.Collection(
failure.InnerExceptions,
error => Assert.Equal("first reset failed", error.Message),
error => Assert.Equal("second reset failed", error.Message));
Assert.Equal(1, first.ResetCount);
Assert.Equal(1, second.ResetCount);
Assert.Equal(0, queue.PendingCount);
Assert.False(queue.IsCollecting);
}
private sealed class RecordingSource(string name, List<string> log) : IRetailAlphaDrawSource
{
public List<int> BatchSizes { get; } = new();
public int ResetCount { get; private set; }
public int PrepareCount { get; private set; }
private int[] _prepared = [];
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{
PrepareCount++;
_prepared = tokens.ToArray();
}
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
BatchSizes.Add(drawCount);
for (int i = 0; i < drawCount; i++)
log.Add($"{name}:{_prepared[firstPreparedDraw + i]}");
}
public void ResetAlphaSubmissions() => ResetCount++;
}
/// <summary>A source that only counts — used for the high-volume
/// capacity/threshold tests where recording every token as a string
/// would be wasted allocation.</summary>
private sealed class CountingSource : IRetailAlphaDrawSource
{
public int PrepareCount { get; private set; }
public int ResetCount { get; private set; }
public int LastDrawCount { get; private set; }
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens) => PrepareCount++;
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) =>
LastDrawCount = drawCount;
public void ResetAlphaSubmissions() => ResetCount++;
}
private sealed class FailureSource(
string? drawFailure,
string? resetFailure) : IRetailAlphaDrawSource
{
public int ResetCount { get; private set; }
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{
}
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
if (drawFailure is not null)
throw new InvalidOperationException(drawFailure);
}
public void ResetAlphaSubmissions()
{
ResetCount++;
if (resetFailure is not null)
throw new InvalidOperationException(resetFailure);
}
}
}