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)]