feat(render) Campaign FW3.2a: the walk-to-draw population layer

The piece that turns walk-visited static content into draws, with no
production frame wiring (FW3.2b roots the frame):

- TryClassifyBatch: ONE shared per-batch classify core (the #426
  untextured gate, #188 opacity promotion, texture resolve, foliage
  classification, in the exact original order) extracted from
  ClassifyBatches; the classic and packed classifiers now call it -
  behavior-identical, proven by the full hermetic + InstalledDat +
  Core Wb suites.
- ClassifyEntityForWalk / WalkClassifiedBatch: the per-entity seam
  yielding per-batch keys + instance data WITHOUT InstanceGroup
  bucketing, plus the per-part selection data (picking stays alive on
  the walk path - the survey's unlisted-consumer fix).
- WalkStaticStreamPopulator: per-entity walk-ordered opaque appends
  (under depth Less, opaque order is pixel-relevant only for coplanar
  surfaces, which retail resolves first-drawn-wins in ITS order -
  never material-grouped), translucent instances to the SAME
  RetailAlphaQueue via SubmitWalkAlphaInstance (identical viewer
  distances; walk-order submission improves retail's tie fidelity),
  selection parts published per entity.
- SubmitOrderedStream now owns _orderedDrawCullModes, retiring the
  FW2-recorded alpha-scope interleaving constraint;
  DrawIndirectRangeRhi takes an optional cull array (all existing
  call sites unchanged). The referee test was verified to FAIL
  against the old shared-scratch behavior.
- WalkDrawStage.OutdoorStatic added for the landscape turn.

Suites: full Release build 0 warnings; Walk lane 195/1 skip;
hermetic 6,747/0 (the two failures the implementation round reported
were transient - both pass in isolation and in the full run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-30 13:52:23 +02:00
parent b10ad662b0
commit 81c6531727
9 changed files with 1264 additions and 116 deletions

View file

@ -70,6 +70,47 @@ internal readonly record struct RenderInstanceCandidate(
TupleLandblockId: tupleLandblockId); TupleLandblockId: tupleLandblockId);
} }
/// <summary>
/// Campaign FW stage FW3.2a: builds a candidate straight from a
/// <see cref="RenderProjectionRecord"/>, without the packed route's
/// <see cref="RenderFrameEntityCandidate"/>/frame-arena mesh-part
/// flattening. The walk populator reads <see cref="RenderSceneQuery"/>
/// records directly (<c>CopyCellStaticsTo</c> / <c>CopyIndexTo</c>), so it
/// has no frame arena to look the source candidate up in — every field
/// this needs already lives on the record's own <see cref="RenderSourceMetadata"/>
/// and <see cref="RenderEntityPayload"/>.
///
/// <para><paramref name="animated"/> defaults to false: the walk's static
/// routes (<c>RenderProjectionClass.OutdoorStatic</c> /
/// <c>IndoorCellStatic</c>) never carry retail's per-part
/// <c>TransparentPartHook</c> animation — that mechanic keys off a live
/// entity's ServerGuid, not a world static's. A future dynamic walk route
/// passes true explicitly.</para>
/// </summary>
internal static RenderInstanceCandidate FromProjection(
in RenderProjectionRecord projection,
uint tupleLandblockId,
bool animated = false)
{
RenderEntityPayload payload = projection.EntityPayload;
return new RenderInstanceCandidate(
ProjectionId: projection.Id,
LocalEntityId: projection.Source.LocalEntityId,
ServerGuid: projection.Source.ServerGuid,
SourceId: projection.Source.SourceId,
ParentCellId: projection.Source.ParentCellId,
RootWorld: projection.Transform.LocalToWorld,
Position: projection.Transform.Position,
Rotation: projection.Transform.Rotation,
Scale: projection.Transform.UniformScale,
Bounds: projection.Bounds,
PaletteOverride: payload.PaletteOverride,
IsBuildingShell: payload.IsBuildingShell,
Animated: animated,
MeshPartCount: payload.MeshRefs?.Count ?? 0,
TupleLandblockId: tupleLandblockId);
}
internal static RenderInstanceCandidate FromFrame( internal static RenderInstanceCandidate FromFrame(
in RenderFrameEntityCandidate source, in RenderFrameEntityCandidate source,
uint tupleLandblockId) uint tupleLandblockId)

View file

@ -22,6 +22,20 @@ internal enum WalkDrawStage : byte
/// <c>LScape::grab_visible_cells</c> @0x00504EC0 — outdoor terrain.</summary> /// <c>LScape::grab_visible_cells</c> @0x00504EC0 — outdoor terrain.</summary>
Terrain, Terrain,
/// <summary>
/// Campaign FW stage FW3.2a: the outdoor static objects (scenery,
/// buildings-as-decor, landblock statics —
/// <c>RenderProjectionClass.OutdoorStatic</c>) <c>LScape::draw</c> visits
/// for each landblock alongside its terrain mesh — retail's landscape
/// walk draws a landblock's ground and its static content in the same
/// pass, distinct from the ground mesh itself (<see cref="Terrain"/>) and
/// from a building's own exterior shell (<see cref="BuildingShell"/>).
/// The packed route's analogue is
/// <c>RenderFrameCandidateRoute.LandscapeOutdoorStatic</c>
/// (<c>WbDrawDispatcher.PackedOracle.cs</c>).
/// </summary>
OutdoorStatic,
/// <summary>An indoor <c>PView::DrawCells</c> @0x005A4840 flood's static /// <summary>An indoor <c>PView::DrawCells</c> @0x005A4840 flood's static
/// geometry: EnvCell shells plus the static meshes they contain.</summary> /// geometry: EnvCell shells plus the static meshes they contain.</summary>
CellStatic, CellStatic,

View file

@ -0,0 +1,137 @@
using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW stage FW3.2a: the walk→draw population layer. Turns one
/// cell's already-classified static content into <see cref="OrderedDrawCommand"/>
/// appends (opaque, walk order), per-instance deferred-alpha submissions
/// (translucent), and selection-scene publications (picking) — WITHOUT any
/// production frame wiring. <c>WorldSceneRenderer</c> does not call this
/// class yet; FW3.2b roots it into the real frame.
///
/// <para><b>Why per-entity append instead of the classic material grouping</b>:
/// under <see cref="WorldDepthContract"/>'s depth-compare Less, opaque draw
/// order is pixel-relevant ONLY for coplanar surfaces (a rug on a floor, a
/// shell over terrain) — and retail resolves those by first-drawn-wins in
/// ITS walk/cell-content order, not by any texture/material grouping. So
/// this populator appends one <see cref="OrderedDrawCommand"/> per (entity,
/// opaque batch) in the SAME order the caller's <c>records</c> span presents
/// them — never material-grouped, never re-sorted. Translucent batches go to
/// the SAME <see cref="RetailAlphaQueue"/> the classic/packed paths already
/// use (global far→near re-sort still applies; walk-order submission only
/// improves retail's submission-order tie-break fidelity for coincident
/// distances — <c>WbDrawDispatcher.DeferTransparentGroups</c>'s own doc
/// comment cites the same <c>CShadowPart::insertion_sort</c> stability this
/// preserves).</para>
///
/// <para>Reads no retained scene state itself: every method takes the target
/// <see cref="OrderedDrawStream"/> and a caller-supplied span of already
/// -queried <see cref="RenderProjectionRecord"/>s (from
/// <c>RenderSceneQuery.CopyCellStaticsTo</c> / <c>CopyIndexTo</c>). Owns only
/// two small per-call scratch lists — the classify seam's per-entity output
/// — reused across records to keep this hot path allocation-free after
/// warmup.</para>
/// </summary>
internal sealed class WalkStaticStreamPopulator
{
private readonly WbDrawDispatcher _dispatcher;
private readonly List<WbDrawDispatcher.WalkClassifiedBatch> _batchScratch = new();
private readonly List<WbDrawDispatcher.WalkClassifiedSelectionPart> _selectionScratch = new();
internal WalkStaticStreamPopulator(WbDrawDispatcher dispatcher)
{
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
}
/// <summary>
/// Populates <paramref name="stream"/> from one indoor cell's static
/// content (<c>RenderProjectionClass.IndoorCellStatic</c> —
/// <c>RenderSceneQuery.CopyCellStaticsTo</c>). <paramref name="stage"/>
/// is caller-selected per the walk turn this cell's content belongs to
/// — <see cref="WalkDrawStage.CellStatic"/> for an ordinary
/// <c>PView::DrawCells</c> flood, <see cref="WalkDrawStage.LookInStatic"/>
/// for a <c>ConstructView(CBldPortal)</c> look-in, or
/// <see cref="WalkDrawStage.BuildingShell"/> for a building's own
/// exterior shell content.
/// </summary>
internal void PopulateCell(
OrderedDrawStream stream,
WalkDrawStage stage,
uint cellId,
ReadOnlySpan<RenderProjectionRecord> records,
uint tupleLandblockId,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection)
{
ArgumentNullException.ThrowIfNull(stream);
for (int i = 0; i < records.Length; i++)
{
ClassifyAndAppend(
stream, stage, cellId, in records[i], tupleLandblockId,
cameraWorldPosition, viewProjection);
}
}
/// <summary>
/// The landscape entry point: outdoor static content
/// (<c>RenderProjectionClass.OutdoorStatic</c> —
/// <c>RenderSceneQuery.CopyIndexTo(RenderSceneIndex.OutdoorStatic, ...)</c>)
/// at <see cref="WalkDrawStage.OutdoorStatic"/>, the terrain-adjacent
/// stage <c>LScape::draw</c> visits a landblock's static objects at
/// alongside its ground mesh (see that stage value's own doc comment).
/// <paramref name="cellId"/> is the OUTDOOR landblock id — outdoor
/// statics have no EnvCell of their own, so this is walk-order
/// provenance only, not a clip-slot key.
/// </summary>
internal void PopulateOutdoorStatics(
OrderedDrawStream stream,
uint cellId,
ReadOnlySpan<RenderProjectionRecord> records,
uint tupleLandblockId,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection) =>
PopulateCell(
stream, WalkDrawStage.OutdoorStatic, cellId, records,
tupleLandblockId, cameraWorldPosition, viewProjection);
private void ClassifyAndAppend(
OrderedDrawStream stream,
WalkDrawStage stage,
uint cellId,
in RenderProjectionRecord record,
uint tupleLandblockId,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection)
{
_batchScratch.Clear();
_selectionScratch.Clear();
_dispatcher.ClassifyEntityForWalk(
in record, tupleLandblockId, _batchScratch, _selectionScratch);
for (int i = 0; i < _batchScratch.Count; i++)
{
WbDrawDispatcher.WalkClassifiedBatch batch = _batchScratch[i];
if (batch.IsOpaque)
{
stream.Append(new OrderedDrawCommand(
batch.Key, batch.Transform, stage, cellId, batch.ClipSlot,
batch.Lights, batch.IndoorFlag, batch.Alpha,
batch.SelectionLighting, batch.DetailCategory));
}
else
{
_dispatcher.SubmitWalkAlphaInstance(
in batch, cameraWorldPosition, viewProjection);
}
}
for (int i = 0; i < _selectionScratch.Count; i++)
{
WbDrawDispatcher.WalkClassifiedSelectionPart part = _selectionScratch[i];
_dispatcher.PublishWalkSelectionPart(in part);
}
}
}

View file

@ -253,14 +253,14 @@ public sealed unsafe partial class WbDrawDispatcher
// Per-instance-first emission — the PrepareDeferredAlphaDraws shape, // Per-instance-first emission — the PrepareDeferredAlphaDraws shape,
// into the SAME per-frame scratch arrays PrepareDeferredAlphaDraws/ // into the SAME per-frame scratch arrays PrepareDeferredAlphaDraws/
// SubmitRhi write. Most are consumed immediately by the ring uploads // SubmitRhi write, EXCEPT cull modes: this stage (FW3.2a) gives the
// below, but _drawCullModes is NOT write-then-consume: the deferred- // ordered path its own _orderedDrawCullModes scratch (see
// alpha path reads it at FLUSH time (DrawIndirectRangeRhi's internal // DrawIndirectRangeRhi's doc comment) precisely so this loop and its
// cull split), so an ordered submission may never interleave between // draws below can freely interleave with a mid-flight
// RetailAlphaQueue prepare and flush. FW2 has no production caller; // RetailAlphaQueue scope without corrupting — or being corrupted by
// the FW3 wiring must either sequence around the alpha scope or give // — the alpha path's _drawCullModes.
// this path its own cull scratch.
EnsureDeferredAlphaCapacity(count); EnsureDeferredAlphaCapacity(count);
EnsureOrderedCullModeCapacity(count);
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
GroupKey key = stream.Keys[i]; GroupKey key = stream.Keys[i];
@ -286,7 +286,7 @@ public sealed unsafe partial class WbDrawDispatcher
BaseVertex = key.BaseVertex, BaseVertex = key.BaseVertex,
BaseInstance = (uint)i, BaseInstance = (uint)i,
}; };
_drawCullModes[i] = key.CullMode; _orderedDrawCullModes[i] = key.CullMode;
} }
// Write every section ONCE — the PrepareRhiAlphaSections shape, but // Write every section ONCE — the PrepareRhiAlphaSections shape, but
@ -350,10 +350,10 @@ public sealed unsafe partial class WbDrawDispatcher
// One in-order pass over the pre-built merge runs: bind the run's // One in-order pass over the pre-built merge runs: bind the run's
// pipeline, set RenderPass, draw. DrawIndirectRangeRhi still splits // pipeline, set RenderPass, draw. DrawIndirectRangeRhi still splits
// internally on _drawCullModes (issue #52's absolute DrawIdOffset per // internally on _orderedDrawCullModes (issue #52's absolute
// sub-call) — every run here already shares one cull mode by // DrawIdOffset per sub-call) — every run here already shares one
// construction, so that inner split is a no-op here, never a second // cull mode by construction, so that inner split is a no-op here,
// boundary this loop failed to expect. // never a second boundary this loop failed to expect.
foreach (OrderedMergeRun run in runs) foreach (OrderedMergeRun run in runs)
{ {
ValidateMergeRun(stream, run); ValidateMergeRun(stream, run);
@ -365,7 +365,20 @@ public sealed unsafe partial class WbDrawDispatcher
BindPipelineWithMesh(encoder, pipeline, global); BindPipelineWithMesh(encoder, pipeline, global);
DrawIndirectRangeRhi( DrawIndirectRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase, encoder, ref pushConstants, commandBuffer, commandBase,
run.FirstCommand, run.CommandCount); run.FirstCommand, run.CommandCount, _orderedDrawCullModes);
} }
} }
/// <summary>
/// Grows <see cref="_orderedDrawCullModes"/> to at least
/// <paramref name="count"/> — the same growth shape
/// <c>EnsureDeferredAlphaCapacity</c> uses for <see cref="_drawCullModes"/>,
/// kept as its own method because this scratch array is not part of that
/// method's shared per-instance group (see this file's type doc comment).
/// </summary>
private void EnsureOrderedCullModeCapacity(int count)
{
if (_orderedDrawCullModes.Length < count)
_orderedDrawCullModes = new CullMode[count + 64];
}
} }

View file

@ -635,56 +635,22 @@ public sealed unsafe partial class WbDrawDispatcher
batchIndex < renderData.Batches.Count; batchIndex < renderData.Batches.Count;
batchIndex++) batchIndex++)
{ {
ObjectRenderBatch batch = // Campaign FW stage FW3.2a: mirrors the classic ClassifyBatches
renderData.Batches[batchIndex]; // gate/promotion/resolve/foliage-classify sequence exactly — see
// the one shared core (WbDrawDispatcher.WalkClassify.cs's
// #426: mirrors the classic ClassifyBatches gate exactly — see // TryClassifyBatch) also used by ClassifyBatches and the walk
// RetailUntexturedSubsetPolicy for the retail citation. ONE // classifier, so the classic and packed classifiers cannot drift
// shared predicate so the classic and packed classifiers cannot // (Campaign VM VM6). `survives=false` still applies
// drift (Campaign VM VM6). // compositePending exactly as before this extraction.
if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid)) bool survives = TryClassifyBatch(
continue; renderData, batchIndex, in entity, meshRef, paletteIdentity,
opacity, entityHasCutoutSubset,
TranslucencyKind translucency = batch.Translucency; out GroupKey key, out bool compositePending);
if (opacity < 1f && IsOpaque(translucency))
translucency = TranslucencyKind.AlphaBlend;
ResolvedTexture texture = ResolveTexture(
in entity,
meshRef,
batch,
paletteIdentity,
out bool compositePending);
if (compositePending) if (compositePending)
reusableAcrossFrames = false; reusableAcrossFrames = false;
if (!texture.Slot.IsAssigned) if (!survives)
continue; continue;
// Campaign VM VM6 review fix round 2 (F1 BLOCKER): the packed
// production classifier never computed FoliageFlags, so the
// production BatchData.flags word was always 0 for every
// scenery entity — the world geometry never swayed even though
// the independently-classified shadow caster did. Classify
// BEFORE constructing the key, from the RAW (pre-#188-
// promotion) batch.Translucency, exactly as the classic
// ClassifyBatches does — see that method's own comment for why
// raw translucency is used for classification but the (possibly
// promoted) local `translucency` is still what the key/group
// partitions draws by.
uint foliageFlags = FoliageWindClassification.Classify(
entity.LocalEntityId,
FoliageWindExclusions.Contains(meshRef.GfxObjId),
batch.Translucency,
entityHasCutoutSubset);
var key = new GroupKey(
batch.FirstIndex,
(int)batch.BaseVertex,
batch.IndexCount,
texture.Slot,
texture.Layer,
translucency,
FoliageFlags: foliageFlags,
CullMode: batch.CullMode);
var classified = new PackedClassifiedBatch( var classified = new PackedClassifiedBatch(
key, key,
restPose, restPose,

View file

@ -796,23 +796,40 @@ public sealed unsafe partial class WbDrawDispatcher
_ => pipelines.AlphaBlend, _ => pipelines.AlphaBlend,
}; };
/// <summary>
/// Reads cull modes from <paramref name="cullModes"/> when the caller
/// supplies one, or from the shared <see cref="_drawCullModes"/> scratch
/// otherwise (every pre-FW3.2a call site). Campaign FW stage FW3.2a:
/// <see cref="SubmitOrderedStream"/> passes its OWN scratch
/// (<see cref="_orderedDrawCullModes"/>) so a walk-ordered submission can
/// interleave with a mid-flight <see cref="RetailAlphaQueue"/> scope —
/// <c>_drawCullModes</c> is written fresh by
/// <c>PrepareDeferredAlphaDraws</c> at every alpha flush and read right
/// back by this method for that draw; an ordered submission sharing the
/// same array between those two steps could silently draw the alpha
/// content's faces with the ordered content's cull mode, or vice
/// versa (the FW2 caveat this stage retires — see
/// <c>WbDrawDispatcher.OrderedStream.cs</c>).
/// </summary>
private void DrawIndirectRangeRhi( private void DrawIndirectRangeRhi(
IGpuPassEncoder encoder, IGpuPassEncoder encoder,
ref GpuPushConstants pushConstants, ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer, IGpuBuffer commandBuffer,
uint commandBaseOffsetBytes, uint commandBaseOffsetBytes,
int startCommand, int startCommand,
int commandCount) int commandCount,
CullMode[]? cullModes = null)
{ {
CullMode[] modes = cullModes ?? _drawCullModes;
int end = startCommand + commandCount; int end = startCommand + commandCount;
int command = startCommand; int command = startCommand;
while (command < end) while (command < end)
{ {
CullMode cullMode = _drawCullModes[command]; CullMode cullMode = modes[command];
ApplyCullModeRhi(encoder, cullMode); ApplyCullModeRhi(encoder, cullMode);
int runCount = 1; int runCount = 1;
while (command + runCount < end && _drawCullModes[command + runCount] == cullMode) while (command + runCount < end && modes[command + runCount] == cullMode)
runCount++; runCount++;
// Each multi-draw-indirect call restarts gl_DrawID at 0, so a run // Each multi-draw-indirect call restarts gl_DrawID at 0, so a run

View file

@ -0,0 +1,355 @@
using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Campaign FW stage FW3.2a: the walk-order population layer. This partial
/// holds two things.
///
/// <para><b>The shared per-batch classify core</b> (<see cref="TryClassifyBatch"/>):
/// extracted from <c>ClassifyBatches</c> (this file's sibling
/// <c>WbDrawDispatcher.cs</c>) and <c>ClassifyPackedBatches</c>
/// (<c>WbDrawDispatcher.PackedOracle.cs</c>), which carried byte-identical
/// per-batch logic (the #426 untextured-subset gate, the #188 opacity
/// promotion, texture resolution, Campaign VM foliage classification, the
/// <see cref="GroupKey"/> construction) with only their SURROUNDING
/// bookkeeping differing (classic appends to an <see cref="InstanceGroup"/>
/// via caller-scoped instance fields; packed appends via explicit
/// parameters). Both call sites now call this one method per batch and keep
/// their own append logic — the classic and packed paths are unchanged in
/// every observable way (same hermetic + <c>Lane=InstalledDat</c> suites
/// stay the referee); this stage's OWN new walk classifier
/// (<see cref="ClassifyEntityForWalk"/>) is the third caller.</para>
///
/// <para><b>The per-entity walk classify seam</b>
/// (<see cref="ClassifyEntityForWalk"/>): given ONE <see cref="RenderProjectionRecord"/>
/// — the same shape <c>ClassifyPackedEntity</c> consumes, but read straight
/// off the record's own <see cref="RenderEntityPayload"/> via
/// <c>RenderInstanceCandidate.FromProjection</c> rather than the packed
/// route's frame-arena mesh-part flattening — yields one
/// <see cref="WalkClassifiedBatch"/> per surviving (part, batch) pair WITHOUT
/// touching any <see cref="InstanceGroup"/>, plus the per-part selection
/// data <c>AddPackedSelectionPart</c> would have published, for
/// <c>WalkStaticStreamPopulator</c> to publish itself (deliverable
/// 1 leaves the publish decision — timing, stage/cell provenance — to the
/// caller; see <see cref="PublishWalkSelectionPart"/>, the thin internal seam
/// that keeps <c>_selectionSink</c> encapsulated).</para>
///
/// <para>Scope: this stage classifies static content only (no production
/// frame wiring — see plan §FW3.2a). Per-part translucency-fade
/// (<c>TranslucencyFadeManager</c>/<c>EntityOpacity</c>) is NOT threaded
/// through: that mechanic is <c>TransparentPartHook</c>, a retail LIVE-entity
/// behavior keyed by ServerGuid, not something world statics undergo — every
/// classified batch here carries <c>Alpha = 1f</c>. Likewise the async
/// mesh-miss self-heal request (<c>_missRequested</c>/<c>EnsureLoaded</c>,
/// frame-scoped state cleared by <c>BeginEntityDispatch</c>) is not fired
/// here — this seam has no production frame to be scoped to yet; a missing
/// mesh is simply skipped, matching every other unwired FW2/FW3.2a path.</para>
/// </summary>
public sealed partial class WbDrawDispatcher
{
/// <summary>
/// One walk-classified (entity, part, batch) draw candidate — exactly
/// <c>OrderedDrawCommand</c>'s per-instance field set (minus
/// the walk-provenance <c>Stage</c>/<c>CellId</c> fields, which the
/// populator stamps on since the classifier has no notion of either)
/// plus two fields an opaque command has no use for but a translucent
/// 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).
/// </summary>
internal readonly record struct WalkClassifiedBatch(
GroupKey Key,
Matrix4x4 Transform,
uint ClipSlot,
InstanceLightSet Lights,
uint IndoorFlag,
float Alpha,
Vector2 SelectionLighting,
uint DetailCategory,
bool IsOpaque,
Vector3 LocalSortCenter);
/// <summary>
/// One retail-picking part surfaced by <see cref="ClassifyEntityForWalk"/>
/// — the exact argument tuple <c>AddPackedSelectionPart</c>'s
/// <c>publishSelection: true</c> branch passes to
/// <c>IRetailSelectionRenderSink.AddVisiblePart</c>. The classify seam
/// surfaces this; <see cref="PublishWalkSelectionPart"/> is the caller's
/// publish call.
/// </summary>
internal readonly record struct WalkClassifiedSelectionPart(
uint ServerGuid,
uint LocalEntityId,
int PartIndex,
uint GfxObjId,
Matrix4x4 LocalToWorld);
/// <summary>
/// The shared per-batch classify core. Given one already-resolved
/// <paramref name="renderData"/> and the batch at <paramref name="batchIndex"/>,
/// applies — in this exact order, matching both <c>ClassifyBatches</c> and
/// <c>ClassifyPackedBatches</c> before this extraction — the #426
/// untextured-subset-on-a-shell-only gate, the #188 mid-fade-forces-
/// AlphaBlend promotion, texture resolution, and Campaign VM foliage
/// classification, then builds the surviving batch's <see cref="GroupKey"/>.
///
/// <para>Returns false when the batch does not survive — either the
/// untextured-subset gate rejected it (in which case
/// <paramref name="compositePending"/> is always false: <c>ResolveTexture</c>
/// is never called) or its resolved texture slot is unassigned (in which
/// case <paramref name="compositePending"/> still reflects whatever
/// <c>ResolveTexture</c> reported — a caller must apply it to its own
/// reusability/readiness tracking regardless of the false return, exactly
/// as both existing callers did before this extraction).</para>
/// </summary>
private bool TryClassifyBatch(
ObjectRenderData renderData,
int batchIndex,
in RenderInstanceCandidate entity,
MeshRef meshRef,
PaletteCompositeIdentity paletteIdentity,
float opacityMultiplier,
bool entityHasCutoutSubset,
out GroupKey key,
out bool compositePending)
{
key = default;
compositePending = false;
ObjectRenderBatch batch = renderData.Batches[batchIndex];
// #426 — see RetailUntexturedSubsetPolicy for the retail citation.
// ONE shared predicate for every classifier so they cannot drift.
if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid))
return false;
TranslucencyKind translucency = batch.Translucency;
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
// must route through the alpha-blend pass so mesh_modern.frag's
// (blend-enabled) shader actually composites the reduced alpha — the
// no-blend opaque pass would ignore it.
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
translucency = TranslucencyKind.AlphaBlend;
ResolvedTexture texture = ResolveTexture(
in entity, meshRef, batch, paletteIdentity, out compositePending);
if (!texture.Slot.IsAssigned)
return false;
// Classify from the RAW (pre-#188-promotion) batch.Translucency — a
// mid-fade trunk is still a trunk, it just landed in the alpha-blend
// group instead of opaque (Campaign VM VM6 review fix round).
uint foliageFlags = FoliageWindClassification.Classify(
entity.LocalEntityId,
FoliageWindExclusions.Contains(meshRef.GfxObjId),
batch.Translucency,
entityHasCutoutSubset);
key = new GroupKey(
batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texture.Slot, texture.Layer, translucency,
FoliageFlags: foliageFlags,
CullMode: batch.CullMode);
return true;
}
/// <summary>
/// Classifies one static entity for the walk populator: resolves its clip
/// slot / light set / selection lighting exactly as
/// <c>ClassifyPackedEntity</c> does (via the shared
/// <see cref="ResolveSlotForFrame"/> / <c>ResolvePackedLightSet</c>
/// helpers), walks its Setup parts or single mesh (mirroring
/// <c>ClassifyPackedEntity</c>'s own shape), and appends one
/// <see cref="WalkClassifiedBatch"/> per surviving batch to
/// <paramref name="batches"/> plus one <see cref="WalkClassifiedSelectionPart"/>
/// per part to <paramref name="selectionParts"/> — WITHOUT touching
/// <c>_groups</c>/<c>_packedGroups</c> or publishing selection itself (see
/// this file's type doc comment).
///
/// <para>A culled entity (<see cref="ResolveSlotForFrame"/> returns
/// <c>Culled: true</c> — not visible through the active clip route)
/// contributes nothing, matching every other classifier's cull gate.</para>
/// </summary>
internal void ClassifyEntityForWalk(
in RenderProjectionRecord projection,
uint tupleLandblockId,
List<WalkClassifiedBatch> batches,
List<WalkClassifiedSelectionPart> selectionParts)
{
ArgumentNullException.ThrowIfNull(batches);
ArgumentNullException.ThrowIfNull(selectionParts);
RenderInstanceCandidate entity =
RenderInstanceCandidate.FromProjection(in projection, tupleLandblockId);
(uint slot, bool culled) = ResolveSlotForFrame(
_clipRoutingActive, entity.ServerGuid, entity.ParentCell,
_cellIdToSlot, _outdoorSlot, _outdoorVisible);
if (culled)
return;
ResolvePackedLightSet(in entity, out InstanceLightSet lights, out bool indoor);
Vector2 selectionLighting =
_selectionLighting?.TryGetLighting(
entity.ServerGuid, entity.LocalEntityId, out RetailSelectionLighting lighting) == true
? new Vector2(lighting.Luminosity, lighting.Diffuse)
: new Vector2(0f, 1f);
uint detailCategory = entity.IsBuildingShell ? 1u : 0u;
PaletteCompositeIdentity paletteIdentity = default;
if (entity.PaletteOverride is not null)
paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride);
IReadOnlyList<MeshRef>? meshRefs = projection.EntityPayload.MeshRefs;
if (meshRefs is null)
return;
for (int partIndex = 0; partIndex < meshRefs.Count; partIndex++)
{
MeshRef meshRef = meshRefs[partIndex];
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
if (renderData is null)
continue;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
// Same entity-scoped OR the classic/packed classifiers compute
// — a Setup composite's parts are separate GfxObjs with their
// own independently cached HasCutoutSubset (Campaign VM VM6
// review fix round A4/F1).
bool entityHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
renderData.SetupParts,
_meshAdapter,
static (adapter, part) => adapter.TryGetRenderData(part.GfxObjId)
is { HasCutoutSubset: true });
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
{
(ulong gfxObjId, Matrix4x4 partTransform) = renderData.SetupParts[setupPartIndex];
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(gfxObjId);
if (partData is null)
continue;
Matrix4x4 restPose = partTransform * meshRef.PartTransform;
Matrix4x4 model = restPose * entity.RootWorld;
int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF));
EmitClassifiedBatches(
partData, model, in entity, meshRef, paletteIdentity,
entityHasCutoutSubset, slot, lights, indoor, selectionLighting,
detailCategory, batches);
selectionParts.Add(new WalkClassifiedSelectionPart(
entity.ServerGuid, entity.LocalEntityId, selectionPartIndex,
(uint)gfxObjId, model));
}
}
else
{
Matrix4x4 model = meshRef.PartTransform * entity.RootWorld;
EmitClassifiedBatches(
renderData, model, in entity, meshRef, paletteIdentity,
entityHasCutoutSubsetOverride: null, slot, lights, indoor,
selectionLighting, detailCategory, batches);
selectionParts.Add(new WalkClassifiedSelectionPart(
entity.ServerGuid, entity.LocalEntityId, partIndex,
(uint)meshRef.GfxObjId, model));
}
}
}
/// <summary>
/// Walks one resolved mesh's batches through <see cref="TryClassifyBatch"/>
/// and appends every surviving one to <paramref name="sink"/> at
/// <c>Alpha = 1f</c> (see this file's type doc comment for why statics
/// never carry a per-part opacity multiplier here).
/// </summary>
private void EmitClassifiedBatches(
ObjectRenderData renderData,
Matrix4x4 model,
in RenderInstanceCandidate entity,
MeshRef meshRef,
PaletteCompositeIdentity paletteIdentity,
bool? entityHasCutoutSubsetOverride,
uint slot,
InstanceLightSet lights,
bool indoor,
Vector2 selectionLighting,
uint detailCategory,
List<WalkClassifiedBatch> sink)
{
bool entityHasCutoutSubset = entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset;
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
{
bool survives = TryClassifyBatch(
renderData, batchIdx, in entity, meshRef, paletteIdentity,
opacityMultiplier: 1.0f, entityHasCutoutSubset,
out GroupKey key, out _);
if (!survives)
continue;
sink.Add(new WalkClassifiedBatch(
key, model, slot, lights, indoor ? 1u : 0u, Alpha: 1f,
selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency),
LocalSortCenter: renderData.SortCenter));
}
}
/// <summary>
/// The thin internal publish seam <c>WalkStaticStreamPopulator</c>
/// calls for every <see cref="WalkClassifiedSelectionPart"/>
/// <see cref="ClassifyEntityForWalk"/> surfaced — same call shape as
/// <c>AddPackedSelectionPart</c>'s <c>publishSelection: true</c> branch.
/// Keeps <c>_selectionSink</c> encapsulated: the populator lives outside
/// the dispatcher and must not reach the field directly.
/// </summary>
internal void PublishWalkSelectionPart(in WalkClassifiedSelectionPart part) =>
_selectionSink?.AddVisiblePart(
part.ServerGuid, part.LocalEntityId, part.PartIndex, part.GfxObjId, part.LocalToWorld);
/// <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.
/// </summary>
internal void SubmitWalkAlphaInstance(
in WalkClassifiedBatch batch,
Vector3 cameraWorldPosition,
Matrix4x4 viewProjection)
{
RetailAlphaQueue queue = _alphaQueue
?? throw new InvalidOperationException(
"SubmitWalkAlphaInstance requires an active RetailAlphaQueue.");
if (_deferredAlpha.Count == 0)
_deferredAlphaViewProjection = viewProjection;
else if (_deferredAlphaViewProjection != viewProjection)
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);
}
}

View file

@ -598,6 +598,12 @@ public sealed partial class WbDrawDispatcher : IDisposable
private BatchData[] _batchData = new BatchData[256]; private BatchData[] _batchData = new BatchData[256];
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256]; private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
private CullMode[] _drawCullModes = new CullMode[256]; private CullMode[] _drawCullModes = new CullMode[256];
// Campaign FW stage FW3.2a: SubmitOrderedStream's OWN cull-mode scratch,
// separate from _drawCullModes above. See DrawIndirectRangeRhi's doc
// comment for why sharing the array made an ordered submission unsafe to
// interleave with a mid-flight RetailAlphaQueue scope.
private CullMode[] _orderedDrawCullModes = new CullMode[256];
private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256]; private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256];
private readonly List<IndirectGroupInput> _groupInputScratch = new(256); private readonly List<IndirectGroupInput> _groupInputScratch = new(256);
private readonly List<GroupKey> _retiredGroupKeys = new(); private readonly List<GroupKey> _retiredGroupKeys = new();
@ -3312,62 +3318,25 @@ public sealed partial class WbDrawDispatcher : IDisposable
bool allTexturesReady = true; bool allTexturesReady = true;
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
{ {
var batch = renderData.Batches[batchIdx]; // Campaign FW stage FW3.2a: the untextured-subset gate, the #188
// opacity promotion, texture resolution, and Campaign VM foliage
// #426: retail's D3DPolyRender::DrawMesh skips an UNTEXTURED // classification now live in the one shared core
// (solid-colour) subset only on a BUILDING SHELL // (WbDrawDispatcher.WalkClassify.cs) also used by
// (RenderDeviceD3D::DrawBuilding sets ObjBuildingOrBuildingPart); // ClassifyPackedBatches and the walk classifier — see
// ordinary statics/scenery/creatures/items draw it same as any // TryClassifyBatch's doc comment. `survives=false` still applies
// textured subset. ONE shared predicate with ClassifyPackedBatches // compositePending exactly as before this extraction (a batch
// and AddDirectionalShadowBatches — see RetailUntexturedSubsetPolicy. // that fails the untextured-subset gate never touched
if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid)) // ResolveTexture, so compositePending is always false there; a
continue; // batch with an unresolved texture slot still reports it).
bool survives = TryClassifyBatch(
TranslucencyKind translucency = batch.Translucency; renderData, batchIdx, in entity, meshRef, paletteIdentity,
opacityMultiplier, entityHasCutoutSubset,
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap out GroupKey key, out bool compositePending);
// must route through the alpha-blend pass so mesh_modern.frag's
// (blend-enabled) shader actually composites the reduced alpha —
// the no-blend opaque pass would ignore it.
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
translucency = TranslucencyKind.AlphaBlend;
ResolvedTexture texture = ResolveTexture(
in entity,
meshRef,
batch,
paletteIdentity,
out bool compositePending);
if (compositePending) if (compositePending)
allTexturesReady = false; allTexturesReady = false;
// Campaign V slice V4t: an unassigned slot is the "no texture yet" if (!survives)
// case a zero handle used to signal. It is a real sentinel continue;
// (GpuTextureSlot.Unassigned == ACDREAM_TEXTURE_NONE), not the GpuTextureSlot texSlot = key.TextureSlot;
// default value, so nothing here can silently resolve to slot 0.
if (!texture.Slot.IsAssigned) continue;
GpuTextureSlot texSlot = texture.Slot;
uint texLayer = texture.Layer;
// Campaign VM VM6 review fix round: classify BEFORE constructing
// the key and fold the result INTO the key (rather than
// stamping it onto whatever group the key already resolves to).
// Classification is from the RAW (pre-#188-promotion)
// batch.Translucency — a mid-fade trunk is still a trunk, it
// just landed in the alpha-blend group instead of opaque. This
// is what keeps a scenery instance and a non-scenery instance
// of the identical mesh subset in two SEPARATE groups instead of
// coalescing into one group whose classification depends on
// whichever entity classified it last.
uint foliageFlags = FoliageWindClassification.Classify(
entity.LocalEntityId,
FoliageWindExclusions.Contains(meshRef.GfxObjId),
batch.Translucency,
entityHasCutoutSubset);
var key = new GroupKey(
batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texSlot, texLayer, translucency,
FoliageFlags: foliageFlags,
CullMode: batch.CullMode);
InstanceGroup grp = GetOrCreateInstanceGroup(key); InstanceGroup grp = GetOrCreateInstanceGroup(key);
grp.Matrices.Add(model); grp.Matrices.Add(model);

View file

@ -0,0 +1,636 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Walk;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using Microsoft.Extensions.Logging.Abstractions;
namespace AcDream.App.Tests.Rendering.Walk;
/// <summary>
/// Campaign FW stage FW3.2a: the walk→draw population layer's data-
/// equivalence referee. Covers <see cref="WbDrawDispatcher.ClassifyEntityForWalk"/>
/// (the shared per-entity classify seam), <see cref="WalkStaticStreamPopulator"/>
/// (opaque → <see cref="OrderedDrawStream"/>, translucent → the alpha queue,
/// selection publish), and the FW3.2a own-cull-scratch fix to
/// <c>SubmitOrderedStream</c>.
/// </summary>
public sealed class WalkStaticStreamPopulatorTests
{
// ── Test doubles ────────────────────────────────────────────────────────
private sealed class RecordingSelectionSink : IRetailSelectionRenderSink
{
public readonly List<(uint ServerGuid, uint LocalEntityId, int PartIndex, uint GfxObjId, Matrix4x4 LocalToWorld)>
Calls = new();
public void AddVisiblePart(
uint serverGuid, uint localEntityId, int partIndex, uint gfxObjId, Matrix4x4 partWorld) =>
Calls.Add((serverGuid, localEntityId, partIndex, gfxObjId, partWorld));
}
// ── Synthetic RenderProjectionRecord construction ──────────────────────
private static RenderProjectionRecord MakeRecord(
uint localEntityId,
uint serverGuid,
Vector3 position,
IReadOnlyList<MeshRef> meshRefs,
bool isBuildingShell = false,
uint parentCellId = 0u) =>
new(
Id: RenderProjectionId.FromRaw(localEntityId),
ProjectionClass: RenderProjectionClass.OutdoorStatic,
OwnerIncarnation: RenderOwnerIncarnation.FromRaw(1),
Transform: new RenderTransform(Matrix4x4.CreateTranslation(position)),
PreviousTransform: default,
MeshSet: default,
Material: default,
Residency: default,
Bounds: default,
Flags: RenderProjectionFlags.Draw,
DegradeState: default,
SortKey: new RenderSortKey(0),
DirtyMask: default,
Source: new RenderSourceMetadata(
LocalEntityId: localEntityId,
ServerGuid: serverGuid,
SourceId: 0,
ParentCellId: parentCellId,
EffectCellId: 0,
BuildingShellAnchorCellId: 0,
TransformFingerprint: default,
GeometryFingerprint: default,
AppearanceFingerprint: default),
EntityPayload: new RenderEntityPayload(
MeshRefs: meshRefs,
PaletteOverride: null,
IsBuildingShell: isBuildingShell));
private static ObjectRenderBatch MakeBatch(
uint surfaceId,
TranslucencyKind translucency,
uint firstIndex,
int baseVertex,
int indexCount,
uint textureSlotIndex,
uint textureLayer = 0,
CullMode cullMode = CullMode.CounterClockwise) =>
new()
{
Key = new TextureKey { SurfaceId = surfaceId, IsSolid = false },
Translucency = translucency,
FirstIndex = firstIndex,
BaseVertex = (uint)baseVertex,
IndexCount = indexCount,
TextureSlot = new GpuTextureSlot(textureSlotIndex),
TextureIndex = (int)textureLayer,
};
private static ObjectRenderData MakeFlatMesh(params ObjectRenderBatch[] batches) =>
new() { Batches = new List<ObjectRenderBatch>(batches) };
// ── Reflection seam: ObjectMeshManager owns no test-injection API, and
// driving real GPU/GfxObj upload for a unit test is out of this stage's
// scope — ObjectRenderData/ObjectRenderBatch are plain settable classes,
// so this seeds the manager's private cache directly. ──────────────────
private static void InjectRenderData(ObjectMeshManager manager, ulong id, ObjectRenderData data)
{
FieldInfo field = typeof(ObjectMeshManager).GetField(
"_renderData", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException(
"ObjectMeshManager._renderData field not found — test relies on this exact name.");
var dict = (ConcurrentDictionary<ulong, ObjectRenderData>)field.GetValue(manager)!;
dict[id] = data;
}
// ── Deliverable 1: ClassifyEntityForWalk data equivalence ─────────────
[Fact]
public void ClassifyEntityForWalk_OneOpaqueAndOneTranslucentPart_YieldsBatchesInRecordOrderWithCorrectIsOpaque()
{
using var fx = new DispatcherFixture();
const ulong opaqueGfxObj = 0x0100_0001UL;
const ulong alphaGfxObj = 0x0100_0002UL;
InjectRenderData(fx.Manager, opaqueGfxObj, MakeFlatMesh(
MakeBatch(0x08000001u, TranslucencyKind.Opaque, firstIndex: 0, baseVertex: 0, indexCount: 3, textureSlotIndex: 1)));
InjectRenderData(fx.Manager, alphaGfxObj, MakeFlatMesh(
MakeBatch(0x08000002u, TranslucencyKind.AlphaBlend, firstIndex: 3, baseVertex: 4, indexCount: 6, textureSlotIndex: 2)));
var meshRefs = new[]
{
new MeshRef((uint)opaqueGfxObj, Matrix4x4.CreateTranslation(1, 0, 0)),
new MeshRef((uint)alphaGfxObj, Matrix4x4.CreateTranslation(0, 1, 0)),
};
RenderProjectionRecord record = MakeRecord(
localEntityId: 100, serverGuid: 0, position: new Vector3(5, 6, 7), meshRefs);
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
fx.Dispatcher.ClassifyEntityForWalk(in record, tupleLandblockId: 0x8C04u, batches, selectionParts);
Assert.Equal(2, batches.Count);
WbDrawDispatcher.WalkClassifiedBatch opaque = batches[0];
Assert.True(opaque.IsOpaque);
Assert.Equal(TranslucencyKind.Opaque, opaque.Key.Translucency);
Assert.Equal(0u, opaque.Key.FirstIndex);
Assert.Equal(3, opaque.Key.IndexCount);
Assert.Equal(1u, opaque.Key.TextureSlot.Index);
Assert.Equal(1f, opaque.Alpha);
Assert.Equal(meshRefs[0].PartTransform * record.Transform.LocalToWorld, opaque.Transform);
WbDrawDispatcher.WalkClassifiedBatch translucent = batches[1];
Assert.False(translucent.IsOpaque);
Assert.Equal(TranslucencyKind.AlphaBlend, translucent.Key.Translucency);
Assert.Equal(3u, translucent.Key.FirstIndex);
Assert.Equal(6, translucent.Key.IndexCount);
Assert.Equal(2u, translucent.Key.TextureSlot.Index);
Assert.Equal(meshRefs[1].PartTransform * record.Transform.LocalToWorld, translucent.Transform);
Assert.Equal(2, selectionParts.Count);
Assert.Equal(100u, selectionParts[0].LocalEntityId);
Assert.Equal(0, selectionParts[0].PartIndex);
Assert.Equal((uint)opaqueGfxObj, selectionParts[0].GfxObjId);
Assert.Equal(opaque.Transform, selectionParts[0].LocalToWorld);
Assert.Equal(1, selectionParts[1].PartIndex);
Assert.Equal((uint)alphaGfxObj, selectionParts[1].GfxObjId);
}
[Fact]
public void ClassifyEntityForWalk_SetupComposite_EncodesPartAndSetupPartIndexLikePackedRoute()
{
using var fx = new DispatcherFixture();
const ulong setupGfxObj = 0x1000_0010UL;
const ulong trunkGfxObj = 0x0100_0011UL;
const ulong leavesGfxObj = 0x0100_0012UL;
InjectRenderData(fx.Manager, trunkGfxObj, MakeFlatMesh(
MakeBatch(0x08000011u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
InjectRenderData(fx.Manager, leavesGfxObj, MakeFlatMesh(
MakeBatch(0x08000012u, TranslucencyKind.ClipMap, 3, 4, 6, 2)));
InjectRenderData(fx.Manager, setupGfxObj, new ObjectRenderData
{
IsSetup = true,
SetupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>
{
(trunkGfxObj, Matrix4x4.CreateTranslation(0, 0, 1)),
(leavesGfxObj, Matrix4x4.CreateTranslation(0, 0, 2)),
},
});
var meshRefs = new[] { new MeshRef((uint)setupGfxObj, Matrix4x4.Identity) };
RenderProjectionRecord record = MakeRecord(200, 0, Vector3.Zero, meshRefs);
var batches = new List<WbDrawDispatcher.WalkClassifiedBatch>();
var selectionParts = new List<WbDrawDispatcher.WalkClassifiedSelectionPart>();
fx.Dispatcher.ClassifyEntityForWalk(in record, 0x8C04u, batches, selectionParts);
Assert.Equal(2, batches.Count);
Assert.Equal(2, selectionParts.Count);
// partIndex=0 (the entity's single top-level MeshRef) << 16 | setupPartIndex.
Assert.Equal(0, selectionParts[0].PartIndex);
Assert.Equal(1, selectionParts[1].PartIndex);
Assert.Equal((uint)trunkGfxObj, selectionParts[0].GfxObjId);
Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId);
}
// ── Deliverable 2: WalkStaticStreamPopulator routing ───────────────────
[Fact]
public void PopulateCell_OpaqueBatchAppendsOrderedDrawCommandInRecordOrderWithStageAndCellProvenance()
{
using var fx = new DispatcherFixture();
const ulong gfxObj = 0x0100_0003UL;
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
MakeBatch(0x08000003u, TranslucencyKind.Opaque, 10, 2, 12, 5)));
var record = MakeRecord(300, 0, new Vector3(1, 2, 3), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
var stream = new OrderedDrawStream();
populator.PopulateCell(
stream, WalkDrawStage.CellStatic, cellId: 0x8C040100u,
new[] { record }, tupleLandblockId: 0x8C04u,
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
Assert.Equal(1, stream.Count);
Assert.Equal(WalkDrawStage.CellStatic, stream.Stages[0]);
Assert.Equal(0x8C040100u, stream.CellIds[0]);
Assert.Equal(10u, stream.Keys[0].FirstIndex);
Assert.Equal(12, stream.Keys[0].IndexCount);
Assert.Equal(1f, stream.Alphas[0]);
Assert.Equal(record.Transform.LocalToWorld, stream.Transforms[0]);
}
[Fact]
public void PopulateOutdoorStatics_UsesTheOutdoorStaticStage()
{
using var fx = new DispatcherFixture();
const ulong gfxObj = 0x0100_0004UL;
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
MakeBatch(0x08000004u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
var record = MakeRecord(400, 0, Vector3.Zero, new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
var stream = new OrderedDrawStream();
populator.PopulateOutdoorStatics(
stream, cellId: 0x8C040000u, new[] { record }, tupleLandblockId: 0x8C04u,
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
Assert.Equal(1, stream.Count);
Assert.Equal(WalkDrawStage.OutdoorStatic, stream.Stages[0]);
}
[Fact]
public void PopulateCell_TranslucentBatchDoesNotAppendToTheStreamAndReachesTheAlphaQueue()
{
using var fx = new DispatcherFixture(withAlphaQueue: true);
const ulong gfxObj = 0x0100_0005UL;
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
MakeBatch(0x08000005u, TranslucencyKind.AlphaBlend, 0, 0, 3, 1)));
var record = MakeRecord(500, 0, new Vector3(0, 0, 10), new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
var stream = new OrderedDrawStream();
fx.AlphaQueue!.BeginFrame();
populator.PopulateCell(
stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u,
cameraWorldPosition: Vector3.Zero, viewProjection: Matrix4x4.Identity);
Assert.Equal(0, stream.Count);
Assert.Equal(1, fx.AlphaQueue.PendingCount);
fx.AlphaQueue.AbortFrame();
}
[Fact]
public void PopulateCell_PublishesSelectionPartsForEveryClassifiedEntity()
{
var sink = new RecordingSelectionSink();
using var fx = new DispatcherFixture(selectionSink: sink);
const ulong gfxObj = 0x0100_0006UL;
InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh(
MakeBatch(0x08000006u, TranslucencyKind.Opaque, 0, 0, 3, 1)));
var record = MakeRecord(600, serverGuid: 0x8000_0060u, new Vector3(1, 1, 1),
new[] { new MeshRef((uint)gfxObj, Matrix4x4.Identity) });
var populator = new WalkStaticStreamPopulator(fx.Dispatcher);
var stream = new OrderedDrawStream();
populator.PopulateCell(
stream, WalkDrawStage.CellStatic, 0x8C040100u, new[] { record }, 0x8C04u,
Vector3.Zero, Matrix4x4.Identity);
var call = Assert.Single(sink.Calls);
Assert.Equal(0x8000_0060u, call.ServerGuid);
Assert.Equal(600u, call.LocalEntityId);
Assert.Equal(0, call.PartIndex);
Assert.Equal((uint)gfxObj, call.GfxObjId);
Assert.Equal(record.Transform.LocalToWorld, call.LocalToWorld);
}
// ── SubmitWalkAlphaInstance: same viewer distance + per-instance data as
// DeferTransparentGroups, through the REAL RetailAlphaQueue. ───────────
[Fact]
public void SubmitWalkAlphaInstance_SubmitsTheSameViewerDistanceComputeViewerDistanceWouldProduce()
{
using var fx = new DispatcherFixture(withAlphaQueue: true);
fx.AlphaQueue!.BeginFrame();
var key = new GroupKey(10, 2, 6, new GpuTextureSlot(3), 1, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
Vector3 localSortCenter = new(1, 2, 3);
Matrix4x4 model = Matrix4x4.CreateTranslation(4, 5, 6);
var cameraWorldPosition = Vector3.Zero;
var batch = new WbDrawDispatcher.WalkClassifiedBatch(
key, model, ClipSlot: 7, WbDrawDispatcher.InstanceLightSet.Disabled, IndoorFlag: 1,
Alpha: 0.5f, SelectionLighting: new Vector2(0.25f, 0.75f), DetailCategory: 1,
IsOpaque: false, LocalSortCenter: localSortCenter);
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, cameraWorldPosition, Matrix4x4.Identity);
Assert.Equal(1, fx.AlphaQueue.PendingCount);
// 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);
fx.AlphaQueue.AbortFrame();
}
[Fact]
public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope()
{
using var fx = new DispatcherFixture(withAlphaQueue: true);
fx.AlphaQueue!.BeginFrame();
var key = new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.AlphaBlend, FoliageFlags: 0);
var batch = new WbDrawDispatcher.WalkClassifiedBatch(
key, Matrix4x4.Identity, 0, WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f,
Vector2.Zero, 0, IsOpaque: false, LocalSortCenter: new Vector3(0, 0, 10));
fx.Dispatcher.SubmitWalkAlphaInstance(in batch, Vector3.Zero, Matrix4x4.Identity);
Assert.Throws<InvalidOperationException>(() =>
fx.Dispatcher.SubmitWalkAlphaInstance(
in batch, Vector3.Zero, Matrix4x4.CreateTranslation(1, 0, 0)));
fx.AlphaQueue.AbortFrame();
}
// ── Deliverable 3: SubmitOrderedStream's own cull scratch ──────────────
[Fact]
public void SubmitOrderedStream_DoesNotReadOrCorruptTheSharedAlphaCullScratch()
{
using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw();
// Poison the SHARED _drawCullModes scratch the alpha path owns — the
// exact array SubmitOrderedStream used to write into before FW3.2a.
// Under the OLD shared-scratch behavior this test's second assertion
// fails: SubmitOrderedStream's own command overwrites index 0 with
// its own cull mode (Clockwise), destroying the alpha path's poison.
FieldInfo field = typeof(WbDrawDispatcher).GetField(
"_drawCullModes", BindingFlags.NonPublic | BindingFlags.Instance)!;
var poisonModes = (CullMode[])field.GetValue(fx.Dispatcher)!;
poisonModes[0] = CullMode.None;
var stream = new OrderedDrawStream();
stream.Append(new OrderedDrawCommand(
new GroupKey(0, 0, 3, new GpuTextureSlot(1), 0, TranslucencyKind.Opaque, FoliageFlags: 0, CullMode: CullMode.Clockwise),
Matrix4x4.Identity, WalkDrawStage.Terrain, 0, 0,
WbDrawDispatcher.InstanceLightSet.Disabled, 0, 1f, Vector2.Zero, 0));
fx.Dispatcher.SubmitOrderedStream(draw.Frame, draw.Pass, stream, Matrix4x4.Identity);
// (1) The ordered submission's OWN recorded cull call reflects the
// STREAM's cull mode (Clockwise -> GpuCullMode.Front), not the
// poisoned shared array's (None).
List<GpuCullMode> cullCalls = [.. fx.Device.Calls.OfType<GpuRecordedCullMode>().Select(c => c.CullMode)];
Assert.Equal([GpuCullMode.Front], cullCalls);
// (2) The shared _drawCullModes scratch is UNTOUCHED — the ordered
// path never wrote through it.
var afterModes = (CullMode[])field.GetValue(fx.Dispatcher)!;
Assert.Equal(CullMode.None, afterModes[0]);
}
// ── Fixture ─────────────────────────────────────────────────────────────
private readonly struct DrawScope : IDisposable
{
private readonly IDisposable _publication;
private readonly IGpuPassEncoder _pass;
public DrawScope(IGpuFrame frame, IGpuPassEncoder pass, IDisposable publication)
{
Frame = frame;
_pass = pass;
_publication = publication;
}
public IGpuFrame Frame { get; }
public IGpuPassEncoder Pass => _pass;
public void Dispose()
{
_publication.Dispose();
_pass.Dispose();
}
}
private sealed class DispatcherFixture : IDisposable
{
private readonly WbMeshAdapter _meshAdapter;
private readonly TextureCache _textures;
public DispatcherFixture(
bool withAlphaQueue = false,
IRetailSelectionRenderSink? selectionSink = null)
{
Device = new RecordingGpuDevice();
FrameLifetime = new GpuDeviceFrameLifetime(Device);
Scope = new VulkanWorldPassScope(sampleCount: 1);
_textures = new TextureCache(Device, new NoopDatReaderWriter());
_meshAdapter = new WbMeshAdapter(
Device,
new NoopDatReaderWriter(),
new NullPreparedAssetSource(),
NullLogger<WbMeshAdapter>.Instance,
Device.Retirement);
var entitySpawnAdapter = new EntitySpawnAdapter(
_textures,
_ => throw new NotSupportedException("Not exercised by these tests."));
AlphaQueue = withAlphaQueue ? new RetailAlphaQueue() : null;
Dispatcher = new WbDrawDispatcher(
Device,
FrameLifetime,
Scope,
_textures,
_meshAdapter,
entitySpawnAdapter,
new EntityClassificationCache(),
new AcDream.Core.Rendering.TranslucencyFadeManager(),
selectionSink: selectionSink,
alphaQueue: AlphaQueue);
}
public RecordingGpuDevice Device { get; }
public GpuDeviceFrameLifetime FrameLifetime { get; }
public VulkanWorldPassScope Scope { get; }
public WbDrawDispatcher Dispatcher { get; }
public RetailAlphaQueue? AlphaQueue { get; }
public ObjectMeshManager Manager => _meshAdapter.MeshManager!;
public DrawScope BeginDraw()
{
FrameLifetime.BeginFrame();
IGpuFrame frame = FrameLifetime.CurrentFrame!;
IGpuPassEncoder pass = frame.BeginPass(
GpuPassDescription.BackbufferClear(
"fw3-2a-walk-populator-test", Vector4.Zero, sampleCount: 1));
IDisposable publication = Scope.Publish(pass);
Device.Clear();
return new DrawScope(frame, pass, publication);
}
public void Dispose()
{
Dispatcher.Dispose();
_meshAdapter.Dispose();
_textures.Dispose();
Device.Dispose();
}
}
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose()
{
}
}
private sealed class NoopDatReaderWriter : IDatReaderWriter
{
private readonly StubDatabase _portal = new();
private readonly StubDatabase _highRes = new();
private readonly StubDatabase _language = new();
private readonly StubDatabase _cell = new();
public string SourceDirectory => string.Empty;
public IDatDatabase Portal => _portal;
public IDatDatabase Cell => _cell;
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
new(new Dictionary<uint, IDatDatabase>());
public IDatDatabase HighRes => _highRes;
public IDatDatabase Language => _language;
public IDatDatabase Local => _language;
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
new(new Dictionary<uint, uint>());
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public bool TryGetFileBytes(
uint regionId,
uint fileId,
ref byte[] bytes,
out int bytesRead)
{
bytesRead = 0;
return false;
}
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
Array.Empty<IDatReaderWriter.IdResolution>();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public bool TrySave<T>(
uint regionId,
T obj,
int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj => default;
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public void Dispose()
{
}
private sealed class StubDatabase : IDatDatabase
{
public DatDatabase Db => throw new NotSupportedException();
public int Iteration => 0;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
Array.Empty<uint>();
public bool TryGet<T>(
uint fileId,
[MaybeNullWhen(false)] out T value) where T : IDBObj
{
value = default;
return false;
}
public bool TryGetFileBytes(
uint fileId,
[MaybeNullWhen(false)] out byte[] value)
{
value = null;
return false;
}
public bool TryGetFileBytes(
uint fileId,
ref byte[] bytes,
out int bytesRead)
{
bytesRead = 0;
return false;
}
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException();
public void Dispose()
{
}
}
}
}