acdream/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs
Erik fccba8390d refactor(render): one group-creation seam, required foliage key field, first-frame wind snap (Campaign VM VM6 review 3)
Narrow re-review of a82959f1: APPROVE, with follow-ups. All items landed.

N2 (structural): (a) extracted the ONE shared InstanceGroup-from-key
construction seam, WbDrawDispatcher.CreateGroupFromKey(key, registration,
frame) — before this there were two near-identical `new InstanceGroup
{ ... }` initializers (GetOrCreateInstanceGroup and GetOrCreatePackedGroup)
that had already drifted once (the round-2 F1 bug). Both routes call it now;
CreateGroupFromKey's own `new()` is the only production InstanceGroup
construction site repo-wide, same precedent as AppendPackedInstance. (b)
GroupKey.FoliageFlags lost its `= 0u` default and moved before CullMode in
the declaration (CullMode keeps its default, C# requires optional params to
trail required ones), so a `new GroupKey(...)` that omits it is a compile
error. Fixed every real construction site the reorder/requirement touched:
the 2 production sites, ToKey (a reconstruction from InstanceGroup the
review didn't count but the reorder broke), and 5 test sites (one more than
the review's "4" — InstanceGroupClearTests had a second, implicit
target-typed `MakeKey` factory the original count missed). Verified by a
full solution build.

N1: added CreateGroupFromKey_CopiesFoliageFlagsFromTheKey
(InstanceGroupClearTests) — a key carrying FoliageFlags 0x2 in, the created
group's FoliageFlags 0x2 out. That test plus N2b's required field are what
actually guard the round-2 F1 blocker; reworded PackedDispatcherOracleTests'
existing test comment to say what IT proves (the classification-to-
BuildIndirectArrays-to-BatchData.flags path), not that it guards the
classifier.

N3: corrected the plan's round-2 paragraph — folding FoliageFlags into the
G2/G3 digest is correct and symmetric, but CompareClassifiedOutput only
runs from RenderScenePViewFrameProductController.BuildAndCompare, which has
no production caller anywhere in src/AcDream.App/, and both of
RenderScenePViewFrameProductTests's own callers construct the controller
without the optional dispatcher argument — so the fold catches nothing
until that oracle is wired to an actual caller.

N4: the plan's F6 note now names both classification caches — the classic
route's EntityClassificationCache.EntityCacheEntry (self-heals per entity
on its own next eviction) and the packed route's
PackedProjectionClassificationEntry/PackedClassifiedBatch.Key
(PackedProjectionClassificationCache.BeginFrame clears its entire cache in
one shot on a RenderSceneGeneration change) — and notes neither mechanism
is keyed to a pack switch specifically.

N5: deleted the now-unused single-generic ComputeEntityHasCutoutSubset<T>
overload; its 4 test call sites now use the two-generic, zero-alloc
overload with an unused int context and a static (_, value) => value
lambda, so there is exactly one ComputeEntityHasCutoutSubset to keep
correct.

A6 (reviewer-filed): ResolveFoliageWind's _windMean/_windGust started at 0
and always eased toward the weather target by clock delta, with no
distinction for a graph's first-ever advance. A pinned clock
(ACDREAM_SKY_PHASE_SECONDS, the offline pixel gate's determinism pin) never
advances between calls, so the wind reached only whatever fraction the
first (1-second-clamped) step produced and sat there forever; live, the
first 10 s after a graph is constructed (pack selection / login) spun up
from dead calm even though the weather already IS what it is. Fixed at the
root: the first advance (_windFrameSerial == -1, the constructor sentinel)
now snaps _windMean/_windGust straight to the target; every later advance
eases over WeatherSystem.TransitionSeconds exactly as before. Added a
SetWindClockSecondsOverrideForTesting seam (_windClockSecondsOverride is no
longer readonly) so a hermetic test can advance the pinned clock by an
exact amount between two resolves without a real-time Thread.Sleep; two new
tests prove the first-advance snap is exact and a second advance still
eases at the normal rate. The three existing indoor/wind-disabled/amplitude
gate tests pass unchanged.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,046/0 failed. Core.Tests 4,695/0 failed. Full hermetic-filtered solution:
15,274/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 03:03:56 +02:00

250 lines
11 KiB
C#

using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
namespace AcDream.App.Tests.Rendering.Wb;
/// <summary>
/// Campaign VM VM6: <see cref="FoliageWindClassification"/> — the pure
/// function <c>WbDrawDispatcher.ClassifyBatches</c> (the world receiver) and
/// <c>WbDrawDispatcher.AddDirectionalShadowBatches</c> (the directional-
/// shadow caster) both call with the same four inputs (entity id, exclusion
/// check, subset translucency, mesh-level HasCutoutSubset), which is what
/// makes the two agree by construction — see foliage_wind.glsl.
/// </summary>
public sealed class FoliageWindClassificationTests
{
private const uint ProceduralSceneryEntityId = 0x80010203u; // bit 31 set
private const uint OrdinaryEntityId = 0x00010203u; // bit 31 clear
[Fact]
public void ProceduralSceneryCutoutSubsetGetsTheCutoutFlag()
{
uint flags = FoliageWindClassification.Classify(
ProceduralSceneryEntityId,
isExcluded: false,
TranslucencyKind.ClipMap,
meshHasCutoutSubset: true);
Assert.Equal(FoliageWindClassification.CutoutFoliageFlag, flags);
}
[Fact]
public void ProceduralSceneryOpaqueSubsetGetsTheTrunkFlagOnlyWhenTheMeshOwnsACutoutSubset()
{
uint withCutoutSibling = FoliageWindClassification.Classify(
ProceduralSceneryEntityId,
isExcluded: false,
TranslucencyKind.Opaque,
meshHasCutoutSubset: true);
uint withoutCutoutSibling = FoliageWindClassification.Classify(
ProceduralSceneryEntityId,
isExcluded: false,
TranslucencyKind.Opaque,
meshHasCutoutSubset: false);
Assert.Equal(FoliageWindClassification.TrunkFlag, withCutoutSibling);
Assert.Equal(0u, withoutCutoutSibling); // a rock, not a tree trunk
}
[Fact]
public void NonProceduralSceneryEntityGetsNeitherBitRegardlessOfMaterial()
{
uint cutout = FoliageWindClassification.Classify(
OrdinaryEntityId,
isExcluded: false,
TranslucencyKind.ClipMap,
meshHasCutoutSubset: true);
uint opaqueWithCutoutSibling = FoliageWindClassification.Classify(
OrdinaryEntityId,
isExcluded: false,
TranslucencyKind.Opaque,
meshHasCutoutSubset: true);
Assert.Equal(0u, cutout);
Assert.Equal(0u, opaqueWithCutoutSibling);
}
[Fact]
public void AnExcludedObjectIdGetsNeitherBitEvenWhenOtherwiseQualifying()
{
uint cutout = FoliageWindClassification.Classify(
ProceduralSceneryEntityId,
isExcluded: true,
TranslucencyKind.ClipMap,
meshHasCutoutSubset: true);
uint trunk = FoliageWindClassification.Classify(
ProceduralSceneryEntityId,
isExcluded: true,
TranslucencyKind.Opaque,
meshHasCutoutSubset: true);
Assert.Equal(0u, cutout);
Assert.Equal(0u, trunk);
}
[Theory]
[InlineData(TranslucencyKind.AlphaBlend)]
[InlineData(TranslucencyKind.Additive)]
[InlineData(TranslucencyKind.InvAlpha)]
public void OtherMaterialKindsOnProceduralSceneryGetNeitherBit(TranslucencyKind translucency)
{
uint flags = FoliageWindClassification.Classify(
ProceduralSceneryEntityId,
isExcluded: false,
translucency,
meshHasCutoutSubset: true);
Assert.Equal(0u, flags);
}
// Review fix round: the classifier tests the full top-nibble 0x8000_0000,
// NOT bit 31 alone — bit 31 is also set by LandblockStaticEntityIdAllocator's
// 0xC... namespace (top nibble 1100) and by the synthetic render ids
// 0xDA11_D0xx (paperdoll) and 0xFFFF_FF01 (portal tunnel). The prior
// [InlineData(0xFFFFFFFFu, true)] case pinned exactly this bug (0xFFFFFFFFu
// has bit 31 set but is NOT a procedural-scenery id under the top-nibble
// rule) — it is corrected to false below.
[Theory]
[InlineData(0x80000000u, true)] // top nibble 0x8 exactly
[InlineData(0x8FFFFFFFu, true)] // top nibble 0x8, every other bit set
[InlineData(0x7FFFFFFFu, false)] // top nibble 0x7 — bit 31 clear
[InlineData(0x00000000u, false)]
[InlineData(0xFFFFFFFFu, false)] // top nibble 0xF — bit 31 set, NOT procedural scenery
[InlineData(0xC0010203u, false)] // LandblockStaticEntityIdAllocator (top nibble 0xC) — fence/gate/building shell
[InlineData(0xDA11D012u, false)] // ChargenPreviewEntityBuilder/CreatureAppraisalPresentation synthetic doll id
[InlineData(0xFFFFFF01u, false)] // PortalTunnelPresentation synthetic id
public void IsProceduralSceneryDecodesTheFullTopNibbleNotJustBit31(uint entityId, bool expected)
{
Assert.Equal(expected, FoliageWindClassification.IsProceduralScenery(entityId));
}
/// <summary>
/// Campaign VM VM6 review fix round (A2): GroupKey now carries
/// FoliageFlags, so two otherwise-identical mesh-subset keys — same
/// geometry/texture/translucency/cull mode — with different
/// classification results are UNEQUAL keys, which is what makes a
/// scenery instance and a non-scenery instance of the same GfxObj land
/// in two separate WbDrawDispatcher groups instead of coalescing into
/// one group whose flags depended on whichever entity classified it
/// last. Flags are derived from real entity ids through
/// <see cref="FoliageWindClassification.Classify"/> — a procedural-
/// scenery id (0x8…, a tree's cutout leaf subset) versus a landblock-
/// static id (0xC…, e.g. a fence with a cutout mesh subset) — matching
/// the review's exact pairing and its 0x2/0x0 expected words, and the
/// same pairing <c>DirectionalShadowPreparedDrawTests
/// .SameSubsetDifferentFoliageClassificationNeverCoalescesIntoOneCasterBatch</c>
/// proves the caster keeps separate.
/// </summary>
[Fact]
public void GroupKeysWithDifferentFoliageFlagsAreNeverEqualEvenWithIdenticalGeometry()
{
const uint proceduralSceneryTreeId = 0x80010203u; // top nibble 0x8
const uint landblockStaticFenceId = 0xC0010203u; // top nibble 0xC
uint sceneryFlags = FoliageWindClassification.Classify(
proceduralSceneryTreeId,
isExcluded: false,
TranslucencyKind.ClipMap,
meshHasCutoutSubset: true);
uint landblockStaticFlags = FoliageWindClassification.Classify(
landblockStaticFenceId,
isExcluded: false,
TranslucencyKind.ClipMap,
meshHasCutoutSubset: true);
Assert.Equal(FoliageWindClassification.CutoutFoliageFlag, sceneryFlags); // 0x2
Assert.Equal(0u, landblockStaticFlags); // 0x0
var sceneryKey = new GroupKey(
FirstIndex: 100,
BaseVertex: 5,
IndexCount: 12,
TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(42),
TextureLayer: 0,
Translucency: TranslucencyKind.ClipMap,
CullMode: DatReaderWriter.Enums.CullMode.CounterClockwise,
FoliageFlags: sceneryFlags);
var landblockStaticKey = sceneryKey with { FoliageFlags = landblockStaticFlags };
// Two otherwise-identical mesh-subset keys, differing only by the
// entity-driven classification, are unequal — this is what makes
// WbDrawDispatcher.GetOrCreateInstanceGroup place the two instances
// in two separate InstanceGroups instead of coalescing them.
Assert.NotEqual(sceneryKey, landblockStaticKey);
}
/// <summary>
/// Campaign VM VM6 review fix round (A4): ComputeEntityHasCutoutSubset
/// ORs across every part regardless of WHICH part carries the cutout
/// subset — this pins the "two-part Setup, part B has cutout" scenario
/// at the algorithm level, decoupled from ObjectRenderData/mesh-adapter
/// plumbing.
/// </summary>
[Theory]
[InlineData(new[] { false, false }, false)]
[InlineData(new[] { true, false }, true)]
[InlineData(new[] { false, true }, true)] // part B (index 1) has the cutout
[InlineData(new[] { true, true }, true)]
public void ComputeEntityHasCutoutSubsetOrsAcrossEveryPart(bool[] partHasCutout, bool expected)
{
bool result = FoliageWindClassification.ComputeEntityHasCutoutSubset(
partHasCutout,
context: 0,
static (_, value) => value);
Assert.Equal(expected, result);
}
[Fact]
public void ComputeEntityHasCutoutSubsetIsFalseForAnEmptyPartList()
{
Assert.False(
FoliageWindClassification.ComputeEntityHasCutoutSubset(
Array.Empty<bool>(),
context: 0,
static (_, value) => value));
}
/// <summary>
/// Campaign VM VM6 review fix round (A4), end to end at the
/// classification level: a two-part Setup — part A is an opaque trunk
/// with no cutout batches of its own, part B is the leaves (has a
/// cutout subset). Classifying part A's opaque subset from its OWN
/// (false) HasCutoutSubset never yields the trunk flag (the pre-fix
/// bug); classifying it from the entity-scoped OR that
/// ComputeEntityHasCutoutSubset computes across BOTH parts correctly
/// yields TrunkFlag. Both WbDrawDispatcher.ClassifyBatches (receiver)
/// and AddDirectionalShadowBatches (caster) now pass the entity-scoped
/// value here instead of the part's own.
/// </summary>
[Fact]
public void TwoPartSetupGivesTheOpaqueTrunkPartTheTrunkFlagWhenAnotherPartHasCutout()
{
const uint sceneryEntityId = 0x80010203u;
bool[] partAHasCutoutSubset = [false]; // the trunk part's OWN mesh has no cutout batches
bool[] setupPartsHasCutoutSubset = [false, true]; // part A (trunk), part B (leaves)
bool partAOwnHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
partAHasCutoutSubset,
context: 0,
static (_, value) => value);
bool entityScopedHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
setupPartsHasCutoutSubset,
context: 0,
static (_, value) => value);
uint flagsFromPartOwnValue = FoliageWindClassification.Classify(
sceneryEntityId,
isExcluded: false,
TranslucencyKind.Opaque,
partAOwnHasCutoutSubset);
uint flagsFromEntityScopedValue = FoliageWindClassification.Classify(
sceneryEntityId,
isExcluded: false,
TranslucencyKind.Opaque,
entityScopedHasCutoutSubset);
Assert.Equal(0u, flagsFromPartOwnValue); // the pre-fix bug: no flag at all
Assert.Equal(FoliageWindClassification.TrunkFlag, flagsFromEntityScopedValue);
}
}