test: Phase 0.5 — the three automation tests (density purity, ClassifyTile, DiffLayer)
The plugin had zero tests, and the docs make dozens of "bit-identical" and "conservative verdict" claims that nothing machine-checks. OPSTACK-PLAN §4 Phase 0.5 asks for these before any op-stack work is built on top. - VoxelForgeTestFixture.h — headless world (transient strate definitions -> UVoxelSettings -> a real UVoxelStrateManager::Initialize), so the tests hit UVoxelGenerator::GetDensityAt where the ~30 thread_local caches actually live. One strate per archetype, pinned via FixedStrates so slot index -> archetype is stable across seeds. - DensityPurity — 10k points re-sampled in shuffled order on the same thread and on N worker threads, asserting BIT equality. AVoxelWorld::ValidateDeterminism is game-thread only and structurally cannot see worker-cache divergence, which is how AUDIT C2 stayed hidden. Includes a flat-field canary so a collapsed noise field (AUDIT C1) can't make the test pass vacuously, and a diff-layer pass that exercises the direct-mapped DiffSlots cache. - ClassifyTileSoundness — scans tiles for a non-Mixed verdict, then brute-forces the exact mesher lattice (g in [-1, Cells+1], margin included) and asserts every sample is on the claimed side of IsoLevel 0. A false AllSolid/AllAir is an invisible collisionless hole; T1.d v1 was reverted for exactly that in June. Errors out rather than passing if no tile yielded a verdict to check. - DiffLayerContention — N reader threads running the worker call mix while the game thread writes and Clear()s, asserting survival and a monotonic ModsVersion. UNVERIFIED: never compiled — this module has never pulled in AutomationTest.h and Private/Tests/ is new. See OPSTACK-PROGRESS.md for the likely error spots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<FTileSpec> 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
|
||||||
@@ -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 <atomic>
|
||||||
|
|
||||||
|
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<int32> Count{ 0 };
|
||||||
|
std::atomic<int32> 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<const uint32*>(&Ref),
|
||||||
|
Got, *reinterpret_cast<const uint32*>(&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<FVector> Points;
|
||||||
|
BuildSamplePoints(World, NumSamples, /*Seed*/ 20260727, Points);
|
||||||
|
|
||||||
|
// ── Référence : ordre linéaire, thread de jeu, caches chauds naturellement. ──
|
||||||
|
TArray<float> 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<uint32> Seen;
|
||||||
|
for (const float V : Ref)
|
||||||
|
{
|
||||||
|
if (FMath::IsFinite(V)) { ++NumFinite; }
|
||||||
|
Seen.Add(*reinterpret_cast<const uint32*>(&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<int32> 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<int32> 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<float> 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<int32> 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
|
||||||
@@ -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 <atomic>
|
||||||
|
|
||||||
|
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<UVoxelDiffLayer> Diff(
|
||||||
|
NewObject<UVoxelDiffLayer>(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<bool> bStop{ false };
|
||||||
|
std::atomic<int32> VersionRegressions{ 0 };
|
||||||
|
std::atomic<int64> 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<TFuture<void>> 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<FVoxelModification> 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<FIntVector> 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<void>& 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
|
||||||
@@ -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.<name>). 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<UVoxelSettings> Settings;
|
||||||
|
TStrongObjectPtr<UVoxelStrateManager> StrateManager;
|
||||||
|
TStrongObjectPtr<UVoxelDiffLayer> DiffLayer;
|
||||||
|
TStrongObjectPtr<UVoxelGenerator> Generator;
|
||||||
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> 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<UVoxelSettings>(
|
||||||
|
NewObject<UVoxelSettings>(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<UVoxelStrateDefinition>(
|
||||||
|
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<UVoxelStrateDefinition>(Def));
|
||||||
|
|
||||||
|
const TSoftObjectPtr<UVoxelStrateDefinition> 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<UVoxelStrateManager>(
|
||||||
|
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
StrateManager->Initialize(Settings.Get(), Settings->Seed);
|
||||||
|
|
||||||
|
DiffLayer = TStrongObjectPtr<UVoxelDiffLayer>(
|
||||||
|
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
|
||||||
|
Generator = TStrongObjectPtr<UVoxelGenerator>(
|
||||||
|
NewObject<UVoxelGenerator>(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<FStrateSlot>& 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<FVector>& 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<int32>& 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<const uint32*>(&A) == *reinterpret_cast<const uint32*>(&B));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
Reference in New Issue
Block a user