diff --git a/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs b/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs
index 5b30afd6..90ceee09 100644
--- a/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs
@@ -70,6 +70,47 @@ internal readonly record struct RenderInstanceCandidate(
TupleLandblockId: tupleLandblockId);
}
+ ///
+ /// Campaign FW stage FW3.2a: builds a candidate straight from a
+ /// , without the packed route's
+ /// /frame-arena mesh-part
+ /// flattening. The walk populator reads
+ /// records directly (CopyCellStaticsTo / CopyIndexTo), so it
+ /// has no frame arena to look the source candidate up in — every field
+ /// this needs already lives on the record's own
+ /// and .
+ ///
+ /// defaults to false: the walk's static
+ /// routes (RenderProjectionClass.OutdoorStatic /
+ /// IndoorCellStatic) never carry retail's per-part
+ /// TransparentPartHook animation — that mechanic keys off a live
+ /// entity's ServerGuid, not a world static's. A future dynamic walk route
+ /// passes true explicitly.
+ ///
+ 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(
in RenderFrameEntityCandidate source,
uint tupleLandblockId)
diff --git a/src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs b/src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs
index 97939e41..11565a33 100644
--- a/src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs
+++ b/src/AcDream.App/Rendering/Walk/OrderedDrawStream.cs
@@ -22,6 +22,20 @@ internal enum WalkDrawStage : byte
/// LScape::grab_visible_cells @0x00504EC0 — outdoor terrain.
Terrain,
+ ///
+ /// Campaign FW stage FW3.2a: the outdoor static objects (scenery,
+ /// buildings-as-decor, landblock statics —
+ /// RenderProjectionClass.OutdoorStatic) LScape::draw 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 () and
+ /// from a building's own exterior shell ().
+ /// The packed route's analogue is
+ /// RenderFrameCandidateRoute.LandscapeOutdoorStatic
+ /// (WbDrawDispatcher.PackedOracle.cs).
+ ///
+ OutdoorStatic,
+
/// An indoor PView::DrawCells @0x005A4840 flood's static
/// geometry: EnvCell shells plus the static meshes they contain.
CellStatic,
diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs
new file mode 100644
index 00000000..d138c2cf
--- /dev/null
+++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs
@@ -0,0 +1,137 @@
+using System.Numerics;
+using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.App.Rendering.Walk;
+
+///
+/// Campaign FW stage FW3.2a: the walk→draw population layer. Turns one
+/// cell's already-classified static content into
+/// appends (opaque, walk order), per-instance deferred-alpha submissions
+/// (translucent), and selection-scene publications (picking) — WITHOUT any
+/// production frame wiring. WorldSceneRenderer does not call this
+/// class yet; FW3.2b roots it into the real frame.
+///
+/// Why per-entity append instead of the classic material grouping:
+/// under '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 per (entity,
+/// opaque batch) in the SAME order the caller's records span presents
+/// them — never material-grouped, never re-sorted. Translucent batches go to
+/// the SAME 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 — WbDrawDispatcher.DeferTransparentGroups's own doc
+/// comment cites the same CShadowPart::insertion_sort stability this
+/// preserves).
+///
+/// Reads no retained scene state itself: every method takes the target
+/// and a caller-supplied span of already
+/// -queried s (from
+/// RenderSceneQuery.CopyCellStaticsTo / CopyIndexTo). 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.
+///
+internal sealed class WalkStaticStreamPopulator
+{
+ private readonly WbDrawDispatcher _dispatcher;
+ private readonly List _batchScratch = new();
+ private readonly List _selectionScratch = new();
+
+ internal WalkStaticStreamPopulator(WbDrawDispatcher dispatcher)
+ {
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ }
+
+ ///
+ /// Populates from one indoor cell's static
+ /// content (RenderProjectionClass.IndoorCellStatic —
+ /// RenderSceneQuery.CopyCellStaticsTo).
+ /// is caller-selected per the walk turn this cell's content belongs to
+ /// — for an ordinary
+ /// PView::DrawCells flood,
+ /// for a ConstructView(CBldPortal) look-in, or
+ /// for a building's own
+ /// exterior shell content.
+ ///
+ internal void PopulateCell(
+ OrderedDrawStream stream,
+ WalkDrawStage stage,
+ uint cellId,
+ ReadOnlySpan 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);
+ }
+ }
+
+ ///
+ /// The landscape entry point: outdoor static content
+ /// (RenderProjectionClass.OutdoorStatic —
+ /// RenderSceneQuery.CopyIndexTo(RenderSceneIndex.OutdoorStatic, ...))
+ /// at , the terrain-adjacent
+ /// stage LScape::draw visits a landblock's static objects at
+ /// alongside its ground mesh (see that stage value's own doc comment).
+ /// 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.
+ ///
+ internal void PopulateOutdoorStatics(
+ OrderedDrawStream stream,
+ uint cellId,
+ ReadOnlySpan 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);
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
index 37c8a9fa..c4e4508e 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.OrderedStream.cs
@@ -253,14 +253,14 @@ public sealed unsafe partial class WbDrawDispatcher
// Per-instance-first emission — the PrepareDeferredAlphaDraws shape,
// into the SAME per-frame scratch arrays PrepareDeferredAlphaDraws/
- // SubmitRhi write. Most are consumed immediately by the ring uploads
- // below, but _drawCullModes is NOT write-then-consume: the deferred-
- // alpha path reads it at FLUSH time (DrawIndirectRangeRhi's internal
- // cull split), so an ordered submission may never interleave between
- // RetailAlphaQueue prepare and flush. FW2 has no production caller;
- // the FW3 wiring must either sequence around the alpha scope or give
- // this path its own cull scratch.
+ // SubmitRhi write, EXCEPT cull modes: this stage (FW3.2a) gives the
+ // ordered path its own _orderedDrawCullModes scratch (see
+ // DrawIndirectRangeRhi's doc comment) precisely so this loop and its
+ // draws below can freely interleave with a mid-flight
+ // RetailAlphaQueue scope without corrupting — or being corrupted by
+ // — the alpha path's _drawCullModes.
EnsureDeferredAlphaCapacity(count);
+ EnsureOrderedCullModeCapacity(count);
for (int i = 0; i < count; i++)
{
GroupKey key = stream.Keys[i];
@@ -286,7 +286,7 @@ public sealed unsafe partial class WbDrawDispatcher
BaseVertex = key.BaseVertex,
BaseInstance = (uint)i,
};
- _drawCullModes[i] = key.CullMode;
+ _orderedDrawCullModes[i] = key.CullMode;
}
// 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
// pipeline, set RenderPass, draw. DrawIndirectRangeRhi still splits
- // internally on _drawCullModes (issue #52's absolute DrawIdOffset per
- // sub-call) — every run here already shares one cull mode by
- // construction, so that inner split is a no-op here, never a second
- // boundary this loop failed to expect.
+ // internally on _orderedDrawCullModes (issue #52's absolute
+ // DrawIdOffset per sub-call) — every run here already shares one
+ // cull mode by construction, so that inner split is a no-op here,
+ // never a second boundary this loop failed to expect.
foreach (OrderedMergeRun run in runs)
{
ValidateMergeRun(stream, run);
@@ -365,7 +365,20 @@ public sealed unsafe partial class WbDrawDispatcher
BindPipelineWithMesh(encoder, pipeline, global);
DrawIndirectRangeRhi(
encoder, ref pushConstants, commandBuffer, commandBase,
- run.FirstCommand, run.CommandCount);
+ run.FirstCommand, run.CommandCount, _orderedDrawCullModes);
}
}
+
+ ///
+ /// Grows to at least
+ /// — the same growth shape
+ /// EnsureDeferredAlphaCapacity uses for ,
+ /// 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).
+ ///
+ private void EnsureOrderedCullModeCapacity(int count)
+ {
+ if (_orderedDrawCullModes.Length < count)
+ _orderedDrawCullModes = new CullMode[count + 64];
+ }
}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs
index d3df7aef..f5e16ffa 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs
@@ -635,56 +635,22 @@ public sealed unsafe partial class WbDrawDispatcher
batchIndex < renderData.Batches.Count;
batchIndex++)
{
- ObjectRenderBatch batch =
- renderData.Batches[batchIndex];
-
- // #426: mirrors the classic ClassifyBatches gate exactly — see
- // RetailUntexturedSubsetPolicy for the retail citation. ONE
- // shared predicate so the classic and packed classifiers cannot
- // drift (Campaign VM VM6).
- if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid))
- continue;
-
- TranslucencyKind translucency = batch.Translucency;
- if (opacity < 1f && IsOpaque(translucency))
- translucency = TranslucencyKind.AlphaBlend;
-
- ResolvedTexture texture = ResolveTexture(
- in entity,
- meshRef,
- batch,
- paletteIdentity,
- out bool compositePending);
+ // Campaign FW stage FW3.2a: mirrors the classic ClassifyBatches
+ // gate/promotion/resolve/foliage-classify sequence exactly — see
+ // the one shared core (WbDrawDispatcher.WalkClassify.cs's
+ // TryClassifyBatch) also used by ClassifyBatches and the walk
+ // classifier, so the classic and packed classifiers cannot drift
+ // (Campaign VM VM6). `survives=false` still applies
+ // compositePending exactly as before this extraction.
+ bool survives = TryClassifyBatch(
+ renderData, batchIndex, in entity, meshRef, paletteIdentity,
+ opacity, entityHasCutoutSubset,
+ out GroupKey key, out bool compositePending);
if (compositePending)
reusableAcrossFrames = false;
- if (!texture.Slot.IsAssigned)
+ if (!survives)
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(
key,
restPose,
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
index 4aabfa8d..47051004 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
@@ -796,23 +796,40 @@ public sealed unsafe partial class WbDrawDispatcher
_ => pipelines.AlphaBlend,
};
+ ///
+ /// Reads cull modes from when the caller
+ /// supplies one, or from the shared scratch
+ /// otherwise (every pre-FW3.2a call site). Campaign FW stage FW3.2a:
+ /// passes its OWN scratch
+ /// () so a walk-ordered submission can
+ /// interleave with a mid-flight scope —
+ /// _drawCullModes is written fresh by
+ /// PrepareDeferredAlphaDraws 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
+ /// WbDrawDispatcher.OrderedStream.cs).
+ ///
private void DrawIndirectRangeRhi(
IGpuPassEncoder encoder,
ref GpuPushConstants pushConstants,
IGpuBuffer commandBuffer,
uint commandBaseOffsetBytes,
int startCommand,
- int commandCount)
+ int commandCount,
+ CullMode[]? cullModes = null)
{
+ CullMode[] modes = cullModes ?? _drawCullModes;
int end = startCommand + commandCount;
int command = startCommand;
while (command < end)
{
- CullMode cullMode = _drawCullModes[command];
+ CullMode cullMode = modes[command];
ApplyCullModeRhi(encoder, cullMode);
int runCount = 1;
- while (command + runCount < end && _drawCullModes[command + runCount] == cullMode)
+ while (command + runCount < end && modes[command + runCount] == cullMode)
runCount++;
// Each multi-draw-indirect call restarts gl_DrawID at 0, so a run
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs
new file mode 100644
index 00000000..1e1351b2
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs
@@ -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;
+
+///
+/// Campaign FW stage FW3.2a: the walk-order population layer. This partial
+/// holds two things.
+///
+/// The shared per-batch classify core ():
+/// extracted from ClassifyBatches (this file's sibling
+/// WbDrawDispatcher.cs) and ClassifyPackedBatches
+/// (WbDrawDispatcher.PackedOracle.cs), which carried byte-identical
+/// per-batch logic (the #426 untextured-subset gate, the #188 opacity
+/// promotion, texture resolution, Campaign VM foliage classification, the
+/// construction) with only their SURROUNDING
+/// bookkeeping differing (classic appends to an
+/// 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 + Lane=InstalledDat suites
+/// stay the referee); this stage's OWN new walk classifier
+/// () is the third caller.
+///
+/// The per-entity walk classify seam
+/// (): given ONE
+/// — the same shape ClassifyPackedEntity consumes, but read straight
+/// off the record's own via
+/// RenderInstanceCandidate.FromProjection rather than the packed
+/// route's frame-arena mesh-part flattening — yields one
+/// per surviving (part, batch) pair WITHOUT
+/// touching any , plus the per-part selection
+/// data AddPackedSelectionPart would have published, for
+/// WalkStaticStreamPopulator to publish itself (deliverable
+/// 1 leaves the publish decision — timing, stage/cell provenance — to the
+/// caller; see , the thin internal seam
+/// that keeps _selectionSink encapsulated).
+///
+/// Scope: this stage classifies static content only (no production
+/// frame wiring — see plan §FW3.2a). Per-part translucency-fade
+/// (TranslucencyFadeManager/EntityOpacity) is NOT threaded
+/// through: that mechanic is TransparentPartHook, a retail LIVE-entity
+/// behavior keyed by ServerGuid, not something world statics undergo — every
+/// classified batch here carries Alpha = 1f. Likewise the async
+/// mesh-miss self-heal request (_missRequested/EnsureLoaded,
+/// frame-scoped state cleared by BeginEntityDispatch) 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.
+///
+public sealed partial class WbDrawDispatcher
+{
+ ///
+ /// One walk-classified (entity, part, batch) draw candidate — exactly
+ /// OrderedDrawCommand's per-instance field set (minus
+ /// the walk-provenance Stage/CellId 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: (so the
+ /// populator can route without re-deriving it from
+ /// ) and
+ /// (the authored GfxObj sort center RetailAlphaOrdering.ComputeViewerDistance
+ /// transforms through — the same value
+ /// InstanceGroup.LocalSortCenters carries per instance today).
+ ///
+ 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);
+
+ ///
+ /// One retail-picking part surfaced by
+ /// — the exact argument tuple AddPackedSelectionPart's
+ /// publishSelection: true branch passes to
+ /// IRetailSelectionRenderSink.AddVisiblePart. The classify seam
+ /// surfaces this; is the caller's
+ /// publish call.
+ ///
+ internal readonly record struct WalkClassifiedSelectionPart(
+ uint ServerGuid,
+ uint LocalEntityId,
+ int PartIndex,
+ uint GfxObjId,
+ Matrix4x4 LocalToWorld);
+
+ ///
+ /// The shared per-batch classify core. Given one already-resolved
+ /// and the batch at ,
+ /// applies — in this exact order, matching both ClassifyBatches and
+ /// ClassifyPackedBatches 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 .
+ ///
+ /// Returns false when the batch does not survive — either the
+ /// untextured-subset gate rejected it (in which case
+ /// is always false: ResolveTexture
+ /// is never called) or its resolved texture slot is unassigned (in which
+ /// case still reflects whatever
+ /// ResolveTexture 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).
+ ///
+ 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;
+ }
+
+ ///
+ /// Classifies one static entity for the walk populator: resolves its clip
+ /// slot / light set / selection lighting exactly as
+ /// ClassifyPackedEntity does (via the shared
+ /// / ResolvePackedLightSet
+ /// helpers), walks its Setup parts or single mesh (mirroring
+ /// ClassifyPackedEntity's own shape), and appends one
+ /// per surviving batch to
+ /// plus one
+ /// per part to — WITHOUT touching
+ /// _groups/_packedGroups or publishing selection itself (see
+ /// this file's type doc comment).
+ ///
+ /// A culled entity ( returns
+ /// Culled: true — not visible through the active clip route)
+ /// contributes nothing, matching every other classifier's cull gate.
+ ///
+ internal void ClassifyEntityForWalk(
+ in RenderProjectionRecord projection,
+ uint tupleLandblockId,
+ List batches,
+ List 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? 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));
+ }
+ }
+ }
+
+ ///
+ /// Walks one resolved mesh's batches through
+ /// and appends every surviving one to at
+ /// Alpha = 1f (see this file's type doc comment for why statics
+ /// never carry a per-part opacity multiplier here).
+ ///
+ 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 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));
+ }
+ }
+
+ ///
+ /// The thin internal publish seam WalkStaticStreamPopulator
+ /// calls for every
+ /// surfaced — same call shape as
+ /// AddPackedSelectionPart's publishSelection: true branch.
+ /// Keeps _selectionSink encapsulated: the populator lives outside
+ /// the dispatcher and must not reach the field directly.
+ ///
+ internal void PublishWalkSelectionPart(in WalkClassifiedSelectionPart part) =>
+ _selectionSink?.AddVisiblePart(
+ part.ServerGuid, part.LocalEntityId, part.PartIndex, part.GfxObjId, part.LocalToWorld);
+
+ ///
+ /// The walk populator's per-instance sibling of DeferTransparentGroups
+ /// (see that method for the retail citations this mirrors): submits ONE
+ /// translucent into the same
+ /// _deferredAlpha/ 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
+ /// call, same
+ /// queue.Submit contract — the walk path denormalizes to one
+ /// instance per call instead of flattening a material group.
+ ///
+ 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);
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index 3ea377bb..32cbf82f 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -598,6 +598,12 @@ public sealed partial class WbDrawDispatcher : IDisposable
private BatchData[] _batchData = new BatchData[256];
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[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 readonly List _groupInputScratch = new(256);
private readonly List _retiredGroupKeys = new();
@@ -3312,62 +3318,25 @@ public sealed partial class WbDrawDispatcher : IDisposable
bool allTexturesReady = true;
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
{
- var batch = renderData.Batches[batchIdx];
-
- // #426: retail's D3DPolyRender::DrawMesh skips an UNTEXTURED
- // (solid-colour) subset only on a BUILDING SHELL
- // (RenderDeviceD3D::DrawBuilding sets ObjBuildingOrBuildingPart);
- // ordinary statics/scenery/creatures/items draw it same as any
- // textured subset. ONE shared predicate with ClassifyPackedBatches
- // and AddDirectionalShadowBatches — see RetailUntexturedSubsetPolicy.
- if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid))
- continue;
-
- 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 bool compositePending);
+ // Campaign FW stage FW3.2a: the untextured-subset gate, the #188
+ // opacity promotion, texture resolution, and Campaign VM foliage
+ // classification now live in the one shared core
+ // (WbDrawDispatcher.WalkClassify.cs) also used by
+ // ClassifyPackedBatches and the walk classifier — see
+ // TryClassifyBatch's doc comment. `survives=false` still applies
+ // compositePending exactly as before this extraction (a batch
+ // that fails the untextured-subset gate never touched
+ // ResolveTexture, so compositePending is always false there; a
+ // batch with an unresolved texture slot still reports it).
+ bool survives = TryClassifyBatch(
+ renderData, batchIdx, in entity, meshRef, paletteIdentity,
+ opacityMultiplier, entityHasCutoutSubset,
+ out GroupKey key, out bool compositePending);
if (compositePending)
allTexturesReady = false;
- // Campaign V slice V4t: an unassigned slot is the "no texture yet"
- // case a zero handle used to signal. It is a real sentinel
- // (GpuTextureSlot.Unassigned == ACDREAM_TEXTURE_NONE), not the
- // 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);
+ if (!survives)
+ continue;
+ GpuTextureSlot texSlot = key.TextureSlot;
InstanceGroup grp = GetOrCreateInstanceGroup(key);
grp.Matrices.Add(model);
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs
new file mode 100644
index 00000000..05afdf00
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs
@@ -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;
+
+///
+/// Campaign FW stage FW3.2a: the walk→draw population layer's data-
+/// equivalence referee. Covers
+/// (the shared per-entity classify seam),
+/// (opaque → , translucent → the alpha queue,
+/// selection publish), and the FW3.2a own-cull-scratch fix to
+/// SubmitOrderedStream.
+///
+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 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(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)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();
+ var selectionParts = new List();
+ 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();
+ var selectionParts = new List();
+ 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)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(() =>
+ 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 cullCalls = [.. fx.Device.Calls.OfType().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.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 CellRegions { get; } =
+ new(new Dictionary());
+
+ public IDatDatabase HighRes => _highRes;
+
+ public IDatDatabase Language => _language;
+
+ public IDatDatabase Local => _language;
+
+ public ReadOnlyDictionary RegionFileMap { get; } =
+ new(new Dictionary());
+
+ 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 GetAllIdsOfType() where T : IDBObj =>
+ Array.Empty();
+
+ public IEnumerable ResolveId(uint id) =>
+ Array.Empty();
+
+ public bool TrySave(T obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public bool TrySave(
+ uint regionId,
+ T obj,
+ int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ [return: MaybeNull]
+ public T Get(uint fileId) where T : IDBObj => default;
+
+ public bool TryGet(
+ 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 GetAllIdsOfType() where T : IDBObj =>
+ Array.Empty();
+
+ public bool TryGet(
+ 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 obj, int iteration = 0) where T : IDBObj =>
+ throw new NotSupportedException();
+
+ public void Dispose()
+ {
+ }
+ }
+ }
+}