diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp new file mode 100644 index 0000000..bc1d6b9 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp @@ -0,0 +1,229 @@ +// VoxelForgeClassifyTileTest.cpp +// Phase 0.5 test #2 — LA SOLIDITÉ DE ClassifyTile / ClassifyTile soundness. +// +// ⚠️ LE TEST LE PLUS IMPORTANT DU PLUGIN / THE HIGHEST-CONSEQUENCE TEST IN THE PLUGIN. +// +// ClassifyTile (T1.d) répond "cette tuile est entièrement solide / entièrement air" AVANT tout +// échantillonnage, et sur un verdict non-Mixed le monde SAUTE GenerateMesh entièrement. Le contrat +// est asymétrique, et le commentaire de la fonction le dit déjà : +// +// un faux Mixed ne coûte que du CPU ; +// un faux AllSolid / AllAir est un TROU — pas de géométrie, PAS DE COLLISION, invisible +// jusqu'à ce qu'un joueur tombe au travers. +// +// ClassifyTile answers "this tile is entirely solid / entirely air" BEFORE any sampling, and on a +// non-Mixed verdict the world SKIPS GenerateMesh completely. The contract is asymmetric: +// a false Mixed only costs CPU; a false AllSolid/AllAir is a HOLE — no geometry, NO COLLISION, +// invisible until a player falls through it. +// +// Cette fonction a DÉJÀ produit cette panne : la v1 de T1.d a été revertée le 2026-06-26 pour une +// borne de plafond pas assez conservative. Jusqu'ici elle n'est validée que par raisonnement. +// Ce test la valide par la force brute : pour chaque tuile jugée non-Mixed, on échantillonne le +// treillis EXACT que le mesher aurait échantillonné (marge ±1 incluse) et on vérifie que chaque +// point est bien du côté annoncé. +// +// This function has ALREADY produced that failure: T1.d v1 was reverted on 2026-06-26 over a +// non-conservative ceiling bound. Until now it was validated by reasoning only. This test +// validates it by brute force: for every tile judged non-Mixed, sample the EXACT lattice the +// mesher would have sampled (±1 margin included) and assert every point is on the claimed side. +// +// CONVENTION (VoxelMarchingCubesMesher.cpp:309, IsoLevel == 0): +// D >= 0 ⇒ côté AIR / air side +// D < 0 ⇒ côté SOLIDE / solid side +// Le classifieur utilise exactement ces inégalités (cf. TestColumn), donc le test aussi. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelForgeTestFixture.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeClassifyTileTest, + "VoxelForge.Determinism.ClassifyTileSoundness", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + /** Tuiles balayées à la recherche d'un verdict non-Mixed (ClassifyTile est bon marché). */ + constexpr int32 NumTilesScanned = 600; + + /** Tuiles réellement brute-forcées (chacune ~(Cells+3)³ appels à GetDensityAt — cher). */ + constexpr int32 MaxTilesVerified = 24; + + struct FTileSpec + { + FIntVector Origin = FIntVector::ZeroValue; + int32 Step = 1; + int32 Cells = 16; + }; +} + +bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + World.Build(); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + const UVoxelGenerator* Gen = World.Generator.Get(); + + // Quelques carves : la garde diff-layer de ClassifyTile doit elle aussi être couverte, et + // c'est la garde la plus facile à casser en ajoutant une feature (elle est globale, pas + // par-archétype). / A few carves: ClassifyTile's diff-layer guard needs covering too, and it + // is the guard most easily broken by a new feature since it is global rather than per-archetype. + { + FVoxelModification Mod; + Mod.Shape = EVoxelBrushShape::Sphere; + Mod.Radius = 10.0f; + Mod.Strength = -12.0f; + for (int32 k = 0; k < 4; ++k) + { + Mod.Center = FVector((float)(k * CHUNK_SIZE * 2), 0.0f, + World.MidVoxelZ() + (float)(k * CHUNK_SIZE)); + World.DiffLayer->ApplyModification(Mod); + } + } + + // ── Balayage : trouver des tuiles où le classifieur ose un verdict. ── + // Les origines suivent la géométrie réelle du clipmap : une tuile couvre Step*Cells voxels et + // est alignée sur son propre pas. / Tile origins follow the real clipmap geometry: a tile + // covers Step*Cells voxels and is aligned to its own extent. + FRandomStream Rng(20260727); + TArray ToVerify; + int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0; + + const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE; + const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE; + + // La moitié des tuiles vise la strate SurfaceWorld : c'est le SEUL archétype dont ClassifyTile + // sait prouver quoi que ce soit aujourd'hui (avec les gaps de bedrock), donc un tirage uniforme + // sur tout le layout gaspillerait le budget en tuiles Mixed garanties. + // Half the tiles target the SurfaceWorld strate: it is the ONLY archetype ClassifyTile can prove + // anything about today (alongside bedrock gaps), so a uniform draw over the whole layout would + // spend the budget on guaranteed-Mixed tiles. + int32 SurfTopZ = 0, SurfBotZ = 0; + const bool bHaveSurface = World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTopZ, SurfBotZ); + + for (int32 t = 0; t < NumTilesScanned; ++t) + { + FTileSpec Spec; + // Step 1/2/4 comme le clipmap ; Cells petit pour que la vérification brute reste tenable. + Spec.Step = 1 << Rng.RandRange(0, 2); + Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16; + const int32 Extent = Spec.Step * Spec.Cells; + + const bool bAimSurface = bHaveSurface && (t % 2 == 0); + const int32 LoZ = bAimSurface ? SurfBotZ : BottomVoxelZ; + const int32 HiZ = bAimSurface ? SurfTopZ : TopVoxelZ; + // Division entière PLANCHER : en C++ la troncature va vers zéro, ce qui décalerait la + // borne basse (négative) d'un extent vers le haut. / Integer FLOOR division: C++ truncates + // toward zero, which would shift the negative low bound up by one extent. + auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; }; + const int32 LoTile = FloorDiv(LoZ, Extent); + const int32 HiTile = FMath::Max(LoTile, FloorDiv(HiZ, Extent)); + + Spec.Origin = FIntVector( + Rng.RandRange(-4, 4) * Extent, + Rng.RandRange(-4, 4) * Extent, + Rng.RandRange(LoTile, HiTile) * Extent); + + const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells); + switch (Verdict) + { + case EVoxelTileClass::Mixed: ++NumMixed; break; + case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break; + case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break; + } + } + + AddInfo(FString::Printf( + TEXT("ClassifyTile verdicts over %d scanned tiles: Mixed %d, AllSolid %d, AllAir %d ") + TEXT("(brute-forcing %d of them). NOTE: cave archetypes always return Mixed today — see ") + TEXT("VoxelGenerator.cpp \"archétype cave [...] pas prouvable en v1\". A low non-Mixed count ") + TEXT("is expected and is exactly the tile-skipping prize OPSTACK-PLAN wants EffectOverBox ") + TEXT("to unlock."), + NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num())); + + if (ToVerify.Num() == 0) + { + AddError(TEXT("VACUOUS: not one scanned tile produced an AllSolid/AllAir verdict, so this ") + TEXT("test verified nothing. Either the fixture's layout has no SurfaceWorld/gap ") + TEXT("chunks in the sampled Z range, or T1.d has stopped emitting verdicts entirely ") + TEXT("(which would be a large silent perf regression). Widen the Z range before ") + TEXT("trusting a green run.")); + return false; + } + + // ── Vérification par force brute, sur le treillis EXACT du mesher. ── + // Les bornes reproduisent ClassifyTile / GenerateMesh : g ∈ [-1, Cells+1] par axe. + int32 NumHoles = 0; + for (const FTileSpec& Spec : ToVerify) + { + const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells); + if (Verdict == EVoxelTileClass::Mixed) { continue; } // verdict instable ⇒ rien à prouver + + const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE); + const int32 GridDim = CPA + 1; + const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); + + bool bTileBad = false; + for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz) + for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy) + for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx) + { + const float X = (float)(Spec.Origin.X + gx * Spec.Step); + const float Y = (float)(Spec.Origin.Y + gy * Spec.Step); + const float Z = (float)(Spec.Origin.Z + gz * Spec.Step); + const float D = Gen->GetDensityAt(X, Y, Z); + + // AllSolid ⇒ tout le treillis doit être D < 0 + // AllAir ⇒ tout le treillis doit être D >= 0 + const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f); + if (!bAgrees) + { + bTileBad = true; + ++NumHoles; + AddError(FString::Printf( + TEXT("HOLE: ClassifyTile said %s for tile origin (%d,%d,%d) Step=%d Cells=%d, ") + TEXT("but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s side. This tile ") + TEXT("would be skipped by the mesher: no triangles and NO COLLISION where there ") + TEXT("should be a surface. Find which guard in ClassifyTile failed to fire for ") + TEXT("the feature at that point."), + bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), + Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells, + X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID"))); + } + } + } + + TestEqual(TEXT("no tile was classified uniform while containing a surface (a false verdict is a hole)"), + NumHoles, 0); + + // ── Stabilité du verdict : ClassifyTile partage GSurfColCache avec GetDensityAt, donc le + // brute-force ci-dessus a réchauffé les caches. Re-classifier doit rendre le MÊME verdict. + // Verdict stability: ClassifyTile shares GSurfColCache with GetDensityAt, so the brute force + // above warmed the caches. Re-classifying must yield the SAME verdict. + for (const FTileSpec& Spec : ToVerify) + { + const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells); + const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells); + if (A != B) + { + AddError(FString::Printf( + TEXT("UNSTABLE VERDICT at tile (%d,%d,%d) Step=%d: two consecutive ClassifyTile ") + TEXT("calls disagreed (%d vs %d). The classifier is reading state that GetDensityAt ") + TEXT("mutates — the shared column cache is the prime suspect."), + Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B)); + } + } + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeDensityPurityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeDensityPurityTest.cpp new file mode 100644 index 0000000..6e66ee6 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeDensityPurityTest.cpp @@ -0,0 +1,271 @@ +// VoxelForgeDensityPurityTest.cpp +// Phase 0.5 test #1 — LA PURETÉ DE LA DENSITÉ / density purity. +// +// L'INVARIANT / THE INVARIANT (ARCHITECTURE §8.4, "window invariance"): +// GetDensityAt(x,y,z) est une fonction PURE de (coords monde, seed, layout). Le même point +// interrogé depuis une autre tuile, un autre ordre de requêtes ou un autre thread doit rendre +// le float BIT-IDENTIQUE. Pas "proche" — identique : un écart d'1 ULP entre deux fenêtres de +// chunk est une COUTURE visible, et en multijoueur une divergence de monde. +// +// GetDensityAt is a PURE function of (world coords, seed, layout). The same point queried from +// a different tile, in a different order, or on a different thread must return the BIT-IDENTICAL +// float. Not "close" — identical: a 1-ULP disagreement between two chunk windows is a visible +// seam, and in multiplayer a world divergence. +// +// POURQUOI CE TEST EXISTE / WHY THIS TEST EXISTS: +// ~30 caches thread_local à clé manuelle vivent sous GetDensityAt (CP_*, GSurfColCache, les +// slots de diff, le cache SDF). Chacun est correct exactement tant que sa CLÉ contient toutes +// les entrées dont dépend la valeur cachée. Une entrée oubliée ne casse rien tout de suite : +// elle produit une mauvaise valeur seulement quand le cache est chaud pour une AUTRE entrée — +// c'est-à-dire de façon intermittente, dépendante de l'ordre, et invisible en jeu jusqu'à ce +// qu'un joueur trouve la couture. C'est exactement ainsi que AUDIT C2 s'est caché. +// +// ~30 hand-keyed thread_local caches live under GetDensityAt. Each is correct exactly as long as +// its KEY contains every input the cached value depends on. A forgotten input breaks nothing +// immediately: it yields a wrong value only when the cache is warm for a DIFFERENT input — i.e. +// intermittently, order-dependently, invisible in play until a player finds the seam. That is +// precisely how AUDIT C2 stayed hidden. +// +// AVoxelWorld::ValidateDeterminism existe déjà mais tourne sur le GAME THREAD uniquement : il ne +// peut structurellement pas voir une divergence de cache worker. Ce test tourne multi-thread. +// AVoxelWorld::ValidateDeterminism already exists but runs on the GAME THREAD only: it +// structurally cannot see a worker-cache divergence. This test runs multi-threaded. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "Async/ParallelFor.h" +#include "HAL/PlatformMisc.h" + +#include "VoxelForgeTestFixture.h" + +#include + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeDensityPurityTest, + "VoxelForge.Determinism.DensityPurity", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + // Assez de points pour traverser plusieurs chunks/strates et faire tourner tous les caches, + // assez peu pour rester sous la seconde. / Enough points to cross many chunks and strates and + // churn every cache, few enough to stay under a second. + constexpr int32 NumSamples = 10000; + + struct FMismatch + { + std::atomic Count{ 0 }; + std::atomic FirstIndex{ -1 }; + + void Record(int32 Index) + { + Count.fetch_add(1, std::memory_order_relaxed); + int32 Expected = -1; + FirstIndex.compare_exchange_strong(Expected, Index, std::memory_order_relaxed); + } + }; + + /** Report the first divergent point with both floats and their raw bits — a mismatch that is + * invisible in decimal (a 1-ULP cache seam) is the exact case this test is for. */ + FString DescribeMismatch(const FVector& P, float Ref, float Got) + { + return FString::Printf( + TEXT("at (%.0f, %.0f, %.0f): reference %.9g [0x%08X] vs re-sample %.9g [0x%08X]"), + P.X, P.Y, P.Z, + Ref, *reinterpret_cast(&Ref), + Got, *reinterpret_cast(&Got)); + } +} + +bool FVoxelForgeDensityPurityTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + World.Build(); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + const UVoxelGenerator* Gen = World.Generator.Get(); + + TArray Points; + BuildSamplePoints(World, NumSamples, /*Seed*/ 20260727, Points); + + // ── Référence : ordre linéaire, thread de jeu, caches chauds naturellement. ── + TArray Ref; + Ref.SetNumUninitialized(NumSamples); + for (int32 i = 0; i < NumSamples; ++i) + { + Ref[i] = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + } + + // Un monde entièrement NaN/constant passerait tout ce qui suit trivialement. Vérifier qu'on + // mesure bien un vrai champ. / An all-NaN or constant world would pass everything below + // trivially. Check we are measuring a real field. (This is also the canary for AUDIT C1: a + // large seed collapses the noise terms and the field goes constant.) + { + int32 NumFinite = 0, NumDistinct = 0; + TSet Seen; + for (const float V : Ref) + { + if (FMath::IsFinite(V)) { ++NumFinite; } + Seen.Add(*reinterpret_cast(&V)); + } + NumDistinct = Seen.Num(); + TestEqual(TEXT("every density sample is finite (no NaN/Inf leaking out of the generator)"), + NumFinite, NumSamples); + if (NumDistinct < NumSamples / 100) + { + AddError(FString::Printf( + TEXT("The density field is suspiciously flat: only %d distinct values across %d ") + TEXT("samples. Either the fixture built an empty world, or the noise field has ") + TEXT("collapsed (see AUDIT-2026-07.md C1 — unbounded SeedF). The purity checks ") + TEXT("below would pass trivially on a constant field, so they prove nothing here."), + NumDistinct, NumSamples)); + } + } + + // ── 1. INDÉPENDANCE À L'ORDRE, même thread. ── + // Un cache dont la clé est incomplète rend une valeur différente selon ce qui l'a précédé. + // An incompletely-keyed cache returns a different value depending on what preceded it. + { + TArray Order; + BuildShuffledOrder(NumSamples, /*Seed*/ 991, Order); + + int32 Mismatches = 0; + FString First; + for (const int32 i : Order) + { + const float Got = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + if (!BitEqual(Got, Ref[i])) + { + if (Mismatches == 0) { First = DescribeMismatch(Points[i], Ref[i], Got); } + ++Mismatches; + } + } + if (Mismatches > 0) + { + AddError(FString::Printf( + TEXT("ORDER DEPENDENCE: %d of %d points changed value when queried in a different ") + TEXT("order on the SAME thread. A per-chunk cache is missing an input from its key. ") + TEXT("First: %s"), Mismatches, NumSamples, *First)); + } + } + + // ── 2. INDÉPENDANCE AU THREAD. ── + // C'est la moitié que ValidateDeterminism (game-thread) ne peut pas voir. Chaque worker + // parcourt SON propre ordre mélangé, donc ses thread_local se réchauffent différemment. + // This is the half game-thread ValidateDeterminism cannot see. Each worker walks its OWN + // shuffled order, so its thread_locals warm up differently. + { + const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); + FMismatch Bad; + + ParallelFor(NumBlocks, [&](int32 Block) + { + TArray Order; + BuildShuffledOrder(NumSamples, /*Seed*/ 4000 + Block, Order); + const UVoxelGenerator* LocalGen = World.Generator.Get(); + for (const int32 i : Order) + { + // Chaque bloc parcourt TOUS les points (pas seulement une tranche) : c'est le + // parcours complet dans un ordre différent qui réchauffe les caches thread_local + // différemment, et c'est exactement ce qu'on cherche à faire diverger. + // Every block walks ALL the points, not a slice: it is the full walk in a + // different order that warms the thread_local caches differently, which is + // precisely what we are trying to make diverge. + const float V = LocalGen->GetDensityAt( + (float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + if (!BitEqual(V, Ref[i])) { Bad.Record(i); } + } + }); + + const int32 Count = Bad.Count.load(); + if (Count > 0) + { + const int32 Idx = Bad.FirstIndex.load(); + AddError(FString::Printf( + TEXT("WORKER DIVERGENCE: %d sample evaluations on worker threads disagreed with the ") + TEXT("game-thread reference. This is the failure mode AVoxelWorld::ValidateDeterminism ") + TEXT("cannot detect, and it means a thread_local cache under GetDensityAt is serving a ") + TEXT("value it should not. First: %s"), + Count, *DescribeMismatch(Points[Idx], Ref[Idx], + Gen->GetDensityAt((float)Points[Idx].X, (float)Points[Idx].Y, + (float)Points[Idx].Z)))); + } + } + + // ── 3. PURETÉ AVEC LA COUCHE DE DIFF ACTIVE. ── + // Les DiffSlots sont un cache direct-mapped à 64 entrées, indexé par les bits bas du chunk. + // Une collision servirait la liste de mods d'un AUTRE chunk : un carve fantôme à distance. + // DiffSlots is a 64-entry direct-mapped cache indexed by the chunk coord's low bits. A + // collision would serve another chunk's mod list: a ghost carve at a distance. + { + FVoxelModification Mod; + Mod.Shape = EVoxelBrushShape::Sphere; + Mod.Radius = 12.0f; + Mod.Strength = -10.0f; + for (int32 k = 0; k < 8; ++k) + { + Mod.Center = FVector((float)(k * CHUNK_SIZE), (float)(-k * CHUNK_SIZE), World.MidVoxelZ()); + World.DiffLayer->ApplyModification(Mod); + } + TestTrue(TEXT("the diff layer registered the test carves"), World.DiffLayer->HasAnyMods()); + + TArray DiffRef; + DiffRef.SetNumUninitialized(NumSamples); + for (int32 i = 0; i < NumSamples; ++i) + { + DiffRef[i] = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + } + + FMismatch Bad; + const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); + ParallelFor(NumBlocks, [&](int32 Block) + { + TArray Order; + BuildShuffledOrder(NumSamples, /*Seed*/ 7000 + Block, Order); + const UVoxelGenerator* LocalGen = World.Generator.Get(); + for (const int32 i : Order) + { + const float V = LocalGen->GetDensityAt( + (float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + if (!BitEqual(V, DiffRef[i])) { Bad.Record(i); } + } + }); + + const int32 Count = Bad.Count.load(); + if (Count > 0) + { + const int32 Idx = Bad.FirstIndex.load(); + AddError(FString::Printf( + TEXT("DIFF-LAYER IMPURITY: %d evaluations diverged with player edits present. ") + TEXT("Suspect the direct-mapped DiffSlots cache in GetDensityAt (chunk low-bit ") + TEXT("index + ModsVersion). First mismatch index %d at (%.0f, %.0f, %.0f)."), + Count, Idx, Points[Idx].X, Points[Idx].Y, Points[Idx].Z)); + } + + // Et le carve doit vraiment avoir changé quelque chose, sinon le sous-test ci-dessus + // n'a rien testé. / And the carve must actually have changed something, else the sub-test + // above tested nothing. + int32 NumChanged = 0; + for (int32 i = 0; i < NumSamples; ++i) + { + if (!BitEqual(DiffRef[i], Ref[i])) { ++NumChanged; } + } + if (NumChanged == 0) + { + AddError(TEXT("No sample changed after applying 8 carves — the diff layer branch of ") + TEXT("GetDensityAt was never exercised, so the purity check above is vacuous. ") + TEXT("Move the carve centres so they overlap the sample cloud.")); + } + } + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeDiffLayerTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeDiffLayerTest.cpp new file mode 100644 index 0000000..0b7b6b3 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeDiffLayerTest.cpp @@ -0,0 +1,180 @@ +// VoxelForgeDiffLayerTest.cpp +// Phase 0.5 test #3 — LA COUCHE DE DIFF SOUS CONTENTION / DiffLayer under contention. +// +// LE RISQUE / THE RISK: +// UVoxelDiffLayer::ChunkMods est une TMap LUE par les threads de meshing (via GetDensityAt → +// GetChunkModsSnapshot) et ÉCRITE par le thread de jeu (ApplyModification / Clear). TMap n'est +// pas thread-safe : un rehash pendant une lecture est une violation d'accès. Tout est censé +// passer par ModsLock (FRWLock) — et une AV carve-vs-stream a déjà été corrigée exactement là. +// +// UVoxelDiffLayer::ChunkMods is a TMap READ by mesher workers (through GetDensityAt → +// GetChunkModsSnapshot) and WRITTEN by the game thread (ApplyModification / Clear). TMap is not +// thread-safe: a rehash during a read is an access violation. Everything is meant to go through +// ModsLock (FRWLock) — and a carve-vs-stream AV was already fixed in exactly this spot. +// +// CE QUE CE TEST PROUVE / WHAT THIS TEST PROVES: +// 1. Aucun crash quand N lecteurs martèlent la couche pendant que le thread de jeu écrit. +// 2. ModsVersion ne RECULE jamais du point de vue d'un lecteur (c'est la clé sur laquelle les +// caches de snapshot invalident ; une version non monotone rendrait un cache définitivement +// périmé). +// 3. L'état final est exact : chaque carve appliqué est retrouvable. +// Le point (1) est le vrai but, et il ne peut être prouvé que statistiquement — un test vert +// veut dire "pas reproduit ici", pas "impossible". C'est quand même infiniment mieux que rien. +// +// Point (1) is the real target, and it can only ever be shown statistically — a green run means +// "not reproduced here", not "impossible". Still infinitely better than nothing. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "Async/Async.h" +#include "HAL/PlatformMisc.h" +#include "UObject/StrongObjectPtr.h" +#include "UObject/Package.h" + +#include "VoxelTypes.h" +#include "VoxelDiffLayer.h" + +#include + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeDiffLayerContentionTest, + "VoxelForge.Determinism.DiffLayerContention", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + constexpr int32 NumWrites = 400; + constexpr int32 NumChunksX = 8; + + FVoxelModification MakeCarve(int32 Index) + { + FVoxelModification Mod; + Mod.Shape = EVoxelBrushShape::Sphere; + Mod.Radius = 6.0f; + Mod.Strength = -9.0f; + Mod.Center = FVector( + (float)((Index % NumChunksX) * CHUNK_SIZE + 4), + (float)(((Index / NumChunksX) % NumChunksX) * CHUNK_SIZE + 4), + (float)(-((Index / (NumChunksX * NumChunksX)) % 4) * CHUNK_SIZE)); + return Mod; + } +} + +bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters) +{ + TStrongObjectPtr Diff( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Diff->SetBudget(/*MaxMods*/ 0, /*MaxRadius*/ 50.0f, /*MaxVolume*/ 0.0f); // 0 = illimité + + const int32 NumReaders = FMath::Max(3, FMath::Min(8, FPlatformMisc::NumberOfCores() - 1)); + + std::atomic bStop{ false }; + std::atomic VersionRegressions{ 0 }; + std::atomic ReadOps{ 0 }; + + // ── Les lecteurs : exactement le mix d'appels que fait le chemin densité d'un worker. ── + // The readers: exactly the call mix a worker's density path makes. + TArray> Readers; + Readers.Reserve(NumReaders); + for (int32 R = 0; R < NumReaders; ++R) + { + Readers.Add(Async(EAsyncExecution::Thread, [&, R]() + { + uint32 LastVersion = 0; + int64 LocalOps = 0; + FRandomStream Rng(9000 + R); + while (!bStop.load(std::memory_order_relaxed)) + { + const uint32 V = Diff->GetModsVersion(); + if (V < LastVersion) + { + VersionRegressions.fetch_add(1, std::memory_order_relaxed); + } + LastVersion = V; + + const FIntVector Chunk(Rng.RandRange(0, NumChunksX - 1), + Rng.RandRange(0, NumChunksX - 1), + Rng.RandRange(-3, 0)); + + // Le fast-reject sans verrou, puis le vrai chemin sous verrou. + if (Diff->HasAnyMods()) + { + Diff->HasAnyModInChunkRange(Chunk - FIntVector(1, 1, 1), Chunk + FIntVector(1, 1, 1)); + Diff->HasModifications(Chunk); + + TArray Snapshot; + Diff->GetChunkModsSnapshot(Chunk, Snapshot); + + // Toucher réellement les données copiées : un snapshot qui aliaserait la TMap + // (au lieu de la copier) exploserait ici et pas au moment de la copie. + // Actually touch the copied data: a snapshot that aliased the TMap instead of + // copying it would blow up here rather than at copy time. + const float X = (float)(Chunk.X * CHUNK_SIZE + 3); + const float Y = (float)(Chunk.Y * CHUNK_SIZE + 3); + const float Z = (float)(Chunk.Z * CHUNK_SIZE + 3); + const float Sink = UVoxelDiffLayer::EvaluateMods(Snapshot, X, Y, Z) + + Diff->GetDensityOffset(Chunk, X, Y, Z); + // Consommer Sink dans une branche que le compilateur ne peut pas prouver morte, + // sinon tout le bloc de lecture est éliminé et le test ne teste rien. + // Consume Sink in a branch the compiler cannot prove dead, otherwise the whole + // read block is optimised away and the test tests nothing. + if (Sink == 1.2345678e30f) { ++LocalOps; } + } + ++LocalOps; + } + ReadOps.fetch_add(LocalOps, std::memory_order_relaxed); + })); + } + + // ── Phase 1 : écritures pures. L'état final doit être exact. ── + for (int32 i = 0; i < NumWrites; ++i) + { + const TArray Touched = Diff->ApplyModification(MakeCarve(i)); + if (Touched.Num() == 0) + { + AddError(FString::Printf( + TEXT("ApplyModification #%d was rejected. The budget should be unlimited here — ") + TEXT("if this fires, SetBudget(0, ...) no longer means 'no cap'."), i)); + break; + } + } + + TestEqual(TEXT("every carve was recorded"), Diff->GetTotalModificationCount(), NumWrites); + TestTrue(TEXT("the lock-free bHasAnyMods fast-path agrees with the map"), Diff->HasAnyMods()); + TestTrue(TEXT("at least one chunk holds mods"), Diff->GetModifiedChunkCount() > 0); + + // ── Phase 2 : le chemin réellement dangereux — Clear() pendant que les lecteurs tiennent des + // itérateurs potentiels. On n'affirme plus de compte ici, seulement la survie + la monotonie. + // Phase 2: the genuinely dangerous path — Clear() while readers may hold iterators. No count + // assertions here, only survival + monotonicity. + for (int32 Round = 0; Round < 6; ++Round) + { + for (int32 i = 0; i < 60; ++i) { Diff->ApplyModification(MakeCarve(i + Round * 60)); } + Diff->Clear(); + } + + bStop.store(true, std::memory_order_relaxed); + for (TFuture& F : Readers) { F.Wait(); } + + AddInfo(FString::Printf(TEXT("%d reader threads completed %lld read rounds against %d writes + 6 clears."), + NumReaders, (long long)ReadOps.load(), NumWrites + 360)); + + TestEqual(TEXT("ModsVersion never went backwards from a reader's point of view"), + VersionRegressions.load(), 0); + + if (ReadOps.load() < (int64)NumReaders) + { + AddError(TEXT("The reader threads barely ran, so no contention was actually exercised. ") + TEXT("The writes finished before the threads started — increase NumWrites or add ") + TEXT("a barrier before the writer loop.")); + } + + // Après Clear(), l'état doit être franchement vide (pas « presque »). + TestFalse(TEXT("Clear() left no mods behind"), Diff->HasAnyMods()); + TestEqual(TEXT("Clear() reset the modified-chunk count"), Diff->GetModifiedChunkCount(), 0); + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h new file mode 100644 index 0000000..5e14f9e --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h @@ -0,0 +1,234 @@ +// VoxelForgeTestFixture.h +// Fixture partagée par les tests d'automatisation VoxelForge (Phase 0.5 de OPSTACK-PLAN.md). +// Shared fixture for the VoxelForge automation tests (OPSTACK-PLAN.md, Phase 0.5). +// +// WHY THIS EXISTS +// --------------- +// The interesting invariants (density purity across worker threads, ClassifyTile soundness) +// only fire on the REAL path — UVoxelGenerator::GetDensityAt — because that is where the +// thread_local per-chunk caches live (CP_*, GSurfColCache, the diff slots, the SDF cache). +// Calling GetSurfaceDensity / GetMazeDensity directly bypasses every one of them and would +// test almost nothing. GetDensityAt in turn needs a live UVoxelStrateManager, whose only +// entry point is Initialize(UVoxelSettings*, int32) reading TSoftObjectPtr pools. +// +// So the fixture builds a whole synthetic world in memory: transient strate definitions → +// a transient UVoxelSettings pointing at them → a real UVoxelStrateManager::Initialize. +// +// ⚠️ KNOWN RISK, stated rather than hidden: the settings hold TSoftObjectPtr, and we point +// them at TRANSIENT objects (/Engine/Transient.). LoadSynchronous() resolves those via +// FindObject, which works for in-memory objects — but it is the one part of this fixture that +// has never been compiled or run. IsValid() below checks the layout actually materialised, and +// every test hard-FAILS with a clear message when it didn't. A silent skip would be worse than +// a failure: it would look like a pass. +// +// Everything is held by TStrongObjectPtr so the GC cannot eat the world mid-test. + +#pragma once + +#if WITH_DEV_AUTOMATION_TESTS + +#include "CoreMinimal.h" +#include "UObject/StrongObjectPtr.h" +#include "UObject/Package.h" + +#include "VoxelTypes.h" +#include "VoxelSettings.h" +#include "VoxelStrateTypes.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.h" +#include "VoxelDiffLayer.h" +#include "VoxelGenerator.h" + +namespace VoxelForgeTest +{ + /** + * FTestWorld — a complete, headless VoxelForge world: settings + strate layout + + * generator + diff layer. No AActor, no UWorld, no PIE. + * + * The default layout stacks one strate of EVERY archetype (in ECaveGeneratorType order), + * so a single fixture exercises all eight density functions and their per-chunk caches, + * plus the gap-bedrock path when InterStrateGapChunks > 0. + */ + struct FTestWorld + { + TStrongObjectPtr Settings; + TStrongObjectPtr StrateManager; + TStrongObjectPtr DiffLayer; + TStrongObjectPtr Generator; + TArray> Definitions; + + /** World Z (voxel coords) span actually covered by the layout — handy for picking samples. */ + int32 TopChunkZ = 0; + int32 BottomChunkZ = 0; + + /** + * Build the world. Seed stays SMALL on purpose: AUDIT C1 (unbounded SeedF) is a real + * open bug and a large seed would collapse the noise fields to constants, which would + * make a purity test pass trivially for the wrong reason. + */ + void Build(int32 InSeed = 1337, int32 InGapChunks = 2) + { + Settings = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Settings->Seed = InSeed; + Settings->InterStrateGapChunks = InGapChunks; + + // Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize() + // mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z + // dépendante du seed et un message d'échec impossible à relire. + // One strate per archetype, PINNED through FixedStrates rather than the pool: + // Initialize() shuffles the pool by seed, which would make the archetype → Z mapping + // seed-dependent and a failure message unreadable. Slot i == Archetypes[i]. + static const ECaveGeneratorType Archetypes[] = { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::CrystalChamber, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + ECaveGeneratorType::Underwater, + }; + + for (int32 i = 0; i < UE_ARRAY_COUNT(Archetypes); ++i) + { + UVoxelStrateDefinition* Def = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + Def->GeneratorType = Archetypes[i]; + Def->StrateHeightInChunks = 4; + // Hard transitions: param blending across a boundary would make "which archetype + // owns this chunk" ambiguous, and these tests want an unambiguous mapping. + Def->TransitionType = EVoxelStrateTransition::Hard; + Definitions.Add(TStrongObjectPtr(Def)); + + const TSoftObjectPtr SoftDef(Def); + Settings->FixedStrates.Add(i, SoftDef); + Settings->StratePool.Add(SoftDef); // fallback if a fixed entry fails to resolve + } + Settings->TotalStrates = UE_ARRAY_COUNT(Archetypes); + + StrateManager = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + StrateManager->Initialize(Settings.Get(), Settings->Seed); + + DiffLayer = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + + Generator = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Generator->InitializeSettings(Settings.Get()); + Generator->SetStrateManager(StrateManager.Get()); + Generator->SetDiffLayer(DiffLayer.Get()); + + CacheZBounds(); + } + + /** Re-run Initialize (bumps LayoutVersion) — the live-edit path AUDIT C2 is about. */ + void Reinitialize() + { + StrateManager->Initialize(Settings.Get(), Settings->Seed); + CacheZBounds(); + } + + /** False when the soft-pointer resolve failed and no strate layout exists. */ + bool IsValid() const + { + return StrateManager.IsValid() && StrateManager->GetNumStrates() > 0; + } + + FString WhyInvalid() const + { + return TEXT("FTestWorld could not build a strate layout. Most likely the ") + TEXT("TSoftObjectPtr -> transient UVoxelStrateDefinition resolve failed inside ") + TEXT("UVoxelStrateManager::Initialize (LoadSynchronous on /Engine/Transient.*). ") + TEXT("See the header comment in VoxelForgeTestFixture.h. This is a FIXTURE ") + TEXT("failure, not a generator failure — do not read it as a density bug."); + } + + /** Voxel-Z of the middle of the layout — a point guaranteed inside a real strate. */ + float MidVoxelZ() const + { + return (float)((TopChunkZ + BottomChunkZ) / 2 * CHUNK_SIZE + CHUNK_SIZE / 2); + } + + /** Layout slot index of each archetype — the Archetypes[] order in Build(), pinned via + * FixedStrates so it is stable across seeds. SurfaceWorld matters most: it is the only + * archetype ClassifyTile can currently prove anything about (besides bedrock gaps). */ + static constexpr int32 SlotTunnelNetwork = 0; + static constexpr int32 SlotFlatPlain = 1; + static constexpr int32 SlotCrystalChamber = 2; + static constexpr int32 SlotMaze = 3; + static constexpr int32 SlotSurfaceWorld = 4; + static constexpr int32 SlotVerticalShafts = 5; + static constexpr int32 SlotFloatingIsland = 6; + static constexpr int32 SlotUnderwater = 7; + + /** Voxel-Z span of one layout slot. False if the layout is shorter than expected. */ + bool GetSlotVoxelZRange(int32 SlotIndex, int32& OutTopVoxelZ, int32& OutBottomVoxelZ) const + { + const TArray& Layout = StrateManager->GetLayout(); + if (!Layout.IsValidIndex(SlotIndex)) { return false; } + OutTopVoxelZ = Layout[SlotIndex].TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1; + OutBottomVoxelZ = Layout[SlotIndex].BottomChunkZ * CHUNK_SIZE; + return true; + } + + private: + void CacheZBounds() + { + TopChunkZ = 0; + BottomChunkZ = 0; + for (const FStrateSlot& Slot : StrateManager->GetLayout()) + { + TopChunkZ = FMath::Max(TopChunkZ, Slot.TopChunkZ); + BottomChunkZ = FMath::Min(BottomChunkZ, Slot.BottomChunkZ); + } + } + }; + + /** + * A spread of world sample points that deliberately crosses chunk boundaries, strate + * boundaries and bedrock gaps — the exact conditions under which a per-chunk cache with a + * missing key input produces a wrong answer. Integer XY on purpose: that is the branch + * GetDensityAt's T1.a column cache actually takes (fractional XY bypasses the cache). + */ + inline void BuildSamplePoints(const FTestWorld& World, int32 Count, int32 Seed, + TArray& OutPoints) + { + OutPoints.Reset(Count); + FRandomStream Rng(Seed); + const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1; + const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE; + for (int32 i = 0; i < Count; ++i) + { + // XY range spans several chunks either side of the origin so the (0,0) spine, the + // passages and plain interior rock all appear in the sample set. + const int32 X = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE); + const int32 Y = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE); + const int32 Z = Rng.RandRange(BottomVoxelZ, TopVoxelZ); + OutPoints.Add(FVector((float)X, (float)Y, (float)Z)); + } + } + + /** Deterministic shuffle of an index array — the "different query order" half of purity. */ + inline void BuildShuffledOrder(int32 Count, int32 Seed, TArray& OutOrder) + { + OutOrder.Reset(Count); + for (int32 i = 0; i < Count; ++i) { OutOrder.Add(i); } + FRandomStream Rng(Seed); + for (int32 i = Count - 1; i > 0; --i) + { + OutOrder.Swap(i, Rng.RandRange(0, i)); + } + } + + /** Bit-exact float compare — NOT FMath::IsNearlyEqual. Window invariance is a bit property + * (ARCHITECTURE §8.4): a 1-ULP difference between two chunk windows is a visible seam. */ + inline bool BitEqual(float A, float B) + { + return FMath::IsNaN(A) == FMath::IsNaN(B) + && (FMath::IsNaN(A) || *reinterpret_cast(&A) == *reinterpret_cast(&B)); + } +} + +#endif // WITH_DEV_AUTOMATION_TESTS