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:
Erik 2026-09-04 05:50:45 +02:00
parent 3f69f1126b
commit 89f1e2676f
19 changed files with 1913 additions and 837 deletions

View file

@ -323,6 +323,17 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
for (int i = 0; i < _submissionScratch.Count; i++)
{
ParticleSubmission submission = _submissionScratch[i];
// A billboard's blend mode (Additive bool) is either the
// Additive or the plain Alpha surface bit — both live in
// retail's alpha-family union (0x00010300) and construct the
// SAME queue mask (0x02); billboards never carry a ClipMap bit
// (that is a mesh/UV-texture concept a generated particle quad
// has no equivalent of), so they always route to ALPHA.
byte mask = submission.Kind == ParticleSubmissionKind.Billboard
? RetailAlphaMeshRouter.MaskAlphaFamily
: RetailAlphaMeshRouter.MaskFromTranslucencyKind(
_meshDrawListScratch[submission.DrawIndex].Batch.Translucency);
DeferredParticleDraw deferred = submission.Kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
submission.Kind,
@ -337,10 +348,29 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
int token = _deferredAlpha.Count;
_deferredAlpha.Add(deferred);
queue.Submit(
_alphaSource,
token,
MathF.Sqrt(MathF.Max(0f, submission.DistanceSq)));
// S4-c2: same unreachable-branch reasoning as
// WbDrawDispatcher.SubmitToAlphaQueue — this "Scene" pass never
// draws during the Sky leaf (sky/off-screen particles use the
// independent DrawOrdered path entirely, never this method) and
// never installs a detail surface; MultiPassAlpha stays false.
RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route(
currentlyDrawingSky: false,
delayMask: RetailAlphaMeshRouter.DefaultDelayMask,
detailSurfaceActive: false,
multiPassAlpha: false,
subsetMask: mask,
materialHasAlpha: false);
if (decision.Action != RetailAlphaMeshAction.Append)
{
throw new InvalidOperationException(
"Scene particle submissions never draw during the Sky leaf or with a "
+ "detail surface installed, and MultiPassAlpha stays false — DrawMesh's "
+ $"row 1/2/4/5 branches are unreachable here; got {decision.Action}.");
}
// Capacity overflow (spec §5): dropped, no recovery.
queue.TryAppend(decision.List, _alphaSource, token, decision.OverrideClipmap);
}
}
@ -600,11 +630,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position);
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
renderData.SortCenter,
model,
cameraWorldPosition);
float distanceSq = viewerDistance * viewerDistance;
// S4-c2: RetailAlphaOrdering.ComputeViewerDistance is deleted (the
// alpha queue is FIFO now), but this distanceSq is still needed by
// the UNRELATED non-deferred immediate-draw path's own local sort
// (ParticleSubmissionOrdering.Sort, ParticleRenderer.Rhi.cs) —
// inlined here rather than resurrecting the deleted helper.
Vector3 worldSortCenter = Vector3.Transform(renderData.SortCenter, model);
float distanceSq = Vector3.DistanceSquared(worldSortCenter, cameraWorldPosition);
var instance = new MeshParticleInstance(
model,
particle.ColorArgb,

View file

@ -0,0 +1,184 @@
using AcDream.Core.Meshing;
namespace AcDream.App.Rendering;
/// <summary>What a routed subset does with the two alpha lists.</summary>
internal enum RetailAlphaMeshAction
{
/// <summary>Rows 1/5 — draw now; never touches a list.</summary>
Immediate,
/// <summary>Rows 3/4 — append only; no immediate draw this frame.</summary>
Append,
/// <summary>Row 2 — append to CLIP with <c>overrideClipmap=true</c>, AND
/// ALSO draw immediately (with <c>overrideClipmap=false</c>) in the same
/// call.</summary>
AppendClipAndImmediate,
}
internal readonly record struct RetailAlphaMeshDecision(
RetailAlphaMeshAction Action,
RetailAlphaList List,
bool OverrideClipmap);
/// <summary>
/// Pure port of retail's <c>D3DPolyRender::ConstructMesh</c> (@0x0059dfa0,
/// subset-mask construction) and <c>D3DPolyRender::DrawMesh</c> (@0x0059d4a0,
/// immediate/delayed branch table) — no GPU calls, no mutable state. Every
/// branch condition below is quoted from the 2026-09-04 Ghidra decompile of
/// both functions (<c>docs/research/2026-09-01-overhaul/oh1-alpha-list-contract.md</c>
/// §2/§4 is the researched spec this ports; the decompile is the oracle this
/// implementation was independently checked against).
/// </summary>
internal static class RetailAlphaMeshRouter
{
/// <summary>Alpha-family bits (<c>ALPHA|INVALPHA|ADDITIVE</c> =
/// <c>0x00010300</c>) win outright over ClipMap/Translucent in
/// <c>ConstructMesh</c>'s mask.</summary>
internal const byte MaskAlphaFamily = 0x02;
internal const byte MaskTranslucent = 0x04;
internal const byte MaskClipMap = 0x08;
/// <summary>Set only when the polygon's signed stippling byte is
/// positive. Structurally inert under <see cref="DefaultDelayMask"/>
/// (<c>0x0E</c> never has bit 0 set, so it never survives the
/// <c>delayMask &amp; subsetMask</c> AND in row 3) — kept for spec
/// fidelity and the truth-table gate, not because it changes any
/// reachable routing decision today.</summary>
internal const byte MaskPositiveStipple = 0x01;
/// <summary><c>s_AlphaDelayMask</c> @0x00820D88's static default. No
/// acdream call site reads an environment override (contract Must-Not).</summary>
internal const byte DefaultDelayMask = 0x0E;
/// <summary>
/// <c>D3DPolyRender::ConstructMesh</c> @0x0059dfa0 (Ghidra-verified
/// 2026-09-04):
/// <code>
/// if ((Surface.Type &amp; 0x10300) != 0) mask = 0x02;
/// else if ((Surface.Type &amp; 0x04) != 0) mask = 0x08;
/// else if ((Surface.Type &amp; 0x10) != 0) mask = 0x04;
/// else mask = 0x00;
/// // per polygon, OR 0x01 into the owning surface's mask entry when
/// // (signed char)polygon.stippling &gt; 0.
/// </code>
/// Alpha-family bits are checked FIRST and win outright even when
/// ClipMap or Translucent bits are also present.
/// </summary>
internal static byte ConstructSubsetMask(
bool hasAlphaFamilyBit,
bool hasClipMapBit,
bool hasTranslucentBit,
bool hasPositiveStippling)
{
byte mask = hasAlphaFamilyBit
? MaskAlphaFamily
: hasClipMapBit
? MaskClipMap
: hasTranslucentBit
? MaskTranslucent
: (byte)0;
if (hasPositiveStippling)
mask |= MaskPositiveStipple;
return mask;
}
/// <summary>
/// acdream's post-classification <see cref="TranslucencyKind"/> already
/// folds retail's raw <c>SurfaceType</c> through a DIFFERENT priority
/// chain (translucent-override-first, additive-second, ... —
/// <see cref="TranslucencyKindExtensions.FromSurfaceType"/>) built for
/// blend-STATE selection, not queue routing. This reconstructs the
/// spec's queue-routing mask from that already-collapsed classification.
/// <see cref="TranslucencyKind.Opaque"/> and <see cref="TranslucencyKind.ClipMap"/>
/// are pre-filtered as "opaque" by <c>WbDrawDispatcher.IsOpaque</c> and
/// never actually reach a call site that invokes this — both map
/// defensively rather than throwing.
///
/// <para><b>Known gap (register row AP-236b):</b>
/// <see cref="TranslucencyKindExtensions.FromSurfaceType"/>'s
/// "Translucent override" (raw <c>Surface.Type</c> has BOTH the
/// Translucent (0x10) AND Base1ClipMap (0x04) bits, but no alpha-family
/// bit — e.g. cloud GfxObj <c>0x01004C35</c>) resolves to
/// <see cref="TranslucencyKind.AlphaBlend"/>, discarding the raw ClipMap
/// bit. Retail's <c>ConstructMesh</c> would still see that ClipMap bit
/// and produce mask <c>0x08</c> (routes to CLIP); this reconstruction,
/// having only the collapsed <see cref="TranslucencyKind"/>, produces
/// mask <c>0x02</c> (routes to ALPHA) instead. <c>GroupKey</c> does not
/// carry the raw <c>SurfaceType</c> needed to distinguish the two
/// AlphaBlend origins, and plumbing it through would touch
/// <c>GfxObjSubMesh</c>/<c>GroupKey</c>/every mesh-classification call
/// site — out of this chunk's bounded scope. The consequence is
/// compositing-ORDER only (a queue-selection difference, not a blend/
/// visual difference): a translucent+clipmap subset (in practice, only
/// clouds) can interleave with ordinary ALPHA-list content at a narrow
/// overlap instead of retail's CLIP-list interleave.</para>
/// </summary>
internal static byte MaskFromTranslucencyKind(TranslucencyKind kind) => kind switch
{
TranslucencyKind.ClipMap => MaskClipMap,
TranslucencyKind.Opaque => 0,
_ => MaskAlphaFamily, // AlphaBlend, Additive, InvAlpha
};
/// <summary>
/// <c>D3DPolyRender::DrawMesh</c> @0x0059d4a0 (Ghidra-verified
/// 2026-09-04), rows in the order retail evaluates them:
/// <code>
/// // Row 1
/// if (currentlyDrawingSky || delayMask == 0 || detailSurfaceActive)
/// return Immediate;
/// // Row 2
/// if (multiPassAlpha &amp;&amp; (subsetMask &amp; 0x08) != 0)
/// return AppendClipAndImmediate; // CLIP, overrideClipmap=true; ALSO draw now
/// // Row 3
/// if ((delayMask &amp; subsetMask) != 0)
/// return Append(list: (subsetMask &amp; 0x08) != 0 ? Clip : Alpha, overrideClipmap: false);
/// // Row 4
/// if ((delayMask &amp; 0x04) != 0 &amp;&amp; materialHasAlpha)
/// return Append(Alpha, overrideClipmap: false);
/// // Row 5
/// return Immediate;
/// </code>
/// </summary>
internal static RetailAlphaMeshDecision Route(
bool currentlyDrawingSky,
byte delayMask,
bool detailSurfaceActive,
bool multiPassAlpha,
byte subsetMask,
bool materialHasAlpha)
{
// Row 1: m_currentlyDrawingSky || s_AlphaDelayMask == 0 || curr_detail_surface != null
if (currentlyDrawingSky || delayMask == 0 || detailSurfaceActive)
return new RetailAlphaMeshDecision(RetailAlphaMeshAction.Immediate, default, false);
bool clipMapBitSet = (subsetMask & MaskClipMap) != 0;
// Row 2: MultiPassAlpha && (mask & 0x08) != 0
if (multiPassAlpha && clipMapBitSet)
{
return new RetailAlphaMeshDecision(
RetailAlphaMeshAction.AppendClipAndImmediate, RetailAlphaList.Clip, true);
}
// Row 3: (delayMask & mask) != 0 -> CLIP iff bit 0x08 set, else ALPHA
if ((delayMask & subsetMask) != 0)
{
return new RetailAlphaMeshDecision(
RetailAlphaMeshAction.Append,
clipMapBitSet ? RetailAlphaList.Clip : RetailAlphaList.Alpha,
false);
}
// Row 4: (delayMask & 0x04) != 0 && material != null && material.has_alpha != 0
if ((delayMask & MaskTranslucent) != 0 && materialHasAlpha)
return new RetailAlphaMeshDecision(RetailAlphaMeshAction.Append, RetailAlphaList.Alpha, false);
// Row 5: otherwise
return new RetailAlphaMeshDecision(RetailAlphaMeshAction.Immediate, default, false);
}
}

View file

@ -1,25 +1,64 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.ExceptionServices;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
internal static class RetailAlphaOrdering
/// <summary>
/// Retail's two independent <c>D3DPolyRender::AddMeshToAlphaList</c>
/// (@0x0059c230) destinations. Ghidra-decompiled 2026-09-04:
/// <code>
/// if (param_6) {
/// if (alphaedMeshCountClip &lt; 3000) { ... alphaedMeshListClip[...] = ...; return true; }
/// } else if (alphaedMeshCountAlpha &lt; 3000) { ... alphaedMeshListAlpha[...] = ...; return true; }
/// return false;
/// </code>
/// <c>param_6</c> is the cdb capture's <c>listSel</c> byte (raw stack value
/// at <c>@esp+0x18</c>): nonzero (TRUE) selects
/// <c>alphaedMeshCountClip</c>/<c>alphaedMeshListClip</c>; zero (FALSE)
/// selects <c>alphaedMeshCountAlpha</c>/<c>alphaedMeshListAlpha</c>. The two
/// lists are completely independent static arrays of 3000
/// <c>AlphaListEntry</c> records each with their own <c>uint16</c> count —
/// never merged, never cross-sorted.
/// </summary>
internal enum RetailAlphaList : byte
{
/// <summary>
/// Retail <c>CPhysicsPart::UpdateViewerDistance</c> 0x0050E030:
/// transform the GfxObj's authored sort center through the part draw frame,
/// then store its Euclidean distance from the viewer as <c>CYpt</c>.
/// </summary>
public static float ComputeViewerDistance(
Vector3 localSortCenter,
Matrix4x4 model,
Vector3 cameraWorldPosition)
=> Vector3.Distance(Vector3.Transform(localSortCenter, model), cameraWorldPosition);
Alpha = 0,
Clip = 1,
}
/// <summary>
/// Retail's four normal-world <c>D3DPolyRender::FlushAlphaList</c>
/// (@0x0059d2e0) call sites (OH1 contract §7), each confirmed 2026-09-04
/// against <c>docs/research/named-retail/symbols.json</c> — the capture's
/// return address lies inside the named function's range:
/// <list type="bullet">
/// <item><see cref="DrawBuilding"/> — <c>RenderDeviceD3D::DrawBuilding</c>
/// @0x0059f2a0, return site 0x0059f30b, threshold 0f.</item>
/// <item><see cref="SortCellExit"/> — acdream's own name for
/// <c>RenderDeviceD3D::DrawBlock</c> @0x005a18d0's per-land-cell
/// <c>FlushAlphaList(::flush)</c>, return site 0x005a1a07, threshold 0.75f
/// (the immutable global <c>::flush</c>). There is no retail symbol to
/// borrow for the walk's own leaf-event name — the walk expresses this turn
/// as <c>OnSortCellTurn</c>/<c>OnLandscapeCellTurn</c>, not a "DrawBlock"
/// hook.</item>
/// <item><see cref="LandscapeFlush"/> — <c>PView::DrawCells</c> @0x005a4840,
/// return site 0x005a4872, threshold 0f.</item>
/// <item><see cref="RenderNormalMode"/> — <c>SmartBox::RenderNormalMode</c>
/// @0x00453aa0, return site 0x00453b8b, threshold 0f.</item>
/// </list>
/// A fifth retail caller (the private <c>CreatureMode::Render</c>
/// @0x004529d0, return site 0x00452bf0) is out of the normal-world walk this
/// enum covers (OH1 contract §7) and has no acdream call site.
/// </summary>
internal enum RetailAlphaFlushSite
{
DrawBuilding,
SortCellExit,
LandscapeFlush,
RenderNormalMode,
}
/// <summary>
@ -29,8 +68,11 @@ internal static class RetailAlphaOrdering
/// </summary>
internal interface IRetailAlphaDrawSource
{
/// <summary>Uploads this source's complete payload for the current sorted
/// alpha scope exactly once. Tokens arrive in final far-to-near order.</summary>
/// <summary>Uploads this source's complete payload for the current drain
/// exactly once. Tokens arrive in list order — every CLIP-list token
/// this source owns, in append order, followed by every ALPHA-list
/// token this source owns, in append order (OH1 contract §5's drain
/// order: CLIP fully, then ALPHA fully).</summary>
void PrepareAlphaDraws(ReadOnlySpan<int> tokens);
/// <summary>Draws a contiguous range from the payload prepared above.</summary>
@ -39,24 +81,50 @@ internal interface IRetailAlphaDrawSource
void ResetAlphaSubmissions();
}
internal readonly record struct RetailAlphaSubmission(
/// <summary>
/// One retail <c>AlphaListEntry</c>. <see cref="OverrideClipmap"/> mirrors
/// retail's per-entry <c>clip</c> byte (<c>SetSurface</c>'s alpha-blend-vs-
/// clip-test arm selector); <see cref="IsFirstForList"/> mirrors retail's
/// per-entry <c>new</c> byte (true only for the first entry appended to its
/// list since the list was last drained — retail captures the current
/// material/object matrix only for that entry). Neither drives observable
/// acdream behavior today: Vulkan's per-batch material/matrix binding makes
/// retail's per-entry material-capture optimization a no-op here (see
/// <see cref="RetailAlphaQueue"/>'s class doc comment), but both are stored
/// for fidelity and are directly test-observable.
/// </summary>
internal readonly record struct RetailAlphaEntry(
IRetailAlphaDrawSource Source,
int Token,
float ViewerDistance);
bool OverrideClipmap,
bool IsFirstForList);
/// <summary>
/// Frame-scoped port of retail's shared <c>D3DPolyRender</c> alpha list.
/// Scene-particle parts and ordinary GfxObj parts enter one stable,
/// far-to-near stream instead of being composited in renderer-local passes.
/// Frame-scoped port of retail's shared <c>D3DPolyRender</c> alpha lists.
/// Scene-particle parts and ordinary GfxObj parts enter retail's own two
/// FIFO lists instead of being composited in renderer-local passes or a
/// single acdream-only distance sort.
///
/// Retail oracle:
/// <list type="bullet">
/// <item><c>CShadowPart::insertion_sort</c> 0x006B5130 orders all cell parts by
/// <c>CPhysicsPart::CYpt</c>.</item>
/// <item><c>D3DPolyRender::AddMeshToAlphaList</c> 0x0059C230 appends delayed
/// surface subsets in that established order.</item>
/// <item><c>D3DPolyRender::FlushAlphaList</c> 0x0059D2E0 drains without another
/// material/renderer sort.</item>
/// <item><c>D3DPolyRender::AddMeshToAlphaList</c> 0x0059C230 appends,
/// strictly FIFO, no sort — Ghidra-verified 2026-09-04.</item>
/// <item><c>D3DPolyRender::FlushAlphaList</c> 0x0059D2E0 is a no-op only
/// when BOTH counts are strictly below <c>threshold * 3000</c>; otherwise it
/// drains the ENTIRE CLIP list in append order, resets it, then drains the
/// ENTIRE ALPHA list in append order and resets it — Ghidra-verified
/// 2026-09-04:
/// <code>
/// if (((float)clipCount &lt; threshold * 3000f) &amp;&amp; ((float)alphaCount &lt; threshold * 3000f))
/// return false;
/// </code>
/// The comparison is strict <c>&lt;</c>: a count exactly equal to
/// <c>threshold * 3000</c> (e.g. 2250 at the 0.75 valve) does NOT satisfy
/// the early-return condition and therefore drains. It restores the object
/// matrix after replay; it does NOT restore the material — under Vulkan's
/// per-batch material binding (every <see cref="IRetailAlphaDrawSource"/>
/// draw call binds its own material state) this is a no-op with no visible
/// consequence, not a divergence worth a register row.</item>
/// </list>
/// </summary>
internal interface IWorldSceneAlphaFrame
@ -70,15 +138,20 @@ internal interface IWorldSceneAlphaFrame
internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
{
/// <summary><c>D3DPolyRender::AddMeshToAlphaList</c> @0x0059c230's
/// <c>0xbb8</c> capacity constant, verified identical for both lists via
/// Ghidra decompile 2026-09-04.</summary>
internal const int ListCapacity = 3000;
private const int MinimumSubmissionCapacity = 256;
private const int SubmissionGrowthQuantum = 256;
private const int MinimumSourceCapacity = 4;
private readonly List<RetailAlphaSubmission> _submissions = new(256);
private readonly List<RetailAlphaEntry> _clip = new(256);
private readonly List<RetailAlphaEntry> _alpha = new(256);
private readonly List<IRetailAlphaDrawSource> _sources = new(4);
private int[] _tokenScratch = new int[256];
private int[] _sourceDrawOffsets = new int[4];
private RetailAlphaSubmission[] _sortScratch = new RetailAlphaSubmission[256];
private readonly int[] _radixOffsets = new int[256];
private readonly RetainedScratchCapacityPolicy _scratchPolicy;
private readonly long _scratchBudgetBytes;
@ -89,35 +162,52 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
ResidencyBudgetOptions.Default.AlphaScratchBytes).QueueBytes;
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(budget);
_scratchBudgetBytes = budget;
_scratchPolicy = new RetainedScratchCapacityPolicy(Math.Max(
1,
budget - (long)_radixOffsets.Length * sizeof(int)));
_scratchPolicy = new RetainedScratchCapacityPolicy(budget);
}
public bool IsCollecting { get; private set; }
internal int PendingCount => _submissions.Count;
internal int ClipCount => _clip.Count;
internal int AlphaCount => _alpha.Count;
/// <summary>Total pending entries across both lists.</summary>
internal int PendingCount => _clip.Count + _alpha.Count;
internal long ScratchBudgetBytes => _scratchBudgetBytes;
internal long RetainedScratchBytes => checked(
(long)_submissions.Capacity
* Unsafe.SizeOf<RetailAlphaSubmission>()
+ (long)_sortScratch.Length
* Unsafe.SizeOf<RetailAlphaSubmission>()
(long)_clip.Capacity * Unsafe.SizeOf<RetailAlphaEntry>()
+ (long)_alpha.Capacity * Unsafe.SizeOf<RetailAlphaEntry>()
+ (long)_tokenScratch.Length * sizeof(int)
+ (long)_sources.Capacity * IntPtr.Size
+ (long)_sourceDrawOffsets.Length * sizeof(int)
+ (long)_radixOffsets.Length * sizeof(int));
+ (long)_sourceDrawOffsets.Length * sizeof(int));
public void BeginFrame()
{
if (IsCollecting)
throw new InvalidOperationException("Retail alpha frame is already active.");
if (_submissions.Count != 0 || _sources.Count != 0)
if (_clip.Count != 0 || _alpha.Count != 0 || _sources.Count != 0)
throw new InvalidOperationException("Retail alpha queue retained payload outside a frame.");
IsCollecting = true;
}
public void Submit(IRetailAlphaDrawSource source, int token, float viewerDistance)
/// <summary>
/// Retail <c>D3DPolyRender::AddMeshToAlphaList</c> @0x0059c230: append
/// strictly FIFO to <paramref name="list"/>; when that list is already
/// at <see cref="ListCapacity"/>, return <see langword="false"/> and the
/// subset is DROPPED — no recovery, no fallback draw (spec §5). The
/// caller (<see cref="RetailAlphaMeshRouter"/>'s decision) supplies
/// <paramref name="overrideClipmap"/>; <c>IsFirstForList</c> is computed
/// here from whether <paramref name="list"/> was empty before this
/// append.
/// </summary>
internal bool TryAppend(
RetailAlphaList list,
IRetailAlphaDrawSource source,
int token,
bool overrideClipmap)
{
ArgumentNullException.ThrowIfNull(source);
if (!IsCollecting)
@ -125,11 +215,18 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
if (token < 0)
throw new ArgumentOutOfRangeException(nameof(token));
_submissions.Add(new RetailAlphaSubmission(
source,
token,
NormalizeDistance(viewerDistance)));
List<RetailAlphaEntry> target = list == RetailAlphaList.Clip ? _clip : _alpha;
if (target.Count >= ListCapacity)
return false;
bool isFirstForList = target.Count == 0;
target.Add(new RetailAlphaEntry(source, token, overrideClipmap, isFirstForList));
RegisterSource(source);
return true;
}
private void RegisterSource(IRetailAlphaDrawSource source)
{
for (int i = 0; i < _sources.Count; i++)
if (ReferenceEquals(_sources[i], source))
return;
@ -137,52 +234,66 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
/// <summary>
/// Drains the current retail alpha scope and keeps the frame open so the
/// caller can collect the post-depth-clear scope. Only adjacent entries
/// from one renderer are handed over as a batch; the queue never groups by
/// renderer across another entry and therefore cannot alter compositing.
/// Retail <c>D3DPolyRender::FlushAlphaList(threshold)</c> @0x0059d2e0.
/// No-op (both lists left exactly as they were) only when BOTH counts
/// are strictly below <c>threshold * 3000</c>; otherwise drains CLIP
/// fully, then ALPHA fully, in append order, and resets both. Only
/// adjacent same-source entries in the combined CLIP-then-ALPHA order
/// are handed to a source as one batch — the queue never groups across
/// another entry and therefore cannot alter compositing.
/// </summary>
public void Flush()
public void Flush(RetailAlphaFlushSite site, float threshold)
{
_ = site; // site distinguishes call sites for tracing/tests only —
// the drain algorithm itself does not depend on which one.
if (!IsCollecting)
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
if (_clip.Count < threshold * ListCapacity && _alpha.Count < threshold * ListCapacity)
return;
DrainAndReset();
}
private void DrainAndReset()
{
Exception? drawFailure = null;
List<Exception>? resetFailures = null;
try
{
if (_submissions.Count > 0)
int total = _clip.Count + _alpha.Count;
if (total > 0)
{
SortRetailOrder();
EnsureTokenCapacity(_submissions.Count);
EnsureTokenCapacity(total);
EnsureSourceCapacity(_sources.Count);
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
// Prepare each renderer once for this alpha scope. The filtered
// token sequence preserves final retail order for that source, so
// every later adjacent source-run maps to one contiguous prepared
// range without another GPU upload.
// Prepare each source once for this drain. The combined
// CLIP-then-ALPHA token sequence for that source preserves
// retail's drain order, so every later adjacent source-run
// maps to one contiguous prepared range without another
// GPU upload.
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
{
IRetailAlphaDrawSource source = _sources[sourceIndex];
int sourceCount = 0;
for (int i = 0; i < _submissions.Count; i++)
for (int i = 0; i < total; i++)
{
RetailAlphaSubmission submission = _submissions[i];
if (ReferenceEquals(submission.Source, source))
_tokenScratch[sourceCount++] = submission.Token;
RetailAlphaEntry entry = Entry(i);
if (ReferenceEquals(entry.Source, source))
_tokenScratch[sourceCount++] = entry.Token;
}
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
if (sourceCount > 0)
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
}
int start = 0;
while (start < _submissions.Count)
while (start < total)
{
IRetailAlphaDrawSource source = _submissions[start].Source;
IRetailAlphaDrawSource source = Entry(start).Source;
int end = start + 1;
while (end < _submissions.Count
&& ReferenceEquals(_submissions[end].Source, source))
while (end < total && ReferenceEquals(Entry(end).Source, source))
end++;
int count = end - start;
@ -200,8 +311,8 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
finally
{
int submissionCount = _submissions.Count;
int sourceCount = _sources.Count;
int observedClip = _clip.Count;
int observedAlpha = _alpha.Count;
for (int i = 0; i < _sources.Count; i++)
{
try
@ -214,8 +325,9 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
}
_sources.Clear();
_submissions.Clear();
ApplyScratchRetention(submissionCount, sourceCount);
_clip.Clear();
_alpha.Clear();
ApplyScratchRetention(observedClip + observedAlpha, observedClip + observedAlpha);
}
if (drawFailure is not null)
@ -239,105 +351,12 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
}
/// <summary>
/// Drains only the entries at or beyond <paramref name="minViewerDistance"/>
/// and keeps every nearer entry queued with the frame open. This is the
/// pre/inter-building barrier semantics: retail's far→near land walk means
/// <c>DrawBuilding</c>'s <c>FlushAlphaList(0f)</c> @0x0059F2A0 can only
/// flush content from cells FARTHER than that building — a nearer emitter
/// has not been inserted yet and composites after the building at a later
/// flush (the float there is a COUNT threshold, not a depth). The batched
/// landscape has no per-cell walk, so the same outcome is restored by
/// draining the far prefix of the established far→near order (AP-236).
/// Sources are deliberately NOT reset: retained tokens must stay valid
/// for the remaining entries' later <see cref="Flush"/>.
/// </summary>
public void FlushFartherThan(float minViewerDistance)
{
if (!IsCollecting)
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
if (_submissions.Count == 0)
return;
float threshold = NormalizeDistance(minViewerDistance);
SortRetailOrder();
int prefix = 0;
while (prefix < _submissions.Count
&& _submissions[prefix].ViewerDistance >= threshold)
{
prefix++;
}
if (prefix == 0)
return;
try
{
EnsureTokenCapacity(prefix);
EnsureSourceCapacity(_sources.Count);
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
{
IRetailAlphaDrawSource source = _sources[sourceIndex];
int sourceCount = 0;
for (int i = 0; i < prefix; i++)
{
RetailAlphaSubmission submission = _submissions[i];
if (ReferenceEquals(submission.Source, source))
_tokenScratch[sourceCount++] = submission.Token;
}
if (sourceCount > 0)
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
}
int start = 0;
while (start < prefix)
{
IRetailAlphaDrawSource source = _submissions[start].Source;
int end = start + 1;
while (end < prefix
&& ReferenceEquals(_submissions[end].Source, source))
end++;
int count = end - start;
int sourceIndex = FindSourceIndex(source);
int firstPreparedDraw = _sourceDrawOffsets[sourceIndex];
source.DrawPreparedAlphaBatch(firstPreparedDraw, count);
_sourceDrawOffsets[sourceIndex] += count;
start = end;
}
}
catch
{
// Converge to the full-drain failure shape: the retained suffix
// cannot be trusted once a source threw mid-prepare/draw.
_submissions.Clear();
List<Exception>? resetFailures = null;
for (int i = 0; i < _sources.Count; i++)
{
try
{
_sources[i].ResetAlphaSubmissions();
}
catch (Exception error)
{
(resetFailures ??= []).Add(error);
}
}
_sources.Clear();
if (resetFailures is { Count: > 0 })
{
throw new AggregateException(
"Retail alpha partial drain failed and its submissions could not be fully reset.",
resetFailures);
}
throw;
}
_submissions.RemoveRange(0, prefix);
}
/// <summary>Index accessor over the virtual CLIP-then-ALPHA sequence
/// (CLIP indices <c>[0, _clip.Count)</c>, then ALPHA indices
/// <c>[_clip.Count, _clip.Count + _alpha.Count)</c>) — retail's own
/// drain order (OH1 contract §5).</summary>
private RetailAlphaEntry Entry(int index) =>
index < _clip.Count ? _clip[index] : _alpha[index - _clip.Count];
public void EndFrame()
{
@ -346,7 +365,9 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
try
{
Flush();
// SmartBox::RenderNormalMode @0x00453aa0's own FlushAlphaList(0f)
// — the top-level pass-end drain (OH1 contract §7 site 4).
Flush(RetailAlphaFlushSite.RenderNormalMode, 0f);
}
finally
{
@ -378,74 +399,19 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
finally
{
int submissionCount = _submissions.Count;
int sourceCount = _sources.Count;
int observedClip = _clip.Count;
int observedAlpha = _alpha.Count;
_sources.Clear();
_submissions.Clear();
_clip.Clear();
_alpha.Clear();
IsCollecting = false;
ApplyScratchRetention(submissionCount, sourceCount);
ApplyScratchRetention(observedClip + observedAlpha, observedClip + observedAlpha);
}
if (failures is { Count: > 0 })
throw new AggregateException("Retail alpha frame abort failed.", failures);
}
private void SortRetailOrder()
{
int count = _submissions.Count;
if (count <= 1)
return;
EnsureSortCapacity(count);
Span<RetailAlphaSubmission> submissions = CollectionsMarshal.AsSpan(_submissions);
Span<RetailAlphaSubmission> scratch = _sortScratch.AsSpan(0, count);
// Positive IEEE-754 float bits sort in the same order as their numeric
// values. Complementing those bits turns an ascending stable radix pass
// into retail's descending CYpt order. Four stable byte passes preserve
// original submission order for equal distances, matching
// CShadowPart::insertion_sort without List.Sort's O(n log n) interface
// comparator overhead in particle-heavy views.
RadixPass(submissions, scratch, shift: 0);
RadixPass(scratch, submissions, shift: 8);
RadixPass(submissions, scratch, shift: 16);
RadixPass(scratch, submissions, shift: 24);
}
private static float NormalizeDistance(float value)
// value > 0 also canonicalizes -0 to +0 so its IEEE sort key joins the
// same stable equal-distance run as ordinary zero.
=> float.IsFinite(value) && value > 0f ? value : 0f;
private void RadixPass(
ReadOnlySpan<RetailAlphaSubmission> source,
Span<RetailAlphaSubmission> destination,
int shift)
{
Array.Clear(_radixOffsets);
for (int i = 0; i < source.Length; i++)
{
uint key = ~BitConverter.SingleToUInt32Bits(source[i].ViewerDistance);
_radixOffsets[(int)((key >> shift) & 0xFF)]++;
}
int prefix = 0;
for (int bucket = 0; bucket < _radixOffsets.Length; bucket++)
{
int bucketCount = _radixOffsets[bucket];
_radixOffsets[bucket] = prefix;
prefix += bucketCount;
}
for (int i = 0; i < source.Length; i++)
{
RetailAlphaSubmission submission = source[i];
uint key = ~BitConverter.SingleToUInt32Bits(submission.ViewerDistance);
int bucket = (int)((key >> shift) & 0xFF);
destination[_radixOffsets[bucket]++] = submission;
}
}
private void EnsureTokenCapacity(int count)
{
if (_tokenScratch.Length >= count)
@ -460,13 +426,6 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
Array.Resize(ref _sourceDrawOffsets, count + 4);
}
private void EnsureSortCapacity(int count)
{
if (_sortScratch.Length >= count)
return;
Array.Resize(ref _sortScratch, count + 256);
}
private int FindSourceIndex(IRetailAlphaDrawSource source)
{
for (int i = 0; i < _sources.Count; i++)
@ -476,28 +435,28 @@ internal sealed class RetailAlphaQueue : IWorldSceneAlphaFrame
}
private void ApplyScratchRetention(
int observedSubmissionCount,
int observedEntryCount,
int observedSourceCount)
{
int currentCapacity = Math.Max(
_submissions.Capacity,
Math.Max(_tokenScratch.Length, _sortScratch.Length));
int bytesPerSubmission =
Math.Max(_clip.Capacity, _alpha.Capacity),
_tokenScratch.Length);
int bytesPerEntry =
checked(
2 * Unsafe.SizeOf<RetailAlphaSubmission>()
+ 2 * sizeof(int)
2 * Unsafe.SizeOf<RetailAlphaEntry>()
+ sizeof(int)
+ IntPtr.Size);
int targetCapacity = _scratchPolicy.ObserveAndSelectCapacity(
currentCapacity,
observedSubmissionCount,
bytesPerSubmission,
observedEntryCount,
bytesPerEntry,
MinimumSubmissionCapacity,
SubmissionGrowthQuantum);
if (targetCapacity < currentCapacity)
{
_submissions.Capacity = targetCapacity;
_clip.Capacity = targetCapacity;
_alpha.Capacity = targetCapacity;
Array.Resize(ref _tokenScratch, targetCapacity);
Array.Resize(ref _sortScratch, targetCapacity);
int sourceTarget = Math.Max(
MinimumSourceCapacity,

View file

@ -187,8 +187,12 @@ internal sealed partial class RetailPViewPassExecutor
/// <see cref="DrawExitSeals"/> returns the submitted seal-polygon count so
/// the driver can re-arm its persistent <c>PortalsDrawnCount</c> (S3 §8.2
/// B2).</item>
/// <item><see cref="AlphaBarrier"/> → <c>FlushLandscapeAlpha</c>, retail's
/// flush-all <c>FlushAlphaList(0f)</c> @0x0059f30b.</item>
/// <item><see cref="AlphaBarrier"/> → <c>FlushBuildingAlpha</c>, retail's
/// building barrier <c>FlushAlphaList(0f)</c> @0x0059f30b. <see cref="FlushSortCellExit"/>
/// → <c>FlushSortCellExitAlpha</c>, retail's per-land-cell
/// <c>FlushAlphaList(0.75f)</c> @0x005a1a07 (S4-c2: the two used to share one
/// undifferentiated <c>_alpha.Flush()</c> call before the two-list FIFO
/// cutover gave the queue a per-site threshold).</item>
/// </list>
/// </summary>
internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
@ -278,5 +282,7 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
_passes.DrawWalkPunchFan(_frame, _clipAssembly, worldPolygon, activeViewIndex);
public void AlphaBarrier() => _passes.FlushLandscapeAlpha();
public void AlphaBarrier() => _passes.FlushBuildingAlpha();
public void FlushSortCellExit() => _passes.FlushSortCellExitAlpha();
}

View file

@ -333,7 +333,25 @@ public RetailPViewPassExecutor(
: UnattachedEmitterCellScope.InteriorCells);
}
public void FlushLandscapeAlpha() => _alpha.Flush();
/// <summary><c>PView::DrawCells</c> @0x005a4840's own
/// <c>FlushAlphaList(0f)</c> @0x005a4872 (OH1 contract §7 site 3).</summary>
public void FlushLandscapeAlpha() =>
_alpha.Flush(RetailAlphaFlushSite.LandscapeFlush, 0f);
/// <summary><c>RenderDeviceD3D::DrawBuilding</c> @0x0059f2a0's own
/// <c>FlushAlphaList(0f)</c> @0x0059f30b (OH1 contract §7 site 1) — the
/// building alpha barrier. Distinct call from <see cref="FlushLandscapeAlpha"/>
/// even though both drain under the same 0f threshold (S4-c2: the two
/// sites were sharing one undifferentiated call before the two-list
/// FIFO cutover, when no site label existed to distinguish them).</summary>
internal void FlushBuildingAlpha() =>
_alpha.Flush(RetailAlphaFlushSite.DrawBuilding, 0f);
/// <summary><c>RenderDeviceD3D::DrawBlock</c> @0x005a18d0's per-land-cell
/// <c>FlushAlphaList(::flush)</c> @0x005a1a07, the immutable global
/// 0.75f pressure valve (OH1 contract §7 site 2).</summary>
internal void FlushSortCellExitAlpha() =>
_alpha.Flush(RetailAlphaFlushSite.SortCellExit, 0.75f);
public void DrawCellParticles(
RetailPViewFrameInput frame,

View file

@ -239,6 +239,16 @@ public sealed class RetailFrameWalk
block.LandblockId,
block.SideCellCount,
cellIndex);
// S4-c2: RenderDeviceD3D::DrawBlock @0x005a18d0's own
// FlushAlphaList(::flush) @0x005a1a07 call, immediately after
// DrawSortCell under the SAME alwaysDrawObjects||cellInView
// gate that already wraps this whole loop body (the early
// `continue` above) — see OnSortCellExit's own doc comment.
sink.OnSortCellExit(
block.LandblockId,
block.SideCellCount,
cellIndex);
}
}

View file

@ -150,6 +150,25 @@ public interface IWalkEventSink
OnLandscapeCellTurn(
(landblockId & 0xFFFF0000u) | checked((uint)(cellIndex + 1)));
/// <summary>
/// S4-c2: fires once per admitted land-block cell, immediately after
/// that cell's <see cref="OnLandscapeCellTurn(uint,int,int)"/> turn (the
/// object-list turn, itself after the building's own turn) and before
/// the loop moves to the next cell's <see cref="OnLandCellTurn"/> —
/// <c>RenderDeviceD3D::DrawBlock</c> @0x005a18d0's own
/// <c>FlushAlphaList(::flush)</c> @0x005a1a07 call, gated by the exact
/// same <c>alwaysDrawObjects || cellInView</c> condition as
/// <c>DrawSortCell</c> itself (Ghidra-verified 2026-09-04: retail
/// re-evaluates the identical condition for the flush call, immediately
/// after the DrawSortCell call it guards — nothing changes a cell's
/// content between the two checks). <c>::flush</c> is retail's immutable
/// global 0.75f — the DrawBlock pressure-valve threshold, never the same
/// per-cell value DrawBuilding/PView::DrawCells/RenderNormalMode use
/// (those are always 0f). Default no-op — every pre-S4-c2 sink continues
/// to compile and behave identically.
/// </summary>
void OnSortCellExit(uint landblockId, int sideCellCount, int cellIndex) { }
/// <summary>
/// Fires at <see cref="RetailFrameWalk.DrawBuilding"/> once retail's own
/// gate has passed — <c>RenderDeviceD3D::DrawBuilding</c> @0x0059f2a0

View file

@ -237,6 +237,16 @@ internal interface IWalkFrameLeafRenderer
/// alpha when a building turn fires, so this is a full drain with no
/// synthetic viewer-distance threshold.</summary>
void AlphaBarrier();
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a18d0's
/// per-land-cell <c>FlushAlphaList(::flush)</c> @0x005a1a07 — the
/// immutable global 0.75f pressure valve, inert below 2250 entries in
/// either list (a no-op the overwhelming majority of the time at
/// today's scene complexity) but pinned exact by the capacity/threshold
/// tests. Fires once per admitted land-block cell, at the SAME turn
/// <see cref="IWalkEventSink.OnSortCellExit"/> records it (after that
/// cell's object-list turn, before the next cell's land turn).</summary>
void FlushSortCellExit();
}
/// <summary>
@ -365,6 +375,11 @@ internal enum WalkFrameEventKind : byte
/// registry membership (a hidden/suspended owner's emitter still shows —
/// Campaign OVERHAUL S2 chunk 6, the portal-haze fix).</summary>
CellParticles,
/// <summary>S4-c2: <see cref="IWalkFrameLeafRenderer.FlushSortCellExit"/> —
/// retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a18d0's per-land-cell
/// <c>FlushAlphaList(0.75f)</c>.</summary>
SortCellExit,
}
/// <summary>
@ -479,6 +494,9 @@ internal readonly struct WalkFrameEvent
internal static WalkFrameEvent ExitSeals() =>
new(WalkFrameEventKind.ExitSeals, 0, 0, 0f, null);
internal static WalkFrameEvent SortCellExit() =>
new(WalkFrameEventKind.SortCellExit, 0, 0, 0f, null);
}
/// <summary>
@ -1152,6 +1170,10 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
FlushPendingTerrainBatch();
_leafRenderer.AlphaBarrier();
break;
case WalkFrameEventKind.SortCellExit:
FlushPendingTerrainBatch();
_leafRenderer.FlushSortCellExit();
break;
case WalkFrameEventKind.LandscapeFlush:
FlushPendingTerrainBatch();
_leafRenderer.FlushLandscape();
@ -1453,6 +1475,22 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
_events.Add(WalkFrameEvent.LandscapeCellParticles(cellId));
}
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a18d0's
/// per-land-cell <c>FlushAlphaList(::flush)</c> @0x005a1a07 (the 0.75f
/// pressure valve) — fires once per admitted land-block cell, at the SAME
/// gate <see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/>
/// already fires under (<c>RetailFrameWalk.DrawLandscape</c> calls this
/// immediately after that turn, still inside the loop body). Marks first,
/// same as <see cref="IWalkEventSink.OnBuildingTurn"/>'s AlphaBarrier
/// emission, so this cell's own content flushes ahead of the valve.</summary>
void IWalkEventSink.OnSortCellExit(uint landblockId, int sideCellCount, int cellIndex)
{
RequireOpenFrame();
MarkIfGrown();
MarkAlphaIfGrown();
_events.Add(WalkFrameEvent.SortCellExit());
}
void IWalkEventSink.OnLandscapeViews(WalkPortalView activeViews)
{
ArgumentNullException.ThrowIfNull(activeViews);

View file

@ -143,9 +143,11 @@ public sealed partial class WbDrawDispatcher
/// one needs to reach the alpha queue: <see cref="IsOpaque"/> (so the
/// populator can route without re-deriving it from
/// <see cref="GroupKey.Translucency"/>) and <see cref="LocalSortCenter"/>
/// (the authored GfxObj sort center <c>RetailAlphaOrdering.ComputeViewerDistance</c>
/// transforms through <see cref="Transform"/> — the same value
/// <c>InstanceGroup.LocalSortCenters</c> carries per instance today).
/// (the authored GfxObj sort center — the same value
/// <c>InstanceGroup.LocalSortCenters</c> carries per instance today; S4-c2
/// deleted its one alpha-ordering consumer, <c>RetailAlphaOrdering.ComputeViewerDistance</c>,
/// but the field itself stays: <c>InstanceGroup.LocalSortCenters</c> is a
/// general per-instance record this type mirrors, not an alpha-only one).
/// </summary>
internal readonly record struct WalkClassifiedBatch(
GroupKey Key,
@ -876,21 +878,21 @@ public sealed partial class WbDrawDispatcher
/// <summary>
/// The walk populator's per-instance sibling of <c>DeferTransparentGroups</c>
/// (see that method for the retail citations this mirrors): submits ONE
/// translucent <see cref="WalkClassifiedBatch"/> into the same
/// <c>_deferredAlpha</c>/<see cref="RetailAlphaQueue"/> machinery the
/// classic material-grouped path uses, so scenery, particles, and walk
/// content share retail's one stable far-to-near stream. Same
/// view-projection consistency check, same
/// <see cref="RetailAlphaOrdering.ComputeViewerDistance"/> call, same
/// <c>queue.Submit</c> contract — the walk path denormalizes to one
/// instance per call instead of flattening a material group.
/// (see that method and <c>SubmitToAlphaQueue</c> for the retail citations
/// this mirrors): submits ONE translucent <see cref="WalkClassifiedBatch"/>
/// into the same <c>_deferredAlpha</c>/<see cref="RetailAlphaQueue"/>
/// machinery the classic material-grouped path uses, so scenery,
/// particles, and walk content share retail's two FIFO lists. Same
/// view-projection consistency check, same router call — the walk path
/// denormalizes to one instance per call instead of flattening a
/// material group.
/// </summary>
internal void SubmitWalkAlphaInstance(
in WalkClassifiedBatch batch,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection)
{
_ = cameraWorldPosition; // S4-c2: retail's queues are FIFO, not distance-sorted.
RetailAlphaQueue queue = _alphaQueue
?? throw new InvalidOperationException(
"SubmitWalkAlphaInstance requires an active RetailAlphaQueue.");
@ -901,15 +903,10 @@ public sealed partial class WbDrawDispatcher
throw new InvalidOperationException(
"One retail alpha scope cannot combine different view-projection matrices.");
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
batch.LocalSortCenter, batch.Transform, cameraWorldPosition);
if (!float.IsFinite(viewerDistance) || viewerDistance <= 0f)
viewerDistance = 0f;
int token = _deferredAlpha.Count;
_deferredAlpha.Add(new DeferredAlphaInstance(
batch.Key, batch.Transform, batch.ClipSlot, batch.Lights,
batch.IndoorFlag, batch.DetailCategory, batch.Alpha, batch.SelectionLighting));
queue.Submit(_alphaSource, token, viewerDistance);
SubmitToAlphaQueue(queue, batch.Key.Translucency, token);
}
}

View file

@ -2285,6 +2285,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
Vector3 cameraWorldPosition,
List<AlphaFingerprint> scratch)
{
_ = cameraWorldPosition; // S4-c2: the digest reflects retail's FIFO
// submission order, not a distance sort.
scratch.Clear();
for (int groupIndex = 0;
groupIndex < groups.Count;
@ -2295,21 +2297,17 @@ public sealed partial class WbDrawDispatcher : IDisposable
instanceIndex < group.Matrices.Count;
instanceIndex++)
{
float distance =
RetailAlphaOrdering.ComputeViewerDistance(
group.LocalSortCenters[instanceIndex],
group.Matrices[instanceIndex],
cameraWorldPosition);
if (!float.IsFinite(distance) || distance <= 0f)
distance = 0f;
scratch.Add(new AlphaFingerprint(
group,
instanceIndex,
distance,
group.SubmissionOrders[instanceIndex]));
}
}
scratch.Sort(AlphaFingerprintComparer.Instance);
// S4-c2: retail's own queue never sorts (D3DPolyRender::AddMeshToAlphaList
// appends strictly FIFO) — the digest's order key is the same
// SubmissionOrder DeferTransparentGroups itself orders by, not a
// distance comparator.
scratch.Sort(AlphaSubmissionOrderComparer.Instance);
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(scratch.Count);
@ -2330,7 +2328,6 @@ public sealed partial class WbDrawDispatcher : IDisposable
// dispatchers is caught by content, not only by an incidental
// group-count difference.
hash.Add(key.FoliageFlags);
hash.Add(entry.ViewerDistance);
AddSubmissionInstance(
ref hash,
entry.Group,
@ -2342,32 +2339,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
internal readonly record struct AlphaFingerprint(
InstanceGroup Group,
int InstanceIndex,
float ViewerDistance,
int SubmissionOrder);
private sealed class AlphaFingerprintComparer :
IComparer<AlphaFingerprint>
{
public static AlphaFingerprintComparer Instance { get; } =
new();
private AlphaFingerprintComparer()
{
}
public int Compare(
AlphaFingerprint left,
AlphaFingerprint right)
{
int value = right.ViewerDistance.CompareTo(
left.ViewerDistance);
return value != 0
? value
: left.SubmissionOrder.CompareTo(
right.SubmissionOrder);
}
}
private static void AddOpaqueSubmissionGroup(
ref StableRenderHash128 hash,
InstanceGroup group)
@ -2436,6 +2409,9 @@ public sealed partial class WbDrawDispatcher : IDisposable
private void DeferTransparentGroups(Vector3 cameraWorldPosition, Matrix4x4 viewProjection)
{
_ = cameraWorldPosition; // S4-c2: retail's queues are FIFO, not distance-sorted; kept as a
// parameter so callers need not change (WalkClassify's sibling
// call site still needs it for nothing else either).
RetailAlphaQueue queue = _alphaQueue!;
if (_deferredAlpha.Count == 0)
_deferredAlphaViewProjection = viewProjection;
@ -2447,28 +2423,18 @@ public sealed partial class WbDrawDispatcher : IDisposable
// equal-CYpt parts keep the order in which the cell submitted them.
// Material grouping is an acdream batching detail and must not become
// that tiebreak. Reconstruct the original draw-local instance order
// before handing entries to the queue; its stable CYpt radix then
// preserves this sequence for exact-distance ties.
// before handing entries to the queue — S4-c2: retail's own queue is
// FIFO (D3DPolyRender::AddMeshToAlphaList never sorts), so preserving
// this walk-order sequence IS the ordering, not a sort key for a
// later comparator.
_alphaFingerprintScratch.Clear();
foreach (InstanceGroup group in _translucentDraws)
{
for (int i = 0; i < group.Matrices.Count; i++)
{
Matrix4x4 model = group.Matrices[i];
Vector3 localSortCenter = group.LocalSortCenters[i];
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
localSortCenter,
model,
cameraWorldPosition);
if (!float.IsFinite(viewerDistance)
|| viewerDistance <= 0f)
{
viewerDistance = 0f;
}
_alphaFingerprintScratch.Add(new AlphaFingerprint(
group,
i,
viewerDistance,
group.SubmissionOrders[i]));
}
}
@ -2489,13 +2455,53 @@ public sealed partial class WbDrawDispatcher : IDisposable
group.DetailCategories[i],
group.Opacities[i],
group.SelectionLighting[i]));
queue.Submit(
_alphaSource,
token,
entry.ViewerDistance);
SubmitToAlphaQueue(queue, group.Translucency, token);
}
}
/// <summary>
/// S4-c2 shared submit path for <see cref="DeferTransparentGroups"/> and
/// <see cref="SubmitWalkAlphaInstance"/>: both call sites only ever see
/// an already-non-opaque <see cref="TranslucencyKind"/> (opaque and
/// clip-map subsets are filtered out upstream by <c>IsOpaque</c> and
/// never reach <c>_translucentDraws</c>/this path at all — see
/// <see cref="RetailAlphaMeshRouter.MaskFromTranslucencyKind"/>'s own doc
/// comment). Neither call site ever draws during the Sky leaf or with a
/// building/environment detail surface installed (ordinary GfxObj/
/// particle paths never install one — OH1 contract §6), and
/// <c>MultiPassAlpha</c> stays false (no environment override — Must
/// Not). Under those fixed inputs and <see cref="RetailAlphaMeshRouter.DefaultDelayMask"/>,
/// every reachable mask (0x02/0x03, 0x04/0x05, 0x08/0x09) intersects the
/// delay mask, so <c>DrawMesh</c>'s row 3 always fires — rows 1, 2, 4,
/// and 5 are unreachable here and would indicate a real routing bug if
/// ever hit.
/// </summary>
private void SubmitToAlphaQueue(
RetailAlphaQueue queue, TranslucencyKind kind, int token)
{
byte mask = RetailAlphaMeshRouter.MaskFromTranslucencyKind(kind);
RetailAlphaMeshDecision decision = RetailAlphaMeshRouter.Route(
currentlyDrawingSky: false,
delayMask: RetailAlphaMeshRouter.DefaultDelayMask,
detailSurfaceActive: false,
multiPassAlpha: false,
subsetMask: mask,
materialHasAlpha: false);
if (decision.Action != RetailAlphaMeshAction.Append)
{
throw new InvalidOperationException(
"Ordinary translucent GfxObj/particle submissions never install a detail "
+ "surface or draw during the Sky leaf, and MultiPassAlpha stays false — "
+ $"DrawMesh's row 1/2/4/5 branches are unreachable here; got {decision.Action}.");
}
// Capacity overflow (spec §5): TryAppend returns false and the
// subset is DROPPED. The reserved _deferredAlpha token simply never
// gets prepared/drawn — no recovery, matching retail exactly.
queue.TryAppend(decision.List, _alphaSource, token, decision.OverrideClipmap);
}
private sealed class AlphaSubmissionOrderComparer :
IComparer<AlphaFingerprint>
{