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>
This commit is contained in:
parent
3f69f1126b
commit
89f1e2676f
19 changed files with 1913 additions and 837 deletions
286
tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs
Normal file
286
tests/AcDream.App.Tests/Rendering/RetailAlphaMeshRouterTests.cs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Meshing;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// S4-c2: <see cref="RetailAlphaMeshRouter"/>'s port of retail
|
||||
/// <c>D3DPolyRender::DrawMesh</c> @0x0059d4a0's branch table (spec §4).
|
||||
/// </summary>
|
||||
public sealed class RetailAlphaMeshRouterTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Hand-traced literal cases against the Ghidra-verified decomp — each
|
||||
/// one differentiates exactly one row boundary, with the mutation each
|
||||
/// guards against spelled out. delayMask is fixed at the real default
|
||||
/// (0x0E) throughout, matching the contract's fixed axis.
|
||||
/// </summary>
|
||||
public static IEnumerable<object[]> RowBoundaryCases()
|
||||
{
|
||||
// Row 1a: sky wins outright even over a mask that would otherwise
|
||||
// hit row 3. Mutation: dropping the sky check entirely returns
|
||||
// Append(Alpha) instead.
|
||||
yield return new object[]
|
||||
{
|
||||
true, false, false, (byte)0x02, false,
|
||||
(int)RetailAlphaMeshAction.Immediate, (int)RetailAlphaList.Alpha, false,
|
||||
};
|
||||
|
||||
// Row 1c: detailSurfaceActive wins outright even with multipass=true
|
||||
// and a clip-bearing mask that would otherwise hit row 2. Mutation:
|
||||
// dropping the detail check returns AppendClipAndImmediate instead.
|
||||
yield return new object[]
|
||||
{
|
||||
false, true, true, (byte)0x08, false,
|
||||
(int)RetailAlphaMeshAction.Immediate, (int)RetailAlphaList.Alpha, false,
|
||||
};
|
||||
|
||||
// Row 2a: multipass + clip bit (exact 0x08) -> CLIP,
|
||||
// overrideClipmap=true, ALSO immediate. Mutation: an `==` (exact
|
||||
// mask match) instead of a bit test would still pass THIS case...
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, true, (byte)0x08, false,
|
||||
(int)RetailAlphaMeshAction.AppendClipAndImmediate, (int)RetailAlphaList.Clip, true,
|
||||
};
|
||||
|
||||
// ...but Row 2b (0x09 = clip bit + stipple bit) exposes an `==`
|
||||
// mutation: it would fall through to row 3 (Append, no immediate
|
||||
// pass) instead of also matching row 2's bit test.
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, true, (byte)0x09, false,
|
||||
(int)RetailAlphaMeshAction.AppendClipAndImmediate, (int)RetailAlphaList.Clip, true,
|
||||
};
|
||||
|
||||
// Row 2c: multipass=true but mask has NO clip bit (0x02) -> must
|
||||
// fall through to row 3, not row 2. Mutation: a row-2 test that
|
||||
// ignores the clip-bit requirement (fires on multipass alone) would
|
||||
// return AppendClipAndImmediate here instead of plain Append(Alpha).
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, true, (byte)0x02, false,
|
||||
(int)RetailAlphaMeshAction.Append, (int)RetailAlphaList.Alpha, false,
|
||||
};
|
||||
|
||||
// Row 3a: alpha-family mask -> ALPHA, no immediate pass. Mutation:
|
||||
// swapping the CLIP/ALPHA selection (always ALPHA regardless of bit
|
||||
// 0x08) would still pass this one — paired with 3b below.
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, false, (byte)0x02, false,
|
||||
(int)RetailAlphaMeshAction.Append, (int)RetailAlphaList.Alpha, false,
|
||||
};
|
||||
|
||||
// Row 3b: clip-map mask -> CLIP (bit 0x08 selects the list even
|
||||
// without multipass). Mutation: always routing row 3 to ALPHA
|
||||
// regardless of the clip bit fails THIS case (expects Clip).
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, false, (byte)0x08, false,
|
||||
(int)RetailAlphaMeshAction.Append, (int)RetailAlphaList.Clip, false,
|
||||
};
|
||||
|
||||
// Row 3c: translucent-only mask (0x04, no clip bit) -> ALPHA.
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, false, (byte)0x04, false,
|
||||
(int)RetailAlphaMeshAction.Append, (int)RetailAlphaList.Alpha, false,
|
||||
};
|
||||
|
||||
// Row-3-inert-stipple: mask 0x01 (stipple bit ONLY, no alpha/clip/
|
||||
// translucent bit) does NOT intersect the fixed default delay mask
|
||||
// 0x0E (bit 0 is absent from 0x0E) — falls through to row 4.
|
||||
// Mutation: including bit 0 in the effective delay test (treating
|
||||
// 0x0E as if it were 0x0F) would make this case ALSO match row 3
|
||||
// (Append) instead of falling to row 4's material-fallback decision.
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, false, (byte)0x01, true,
|
||||
(int)RetailAlphaMeshAction.Append, (int)RetailAlphaList.Alpha, false,
|
||||
};
|
||||
|
||||
// Row 4a: plain mask (0x00), material has_alpha true -> ALPHA
|
||||
// fallback. Mutation: requiring the TRANSLUCENT bit (0x04) to
|
||||
// ALREADY be set on the mask itself (rather than just checking the
|
||||
// DELAY mask's own bit 0x04, which is fixed on regardless of the
|
||||
// subset's mask) would make this case fall to row 5 (Immediate)
|
||||
// instead.
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, false, (byte)0x00, true,
|
||||
(int)RetailAlphaMeshAction.Append, (int)RetailAlphaList.Alpha, false,
|
||||
};
|
||||
|
||||
// Row 4 vs 5: plain mask, material has_alpha FALSE -> row 4's own
|
||||
// condition fails -> row 5 Immediate. Mutation: dropping the
|
||||
// materialHasAlpha check from row 4 (treating it as "always append
|
||||
// when mask is plain") would return Append here instead.
|
||||
yield return new object[]
|
||||
{
|
||||
false, false, false, (byte)0x00, false,
|
||||
(int)RetailAlphaMeshAction.Immediate, (int)RetailAlphaList.Alpha, false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="expectedActionRaw"/>/<paramref name="expectedListRaw"/>
|
||||
/// are boxed as <see langword="int"/> (not the internal enum types)
|
||||
/// because <c>[Theory]</c> methods must be <see langword="public"/> and a
|
||||
/// public method cannot expose an <see langword="internal"/> parameter
|
||||
/// type — <see cref="RetailAlphaMeshAction"/>/<see cref="RetailAlphaList"/>
|
||||
/// stay internal (this assembly's whole alpha-queue surface is internal).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(RowBoundaryCases))]
|
||||
public void Route_MatchesTheHandTracedDecompBoundary(
|
||||
bool sky, bool detail, bool multipass, byte mask, bool hasAlpha,
|
||||
int expectedActionRaw, int expectedListRaw,
|
||||
bool expectedOverrideClipmap)
|
||||
{
|
||||
var expectedAction = (RetailAlphaMeshAction)expectedActionRaw;
|
||||
var expectedList = (RetailAlphaList)expectedListRaw;
|
||||
RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route(
|
||||
sky, RetailAlphaMeshRouter.DefaultDelayMask, detail, multipass, mask, hasAlpha);
|
||||
|
||||
Assert.Equal(expectedAction, decision.Action);
|
||||
if (expectedAction != RetailAlphaMeshAction.Immediate)
|
||||
{
|
||||
Assert.Equal(expectedList, decision.List);
|
||||
Assert.Equal(expectedOverrideClipmap, decision.OverrideClipmap);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full brute-force sweep over every (mask 0x00-0x09) x sky x detail x
|
||||
/// multipass x hasAlpha cell at the fixed default delay mask (0x0E),
|
||||
/// checked against an INDEPENDENTLY-shaped reference derivation (a
|
||||
/// switch-driven truth table, not a copy of <see cref="RetailAlphaMeshRouter.Route"/>'s
|
||||
/// own if-chain) so a control-flow slip in one is unlikely to survive in
|
||||
/// the other with the same wrong answer. Mutation check: flipping any
|
||||
/// single row's condition in <c>Route</c> (e.g. row 3's <c>!= 0</c> to
|
||||
/// <c>== 0</c>) changes that row's outcome for roughly half the 160
|
||||
/// cells, which this sweep catches immediately (verified by hand during
|
||||
/// S4-c2 development: each of the five rows' conditions was flipped in
|
||||
/// turn and this test failed every time).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Route_MatchesIndependentTruthTableAcrossEveryCell()
|
||||
{
|
||||
byte[] masks = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09];
|
||||
bool[] bothBools = [false, true];
|
||||
int mismatches = 0;
|
||||
var firstMismatch = "";
|
||||
|
||||
foreach (byte mask in masks)
|
||||
foreach (bool sky in bothBools)
|
||||
foreach (bool detail in bothBools)
|
||||
foreach (bool multipass in bothBools)
|
||||
foreach (bool hasAlpha in bothBools)
|
||||
{
|
||||
RetailAlphaMeshDecision actual = RetailAlphaMeshRouter.Route(
|
||||
sky, RetailAlphaMeshRouter.DefaultDelayMask, detail, multipass, mask, hasAlpha);
|
||||
RetailAlphaMeshDecision expected = IndependentReferenceRoute(
|
||||
sky, RetailAlphaMeshRouter.DefaultDelayMask, detail, multipass, mask, hasAlpha);
|
||||
|
||||
bool matches = actual.Action == expected.Action
|
||||
&& (actual.Action == RetailAlphaMeshAction.Immediate
|
||||
|| (actual.List == expected.List
|
||||
&& actual.OverrideClipmap == expected.OverrideClipmap));
|
||||
if (!matches && mismatches++ == 0)
|
||||
{
|
||||
firstMismatch =
|
||||
$"sky={sky} detail={detail} multipass={multipass} mask=0x{mask:x2} "
|
||||
+ $"hasAlpha={hasAlpha}: expected {expected}, got {actual}";
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(mismatches == 0, $"{mismatches} mismatches; first: {firstMismatch}");
|
||||
}
|
||||
|
||||
/// <summary>Independent re-derivation of spec §4's table using a
|
||||
/// switch/lookup shape rather than the production if-chain.</summary>
|
||||
private static RetailAlphaMeshDecision IndependentReferenceRoute(
|
||||
bool sky, byte delayMask, bool detail, bool multipass, byte mask, bool hasAlpha)
|
||||
{
|
||||
bool row1 = sky || delayMask == 0 || detail;
|
||||
if (row1)
|
||||
return new RetailAlphaMeshDecision(RetailAlphaMeshAction.Immediate, default, false);
|
||||
|
||||
bool clipBit = (mask & 0b1000) == 0b1000;
|
||||
bool row2 = multipass switch
|
||||
{
|
||||
true => clipBit,
|
||||
false => false,
|
||||
};
|
||||
if (row2)
|
||||
return new RetailAlphaMeshDecision(RetailAlphaMeshAction.AppendClipAndImmediate, RetailAlphaList.Clip, true);
|
||||
|
||||
int intersect = delayMask & mask;
|
||||
bool row3 = intersect != 0;
|
||||
if (row3)
|
||||
{
|
||||
RetailAlphaList list = clipBit ? RetailAlphaList.Clip : RetailAlphaList.Alpha;
|
||||
return new RetailAlphaMeshDecision(RetailAlphaMeshAction.Append, list, false);
|
||||
}
|
||||
|
||||
bool delayHasTranslucentBit = (delayMask & 0b0100) == 0b0100;
|
||||
bool row4 = delayHasTranslucentBit && hasAlpha;
|
||||
if (row4)
|
||||
return new RetailAlphaMeshDecision(RetailAlphaMeshAction.Append, RetailAlphaList.Alpha, false);
|
||||
|
||||
return new RetailAlphaMeshDecision(RetailAlphaMeshAction.Immediate, default, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RetailAlphaMeshRouter.MaskFromTranslucencyKind"/>'s
|
||||
/// reachable mapping (opaque/ClipMap are pre-filtered upstream and never
|
||||
/// actually call this from a live site, but the function itself must
|
||||
/// still map them defensively).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(TranslucencyKind.AlphaBlend, RetailAlphaMeshRouter.MaskAlphaFamily)]
|
||||
[InlineData(TranslucencyKind.Additive, RetailAlphaMeshRouter.MaskAlphaFamily)]
|
||||
[InlineData(TranslucencyKind.InvAlpha, RetailAlphaMeshRouter.MaskAlphaFamily)]
|
||||
[InlineData(TranslucencyKind.ClipMap, RetailAlphaMeshRouter.MaskClipMap)]
|
||||
[InlineData(TranslucencyKind.Opaque, (byte)0)]
|
||||
public void MaskFromTranslucencyKind_MapsEveryKind(TranslucencyKind kind, byte expectedMask)
|
||||
=> Assert.Equal(expectedMask, RetailAlphaMeshRouter.MaskFromTranslucencyKind(kind));
|
||||
|
||||
/// <summary>Spec §2's priority chain: alpha-family bits win outright over
|
||||
/// ClipMap and Translucent even when both are also present. Mutation
|
||||
/// check: checking ClipMap before the alpha-family union would return
|
||||
/// 0x08 here instead of 0x02.</summary>
|
||||
[Fact]
|
||||
public void ConstructSubsetMask_AlphaFamilyWinsOverClipMapAndTranslucent()
|
||||
{
|
||||
byte mask = RetailAlphaMeshRouter.ConstructSubsetMask(
|
||||
hasAlphaFamilyBit: true, hasClipMapBit: true, hasTranslucentBit: true,
|
||||
hasPositiveStippling: false);
|
||||
Assert.Equal(RetailAlphaMeshRouter.MaskAlphaFamily, mask);
|
||||
}
|
||||
|
||||
/// <summary>ClipMap wins over Translucent when no alpha-family bit is
|
||||
/// present. Mutation check: checking Translucent before ClipMap would
|
||||
/// return 0x04 here instead of 0x08.</summary>
|
||||
[Fact]
|
||||
public void ConstructSubsetMask_ClipMapWinsOverTranslucentWithoutAlphaFamily()
|
||||
{
|
||||
byte mask = RetailAlphaMeshRouter.ConstructSubsetMask(
|
||||
hasAlphaFamilyBit: false, hasClipMapBit: true, hasTranslucentBit: true,
|
||||
hasPositiveStippling: false);
|
||||
Assert.Equal(RetailAlphaMeshRouter.MaskClipMap, mask);
|
||||
}
|
||||
|
||||
/// <summary>Positive stippling ORs bit 0x01 onto whichever base mask
|
||||
/// applies. Mutation check: forgetting the OR (or using an exclusive
|
||||
/// select) would leave the base mask value (0x04) instead of 0x05.</summary>
|
||||
[Fact]
|
||||
public void ConstructSubsetMask_PositiveStipplingOrsIntoTheBaseMask()
|
||||
{
|
||||
byte mask = RetailAlphaMeshRouter.ConstructSubsetMask(
|
||||
hasAlphaFamilyBit: false, hasClipMapBit: false, hasTranslucentBit: true,
|
||||
hasPositiveStippling: true);
|
||||
Assert.Equal((byte)(RetailAlphaMeshRouter.MaskTranslucent | RetailAlphaMeshRouter.MaskPositiveStipple), mask);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +1,170 @@
|
|||
using System.Numerics;
|
||||
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_InterleavesRenderersInDescendingCyptOrder()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var objects = new RecordingSource("object", log);
|
||||
var particles = new RecordingSource("particle", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(objects, token: 0, viewerDistance: 10f); // near crystal
|
||||
queue.Submit(particles, token: 0, viewerDistance: 30f); // far smoke
|
||||
queue.Submit(objects, token: 1, viewerDistance: 25f);
|
||||
queue.Submit(particles, token: 1, viewerDistance: 20f);
|
||||
queue.Submit(objects, token: 2, viewerDistance: 5f);
|
||||
queue.EndFrame();
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"particle:0",
|
||||
"object:1",
|
||||
"particle:1",
|
||||
"object:0",
|
||||
"object:2",
|
||||
},
|
||||
log);
|
||||
Assert.Equal(1, objects.ResetCount);
|
||||
Assert.Equal(1, particles.ResetCount);
|
||||
Assert.Equal(1, objects.PrepareCount);
|
||||
Assert.Equal(1, particles.PrepareCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_EqualDistancePreservesSubmissionOrderAcrossRenderers()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var objects = new RecordingSource("object", log);
|
||||
var particles = new RecordingSource("particle", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(objects, 7, 12f);
|
||||
queue.Submit(particles, 4, 12f);
|
||||
queue.Submit(objects, 8, 12f);
|
||||
queue.EndFrame();
|
||||
|
||||
Assert.Equal(new[] { "object:7", "particle:4", "object:8" }, log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_KeepsFrameOpenForPostDepthClearScope()
|
||||
public void Flush_FifoBeatsReversedDistanceInOneCell()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var source = new RecordingSource("alpha", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(source, 1, 8f);
|
||||
queue.Flush();
|
||||
// "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);
|
||||
queue.Submit(source, 2, 3f);
|
||||
Assert.Equal(new[] { "alpha:1" }, log);
|
||||
|
||||
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, 2, false));
|
||||
queue.EndFrame();
|
||||
|
||||
Assert.False(queue.IsCollecting);
|
||||
|
|
@ -75,173 +172,191 @@ public sealed class RetailAlphaQueueTests
|
|||
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 FlushFartherThan_DrainsOnlyTheFarPrefixAndRetainsNearerEntries()
|
||||
public void Flush_SortCellExitValveDrainsExactlyAtTwoThousandTwoHundredFifty()
|
||||
{
|
||||
// The pre-building barrier: retail's far→near walk means DrawBuilding's
|
||||
// FlushAlphaList(0f) @0x0059F2A0 can only flush content from cells
|
||||
// farther than that building; a nearer candle flame is not inserted
|
||||
// yet and composites after the building at a later flush (AP-236).
|
||||
var log = new List<string>();
|
||||
var objects = new RecordingSource("object", log);
|
||||
var particles = new RecordingSource("particle", log);
|
||||
var source = new CountingSource();
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(particles, 0, 30f); // far waterfall
|
||||
queue.Submit(objects, 0, 25f); // far translucent part
|
||||
queue.Submit(particles, 1, 10f); // exactly at the building threshold
|
||||
queue.Submit(particles, 2, 5f); // near candle flame — must be kept
|
||||
queue.FlushFartherThan(10f);
|
||||
for (int i = 0; i < 2250; i++)
|
||||
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, i, false));
|
||||
|
||||
Assert.True(queue.IsCollecting);
|
||||
Assert.Equal(1, queue.PendingCount);
|
||||
Assert.Equal(new[] { "particle:0", "object:0", "particle:1" }, log);
|
||||
Assert.Equal(0, objects.ResetCount);
|
||||
Assert.Equal(0, particles.ResetCount);
|
||||
queue.Flush(RetailAlphaFlushSite.SortCellExit, 0.75f);
|
||||
|
||||
queue.EndFrame();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "particle:0", "object:0", "particle:1", "particle:2" },
|
||||
log);
|
||||
Assert.Equal(1, objects.ResetCount);
|
||||
Assert.Equal(1, particles.ResetCount);
|
||||
Assert.Equal(2, particles.PrepareCount);
|
||||
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 FlushFartherThan_WithNoFarEntries_LeavesTheQueueUntouched()
|
||||
public void Flush_SortCellExitValveIsANoOpOneBelowTheBoundary()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var source = new RecordingSource("alpha", log);
|
||||
var source = new CountingSource();
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(source, 1, 4f);
|
||||
queue.FlushFartherThan(10f);
|
||||
for (int i = 0; i < 2249; i++)
|
||||
Assert.True(queue.TryAppend(RetailAlphaList.Alpha, source, i, false));
|
||||
|
||||
Assert.Empty(log);
|
||||
Assert.Equal(1, queue.PendingCount);
|
||||
queue.Flush(RetailAlphaFlushSite.SortCellExit, 0.75f);
|
||||
|
||||
Assert.Equal(2249, queue.PendingCount);
|
||||
Assert.Equal(0, source.PrepareCount);
|
||||
Assert.Equal(0, source.ResetCount);
|
||||
queue.EndFrame();
|
||||
Assert.Equal(new[] { "alpha:1" }, log);
|
||||
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 FlushFartherThan_DegenerateThreshold_DrainsAllWithoutResettingSources()
|
||||
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.Submit(source, 1, 8f);
|
||||
queue.Submit(source, 2, 2f);
|
||||
queue.FlushFartherThan(0f);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, source, 1, false);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, source, 2, false);
|
||||
|
||||
Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
|
||||
Assert.Equal(0, queue.PendingCount);
|
||||
Assert.Equal(0, source.ResetCount);
|
||||
Assert.True(queue.IsCollecting);
|
||||
queue.EndFrame();
|
||||
Assert.Equal(1, source.ResetCount);
|
||||
}
|
||||
// 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);
|
||||
|
||||
[Fact]
|
||||
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var objects = new RecordingSource("object", log);
|
||||
var particles = new RecordingSource("particle", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
queue.Flush(RetailAlphaFlushSite.RenderNormalMode, 0f);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, source, 3, false);
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(objects, 0, 40f);
|
||||
queue.Submit(objects, 1, 35f);
|
||||
queue.Submit(particles, 0, 30f);
|
||||
queue.Submit(objects, 2, 25f);
|
||||
queue.EndFrame();
|
||||
|
||||
Assert.Equal(new[] { 2, 1 }, objects.BatchSizes);
|
||||
Assert.Equal(new[] { 1 }, particles.BatchSizes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_LargeInputMatchesStableDescendingRetailOrder()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var source = new RecordingSource("alpha", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
var random = new Random(0xAC2013);
|
||||
float[] distances = new float[4_096];
|
||||
|
||||
queue.BeginFrame();
|
||||
for (int i = 0; i < distances.Length; i++)
|
||||
{
|
||||
distances[i] = i % 127 switch
|
||||
{
|
||||
0 => float.NaN,
|
||||
1 => float.PositiveInfinity,
|
||||
2 => -0f,
|
||||
3 => -1f,
|
||||
_ => random.Next(0, 64),
|
||||
};
|
||||
queue.Submit(source, i, distances[i]);
|
||||
}
|
||||
queue.EndFrame();
|
||||
|
||||
int[] expected = Enumerable.Range(0, distances.Length)
|
||||
.OrderByDescending(i => NormalizeForReference(distances[i]))
|
||||
.ToArray();
|
||||
Assert.Equal(expected, source.PreparedTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_ExtremeFloatKeysMatchNormalizedRetailOrder()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var source = new RecordingSource("alpha", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
float[] distances =
|
||||
[
|
||||
1f,
|
||||
float.BitIncrement(1f),
|
||||
float.BitDecrement(1f),
|
||||
float.Epsilon,
|
||||
float.MaxValue,
|
||||
0f,
|
||||
-0f,
|
||||
float.NaN,
|
||||
float.PositiveInfinity,
|
||||
float.NegativeInfinity,
|
||||
-float.Epsilon,
|
||||
1f,
|
||||
];
|
||||
|
||||
queue.BeginFrame();
|
||||
for (int i = 0; i < distances.Length; i++)
|
||||
queue.Submit(source, i, distances[i]);
|
||||
queue.EndFrame();
|
||||
|
||||
int[] expected = Enumerable.Range(0, distances.Length)
|
||||
.OrderByDescending(i => NormalizeForReference(distances[i]))
|
||||
.ToArray();
|
||||
Assert.Equal(expected, source.PreparedTokens);
|
||||
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 log = new List<string>();
|
||||
var source = new RecordingSource("alpha", log);
|
||||
var source = new CountingSource();
|
||||
var queue = new RetailAlphaQueue(budgetBytes);
|
||||
|
||||
queue.BeginFrame();
|
||||
for (int i = 0; i < 8_192; i++)
|
||||
queue.Submit(source, i, i);
|
||||
queue.TryAppend(i % 2 == 0 ? RetailAlphaList.Clip : RetailAlphaList.Alpha, source, i, false);
|
||||
queue.EndFrame();
|
||||
Assert.True(queue.RetainedScratchBytes > budgetBytes);
|
||||
|
||||
|
|
@ -256,28 +371,6 @@ public sealed class RetailAlphaQueueTests
|
|||
Assert.Equal(0, queue.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_EqualDistanceOrderStartsFreshInEachFrameScope()
|
||||
{
|
||||
var log = new List<string>();
|
||||
var source = new RecordingSource("alpha", log);
|
||||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(source, 1, 12f);
|
||||
queue.Submit(source, 2, 12f);
|
||||
queue.EndFrame();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(source, 9, 12f);
|
||||
queue.Submit(source, 8, 12f);
|
||||
queue.EndFrame();
|
||||
|
||||
Assert.Equal(
|
||||
["alpha:1", "alpha:2", "alpha:9", "alpha:8"],
|
||||
log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbortFrame_DiscardsPayloadAndAllowsTheNextFrameToRender()
|
||||
{
|
||||
|
|
@ -286,7 +379,7 @@ public sealed class RetailAlphaQueueTests
|
|||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(source, 1, 12f);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, source, 1, false);
|
||||
queue.AbortFrame();
|
||||
|
||||
Assert.False(queue.IsCollecting);
|
||||
|
|
@ -295,10 +388,10 @@ public sealed class RetailAlphaQueueTests
|
|||
Assert.Equal(1, source.ResetCount);
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(source, 2, 12f);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, source, 2, false);
|
||||
queue.EndFrame();
|
||||
|
||||
Assert.Equal(["alpha:2"], log);
|
||||
Assert.Equal(new[] { "alpha:2" }, log);
|
||||
Assert.Equal(2, source.ResetCount);
|
||||
}
|
||||
|
||||
|
|
@ -310,8 +403,8 @@ public sealed class RetailAlphaQueueTests
|
|||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(drawSource, 1, 20f);
|
||||
queue.Submit(secondSource, 2, 10f);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, drawSource, 1, false);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, secondSource, 2, false);
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
|
||||
|
||||
|
|
@ -333,8 +426,8 @@ public sealed class RetailAlphaQueueTests
|
|||
var queue = new RetailAlphaQueue();
|
||||
|
||||
queue.BeginFrame();
|
||||
queue.Submit(first, 1, 20f);
|
||||
queue.Submit(second, 2, 10f);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, first, 1, false);
|
||||
queue.TryAppend(RetailAlphaList.Alpha, second, 2, false);
|
||||
|
||||
AggregateException failure = Assert.Throws<AggregateException>(queue.EndFrame);
|
||||
|
||||
|
|
@ -348,34 +441,11 @@ public sealed class RetailAlphaQueueTests
|
|||
Assert.False(queue.IsCollecting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
|
||||
{
|
||||
// Retail CPhysicsPart::UpdateViewerDistance 0x0050E030 applies scale,
|
||||
// orientation, and translation to gfxobj.sort_center before CYpt.
|
||||
var model = Matrix4x4.CreateScale(2f)
|
||||
* Matrix4x4.CreateRotationZ(MathF.PI / 2f)
|
||||
* Matrix4x4.CreateTranslation(10f, 20f, 30f);
|
||||
Vector3 localSortCenter = new(1f, 0f, 0f);
|
||||
Vector3 camera = new(10f, 20f, 30f);
|
||||
|
||||
float cypt = RetailAlphaOrdering.ComputeViewerDistance(
|
||||
localSortCenter,
|
||||
model,
|
||||
camera);
|
||||
|
||||
Assert.Equal(2f, cypt, precision: 5);
|
||||
}
|
||||
|
||||
private static float NormalizeForReference(float value)
|
||||
=> float.IsFinite(value) && value > 0f ? value : 0f;
|
||||
|
||||
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; }
|
||||
public IReadOnlyList<int> PreparedTokens => _prepared;
|
||||
private int[] _prepared = [];
|
||||
|
||||
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
|
||||
|
|
@ -394,6 +464,23 @@ public sealed class RetailAlphaQueueTests
|
|||
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
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ public sealed class RetailFrameWalkTests
|
|||
|
||||
public void OnLandscapeCellTurn(uint cellId) => Combined.Add($"SC:{cellId:x8}");
|
||||
|
||||
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c>'s own
|
||||
/// per-land-cell <c>FlushAlphaList(0.75f)</c> — recorded distinctly
|
||||
/// from <see cref="OnLandscapeCellTurn(uint)"/>'s "SC" line so a test
|
||||
/// can assert its exact position relative to both this cell's own SC
|
||||
/// turn and the NEXT cell's LC turn.</summary>
|
||||
public void OnSortCellExit(uint landblockId, int sideCellCount, int cellIndex)
|
||||
=> Combined.Add(
|
||||
$"SCX:{(landblockId & 0xFFFF0000u) | (uint)(cellIndex + 1):x8}");
|
||||
|
||||
public string Signature()
|
||||
=> string.Join("|", Events.Select(e => e.Kind switch
|
||||
{
|
||||
|
|
@ -301,6 +310,91 @@ public sealed class RetailFrameWalkTests
|
|||
Assert.True(buildingLc < bldIndex && bldIndex < buildingSc, "LC, then BLD, then SC");
|
||||
}
|
||||
|
||||
/// <summary>S4-c2: <c>RenderDeviceD3D::DrawBlock</c>'s own per-land-cell
|
||||
/// <c>FlushAlphaList(0.75f)</c> call — pinned at the exact position spec
|
||||
/// §7 site 2 requires: strictly AFTER that cell's own object-list turn
|
||||
/// (<c>OnLandscapeCellTurn</c>/"SC") and strictly BEFORE the next
|
||||
/// visited cell's terrain turn (<c>OnLandCellTurn</c>/"LC"). Reuses
|
||||
/// <see cref="DrawLandscape_InterleavesLandCellTurnsWithBuildingAndObjectTurns_FarToNear"/>'s
|
||||
/// exact two-block far/near setup so the "next cell" crossing is a real
|
||||
/// cross-BLOCK boundary, not a same-block artifact. Mutation check:
|
||||
/// emitting <c>OnSortCellExit</c> BEFORE <c>OnLandscapeCellTurn</c> (the
|
||||
/// wrong side of the object turn) would place SCX before SC for the far
|
||||
/// cell — the first assertion fails; emitting it once per BLOCK instead
|
||||
/// of once per CELL would produce only one SCX total instead of one per
|
||||
/// near-block cell — the count assertion fails.</summary>
|
||||
[Fact]
|
||||
public void DrawLandscape_EmitsSortCellExitAfterEachCellsObjectTurnAndBeforeTheNextCellsLandTurn()
|
||||
{
|
||||
var ctx = new TestContext
|
||||
{
|
||||
CyPlane = new WalkPlane(new Vector3(1, 0, 0), -30f),
|
||||
};
|
||||
var landscape = new WalkLandscape
|
||||
{
|
||||
MidWidth = 2,
|
||||
Blocks = new WalkLandBlock?[4],
|
||||
ViewerBlockX = 0,
|
||||
ViewerBlockY = 0,
|
||||
ViewerCellX = 0,
|
||||
ViewerCellY = 0,
|
||||
};
|
||||
|
||||
var nearBlock = new WalkLandBlock
|
||||
{
|
||||
LandblockId = 0x11110000u, SideCellCount = 8, MaxZ = 10f, MinZ = 0f,
|
||||
};
|
||||
nearBlock.EnsureCellArrays();
|
||||
var farBlock = new WalkLandBlock
|
||||
{
|
||||
LandblockId = 0x22220000u, SideCellCount = 1, MaxZ = 10f, MinZ = 0f,
|
||||
};
|
||||
farBlock.EnsureCellArrays();
|
||||
landscape.Blocks[0] = nearBlock;
|
||||
landscape.Blocks[3] = farBlock;
|
||||
|
||||
var walk = new RetailFrameWalk();
|
||||
var recorder = new Recorder();
|
||||
walk.DrawLandscape(landscape, new WalkPortalView(), ctx, recorder);
|
||||
|
||||
// The far block's single cell: SCX sits strictly between its own SC
|
||||
// turn and the FIRST cell of the near block (the very next LC the
|
||||
// walk visits, per the far-to-near block order this landscape's
|
||||
// sibling test already proves).
|
||||
const uint farCellId = 0x22220001u;
|
||||
int farSc = recorder.Combined.IndexOf($"SC:{farCellId:x8}");
|
||||
int farScx = recorder.Combined.IndexOf($"SCX:{farCellId:x8}");
|
||||
Assert.True(farSc >= 0 && farScx == farSc + 1, "SCX must immediately follow its own cell's SC");
|
||||
|
||||
int firstNearLc = -1;
|
||||
for (int i = 0; i < recorder.Combined.Count; i++)
|
||||
{
|
||||
if (recorder.Combined[i].StartsWith("LC:11110", StringComparison.Ordinal))
|
||||
{
|
||||
firstNearLc = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.True(firstNearLc > farScx, "the far cell's SCX must precede the near block's first LC");
|
||||
|
||||
// Every near-block cell (visited or not — SC/SCX fire under
|
||||
// AlwaysDrawObjects regardless of LC) gets exactly one SCX, each
|
||||
// immediately after that same cell's own SC.
|
||||
for (int cx = 0; cx < 8; cx++)
|
||||
for (int cy = 0; cy < 8; cy++)
|
||||
{
|
||||
uint cellId = 0x11110000u | (uint)(cx * 8 + cy + 1);
|
||||
int sc = recorder.Combined.IndexOf($"SC:{cellId:x8}");
|
||||
int scx = recorder.Combined.IndexOf($"SCX:{cellId:x8}");
|
||||
Assert.True(sc >= 0, $"cell 0x{cellId:x8} must have an SC turn");
|
||||
Assert.Equal(sc + 1, scx);
|
||||
}
|
||||
|
||||
// Total SCX count equals the total cell count (1 far + 64 near) —
|
||||
// one per cell, never batched per block.
|
||||
Assert.Equal(65, recorder.Combined.Count(e => e.StartsWith("SCX:", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interior_frame_without_exit_views_skips_the_landscape()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Walk;
|
||||
|
||||
|
|
@ -39,6 +40,8 @@ public static class WalkAlphaDepthTrace
|
|||
var frames = new List<WalkAlphaDepthFrame>();
|
||||
List<(int Mode, int CounterBefore)>? currentPm = null;
|
||||
List<(int Ov, int Counter, int ForceClear)>? currentPc = null;
|
||||
List<(bool IsClip, bool IsNew)>? currentAm = null;
|
||||
List<(uint ThreshBits, string Ret, int AmCountAtThisPoint)>? currentFl = null;
|
||||
int currentNumber = 0;
|
||||
|
||||
foreach (string line in lines)
|
||||
|
|
@ -48,12 +51,15 @@ public static class WalkAlphaDepthTrace
|
|||
{
|
||||
if (currentPm is not null)
|
||||
{
|
||||
frames.Add(new WalkAlphaDepthFrame(currentNumber, currentPm, currentPc!));
|
||||
frames.Add(new WalkAlphaDepthFrame(
|
||||
currentNumber, currentPm, currentPc!, currentAm!, currentFl!));
|
||||
}
|
||||
currentNumber = int.Parse(
|
||||
frameMatch.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
currentPm = new List<(int, int)>();
|
||||
currentPc = new List<(int, int, int)>();
|
||||
currentAm = new List<(bool, bool)>();
|
||||
currentFl = new List<(uint, string, int)>();
|
||||
continue;
|
||||
}
|
||||
if (currentPm is null)
|
||||
|
|
@ -76,9 +82,40 @@ public static class WalkAlphaDepthTrace
|
|||
int.Parse(pc.Groups[3].Value, CultureInfo.InvariantCulture)));
|
||||
continue;
|
||||
}
|
||||
// Anything else (FL/AM lines, cdb chrome) is out of this
|
||||
// parser's scope — ignored, matching WalkOracleTrace's own
|
||||
// catch-all.
|
||||
// S4-c2: AM mesh=<ptr> surf=<n> csurf=<ptr> new=<0|1> clip=<0|1>
|
||||
// listSel=<0|1> — D3DPolyRender::AddMeshToAlphaList @0x0059c230's
|
||||
// own printf. listSel nonzero selects CLIP
|
||||
// (alphaedMeshCountClip/List); zero selects ALPHA
|
||||
// (alphaedMeshCountAlpha/List) — Ghidra-verified 2026-09-04, see
|
||||
// RetailAlphaList's own doc comment. "new" is the per-entry
|
||||
// first-for-list flag (param_4); "clip" (param_5, unused here)
|
||||
// is overrideClipmap.
|
||||
Match am = AmPattern.Match(line);
|
||||
if (am.Success)
|
||||
{
|
||||
currentAm!.Add((
|
||||
IsClip: am.Groups["listSel"].Value != "0",
|
||||
IsNew: am.Groups["new"].Value != "0"));
|
||||
continue;
|
||||
}
|
||||
// S4-c2: FL thresh=<hex float bits> ret=<return address> —
|
||||
// D3DPolyRender::FlushAlphaList @0x0059d2e0's own printf. `ret`
|
||||
// is the CALLER's return address (the call instruction's own
|
||||
// address + 5, x86 CALL rel32 being 5 bytes) — NOT the call
|
||||
// instruction's address itself.
|
||||
Match fl = FlPattern.Match(line);
|
||||
if (fl.Success)
|
||||
{
|
||||
currentFl!.Add((
|
||||
uint.Parse(
|
||||
fl.Groups["thresh"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture),
|
||||
fl.Groups["ret"].Value,
|
||||
currentAm!.Count));
|
||||
continue;
|
||||
}
|
||||
// Anything else (PM/PC/FL/AM already handled; cdb chrome) is out
|
||||
// of this parser's scope — ignored, matching WalkOracleTrace's
|
||||
// own catch-all.
|
||||
}
|
||||
|
||||
// The last STARTED frame is deliberately never appended — the
|
||||
|
|
@ -86,6 +123,65 @@ public static class WalkAlphaDepthTrace
|
|||
return frames;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// S4-c2 gate G-c2: reconstructs retail's own sequence of ACTUAL flush
|
||||
/// drains (site, threshold, drained-CLIP-count, drained-ALPHA-count)
|
||||
/// purely from the capture's AM/FL text — no execution needed on this
|
||||
/// side. Mirrors <c>D3DPolyRender::FlushAlphaList</c>'s exact no-op rule
|
||||
/// (Ghidra-verified 2026-09-04: no-op only when BOTH running counts are
|
||||
/// strictly below <c>threshold * 3000</c>) so a threshold that DOESN'T
|
||||
/// drain (the overwhelming majority of 0.75f valve calls at ordinary
|
||||
/// scene complexity) leaves the running counts untouched for the NEXT
|
||||
/// flush call to inherit — exactly retail's own accumulation behavior.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<(RetailAlphaFlushSite Site, float Threshold, int DrainedClip, int DrainedAlpha)>
|
||||
BuildExpectedFlushTranscript(WalkAlphaDepthFrame frame)
|
||||
{
|
||||
var result = new List<(RetailAlphaFlushSite, float, int, int)>();
|
||||
int runningClip = 0;
|
||||
int runningAlpha = 0;
|
||||
int lastAmCount = 0;
|
||||
|
||||
foreach ((uint threshBits, string ret, int amCountAtThisPoint) in frame.FlEvents)
|
||||
{
|
||||
// AM lines recorded (in interleaved parse order) between the
|
||||
// previous FL and this one accumulate onto the running counts —
|
||||
// exactly retail's own accumulation, since a no-op FlushAlphaList
|
||||
// leaves both counters untouched for the next call to inherit.
|
||||
for (int i = lastAmCount; i < amCountAtThisPoint; i++)
|
||||
{
|
||||
if (frame.AmEvents[i].IsClip)
|
||||
runningClip++;
|
||||
else
|
||||
runningAlpha++;
|
||||
}
|
||||
lastAmCount = amCountAtThisPoint;
|
||||
|
||||
float threshold = BitConverter.UInt32BitsToSingle(threshBits);
|
||||
bool noOp = runningClip < threshold * 3000f && runningAlpha < threshold * 3000f;
|
||||
if (!noOp)
|
||||
{
|
||||
result.Add((MapSite(ret), threshold, runningClip, runningAlpha));
|
||||
runningClip = 0;
|
||||
runningAlpha = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static RetailAlphaFlushSite MapSite(string ret) => ret switch
|
||||
{
|
||||
"0059f310" => RetailAlphaFlushSite.DrawBuilding,
|
||||
"005a1a0c" => RetailAlphaFlushSite.SortCellExit,
|
||||
"005a4877" => RetailAlphaFlushSite.LandscapeFlush,
|
||||
"00453b90" => RetailAlphaFlushSite.RenderNormalMode,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"FL return address 0x{ret} is not one of the four normal-world flush sites "
|
||||
+ "(OH1 contract §7) — the fifth private CreatureMode::Render caller, or an "
|
||||
+ "unexpected address, reached this capture."),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<WalkAlphaDepthFrame> Load(string root, string fixtureName)
|
||||
{
|
||||
return Parse(File.ReadLines(ResolvePath(root, fixtureName)));
|
||||
|
|
@ -141,13 +237,22 @@ public static class WalkAlphaDepthTrace
|
|||
@"^PM poly=[0-9a-f]+ mode=([01]) counterBefore=([0-9a-f]{4})\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex PcPattern = new(
|
||||
@"^PC ov=(\d+) counter=([0-9a-f]{4}) fc=([01])\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex AmPattern = new(
|
||||
@"^AM mesh=[0-9a-f]+ surf=-?\d+ csurf=[0-9a-f]+ new=(?<new>[01]) clip=(?<clip>[01]) "
|
||||
+ @"listSel=(?<listSel>[01])\s*$",
|
||||
RegexOptions.Compiled);
|
||||
private static readonly Regex FlPattern = new(
|
||||
@"^FL thresh=(?<thresh>[0-9a-f]{8}) ret=(?<ret>[0-9a-f]{8})\s*$", RegexOptions.Compiled);
|
||||
}
|
||||
|
||||
/// <summary>One capture frame's PM (mode, counterBefore) and PC (ov,
|
||||
/// counter, forceClear) sequences, in the exact order the retail trace
|
||||
/// recorded them. Pointers are intentionally not carried — S4-c1 F3's own
|
||||
/// comparison ignores them.</summary>
|
||||
/// <summary>One capture frame's PM (mode, counterBefore), PC (ov, counter,
|
||||
/// forceClear), AM (isClip, isNew), and FL (thresholdBits, returnAddress,
|
||||
/// cumulativeAmCountAtThisPoint) sequences, in the exact order the retail
|
||||
/// trace recorded them. Pointers/surface indices are intentionally not
|
||||
/// carried — every S4 gate's own comparison ignores them.</summary>
|
||||
public sealed record WalkAlphaDepthFrame(
|
||||
int Number,
|
||||
IReadOnlyList<(int Mode, int CounterBefore)> PmEvents,
|
||||
IReadOnlyList<(int Ov, int Counter, int ForceClear)> PcEvents);
|
||||
IReadOnlyList<(int Ov, int Counter, int ForceClear)> PcEvents,
|
||||
IReadOnlyList<(bool IsClip, bool IsNew)> AmEvents,
|
||||
IReadOnlyList<(uint ThreshBits, string Ret, int AmCountAtThisPoint)> FlEvents);
|
||||
|
|
|
|||
|
|
@ -112,7 +112,22 @@ public sealed partial class WalkFrameDriverTests
|
|||
|
||||
AlphaPendingAtBarrier.Add(alpha.PendingCount);
|
||||
log.Add($"ALPHA:{alpha.PendingCount}");
|
||||
alpha.Flush();
|
||||
alpha.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
|
||||
}
|
||||
|
||||
public readonly List<int> AlphaPendingAtSortCellExit = new();
|
||||
|
||||
public void FlushSortCellExit()
|
||||
{
|
||||
if (alpha is null)
|
||||
{
|
||||
log.Add("SORTCELLEXIT");
|
||||
return;
|
||||
}
|
||||
|
||||
AlphaPendingAtSortCellExit.Add(alpha.PendingCount);
|
||||
log.Add($"SORTCELLEXIT:{alpha.PendingCount}");
|
||||
alpha.Flush(RetailAlphaFlushSite.SortCellExit, 0.75f);
|
||||
}
|
||||
|
||||
public void DrawStaticParticles(uint cellId) => log.Add($"PARTICLES:{cellId:x8}");
|
||||
|
|
@ -1712,7 +1727,11 @@ public sealed partial class WalkFrameDriverTests
|
|||
driver.EndFrame();
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
|
||||
Assert.Equal(new[] { "SKY", "LANDCELL:f4180000:1:0", "PARTICLES:f4180001" }, log);
|
||||
// S4-c2: RenderDeviceD3D::DrawBlock's own FlushAlphaList(0.75f) call
|
||||
// fires once per admitted land-block cell, after this cell's object
|
||||
// turn (here: the particle turn) — see OnSortCellExit.
|
||||
Assert.Equal(
|
||||
new[] { "SKY", "LANDCELL:f4180000:1:0", "PARTICLES:f4180001", "SORTCELLEXIT" }, log);
|
||||
Assert.Equal(new (uint LandblockId, int SideCellCount, int CellIndex)[] { (0xF4180000u, 1, 0) },
|
||||
Assert.Single(leaf.LandCellBatches));
|
||||
}
|
||||
|
|
@ -1800,10 +1819,15 @@ public sealed partial class WalkFrameDriverTests
|
|||
walk, cameraCellId: cell1.CellId, cameraCell: cell1, landscape: landscape,
|
||||
ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
|
||||
|
||||
// S4-c2: this cell's own object turn submits no particle (every
|
||||
// covered bucket is in CellsWithoutEmitters), so SORTCELLEXIT lands
|
||||
// immediately after the LandCell terrain turn — DrawBlock's own
|
||||
// FlushAlphaList(0.75f), before LandscapeFlush (PView::DrawCells'
|
||||
// own FlushAlphaList(0f)).
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"SKY", "LANDCELL:f4180000:1:0", "LFLUSH", "SEALS",
|
||||
"SKY", "LANDCELL:f4180000:1:0", "SORTCELLEXIT", "LFLUSH", "SEALS",
|
||||
"SHELL:00000101", "SHELL:00000100",
|
||||
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000101",
|
||||
"FLUSH:1:CellStatic", "CELL-PARTICLES:00000100",
|
||||
|
|
|
|||
|
|
@ -554,8 +554,15 @@ public sealed class WalkStaticStreamPopulatorTests
|
|||
// ── SubmitWalkAlphaInstance: same viewer distance + per-instance data as
|
||||
// DeferTransparentGroups, through the REAL RetailAlphaQueue. ───────────
|
||||
|
||||
/// <summary>S4-c2: retail's queues are FIFO, not distance-sorted — this
|
||||
/// pins the routing decision instead (an AlphaBlend batch's constructed
|
||||
/// mask is 0x02, which has no ClipMap bit, so
|
||||
/// <see cref="RetailAlphaMeshRouter.Route"/> appends it to ALPHA, never
|
||||
/// CLIP), plus the exact token. Mutation check: routing AlphaBlend to
|
||||
/// CLIP instead makes <c>ClipCount</c> assert fail (1 instead of the
|
||||
/// expected 0) and <c>AlphaCount</c> fail (0 instead of 1).</summary>
|
||||
[Fact]
|
||||
public void SubmitWalkAlphaInstance_SubmitsTheSameViewerDistanceComputeViewerDistanceWouldProduce()
|
||||
public void SubmitWalkAlphaInstance_RoutesAlphaBlendBatchIntoTheAlphaList()
|
||||
{
|
||||
using var fx = new DispatcherFixture(withAlphaQueue: true);
|
||||
fx.AlphaQueue!.BeginFrame();
|
||||
|
|
@ -572,18 +579,14 @@ public sealed class WalkStaticStreamPopulatorTests
|
|||
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, cameraWorldPosition, Matrix4x4.Identity);
|
||||
|
||||
Assert.Equal(1, fx.AlphaQueue.PendingCount);
|
||||
Assert.Equal(1, fx.AlphaQueue.AlphaCount);
|
||||
Assert.Equal(0, fx.AlphaQueue.ClipCount);
|
||||
|
||||
// Same call DeferTransparentGroups makes per instance — independently
|
||||
// computed here so the assertion cannot pass by construction.
|
||||
float expectedDistance = RetailAlphaOrdering.ComputeViewerDistance(
|
||||
localSortCenter, model, cameraWorldPosition);
|
||||
|
||||
FieldInfo submissionsField = typeof(RetailAlphaQueue).GetField(
|
||||
"_submissions", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var submissions = (List<RetailAlphaSubmission>)submissionsField.GetValue(fx.AlphaQueue)!;
|
||||
RetailAlphaSubmission submission = Assert.Single(submissions);
|
||||
Assert.Equal(expectedDistance, submission.ViewerDistance, precision: 4);
|
||||
Assert.Equal(0, submission.Token);
|
||||
FieldInfo alphaListField = typeof(RetailAlphaQueue).GetField(
|
||||
"_alpha", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var alphaList = (List<RetailAlphaEntry>)alphaListField.GetValue(fx.AlphaQueue)!;
|
||||
RetailAlphaEntry entry = Assert.Single(alphaList);
|
||||
Assert.Equal(0, entry.Token);
|
||||
|
||||
fx.AlphaQueue.AbortFrame();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Walk;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -163,7 +164,21 @@ public sealed partial class WalkTraceConformanceTests
|
|||
{
|
||||
private readonly Dictionary<uint, WalkCell> _cells;
|
||||
|
||||
public AlphaDepthLeaf(Dictionary<uint, WalkCell> cells) => _cells = cells;
|
||||
/// <summary>S4-c2 gate G-c2: the real production queue — this gate's
|
||||
/// walk carries no live GfxObj/particle mesh content (same design as
|
||||
/// this file's own PM/PC harness: <see cref="EmptyAlphaDepthWorldData"/>
|
||||
/// returns empty everywhere), so <see cref="_queue"/> stays at
|
||||
/// ClipCount/AlphaCount == 0 throughout — but the SEQUENCE of (site,
|
||||
/// threshold) calls the walk drives it through, and each call's
|
||||
/// real no-op-vs-drain decision under those (always-empty) counts,
|
||||
/// are both genuine production behavior, not stubbed.</summary>
|
||||
private readonly RetailAlphaQueue _queue;
|
||||
|
||||
public AlphaDepthLeaf(Dictionary<uint, WalkCell> cells, RetailAlphaQueue queue)
|
||||
{
|
||||
_cells = cells;
|
||||
_queue = queue;
|
||||
}
|
||||
|
||||
/// <summary>Set once, immediately after construction — the leaf and
|
||||
/// its owning <see cref="WalkFrameDriver"/> are mutually referential
|
||||
|
|
@ -173,6 +188,31 @@ public sealed partial class WalkTraceConformanceTests
|
|||
|
||||
public readonly List<(int Mode, int CounterBefore)> Seals = new();
|
||||
|
||||
/// <summary>Every ACTUAL flush this replay drove the queue through,
|
||||
/// in order — (site, threshold, clip count observed immediately
|
||||
/// BEFORE the drain, alpha count observed immediately BEFORE the
|
||||
/// drain). A no-op call (both counts stay strictly under
|
||||
/// <c>threshold*3000</c>) is not recorded, matching
|
||||
/// <see cref="WalkAlphaDepthTrace.BuildExpectedFlushTranscript"/>'s
|
||||
/// own definition of "a flush" on the capture side.</summary>
|
||||
public readonly List<(RetailAlphaFlushSite Site, float Threshold, int DrainedClip, int DrainedAlpha)>
|
||||
ActualFlushes = new();
|
||||
|
||||
private void RecordFlush(RetailAlphaFlushSite site, float threshold)
|
||||
{
|
||||
int clipBefore = _queue.ClipCount;
|
||||
int alphaBefore = _queue.AlphaCount;
|
||||
// Same no-op predicate as D3DPolyRender::FlushAlphaList itself
|
||||
// (RetailAlphaQueue.Flush) — computed here, before the real
|
||||
// call, purely so this recorder can decide whether THIS call
|
||||
// counts as "a flush" for the transcript.
|
||||
bool noOp = clipBefore < threshold * RetailAlphaQueue.ListCapacity
|
||||
&& alphaBefore < threshold * RetailAlphaQueue.ListCapacity;
|
||||
_queue.Flush(site, threshold);
|
||||
if (!noOp)
|
||||
ActualFlushes.Add((site, threshold, clipBefore, alphaBefore));
|
||||
}
|
||||
|
||||
public void DrawSky() { }
|
||||
public void DrawLandCellBatch(
|
||||
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) { }
|
||||
|
|
@ -181,9 +221,10 @@ public sealed partial class WalkTraceConformanceTests
|
|||
public void DrawStaticParticles(uint cellId) { }
|
||||
public void DrawCellParticles(uint cellId) { }
|
||||
public void ClearInteriorDepth() { }
|
||||
public void FlushLandscape() { }
|
||||
public void FlushLandscape() => RecordFlush(RetailAlphaFlushSite.LandscapeFlush, 0f);
|
||||
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) { }
|
||||
public void AlphaBarrier() { }
|
||||
public void AlphaBarrier() => RecordFlush(RetailAlphaFlushSite.DrawBuilding, 0f);
|
||||
public void FlushSortCellExit() => RecordFlush(RetailAlphaFlushSite.SortCellExit, 0.75f);
|
||||
|
||||
public int DrawExitSeals()
|
||||
{
|
||||
|
|
@ -279,7 +320,13 @@ public sealed partial class WalkTraceConformanceTests
|
|||
|
||||
using var fx = new WalkFrameDriverTests.DispatcherFixture();
|
||||
var worldData = new EmptyAlphaDepthWorldData();
|
||||
var leaf = new AlphaDepthLeaf(world.Cells);
|
||||
// S4-c2: AlphaDepthLeaf now records every flush through a real
|
||||
// RetailAlphaQueue (used by the AM/FL sibling gate below); this PM/PC
|
||||
// gate doesn't care about that queue's transcript, but it still must
|
||||
// be a valid ACTIVE frame or AlphaBarrier/FlushLandscape/
|
||||
// FlushSortCellExit's real Flush() calls throw.
|
||||
var alphaQueue = new RetailAlphaQueue();
|
||||
var leaf = new AlphaDepthLeaf(world.Cells, alphaQueue);
|
||||
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
|
||||
leaf.Driver = driver;
|
||||
var sink = new AlphaDepthCollectSink(driver);
|
||||
|
|
@ -289,6 +336,7 @@ public sealed partial class WalkTraceConformanceTests
|
|||
// pre-capture value instead of priming a throwaway first pass.
|
||||
driver.PortalsDrawnCount = initialCounter;
|
||||
|
||||
alphaQueue.BeginFrame();
|
||||
using (WalkFrameDriverTests.DrawScope draw = fx.BeginDraw())
|
||||
{
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, pose.Origin);
|
||||
|
|
@ -301,6 +349,7 @@ public sealed partial class WalkTraceConformanceTests
|
|||
// ended separately, same order WalkOutsideViewReassemblyTests'
|
||||
// own multi-draw loop uses.
|
||||
fx.FrameLifetime.EndFrame();
|
||||
alphaQueue.EndFrame();
|
||||
|
||||
var actualPm = new List<(int Mode, int CounterBefore)>(sink.Punches);
|
||||
actualPm.AddRange(leaf.Seals);
|
||||
|
|
@ -318,6 +367,162 @@ public sealed partial class WalkTraceConformanceTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// S4-c2 gate G-c2: extends this file's own PM/PC transcript gate from
|
||||
/// depth events to the AM/FL alpha-queue stream. Reuses the EXACT SAME
|
||||
/// replay harness (<see cref="EmptyAlphaDepthWorldData"/>: no live
|
||||
/// GfxObj/particle mesh content, matching this file's own established
|
||||
/// PM/PC pattern) with a real <see cref="RetailAlphaQueue"/> wired
|
||||
/// through <see cref="AlphaDepthLeaf"/>'s flush hooks.
|
||||
///
|
||||
/// <para><b>What this DOES prove, unconditionally (no KnownFailure
|
||||
/// needed):</b> the SEQUENCE of (site, threshold) the walk drives the
|
||||
/// queue through — which of the four normal-world sites fires, how many
|
||||
/// times, in what order — is purely structural (a function of cell/
|
||||
/// building/land-cell traversal, never of mesh content), so it is
|
||||
/// checked here as an unconditional pass/fail.</para>
|
||||
///
|
||||
/// <para><b>What this CANNOT prove with this harness:</b> the drained
|
||||
/// entry count PER LIST. This gate's queue carries zero live content
|
||||
/// (same design choice as the PM/PC gate it extends), so every 0f-
|
||||
/// threshold flush observes count (0, 0) — always a real drain (0 is
|
||||
/// never <c>< 0</c>), matching retail's own always-drains behavior at
|
||||
/// threshold 0f, but with a DIFFERENT count than the capture's real
|
||||
/// content volume. A capture pose whose expected transcript has any
|
||||
/// nonzero drained count therefore diverges on the count dimension by
|
||||
/// harness design, not by a routing bug — see this Fact's own
|
||||
/// <c>[Trait("Status","KnownFailure")]</c> and the S4-c2 packet write-up
|
||||
/// (S4 packet §9) for the full per-pose sequences.</para>
|
||||
/// </summary>
|
||||
private void RunAlphaFlushTranscriptGate(string fixtureName)
|
||||
{
|
||||
IReadOnlyList<WalkOracleFrame> poseFrames = WalkOracleTrace.Load(OhCaptureRoot, fixtureName);
|
||||
Assert.NotEmpty(poseFrames);
|
||||
Assert.True(poseFrames.Count > 1, $"{fixtureName}: need a captured frame 2.");
|
||||
WalkOracleFrame poseFrame = poseFrames[1];
|
||||
Assert.NotNull(poseFrame.Pose);
|
||||
WalkOraclePose pose = poseFrame.Pose!;
|
||||
|
||||
IReadOnlyList<WalkAlphaDepthFrame> depthFrames = WalkAlphaDepthTrace.Load(OhCaptureRoot, fixtureName);
|
||||
Assert.True(depthFrames.Count > 1, $"{fixtureName}: need a captured frame 2's AM/FL content.");
|
||||
WalkAlphaDepthFrame expectedFrame = depthFrames[1];
|
||||
int initialCounter = WalkAlphaDepthTrace.LoadInitialCounter(OhCaptureRoot, fixtureName);
|
||||
var expected = WalkAlphaDepthTrace.BuildExpectedFlushTranscript(expectedFrame);
|
||||
|
||||
using DatCollection dats = OpenDats();
|
||||
WalkLandscapeDatBuilder.BuiltWorld world =
|
||||
WalkLandscapeDatBuilder.Build(dats, pose.CellId, pose.Origin);
|
||||
var ctx = new WalkTraceReplayContext(pose, world.Cells) { Buildings = world.Buildings };
|
||||
WalkCell? camera = (pose.CellId & 0xFFFFu) >= 0x100
|
||||
? Assert.Contains(pose.CellId, world.Cells)
|
||||
: null;
|
||||
|
||||
using var fx = new WalkFrameDriverTests.DispatcherFixture();
|
||||
var worldData = new EmptyAlphaDepthWorldData();
|
||||
var queue = new RetailAlphaQueue();
|
||||
var leaf = new AlphaDepthLeaf(world.Cells, queue);
|
||||
var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData);
|
||||
leaf.Driver = driver;
|
||||
var sink = new AlphaDepthCollectSink(driver);
|
||||
var walk = new RetailFrameWalk();
|
||||
driver.PortalsDrawnCount = initialCounter;
|
||||
|
||||
queue.BeginFrame();
|
||||
using (WalkFrameDriverTests.DrawScope draw = fx.BeginDraw())
|
||||
{
|
||||
driver.BeginFrame(ctx, Matrix4x4.Identity, pose.Origin);
|
||||
walk.WalkFrame(pose.CellId, camera, world.Landscape, ctx, sink);
|
||||
driver.EndFrame();
|
||||
driver.Replay(draw.Frame, draw.Pass);
|
||||
}
|
||||
fx.FrameLifetime.EndFrame();
|
||||
|
||||
// SmartBox::RenderNormalMode's own top-level FlushAlphaList(0f) —
|
||||
// RetailAlphaQueue.EndFrame() calls this internally, so it is
|
||||
// recorded here rather than through a leaf hook (see
|
||||
// RetailAlphaQueue.EndFrame's own doc comment).
|
||||
int clipBeforeFinal = queue.ClipCount;
|
||||
int alphaBeforeFinal = queue.AlphaCount;
|
||||
queue.EndFrame();
|
||||
leaf.ActualFlushes.Add(
|
||||
(RetailAlphaFlushSite.RenderNormalMode, 0f, clipBeforeFinal, alphaBeforeFinal));
|
||||
|
||||
IReadOnlyList<(RetailAlphaFlushSite Site, float Threshold, int DrainedClip, int DrainedAlpha)> actual =
|
||||
leaf.ActualFlushes;
|
||||
|
||||
bool siteSequenceMatches = expected
|
||||
.Select(e => (e.Site, e.Threshold))
|
||||
.SequenceEqual(actual.Select(a => (a.Site, a.Threshold)));
|
||||
|
||||
// The site+threshold sequence is a REAL, unconditional pass/fail —
|
||||
// purely structural, never content-dependent.
|
||||
Assert.True(
|
||||
siteSequenceMatches,
|
||||
$"{fixtureName}: flush SITE sequence diverges (content-independent — this must "
|
||||
+ $"match unconditionally) — expected {FormatFlushSites(expected)}, "
|
||||
+ $"actual {FormatFlushSites(actual)}");
|
||||
|
||||
// The full tuple (including per-list drained counts) is the part
|
||||
// this harness's zero-content design cannot prove — recorded here
|
||||
// as a real, evaluated assertion (never weakened), expected to
|
||||
// diverge on the count dimension for any pose with real content;
|
||||
// callers gate on Status=KnownFailure, not on this passing.
|
||||
bool fullMatches = expected.SequenceEqual(actual);
|
||||
Assert.True(
|
||||
fullMatches,
|
||||
$"{fixtureName}: flush transcript diverges on drained COUNT only (site+threshold "
|
||||
+ "sequence above already verified exact) — this hermetic replay carries no live "
|
||||
+ "GfxObj/particle content (EmptyAlphaDepthWorldData, matching this file's own "
|
||||
+ $"PM/PC design), so every count reads (0,0) — expected {FormatFlushCounts(expected)}, "
|
||||
+ $"actual {FormatFlushCounts(actual)}");
|
||||
}
|
||||
|
||||
private static string FormatFlushSites(
|
||||
IReadOnlyList<(RetailAlphaFlushSite Site, float Threshold, int DrainedClip, int DrainedAlpha)> events) =>
|
||||
"[" + string.Join(", ", events.Select(e => $"({e.Site},thresh={e.Threshold})")) + "]";
|
||||
|
||||
private static string FormatFlushCounts(
|
||||
IReadOnlyList<(RetailAlphaFlushSite Site, float Threshold, int DrainedClip, int DrainedAlpha)> events) =>
|
||||
"[" + string.Join(
|
||||
", ",
|
||||
events.Select(e => $"({e.Site},thresh={e.Threshold},clip={e.DrainedClip},alpha={e.DrainedAlpha})")) + "]";
|
||||
|
||||
/// <summary>S4-c2 gate G-c2. See <see cref="RunAlphaFlushTranscriptGate"/>'s
|
||||
/// own doc comment for what this Fact does and does not prove; tagged
|
||||
/// KnownFailure because this hermetic harness carries no live mesh
|
||||
/// content, so the drained-count dimension diverges from the capture's
|
||||
/// real content volume by design — the S4 packet's §9 subsection quotes
|
||||
/// both sequences per pose.</summary>
|
||||
[Fact]
|
||||
[Trait("Status", "KnownFailure")]
|
||||
public void AlphaFlushTranscript_CathedralArrival_MatchesRetailFrame2()
|
||||
=> RunAlphaFlushTranscriptGate("cathedral-arrival.alphadepth");
|
||||
|
||||
[Fact]
|
||||
[Trait("Status", "KnownFailure")]
|
||||
public void AlphaFlushTranscript_CathedralLeak_MatchesRetailFrame2()
|
||||
=> RunAlphaFlushTranscriptGate("cathedral-leak.alphadepth");
|
||||
|
||||
[Fact]
|
||||
[Trait("Status", "KnownFailure")]
|
||||
public void AlphaFlushTranscript_CathedralStairArch_MatchesRetailFrame2()
|
||||
=> RunAlphaFlushTranscriptGate("cathedral-stair-arch.alphadepth");
|
||||
|
||||
[Fact]
|
||||
[Trait("Status", "KnownFailure")]
|
||||
public void AlphaFlushTranscript_FoundryDeep_MatchesRetailFrame2()
|
||||
=> RunAlphaFlushTranscriptGate("foundry-deep.alphadepth");
|
||||
|
||||
[Fact]
|
||||
[Trait("Status", "KnownFailure")]
|
||||
public void AlphaFlushTranscript_HoltburgDoorwayStill_MatchesRetailFrame2()
|
||||
=> RunAlphaFlushTranscriptGate("holtburg-doorway-still.alphadepth");
|
||||
|
||||
[Fact]
|
||||
[Trait("Status", "KnownFailure")]
|
||||
public void AlphaFlushTranscript_TerraceEdge_MatchesRetailFrame2()
|
||||
=> RunAlphaFlushTranscriptGate("terrace-edge.alphadepth");
|
||||
|
||||
private static string FormatPm(IReadOnlyList<(int Mode, int CounterBefore)> events) =>
|
||||
"[" + string.Join(", ", events.Select(e => $"(mode={e.Mode},counterBefore={e.CounterBefore})")) + "]";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue