using System.Reflection; using AcDream.App.Rendering; namespace AcDream.App.Tests.Rendering; /// /// S4-c2: '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. /// public sealed class RetailAlphaQueueTests { /// Mutation check: reverting TryAppend/Flush to the /// old distance-sorted single queue (submitting these same three entries /// through the old Submit(source, token, viewerDistance) API with /// distances 30/10/5 — far to near) drains far-to-near /// (alpha:0, alpha:1, alpha:2), NOT append order — this assertion /// fails against that old behavior. [Fact] public void Flush_FifoBeatsReversedDistanceInOneCell() { var log = new List(); 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); } /// 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. [Fact] public void Flush_EqualPrioritySourcesPreserveSubmissionInterleave() { var log = new List(); 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); } /// 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 /// above, which /// this test complements by proving traversal never leaks across scope /// boundaries. [Fact] public void Flush_TwoScopesNeverInterleaveRegardlessOfGlobalOrder() { var log = new List(); 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); } /// 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 /// object:1,object:2,particle:1,cell:1 — this assertion fails /// against that grouping. [Fact] public void Flush_ParticleObjectAndCellSourcesOverlapInSubmissionOrder() { var log = new List(); 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); } /// Retail RenderDeviceD3D::DrawBuilding's own /// FlushAlphaList(0f). Mutation check: passing any nonzero /// threshold here (e.g. leaving the old hardcoded 0f-only Flush() /// 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. [Fact] public void Flush_DrawBuildingSiteAtZeroThresholdAlwaysDrains() { var log = new List(); 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(); } /// Pre-clear partial flush (frame stays open, keeping later /// content) then the final end-of-frame flush drains the rest. Mutation /// check: an EndFrame that forgets to flush at all (or a /// Flush that clears ) /// would leave alpha:2 undrained or the frame permanently open — /// both assertions below fail against that bug. [Fact] public void Flush_PreClearThenFinalFlushBothDrainInOrder() { var log = new List(); 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); } /// /// Ghidra-verified 2026-09-04 boundary /// (D3DPolyRender::FlushAlphaList @0x0059d2e0): the early return /// fires only when BOTH counts are STRICTLY below threshold * 3000. /// At exactly 2250 (0.75 * 3000) the ALPHA count is NOT strictly less /// than 2250, so the drain proceeds. Mutation check: using /// <= instead of < for the no-op comparison makes /// this exact-2250 case a no-op — the drained-count assertion (2250, not /// 0) fails against that mutation. [Fact] public void Flush_SortCellExitValveDrainsExactlyAtTwoThousandTwoHundredFifty() { var log = new List(); 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(); } /// 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. [Fact] public void Flush_SortCellExitValveIsANoOpOneBelowTheBoundary() { var log = new List(); 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(); } /// 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 /// PrepareAlphaDraws twice for the shared source instead of once — /// PrepareCount asserted at 1 fails against that mutation. [Fact] public void Flush_BatchesAdjacentSameSourceEntriesAcrossTheClipAlphaBoundary() { var log = new List(); 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); } /// /// D3DPolyRender::AddMeshToAlphaList @0x0059c230 (Ghidra-verified /// 2026-09-04): append returns once the target /// list already holds (3000) /// entries; the subset is DROPPED, no recovery. Mutation check: growing /// the backing list instead of rejecting the 3001st append would make /// TryAppend return and /// PendingCount read 3001 — both assertions fail against that /// mutation. [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); } /// /// S4-c2 final fix R2-3: production callers reserve source-owned payload /// before TryAppend. 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 /// RegisterSource(source) below the capacity return makes the first /// Assert.Equal(1, rejected.ResetCount) fail with actual 0. [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); } /// The ALPHA list has its own independent capacity — filling /// CLIP to capacity must not affect ALPHA appends. [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); } /// /// S4-c2 fix round 1 (A4): DrainAndReset/AbortFrame used to /// pass the ENTRY count as ApplyScratchRetention's SOURCE-count /// argument too (ApplyScratchRetention(observedClip + observedAlpha, /// observedClip + observedAlpha)). A single spike frame first /// registers 100 DISTINCT sources (one entry each) so _sources' /// own List-growth capacity climbs well past its initial 4 — otherwise /// Math.Min(sourceTarget, _sources.Capacity) clamps ANY /// sourceTarget 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 /// 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 /// ApplyScratchRetention(observedClip + observedAlpha, observedClip + /// observedAlpha) makes sourceTarget compute from 10 /// (Math.Max(4, 10*2)=20, clamped by the now-≥20 _sources.Capacity /// to 20) instead of from 1 (Math.Max(4, 1*2)=4) — the actual /// observed capacity under that mutation is 20, and the upper-bound /// assertion below fails against it. /// [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)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)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(); 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(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(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 log) : IRetailAlphaDrawSource { public List BatchSizes { get; } = new(); public int ResetCount { get; private set; } public int PrepareCount { get; private set; } private int[] _prepared = []; public void PrepareAlphaDraws(ReadOnlySpan 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++; } /// A source that only counts — used for the high-volume /// capacity/threshold tests where recording every token as a string /// would be wasted allocation. 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 tokens) => PrepareCount++; public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) => LastDrawCount = drawCount; public void ResetAlphaSubmissions() => ResetCount++; } private sealed class RetainedPayloadSource : IRetailAlphaDrawSource { private readonly List _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 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 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); } } }