acdream/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
Erik 0aa166aa09 fix(render): complete S4 chunk 2 final alpha parity round
Final allowed fix round for S4-c2 on cc8e5677a. This lands every item in
the campaign packet section 12 without adding a flush site, shader, distance,
overflow recovery draw, or graphical-client run.

R2-1 particle row 5: ParticleRenderer now constructs and owns the actual
particle-mesh-opaque pipeline using the existing particle_mesh shaders and
layout, Blend=None, depth test/write enabled with WorldCompare, dynamic
per-batch cull, clockwise front face, and no alpha-to-coverage. The production
dispatch selects it for an opaque-classified mesh particle whose clamped
material alpha is 1.0. Nonopaque mesh pipelines remain depth-write-off. The
production route keeps cached reserve/immediate delegates and the warmed
append/immediate paths allocate 0 B.

R2-2 EnvCell exact per-subset routing: ObjectMeshManager carries Content's
TextureBatchData.RetailSurfaceMask onto ObjectRenderBatch at the real upload
boundary. EnvCellRenderer scans the active prepared cell snapshot and feeds
each real transparent batch's exact mask through RetailAlphaMeshRouter. Pure
0x08 Base1ClipMap reaches CLIP, 0x02 alpha-family reaches ALPHA, and table
Immediate subsets draw at the cell turn. Separate fixed-route draw sources
coalesce to at most one token per (cell,list) and filtered replay draws only
that list's subsets; a mixed cell contributes to both lists without duplicate
replay. Detail-on routes eligible subsets immediately with the detail pass.
The warmed dispatch/source allocation pins measure 0 B; the production scan/filter is covered behaviorally and uses only retained scratch/enumerators (static allocation audit).

R2-3 capacity cleanup: RetailAlphaQueue registers a source before the 3,000
entry capacity return. A source whose first append is rejected is therefore
reset by flush, EndFrame, or abort, but its rejected payload is never prepared
or drawn.

R2-4 production proof and prose: both actual Wb submit sites are exercised;
the particle tests call the production dispatcher and inspect the constructed
owner's production pipeline/selector; EnvCell tests upload real Content batch
masks through ObjectMeshManager and drain real filtered MDI calls. The A1
positive proof executes WorldSceneRenderer's real outdoor frame owner through
RetailPViewRenderer.DrawInside and RetailAlphaQueue.EndFrame and observes
[DrawBuilding x N, RenderNormalMode] with no LandscapeFlush. The packet and
register now state the varying retail first-for-list truth and the exact
per-subset EnvCell/AP-238, visible AP-239 compositing, and AP-240 feeder scope.
Physical active register counts remain AP=159 and AD=92.

Final clean-state gates (actual output):
- Release solution build: Build succeeded; 0 Warning(s); 0 Error(s).
- Hermetic solution filter: every project green, 16,728 passed / 0 failed /
  0 skipped total; AcDream.App.Tests 6,875/6,875.
- InstalledDat: 255 passed / 10 failed / 1 skipped / 266 total, exactly the
  allowed identities: TowerAscent_StaircaseStaysConeVisible_EveryStep;
  MainGameUiAndChatInput_MediaBearingChildrenNowBuildAsRealWidgets (#383);
  EveryAuthoredInvisibleWidget_StartsHiddenAcrossAllLayouts (#383);
  Oh_doorway_still_first_frame_diff (#458); and the six
  AlphaFlushCounts_{CathedralArrival,CathedralLeak,CathedralStairArch,
  FoundryDeep,HoltburgDoorwayStill,TerraceEdge}_MatchesRetailFrame2. All six
  AlphaFlushSites_* pass in the same lane.
- VulkanShaderDescriptorContractTests + VulkanShaderManifestTests +
  RenderPackSpirvValidatorTests: 32 passed / 0 failed / 0 skipped.
- Corrected queue/router/walk/driver/particle/Wb/EnvCell/PView production
  filter: 211 passed / 0 failed / 0 skipped.
- Explicit warmed production allocation pins: 2 passed; both measure 0 B.
- Register: physical AP-238/AP-239/AP-240/AD-120/~~AP-34~~ rows each count
  exactly 1; active physical rows AP=159 and AD=92.
- git diff --check: PASS.

Production mutation checks (each applied, run to the named first failure, and
exactly reversed before the final gates):

1. Restoring the particle row-5 throw fails
   OpaqueClassifiedMeshBatch_WithNoMaterialAlpha_DrawsImmediateOnOpaqueDepthState
   first with InvalidOperationException: mutation: row 5 unreachable.

2. Deleting row 5's immediate callback fails that same production-dispatch
   test's first collection assertion: expected [(Mesh, 11, True)], actual [].

3. Constructing the actual owner pipeline with depthWrite:false fails
   ImmediateOpaqueMesh_UsesProductionParticleMeshOpaquePipelineDescription
   first at Assert.True(description.Depth.Write): expected true, actual false.

4. Mapping the production selector back to _meshAlphaPipeline fails that same
   test first at Assert.Same: expected particle-mesh-opaque, actual
   particle-mesh-alpha.

5. Dropping RetailSurfaceMask at the real ObjectMeshManager upload boundary
   fails the mixed-cell production scan first: expected Clip | Alpha, actual
   Immediate.

6. Hardcoding the uploaded EnvCell scan to MaskAlphaFamily fails the mixed-cell
   production scan first: expected Clip | Alpha, actual Alpha; the pure-mask
   pin also reports expected Clip, actual Alpha.

7. Inverting the production detail predicate fails the detail-on production
   pin first: expected Immediate, actual Clip.

8. Removing both EnvCell replay filters fails the mixed production drain's real
   MDI assertions: each call expected DrawCount 1, actual DrawCount 2.

9. Moving RegisterSource below the full-capacity return fails every
   RejectedFirstUseSource_IsCleanedWithoutPrepareOrDraw row (flush, EndFrame,
   abort) at the first ResetCount assertion: expected 1, actual 0.

10. Hardcoding detailSurfaceActive=false at the real Wb dispatch fails both
    production submit-site tests at their first queue-count assertion:
    expected 0, actual 1.

11. Restoring RetailPViewRenderer's removed outdoor LandscapeFlush call fails
    OutdoorProductionPView_DrainsBuildingThenRenderNormalModeWithoutLandscapeFlush
    first at the real drain sequence: expected [DrawBuilding,
    RenderNormalMode], actual [DrawBuilding, LandscapeFlush,
    RenderNormalMode].

12. Deleting WorldSceneRenderer's final EndFrame owner drain fails that same
    full-path A1 test first: expected [DrawBuilding, RenderNormalMode], actual
    [DrawBuilding].

Not done/deferred: none. No retail conflict or infeasible contract item was
found. No graphical client was launched.

Co-Authored-By: Codex <noreply@openai.com>
2026-09-04 11:53:24 +02:00

644 lines
27 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) is never prepared or drawn — only
// the 3000 accepted entries draw. Its source still participates in
// cleanup; the two-source pin below discriminates that case.
Assert.Equal(RetailAlphaQueue.ListCapacity, source.LastDrawCount);
}
/// <summary>
/// S4-c2 final fix R2-3: production callers reserve source-owned payload
/// before <c>TryAppend</c>. If source A already fills ALPHA, source B's
/// FIRST append is rejected but B must still be registered for cleanup.
/// It is never prepared/drawn, is reset exactly once by every terminal
/// path, and starts the next frame empty. Mutation check: moving
/// <c>RegisterSource(source)</c> below the capacity return makes the first
/// <c>Assert.Equal(1, rejected.ResetCount)</c> fail with actual 0.</summary>
[Theory]
[InlineData("flush")]
[InlineData("end")]
[InlineData("abort")]
public void RejectedFirstUseSource_IsCleanedWithoutPrepareOrDraw(string terminal)
{
var filling = new CountingSource();
var rejected = new RetainedPayloadSource();
var queue = new RetailAlphaQueue();
queue.BeginFrame();
for (int i = 0; i < RetailAlphaQueue.ListCapacity; i++)
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, filling, i, false));
int rejectedToken = rejected.Reserve(91);
Assert.False(queue.TryAppend(
RetailAlphaList.Alpha,
rejected,
rejectedToken,
overrideClipmap: false));
switch (terminal)
{
case "flush":
queue.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
queue.AbortFrame();
break;
case "end":
queue.EndFrame();
break;
case "abort":
queue.AbortFrame();
break;
default:
throw new ArgumentOutOfRangeException(nameof(terminal));
}
Assert.Equal(0, rejected.PrepareCount);
Assert.Equal(0, rejected.DrawCount);
Assert.Equal(1, rejected.ResetCount);
Assert.Equal(0, rejected.PendingCount);
queue.BeginFrame();
int acceptedToken = rejected.Reserve(92);
Assert.True(queue.TryAppend(
RetailAlphaList.Alpha,
rejected,
acceptedToken,
overrideClipmap: false));
queue.EndFrame();
Assert.Equal(1, rejected.PrepareCount);
Assert.Equal(1, rejected.DrawCount);
Assert.Equal(2, rejected.ResetCount);
Assert.Equal(0, rejected.PendingCount);
}
/// <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();
}
[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);
}
/// <summary>
/// S4-c2 fix round 1 (A4): <c>DrainAndReset</c>/<c>AbortFrame</c> used to
/// pass the ENTRY count as <c>ApplyScratchRetention</c>'s SOURCE-count
/// argument too (<c>ApplyScratchRetention(observedClip + observedAlpha,
/// observedClip + observedAlpha)</c>). A single spike frame first
/// registers 100 DISTINCT sources (one entry each) so <c>_sources</c>'
/// own List-growth capacity climbs well past its initial 4 — otherwise
/// <c>Math.Min(sourceTarget, _sources.Capacity)</c> clamps ANY
/// <c>sourceTarget</c> down to that unchanged initial 4 and the two
/// formulas become indistinguishable, which is why a single-source
/// spike does not discriminate this bug. Three low-demand frames then
/// each resubmit 10 entries through the SAME ONE source (matching the
/// three consecutive observations <see cref="Residency.RetainedScratchCapacityPolicy"/>
/// requires before it recommends shrinking) — at the moment the shrink
/// fires, the ENTRY count (10) and the real SOURCE count (1) genuinely
/// diverge. Mutation check: reverting to
/// <c>ApplyScratchRetention(observedClip + observedAlpha, observedClip +
/// observedAlpha)</c> makes <c>sourceTarget</c> compute from 10
/// (<c>Math.Max(4, 10*2)=20</c>, clamped by the now-≥20 <c>_sources.Capacity</c>
/// to 20) instead of from 1 (<c>Math.Max(4, 1*2)=4</c>) — the actual
/// observed capacity under that mutation is 20, and the upper-bound
/// assertion below fails against it.
/// </summary>
[Fact]
public void RetainedSourceCapacity_ConvergesToTheRealSourceCountNotTheEntryCount()
{
const int budgetBytes = 128 * 1024;
var manySources = new CountingSource[100];
for (int i = 0; i < manySources.Length; i++)
manySources[i] = new CountingSource();
var queue = new RetailAlphaQueue(budgetBytes);
FieldInfo sourcesField = typeof(RetailAlphaQueue).GetField(
"_sources", BindingFlags.NonPublic | BindingFlags.Instance)!;
// 8,192 entries (enough to also push the ENTRY capacity itself past
// its low-demand budget, matching RetainedScratchConvergesAfterAOneScopeSpike's
// own spike size) spread across the 100 distinct sources, so BOTH
// _sources' own capacity AND the entry-side capacity are in their
// post-spike high-water state together.
queue.BeginFrame();
for (int i = 0; i < 8_192; i++)
queue.TryAppend(RetailAlphaList.Alpha, manySources[i % manySources.Length], i, false);
queue.EndFrame();
var sourcesAfterSpike = (List<IRetailAlphaDrawSource>)sourcesField.GetValue(queue)!;
Assert.True(
sourcesAfterSpike.Capacity > 8,
"Test setup check: the 100-distinct-source spike must grow _sources' own capacity "
+ $"past its initial 4 (observed {sourcesAfterSpike.Capacity}) — otherwise the "
+ "Math.Min clamp below hides the bug regardless of which formula runs.");
CountingSource repeatedSource = manySources[0];
for (int i = 0; i < 3; i++)
{
queue.BeginFrame();
for (int j = 0; j < 10; j++)
queue.TryAppend(RetailAlphaList.Alpha, repeatedSource, j, false);
queue.EndFrame();
}
var sources = (List<IRetailAlphaDrawSource>)sourcesField.GetValue(queue)!;
Assert.True(
sources.Capacity <= 8,
"Expected the retained source-array capacity to converge toward the real source "
+ $"count (1), but it stayed at {sources.Capacity} — the entry count (10), not the "
+ "source count (1), must have driven ApplyScratchRetention's second argument.");
}
[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 RetainedPayloadSource : IRetailAlphaDrawSource
{
private readonly List<int> _pending = new();
private int[] _prepared = [];
public int PrepareCount { get; private set; }
public int DrawCount { get; private set; }
public int ResetCount { get; private set; }
public int PendingCount => _pending.Count;
public int Reserve(int value)
{
int token = _pending.Count;
_pending.Add(value);
return token;
}
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{
PrepareCount++;
_prepared = new int[tokens.Length];
for (int i = 0; i < tokens.Length; i++)
_prepared[i] = _pending[tokens[i]];
}
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
Assert.InRange(firstPreparedDraw, 0, _prepared.Length - drawCount);
DrawCount += drawCount;
}
public void ResetAlphaSubmissions()
{
ResetCount++;
_pending.Clear();
_prepared = [];
}
}
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);
}
}
}