Fix round 1 of the two the plan allows for S4-c2 (048d5b12f), addressing every item M1-M8/A1-A8 from the campaign branch's packet section 11. M1 (BLOCKING): ParticleRenderer.DeferToRetailAlphaQueue threw whenever a mesh-particle batch's TranslucencyKind.Opaque classification (mask 0x00) reached a non-Append DrawMesh row - a real, reachable outcome since TryAppendMeshDraws applies no IsOpaque-style pre-filter unlike WbDrawDispatcher's path. Deleted both throws (ParticleRenderer.cs, WbDrawDispatcher.cs SubmitToAlphaQueue), extracted the router-input derivation into internal static ParticleRenderer.RouteParticleSubmission (directly testable), and implemented rows 1/5's immediate draw by reusing the existing per-instance billboard/mesh draw code (new DrawImmediateParticleSubmissionRhi, no new renderer, no sort per A8). materialHasAlpha (row 4) is derived from CMaterial::CheckAlphaValues @0x005396a0 (Ghidra-verified 2026-09-04): has_alpha=0 only when Ambient.a==Diffuse.a==Specular.a==Emissive.a==1.0f, else 1 - and CMaterial::SetTranslucencySimple @0x005396f0 (reached from CPhysicsPart::SetTranslucency <- Particle::Update @0x0051c290's per-frame alpha-fade) sets all four channels to (1f - translucency) uniformly, so has_alpha == "this particle's current alpha isn't 1.0" - already carried in ColorArgb's top byte (Lerp(StartAlpha,EndAlpha,tLife), ParticleSystem.cs 1039-1041/1453). M3: SubmitToAlphaQueue now takes isBuildingShell + viewProjection and feeds Row 1's detailSurfaceActive gate for translucent building-shell instances (retail DrawBuilding @0x0059f2a0 installs building detail before its own shell subsets reach DrawMesh). Row 1 (detail on) draws the instance immediately at its own walk-stream AlphaSubmitMark via new DrawImmediateAlphaInstance/DrawImmediateAlphaInstanceRhi (reusing WriteDeferredAlphaEntrySlot + the drain's pipeline/detail code for slot 0); never touches _deferredAlpha/the queue for pure-Immediate submissions (fixes a latent leak: previously reserving a _deferredAlpha slot for every candidate meant a frame with ALL-immediate content never registered a source, so ResetAlphaSubmissions never ran). M3c: DrawPreparedAlphaBatchRhi's own per-command hasDetail/DrawBuildingDetailRangeRhi branch is deleted - detail-eligible content no longer reaches the drain (spec: delayed replay is always detailEnabled=0). New AD-120 register row for the "in place -> at the entity's own stream mark" granularity adaptation this introduces. M6: RetailPViewPassExecutor.SubmitOrDrawTransparentCellShell routes an EnvCell's transparent shell through the SAME RetailAlphaMeshRouter table (mask 0x02, detailSurfaceActive = EnvCellRenderer.TransparentDetailEnabled) instead of a hand-coded branch. Detail on: unchanged immediate draw. Detail off: appends ONE token per CELL via new EnvCellAlphaDrawSource (internal, delegate-based so it's unit-testable without a GPU harness), replayed through EnvCellRenderer.RenderTransparentOrdered at the next flush - RenderTransparentOrdered already replayed a cell's batch on demand, so no new deferred-replay abstraction was needed (the original landing's claim that one was required is corrected in the packet). M4: RetailAlphaEntry.IsFirstForList deleted. Retail's per-DrawMesh- invocation "first for this list" flag is trivially true for every subset retail ever appends (DrawMesh @0x0059d4a0 sets both flags true at entry, 0059d4cc/0059d4d0, clearing each independently after ITS OWN first append within that call, 0059d5ef) - the captures confirm it (new=1 on 9,685/10,556 AM lines, impossible under "first since last drain"). acdream's deleted isFirstForList = target.Count == 0 computed the wrong quantifier. One acdream append already IS one DrawMesh call, so the field carried no information and its own pin was vacuous (feedback_every_new_pin_must_be_ shown_to_fail) - deleted rather than kept as an always-true no-op. M5: split all six AlphaFlushTranscript_* KnownFailure Facts into live AlphaFlushSites_* (site+threshold sequence only, content-independent, unconditional pass) and AlphaFlushCounts_* (KnownFailure, full tuple incl. drained counts) sharing one RunAlphaFlushTranscriptReplay helper. Rewrote the KnownFailure reason honestly: THREE independent causes, not "harness content only" - (1) zero live mesh content, (2) acdream appends one entry per INSTANCE vs retail's one per SUBSET per DrawMesh call, (3) acdream's CLIP list is structurally empty for ordinary content (M2). Added ONE live valve pin (WalkFrameDriverTests.SortCellExit_ValveDrainsThroughReplayAtThe ExactBoundary, a Theory over 2250/2249) that goes through WalkFrameDriver. Replay's own SortCellExit arm, not RetailAlphaQueue.Flush called directly. M2/A2/M8c (register): ~~AP-34~~ restored as struck-through RETIRED (it had been deleted outright, not struck, breaking the register's own convention) citing residuals AP-238/AP-239/AP-240. The chunk-2 commit's "AP-236"/ "AP-237" collided with an id ALREADY filed-and-retired on main by an unrelated #132 fix (docs/ISSUES.md:19976) - ids are never reused, so both are renumbered: AP-236->AP-238 (EnvCell; rewritten per M6 - granularity-only residual now, "never through the shared queue" is retired), AP-237->AP-239 (mask-reconstruction gap; the dangling "AP-236b" citation in RetailAlphaMeshRouter.cs never matched either filed id and is corrected to AP-239). New AP-240 filed for M2: WbDrawDispatcher.IsOpaque filters ClipMap out upstream, so ordinary clip-mapped content never reaches retail's CLIP FIFO (draws immediately on the pre-existing OpaqueAlphaToCoverage pipeline instead) - no pipeline change this round, per the contract. AP section header recounted honestly: 159 active (was stale at 161); AD section 92 (was 91, +AD-120). M7: AP-239's only cited example (cloud GfxObj 0x01004C35, surface 0x08000023) is DISPROVEN - decoded Type=0x10114 has alpha-family bits that win in BOTH retail's real priority and acdream's reconstruction, so it never actually diverges. A 2026-09-04 scan of all 6,152 Portal-dat Surfaces found 27 real Translucent+Base1ClipMap-no-alpha-family instances (Type=0x14); cross-referencing GfxObjs found surface 0x08000015 on GfxObj 0x010001EC - cited in place of the disproven cloud example. M8: (a) RenderDeviceD3D::DrawBlock is @0x005a17c0 per symbols.json, not @0x005a18d0 (that is its per-land-cell loop head) - fixed across RetailAlphaQueue.cs, RetailPViewPassExecutor.cs, WalkEvents.cs, WalkFrameDriver.cs (x3), RetailFrameWalk.cs. (b) RetailAlphaQueue.cs's four FlushAlphaList addresses were labelled "return site" but are the CALL instruction's own address (x86 CALL rel32 is 5 bytes) - relabelled "call at X (returns to X+5)". (d) worldbuilder-inventory.md:420's AP-34 citation updated. A1: RetailPViewRenderer.cs's outdoor-root passes.FlushLandscapeAlpha() call (labelled LandscapeFlush) is deleted - retail's real LandscapeFlush (PView::DrawCells @0x005a4840's own FlushAlphaList(0f) @0x005a4872) sits strictly inside if (outside_view.view_count > 0) on the INTERIOR PView; an outdoor root never reaches it (terrace-edge capture: zero 005a4877 FL lines). Verified no opaque draw intervenes between this deletion point and RetailAlphaQueue.EndFrame()'s own RenderNormalMode pass-end flush (WorldSceneRenderer.cs: DrawPostWorldParticles's PView branch is an explicit no-op; only diagnostic wireframes follow) - #132's "drain after the finished opaque world" invariant is kept by that flush alone. A4: RetailAlphaQueue's ApplyScratchRetention call sites passed the ENTRY count as the SOURCE-count argument too (observedClip + observedAlpha twice), so _sources/_sourceDrawOffsets never shrank toward the real source count. Fixed to capture _sources.Count before Clear(). A5/A6/A7: WalkStaticStreamPopulatorTests.cs's stale "same viewer distance" banner corrected; two off-by-one entry counts in the packet's section 10 sequences corrected (foundry-deep x10->x11, holtburg-doorway-still x5->x6); WalkAlphaDepthTrace.cs's "new=" comment explains why IsNew is retained but never compared (M4's finding makes a per-subset comparison meaningless at acdream's per-instance granularity); RetailAlphaMeshRouterTests.cs's IndependentReferenceRoute (never actually independently-shaped) renamed to RestatedBranchTableRoute with an honest doc comment, and the unverified "flipped in turn and failed every time" claim deleted. A3 was resolved as a side effect of M1's SubmitToAlphaQueue doc-comment rewrite (the wrong "0x02/0x03, 0x04/0x05, 0x08/0x09" enumeration no longer exists); RetailAlphaMeshRouter.cs's own MaskFromTranslucencyKind comment updated in the same spirit (M1's particle-site Opaque-reachability finding). Mutation checks (each applied to a scratch-restored copy, confirmed failing, then byte-exact reverted - diffed against a /tmp backup after restore): - A1 (RetailPViewRendererTests.DrawInside_NeverCallsFlushLandscapeAlphaDirectly): restoring the deleted "if (ctx.RootCell.IsOutdoorNode) passes.FlushLandscapeAlpha();" line reintroduces a direct RetailPViewPassExecutor.FlushLandscapeAlpha call in DrawInside's compiled body. Actual failure: "Assert.DoesNotContain() Failure: Filter matched in collection ... CompiledCall { ... Target = Void FlushLandscapeAlpha() }". - A4 (RetailAlphaQueueTests.RetainedSourceCapacity_ConvergesToTheReal SourceCountNotTheEntryCount): reverting both ApplyScratchRetention calls to (observedClip + observedAlpha, observedClip + observedAlpha) - with 100 distinct sources in the spike frame (so _sources.Capacity grows to 128, past its initial 4) followed by three low-demand frames of 10 entries via ONE repeated source - makes sourceTarget compute from 10 (Math.Max(4,10*2)=20) instead of 1 (Math.Max(4,1*2)=4). Actual failure: "Expected the retained source-array capacity to converge toward the real source count (1), but it stayed at 20". (A single-source-only spike does NOT discriminate this bug - Math.Min(sourceTarget, _sources.Capacity) clamps both formulas to the same unchanged initial 4 - hence the 100-source setup.) - M5 valve (WalkFrameDriverTests.SortCellExit_ValveDrainsThroughReplayAt TheExactBoundary, 2250 case): flipping RetailAlphaQueue.Flush's no-op comparison from "<" to "<=" (S4-c1/c2's own boundary mutation) makes the 2250-entries case a no-op instead of draining. Actual failure: "Assert.Equal() Failure: Values differ Expected: 0 Actual: 2250". - M6 (EnvCellAlphaDrawSourceTests, both Facts): adding an immediate renderTransparentOrdered(new[]{cellId}) call inside EnvCellAlphaDrawSource.AddPendingCellId (simulating the M1/M3 "drew immediately instead of deferring" bug class) makes PendingCellToken_IsDrainedAtTheFlushAndNeverBeforeIt fail with "Assert.Empty() Failure: Collection was not empty Collection: [[4660]]" and ParticleAppendedBetweenTwoCellTokens_KeepsItsPositionInTheCombined Drain fail with 'Expected: string[] ["cell:256", "particle:7", "cell:512"] Actual: List<string> ["cell:256", "cell:512", "cell:256", "particle:7", "cell:512"]'. - M1 (ParticleRendererRouteTests.OpaqueClassifiedMeshBatch_WithMaterialAlpha _RoutesToAlphaAppend): hardcoding materialHasAlpha to false inside RouteParticleSubmission makes the Opaque-mask+has-alpha case route Immediate instead of Append. Actual failure: "Assert.Equal() Failure: Values differ Expected: Append Actual: Immediate". Gates (real output lines): - dotnet build AcDream.slnx -c Release -> "Build succeeded. 0 Warning(s) 0 Error(s)". - Hermetic lane (--filter "Lane!=InstalledDat&Lane!=PreparedPackage& Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux& Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure") -> "Passed! - Failed: 0, Passed: 6864, Skipped: 0, Total: 6864". - InstalledDat lane (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, ACDREAM_DAT_DIR="C:/Users/erikn/Documents/Asheron's Call", --filter "Lane=InstalledDat") -> "Failed: 10, Passed: 255, Skipped: 1, Total: 266" - the exact 4 pre-existing failures (TowerAscent_StaircaseStaysConeVisible_EveryStep, LayoutImporterMediaBearingChildSweepTests, LayoutImporterInvisibleSweep Tests, Oh_doorway_still_first_frame_diff) plus the six AlphaFlushCounts_* KnownFailure Facts, nothing else; all six AlphaFlushSites_* Facts pass inside this same lane (the whole WalkTraceConformanceTests partial class carries Lane=InstalledDat, so they cannot run hermetically). - Shader classes (VulkanShaderDescriptorContractTests, VulkanShaderManifestTests, RenderPackSpirvValidatorTests) -> "Passed! - Failed: 0, Passed: 32, Skipped: 0, Total: 32". - RetailAlphaQueueTests, RetailAlphaMeshRouterTests, RetailFrameWalkTests, WalkFrameDriverTests, ParticleRenderer*Tests, EnvCellRenderer*Tests (combined filter) -> "Passed! - Failed: 0, Passed: 120, Skipped: 0, Total: 120". - Register collision grep: AP-238/AP-239/AP-240/AD-120 each count exactly 1; ~~AP-34~~ count exactly 1; live totals AP=159, AD=92. Not done / deferred: none - all M1-M8/A1-A8 items landed; A8 was a no-action note. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
539 lines
23 KiB
C#
539 lines
23 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><=</c> instead of <c><</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();
|
|
}
|
|
|
|
[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 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);
|
|
}
|
|
}
|
|
}
|