diff --git a/docs/plans/2026-08-22-visualmaster-campaign.md b/docs/plans/2026-08-22-visualmaster-campaign.md index 3f1ecb15..1bd046b5 100644 --- a/docs/plans/2026-08-22-visualmaster-campaign.md +++ b/docs/plans/2026-08-22-visualmaster-campaign.md @@ -564,6 +564,54 @@ precision at far landblock corners) and A8 (`FoliageWindExclusions` as a `FrozenSet`) also landed; A7 is the divergence note on the `max(maxHeight, 0.5)` guard above. +**Review fix round 2 (2026-08-23):** a narrow re-review found one more +blocker. (F1) The PRODUCTION packed classifier (`WbDrawDispatcher +.PackedOracle.cs`'s `ClassifyPackedBatches`/`GetOrCreatePackedGroup` — the +route `RetailPViewPassExecutor.DrawPackedProductionRoute` actually draws +from) never computed `FoliageFlags` at all: it built its `GroupKey` with the +field defaulting to `0u` and never copied it onto the created +`InstanceGroup`, so production `BatchData.flags` bits 1/2 were always zero +for every scenery entity — the world geometry never swayed even though the +independently-classified shadow caster did, so shadows visibly swayed under +rigid trees. Both classifier call sites now compute `FoliageFlags` via the +identical `FoliageWindClassification.Classify` call and entity-scoped +`HasCutoutSubset` OR the classic path uses, and `GetOrCreatePackedGroup` +copies it exactly like `GetOrCreateInstanceGroup` always has. The G2/G3 +classified-output digest (`AddOpaqueSubmissionGroup`/ +`BuildTransparentSubmissionDigest`) now also folds `GroupKey.FoliageFlags` +into its hash — previously present in the key but never actually read by +either digest function, so a content-level (not just count-level) classic- +vs-packed divergence is now caught. (F2, medium) The delayed-alpha replay +path (`PrepareDeferredAlphaDraws`) hardcoded `Flags = 1`, dropping bits 1/2 +for any group replayed through it — a trunk instance promoted into the +alpha-blend group mid-fade (the `#188` translucency-promotion case) would +stop swaying for the duration of its fade; now `1u | key.FoliageFlags`. (F3) +`ComputeEntityHasCutoutSubset`'s three call sites (classic, caster, and the +newly-fixed packed classifier) each allocated a closure over `_meshAdapter` +per Setup entity per frame; a new context-taking overload passes the mesh +adapter as an explicit argument to a `static` lambda instead, letting the +compiler cache one delegate for the method's lifetime. A3's test gap also +closed: `DirectionalShadowGpuTests` now has a companion test proving +`WbDrawDispatcher.BindDirectionalShadowReceiver` (made `internal` for this) +actually emits the `UniformAtmosphericFrame` bind with the exact +buffer/offset/size a `DirectionalShadowFrameBinding` carries, pairing with +the existing test that proves that binding carries the caster's real bind +forward untouched. F4 (the `foliage_wind.glsl` header comment) and F5 (a +comment at the receiver bind site: the caster's own `AtmosphericFrameBuffer- +Binding` has its seven ABI v1 members zero/Identity by construction — only +the two v2 wind members are valid — safe today because `mesh_atmospheric +.vert` reads this binding solely for wind displacement, a footgun for a +future v1-reading addition to that shader) also landed. F6, noted rather +than fixed: `EntityClassificationCache`'s `EntityCacheEntry` bakes +`GroupKey.FoliageFlags` (hence exclusion membership) in at classification +time and is not proactively invalidated when `FoliageWindExclusions` +changes — a newly-excluded or newly-included object can show its previous +classification until the entity's cache entry is next evicted (e.g. a +landblock demote/reload) rather than immediately on a pack switch. Harmless +with the pack off (exclusions are pack-scoped) and self-heals on the next +natural cache eviction; not worth a proactive invalidation sweep for a +rarely-changing, pack-scoped list. + ## VM7 — Closeout and merge - Full gates: `tools/run-release-gate.ps1` (hermetic lanes), the AR diff --git a/src/AcDream.App/Rendering/Shaders/foliage_wind.glsl b/src/AcDream.App/Rendering/Shaders/foliage_wind.glsl index 5837f61e..2cafbb6e 100644 --- a/src/AcDream.App/Rendering/Shaders/foliage_wind.glsl +++ b/src/AcDream.App/Rendering/Shaders/foliage_wind.glsl @@ -2,8 +2,10 @@ #define ACDREAM_FOLIAGE_WIND_GLSL // Campaign VM VM6 (2026-08-22): weather-driven foliage sway for procedural -// scenery (trees/bushes — see ProceduralSceneryIdAllocator, bit 31 of the -// entity id). Shared verbatim between mesh_atmospheric.vert (the world +// scenery (trees/bushes — see ProceduralSceneryIdAllocator.IsInNamespace, +// the top nibble 0x8 of the entity id — NOT bit 31 alone, which also +// matches LandblockStaticEntityIdAllocator's 0xC... namespace). Shared +// verbatim between mesh_atmospheric.vert (the world // receiver) and the four directional_shadow_world_*.vert casters so a // displaced leaf's shadow moves with it by construction — the caster and // receiver read the identical uAtmosphereClockWind/uAtmosphereWindAmplitude diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index cd8b246a..d6d0d9ca 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -151,7 +151,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "ae6026d4bc04bc07e5c67c1dca6c3dfd16ad377104153808a2e11b60bbe492f7", + "sourceSha256": "a30acec1fd60fcbc8a0cfa377726a65077a82836cbcf730cbcc756ce1b9299c5", "compiled": true }, { @@ -167,7 +167,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "c26158346a1db03e305192a23b595d288ed27a3622b8c2b49cc2fe6b27550946", + "sourceSha256": "7651a9622227c0322f75c8e3af2685bceb9bc28b692b53df305ec73161e589ff", "compiled": true }, { @@ -183,7 +183,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "c752d8f85e0aa4a5bd2b7cb8679276ba46850844362eef0efce885401021433b", + "sourceSha256": "9513c5baa4f269a9ddc265e9fa49c71a2834a004aca36939b99f9b5067d25ed1", "compiled": true }, { @@ -199,7 +199,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "e483aae19e35ad9bdd7226cb1604db33268f8ae4474d774dfa08d5ca681717ee", + "sourceSha256": "edf89ab8a8c02bf8fc3099b091ca3508a5b9c18e2ebbfe5bb5a7d6c50a82c745", "compiled": true }, { @@ -215,7 +215,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "949a50149a6dd7f8f2b44fa035ab80a4227a71a1cbd4ec812bf451d1c2971db3", + "sourceSha256": "a00aa83a8bd8ab4791b60bfb967a80b43bafcb0bf758f5e2e056b4272f3c8b1b", "compiled": true }, { diff --git a/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs b/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs index 46f9a045..02f4a063 100644 --- a/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs +++ b/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs @@ -81,6 +81,33 @@ internal static class FoliageWindClassification return false; } + /// + /// Campaign VM VM6 review fix round 2 (F3): zero-alloc production + /// overload of . The + /// single-generic overload above is exactly what a caller needs to + /// predicate on an instance field (e.g. the mesh adapter) — but doing so + /// with a lambda that closes over this allocates a fresh closure + /// object AND a fresh delegate on every call, and this is called once + /// per Setup entity per frame from the world receiver, the shadow + /// caster, and the packed production classifier. Passing the would-be- + /// captured value as an explicit + /// argument to a lambda (no captures at all) + /// lets the C# compiler cache a single delegate instance for the + /// method's lifetime instead of allocating one per call. + /// + internal static bool ComputeEntityHasCutoutSubset( + IReadOnlyList setupParts, + TContext context, + Func hasCutoutSubset) + { + for (int i = 0; i < setupParts.Count; i++) + { + if (hasCutoutSubset(context, setupParts[i])) + return true; + } + return false; + } + /// /// The full top-nibble 0x8... test via /// — NOT bit 31 diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs index a7d66221..5b4ba103 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs @@ -108,7 +108,12 @@ public sealed partial class WbDrawDispatcher return PipelinesFor(encoder); } - private static void BindDirectionalShadowReceiver( + // Campaign VM VM6 review fix round 2 (A3 test gap): internal, not + // private, so DirectionalShadowReceiverTests can drive it directly with + // a bare RecordingGpuDevice pass encoder instead of standing up the + // full WbDrawDispatcher/mesh-manager/world-pass-scope dependency chain + // just to prove this one bind call is correct. + internal static void BindDirectionalShadowReceiver( IGpuPassEncoder encoder, in DirectionalShadowFrameBinding binding) { @@ -127,6 +132,20 @@ public sealed partial class WbDrawDispatcher // depending on that earlier bind surviving un-reset until this pass // runs. Unbound (the declared-pack / no-caster-this-frame case) is a // no-op here exactly like the caster side. + // + // Review fix round 2 (F5): the buffer bound here is the CASTER's own + // independent allocation (AtmosphericPostProcessGraph + // .BuildShadowAtmosphericFrameBinding), NOT the receiver's/post- + // process's fuller frame block. Its seven ABI v1 members (sun + // screen/color, viewport, weather, sun direction, policy, inverse- + // view-projection) are ALL ZERO/Identity by construction — only the + // two appended v2 members (clock/wind, wind amplitude) carry real + // content. That is safe today because mesh_atmospheric.vert reads + // this binding SOLELY for foliage_wind.glsl's wind displacement; if + // a future change makes mesh_atmospheric.vert or .frag read any v1 + // member (sun/weather/policy/etc.) during this same world-mesh + // pass, it will read zeros here and must get its own real binding + // instead of assuming this one is fully populated. if (binding.AtmosphericFrame.IsBound) { encoder.BindUniformBuffer( diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs index 885e8c3f..cdee27ef 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs @@ -1028,11 +1028,16 @@ public sealed partial class WbDrawDispatcher // FoliageWindClassification.ComputeEntityHasCutoutSubset's // doc comment. Same shared helper the world-receiver // Setup-part loop uses, so the caster's trunk-part - // classification agrees with the receiver's. + // classification agrees with the receiver's. Review + // fix round 2 (F3): the context-taking overload + // passes _meshAdapter explicitly to a static lambda + // instead of closing over it — zero allocation per + // entity per frame. bool entityHasCutoutSubset = FoliageWindClassification .ComputeEntityHasCutoutSubset( renderData.SetupParts, - part => _meshAdapter.TryGetRenderData(part.GfxObjId) + _meshAdapter, + static (adapter, part) => adapter.TryGetRenderData(part.GfxObjId) is { HasCutoutSubset: true }); for (int setupPartIndex = 0; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index 78a6b0e6..c955713d 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -437,6 +437,25 @@ public sealed unsafe partial class WbDrawDispatcher if (renderData.IsSetup && renderData.SetupParts.Count > 0) { + // Campaign VM VM6 review fix round 2 (F1): same entity- + // scoped HasCutoutSubset OR the classic world-receiver loop + // computes (WbDrawDispatcher.cs) and the caster loop + // (DirectionalShadows.cs) — see + // FoliageWindClassification.ComputeEntityHasCutoutSubset's + // doc comment. A Setup composite's parts are separate + // GfxObjs with independently-cached HasCutoutSubset; without + // this bounded per-entity scan, the opaque trunk part of a + // production-classified tree would never get the trunk + // flag. The context-taking overload passes _meshAdapter + // explicitly to a static lambda (F3) — zero allocation per + // entity per frame. + 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++) @@ -477,7 +496,8 @@ public sealed unsafe partial class WbDrawDispatcher indoor, selectionLighting, opacity, - cacheEntry)) + cacheEntry, + entityHasCutoutSubset)) { reusableAcrossFrames = false; } @@ -593,8 +613,17 @@ public sealed unsafe partial class WbDrawDispatcher bool indoor, Vector2 selectionLighting, float opacity, - PackedProjectionClassificationEntry? cacheEntry) + PackedProjectionClassificationEntry? cacheEntry, + // Campaign VM VM6 review fix round 2 (F1): mirrors the classic + // dispatcher's ClassifyBatches override exactly. HasCutoutSubset is + // a per-PART (per-GfxObj) fact; the caller passes the ENTITY/Setup- + // scoped OR across every resolved part for a Setup composite. null + // (the default) means "use renderData.HasCutoutSubset directly", + // which is already correct for a non-Setup single-mesh entity. + bool? entityHasCutoutSubsetOverride = null) { + bool entityHasCutoutSubset = + entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset; bool reusableAcrossFrames = true; for (int batchIndex = 0; batchIndex < renderData.Batches.Count; @@ -617,6 +646,22 @@ public sealed unsafe partial class WbDrawDispatcher if (!texture.Slot.IsAssigned) 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, @@ -624,7 +669,8 @@ public sealed unsafe partial class WbDrawDispatcher texture.Slot, texture.Layer, translucency, - batch.CullMode); + batch.CullMode, + foliageFlags); var classified = new PackedClassifiedBatch( key, restPose, @@ -759,6 +805,17 @@ public sealed unsafe partial class WbDrawDispatcher TextureLayer = key.TextureLayer, Translucency = key.Translucency, CullMode = key.CullMode, + // Campaign VM VM6 review fix round 2 (F1 BLOCKER): this was the + // missing copy. GetOrCreateInstanceGroup (the classic route) has + // set FoliageFlags from key.FoliageFlags since the round-1 + // fix; this packed-route sibling never did, so every packed + // production InstanceGroup carried FoliageFlags == 0 regardless + // of what ClassifyPackedBatches computed into the key — + // BatchData.flags bits 1/2 were always 0 for production world + // geometry even though the independently-classified shadow + // caster set them correctly, so casters swayed while the + // meshes they shadowed did not. + FoliageFlags = key.FoliageFlags, Registration = _nextPackedGroupRegistration++, LastUsedFrame = _packedGroupFrame, }; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 38f393e3..d45fc48e 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -1855,9 +1855,14 @@ public sealed partial class WbDrawDispatcher : IDisposable // each part's own. A part missing this frame contributes // nothing; the entity is already marked incomplete by the // loop below and reclassifies once every part has loaded. + // Review fix round 2 (F3): the context-taking overload + // passes _meshAdapter as an explicit argument to a static + // lambda instead of closing over it, so this call allocates + // nothing per entity per frame. bool entityHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset( renderData.SetupParts, - part => _meshAdapter.TryGetRenderData(part.GfxObjId) is { HasCutoutSubset: true }); + _meshAdapter, + static (adapter, part) => adapter.TryGetRenderData(part.GfxObjId) is { HasCutoutSubset: true }); // #188: setupPartIndex is the SAME index space // TransparentPartHook.PartIndex addresses — retail's CPartArray @@ -2576,6 +2581,12 @@ public sealed partial class WbDrawDispatcher : IDisposable hash.Add(key.TextureLayer); hash.Add((int)key.Translucency); hash.Add((int)key.CullMode); + // Review fix round 2 (F1): fold FoliageFlags into the G2/G3 + // classified-output digest so a scenery/non-scenery group- + // partition divergence between the classic and packed + // dispatchers is caught by content, not only by an incidental + // group-count difference. + hash.Add(key.FoliageFlags); hash.Add(entry.ViewerDistance); AddSubmissionInstance( ref hash, @@ -2626,6 +2637,10 @@ public sealed partial class WbDrawDispatcher : IDisposable hash.Add(key.TextureLayer); hash.Add((int)key.Translucency); hash.Add((int)key.CullMode); + // Review fix round 2 (F1): see the matching comment in + // BuildTransparentSubmissionDigest — folds FoliageFlags into the + // G2/G3 classified-output digest. + hash.Add(key.FoliageFlags); hash.Add(group.Matrices.Count); ulong xorLow = 0; @@ -2786,8 +2801,17 @@ public sealed partial class WbDrawDispatcher : IDisposable // DrawMesh invokes RenderMeshSubset with detail enabled for // every built-mesh material subset while curr_detail_surface // is installed. The per-instance category still rejects - // ordinary objects in mesh_detail. - Flags = 1, + // ordinary objects in mesh_detail. Review fix round 2 (F2): + // bit 0 (the #226 built-mesh marker) is unconditional here — + // every delayed-alpha entry IS a built-mesh alpha submission + // — but the previous hardcoded `1` silently dropped bits 1/2 + // (Campaign VM VM6 foliage classification). A trunk instance + // promoted into the alpha-blend group mid-fade (translucency + // < 1 forces AlphaBlend even for an otherwise-Opaque trunk — + // see ClassifyBatches's #188 promotion) must keep swaying + // through the deferred-alpha replay instead of going rigid + // for the duration of its fade. + Flags = 1u | key.FoliageFlags, }; _indirectCommands[i] = new DrawElementsIndirectCommand { diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs index 93375d0a..a55f1868 100644 --- a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs +++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs @@ -467,6 +467,77 @@ public sealed class DirectionalShadowGpuTests Assert.Equal(atmosphericFrame.SizeBytes, binding.AtmosphericFrame.SizeBytes); } + /// + /// Campaign VM VM6 review fix round 2 (A3 test gap): the previous test + /// proves the caster's real bind lands unchanged in + /// DirectionalShadowFrameBinding.AtmosphericFrame. This proves the OTHER + /// half of the seam — that WbDrawDispatcher.BindDirectionalShadowReceiver, + /// given that exact binding, actually emits an encoder bind for + /// UniformAtmosphericFrame with the SAME buffer/offset/size, not a stale + /// or default one. Together the two tests prove the caster and receiver + /// agree end to end without standing up the full + /// WbDrawDispatcher/mesh-manager/world-pass-scope dependency chain — + /// BindDirectionalShadowReceiver only needs a bare pass encoder. + /// + [Fact] + public void BindDirectionalShadowReceiverEmitsAtmosphericFrameWithTheExactCasterBufferOffsetAndSize() + { + using var device = new RecordingGpuDevice(); + using IGpuBuffer shadowBuffer = Buffer(device, "shadow-frame", GpuBufferUsage.Uniform); + using IGpuBuffer atmosphericBuffer = Buffer(device, "test-atmospheric-frame", GpuBufferUsage.Uniform); + var binding = new DirectionalShadowFrameBinding( + FrameSerial: 1, + Enabled: true, + Buffer: shadowBuffer, + OffsetBytes: 0u, + SizeBytes: 128u, + TextureSlot: GpuTextureSlot.Unassigned, + CascadeCount: 4, + AtmosphericFrame: new AtmosphericFrameBufferBinding( + atmosphericBuffer, + OffsetBytes: 64u, + SizeBytes: 192u)); + + device.Clear(); + var target = device.CreateRenderTarget(new GpuRenderTargetDescription( + "test-world-hdr", + 640, + 480, + GpuTextureFormat.Rgba16FloatRenderTarget, + GpuTextureFormat.Depth24Stencil8, + SampleCount: 1)); + using IGpuFrame frame = device.BeginFrame(); + using (IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription + { + Name = "test-world-hdr", + Color = new GpuColorAttachment( + target, + GpuLoadOp.Clear, + GpuStoreOp.Store, + Vector4.Zero), + Depth = new GpuDepthAttachment(GpuLoadOp.Clear, GpuStoreOp.Store, 1f, 0), + SampleCount = 1, + })) + { + WbDrawDispatcher.BindDirectionalShadowReceiver(encoder, in binding); + } + frame.End(); + + GpuRecordedUniformBind atmosphericBind = Assert.Single( + device.OfKind(), + call => call.Binding == GpuBindingModel.UniformAtmosphericFrame); + Assert.Equal("test-atmospheric-frame", atmosphericBind.BufferName); + Assert.Equal(64u, atmosphericBind.OffsetBytes); + Assert.Equal(192u, atmosphericBind.SizeBytes); + + // The shadow-map binding fired too — BindDirectionalShadowReceiver + // is not a no-op that only happens to satisfy the assertion above. + GpuRecordedUniformBind shadowBind = Assert.Single( + device.OfKind(), + call => call.Binding == GpuBindingModel.UniformDirectionalShadow); + Assert.Equal("shadow-frame", shadowBind.BufferName); + } + [Fact] public void StableTopology_ReusesRetainedCommandBuffersWithoutFrameRingCopies() { diff --git a/tests/AcDream.App.Tests/Rendering/Wb/PackedDispatcherOracleTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/PackedDispatcherOracleTests.cs index 4e48e92a..c7bf6fa8 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/PackedDispatcherOracleTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/PackedDispatcherOracleTests.cs @@ -1,10 +1,73 @@ using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Gpu; +using AcDream.Core.Meshing; using System.Numerics; namespace AcDream.App.Tests.Rendering.Wb; public sealed class PackedDispatcherOracleTests { + /// + /// Campaign VM VM6 review fix round 2 (F1 BLOCKER): the production + /// packed classifier (WbDrawDispatcher.PackedOracle.cs's + /// ClassifyPackedBatches/GetOrCreatePackedGroup) is a private instance + /// pipeline reached only through the full RetailPViewPassExecutor → + /// DrawPackedProductionRoute route, which needs a real IGpuDevice, + /// world-pass scope, mesh manager, and compiled pipelines to construct — + /// no test in this suite (or anywhere in the App test project) stands + /// one up, so driving ClassifyPackedBatches/GetOrCreatePackedGroup + /// directly is not a "cheap test." This proves the two halves of the + /// fix that ARE cheaply testable and, chained together, prove the exact + /// claim the review asked for: (1) FoliageWindClassification.Classify — + /// called with the SAME argument shape ClassifyPackedBatches now uses + /// (entity.LocalEntityId, exclusion membership, batch.Translucency, + /// entity-scoped HasCutoutSubset) — resolves a real procedural-scenery + /// entity id (0x8…) to CutoutFoliageFlag (0x2); (2) that flags value, + /// carried on an IndirectGroupInput exactly like GetOrCreatePackedGroup + /// now carries it on InstanceGroup.FoliageFlags, reaches the literal + /// BatchData.flags word through BuildIndirectArrays — the same shared, + /// already-tested production step both the classic and packed group + /// lists feed into (WbDrawDispatcherIndirectBuilderTests pins bit 0; + /// this pins bits 1/2 landing alongside it for a real scenery id). + /// + [Fact] + public void SceneryCutoutEntityClassificationReachesTheBatchDataFlagsWordAsBit0x2() + { + const uint proceduralSceneryTreeId = 0x80010203u; // top nibble 0x8 + + uint foliageFlags = FoliageWindClassification.Classify( + proceduralSceneryTreeId, + isExcluded: false, + TranslucencyKind.ClipMap, + meshHasCutoutSubset: true); + Assert.Equal(FoliageWindClassification.CutoutFoliageFlag, foliageFlags); + Assert.Equal(0x2u, foliageFlags); + + var groups = new List + { + new( + IndexCount: 12, + FirstIndex: 0, + BaseVertex: 0, + InstanceCount: 1, + FirstInstance: 0, + TextureIndex: 0x5, + TextureLayer: 0, + Translucency: TranslucencyKind.ClipMap, + FoliageFlags: foliageFlags), + }; + var indirect = new DrawElementsIndirectCommand[4]; + var batches = new WbDrawDispatcher.BatchDataPublic[4]; + + WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batches); + + // Bit 0 (the #226 built-mesh marker) | bit 1 (cutout foliage) = 0x3; + // isolating bit 1 with a mask proves the foliage classification + // specifically reached the word, not merely that SOME nonzero value + // did. + Assert.Equal(0x2u, batches[0].Flags & 0x2u); + } + [Theory] [InlineData(false, 0u)] [InlineData(true, 1u)]