acdream/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs
Erik 15ed57a1e7 fix(rendering): carry retail SetSurface state to detail draws
Resolve exact SetSurface blend, alpha-test, and fog state once during extraction and preserve it through recipe-10 prepared payloads, Wb/EnvCell command data, Vulkan pipelines, push constants, and both ordinary/atmospheric one-pass shaders. Preserve AP-240 Wb pure-Clip immediate opaque/A2C while EnvCell uses retail premultiplied Clip; detail-off routing remains unchanged. Correct AP-232 and register the remaining detail-off state divergence.

Mutation first failures (all restored):
- raw Add->SRCALPHA/ONE: WalkStaticStreamPopulatorTests.ImmediateBuildingDetail_UsesExactResolvedSetSurfaceState line 1278, expected wb-mesh-raw-additive-1x.
- inverse-add->alpha-add: same test line 1278, expected wb-mesh-inverse-additive-1x.
- remove Env inverse: EnvCellAlphaDrawSourceTests.DetailOn_EveryEnvCellFamilyDrawsOnceInPlaceWithAuthoredOpacity line 132, expected envcell-inverse.
- raw IsAdditive precedence: same test line 132, Translucent|Clip|Additive expected envcell-alpha.
- Wb paletted ParamB=0: OrderPreservingSubmitterTests line 333, expected 0.392156869.
- Wb DDS ParamB=0.05: same test line 333, expected 0.784313738.
- disable Alpha+Clip test: WalkStaticStreamPopulatorTests line 1286, expected 0.784313738.
- always fog raw Add: same test line 1287, expected no-fog true.
- disable fog non-Add: same test line 1287, expected no-fog false.
- X=a*qA: RetailDetailTextureContractTests line 261, shared squared-alpha substring absent.
- X includes base alpha: same test line 262, forbidden baseTexel.a present.
- CLIP uses 0.05: EnvCellAlphaDrawSourceTests.ClipShaders_UseGreaterEqualForThePerRangeReference line 367.
- second detail draw: EnvCell detail-on line 131, collection contained 2 draws.
- straight-alpha substitute: Wb immediate line 1278, expected wb-mesh-additive-1x.
- omit ordered detail arm: OrderPreservingSubmitterTests line 318, expected (77,3.5), got (0,0).
- omit atmospheric combine: RetailDetailTextureContractTests line 260, shared include absent.
- drop serialized opacity: ObjectMeshDataSerializerTests line 292, expected opacity bits, got 1.0.
- stale detail arm: atmospheric adjacency line 402, expected slot 0, got 77.
- per-frame surface map: EnvCell warmed allocation line 285, expected 0 B, got 204800 B.
2026-09-05 02:03:13 +02:00

251 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,
MaterialState: RetailSetSurfaceMaterialState.Opaque,
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);
}
}