From 81c6531727e499dc9e8d2c667bb9333f1cbf88a3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 30 Aug 2026 13:52:23 +0200 Subject: [PATCH] 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 --- .../Scene/RenderInstanceCandidate.cs | 41 ++ .../Rendering/Walk/OrderedDrawStream.cs | 14 + .../Walk/WalkStaticStreamPopulator.cs | 137 ++++ .../Wb/WbDrawDispatcher.OrderedStream.cs | 39 +- .../Wb/WbDrawDispatcher.PackedOracle.cs | 58 +- .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 23 +- .../Wb/WbDrawDispatcher.WalkClassify.cs | 355 ++++++++++ .../Rendering/Wb/WbDrawDispatcher.cs | 77 +-- .../Walk/WalkStaticStreamPopulatorTests.cs | 636 ++++++++++++++++++ 9 files changed, 1264 insertions(+), 116 deletions(-) create mode 100644 src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs create mode 100644 src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs 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() + { + } + } + } +}