Files
VoxelForge/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h
T
Fr0zka 6a8390f11f feat(opstack): ClassifyTile consumes ClassifyBox for cave archetypes -- the T1.d prize, behind the same opt-in
⚠️ THIS IS THE ONE COMMIT OF THIS RUN WHOSE FAILURE MODE IS A HOLE, NOT A REGRESSION. A non-Mixed
verdict makes the world skip GenerateMesh entirely: no triangles, no collision, invisible until a
player falls through. Read VoxelForge.OpStack.ClassifyTileSoundness before trusting it.

WHAT CHANGED
ClassifyTile used to `return Mixed` without a call for every cave archetype ("archétype cave [...]
pas prouvable en v1"). It now builds that strate's stack and folds ClassifyBox. SurfaceWorld and
bedrock gaps keep their hand-written proofs untouched -- the exact-lattice column test is strictly
better than any box bound, so the stack has nothing to add there.

ONE DEFINITION OF THE STACK MAPPING, and that is the load-bearing part
GetDensityAt's ~90-line build switch is extracted into VF_BuildOpStackForChunk and both callers now
use it. A second copy would be the worst bug available here: a tile skipped on the verdict of a
stack that is not the one producing its density is precisely a hole. A "keep these in sync" comment
would not have been enough; there had to be only one. Params are passed in by pointer, never fetched
inside, because both callers already have them.

SIX GUARDS, ALL FAILING TO MIXED, none giving the benefit of the doubt
1. The strate must actually opt in -- on EVERY chunk coord the box touches, not just the one that
   triggered the attempt. Otherwise the classifier judges a field the mesher will not produce.
2. One cave slot per tile. Two slots means two param sets, and a stack answers only for its strate.
3. No mixed cave/surface/gap tile: the cave stack's box would cover Z belonging to another strate.
4. The params must be BIT-IDENTICAL across every chunk coord the box touches. This is the guard that
   matters, and it exists because of the finding committed earlier today: GetGenerationParams blends
   params inside a strate (Alpha depends on chunk Z for Gradient, and on chunk XY as well for
   Interleaved), so one stack genuinely cannot represent a tile that straddles a transition band.
   Memcmp on POD: differing padding can only produce a false MISMATCH, i.e. one Mixed too many.
5. A 27-chunk-coord cap, so a very wide tile does not pay for the check. We give up the gain, never
   the safety.
6. The disturbances are folded by hand (chasm ⇒ CarveOnly, bridge/ridge ⇒ FillOnly), because
   DECOMPOSITION 10.2 leaves them OUTSIDE the stack -- GetDensityAt applies them after the negate.
   A verdict that ignored them would be wrong exactly where they act. Same inequalities the
   SurfaceWorld branch already uses.

The diff layer needs no new guard: ClassifyTile already returns Mixed for any tile with player mods
in range, before any of this.

TEST: VoxelForge.OpStack.ClassifyTileSoundness -- the same brute-force oracle as the existing
ClassifyTileSoundness, on a world where every strate opted in. It does not check the fold (that is
BoxVerdictFold) or the operators (those are the eight equivalence tests); it checks the WIRING. The
number to read first is the count of tiles actually brute-forced: zero non-Mixed verdicts would mean
the test proved nothing, so that case is an ERROR rather than a quiet pass. Its failure message
lists the four suspects in the order worth checking.

FIXTURE: FTestWorld::Build gains a bUseOperatorStack parameter (default false, so the thirteen
existing tests still exercise the switch), and every test world now gets a PROCESS-UNIQUE
LayoutVersion. That second change fixes a real cross-test hazard that was only ever hidden by an
accident: PassagesVersion is per-instance and starts at 0, so two FTestWorlds both reported 1, and
GetDensityAt's per-chunk caches are keyed on (ChunkCoord, LayoutVersion) -- one world could be
served the previous world's params AND its CP_UseOpStack flag. Invisible while every world agreed
the flag was false. The first world that ticks it removes that coincidence, in both directions.

WHAT THIS BUYS TODAY: Maze, FlatPlain/CrystalChamber, VerticalShafts and FloatingIslands can now
prove tiles in production, which is where the measured skipping (Maze 23/60, slabs 36-40/60) turns
into frames. TunnelNetwork and Underwater still prove nothing: their chain dies at FRoomGraphSource,
which answers Both with unknown amplitude. Making it answer spatially means building the SDF cache
for the queried box -- now worth doing, since a skipped tile saves 30k+ density evaluations, and the
amplitude fold plus the modifiers' Identity inheritance are already in place to receive it. That is
the next piece.

And nothing here changes Jahni's current world: no strate asset has bUseOperatorStack ticked, so
every guard above is unreachable in his project until he ticks one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:05:42 +02:00

276 lines
14 KiB
C++

// 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.
*
* The default seed stays SMALL, but the reason has changed. It USED to be a workaround:
* AUDIT §C1 (unbounded `SeedF`) meant a large seed collapsed the noise fields to constants,
* which would have made a purity test pass trivially for the wrong reason.
*
* **§C1 is fixed** (`VoxelHash::SeedOffset` — bounded and site-salted). The small default
* now just keeps failure messages comparable across tests. A large seed is no longer
* dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones
* (up to 2e9) to prove it stays that way.
*/
void Build(int32 InSeed = 1337, int32 InGapChunks = 2, bool bUseOperatorStack = false)
{
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,
};
const int32 NumArchetypes = (int32)UE_ARRAY_COUNT(Archetypes);
for (int32 i = 0; i < NumArchetypes; ++i)
{
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
Def->GeneratorType = Archetypes[i];
Def->StrateHeightInChunks = 4;
// L'OPT-IN de la pile d'opérateurs. Faux par défaut : les treize tests existants
// doivent continuer à exercer le `switch`, qui reste le comportement de référence.
// Seul le test de solidité de ClassifyTie côté pile le passe à vrai.
Def->bUseOperatorStack = bUseOperatorStack;
// 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 = NumArchetypes;
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
//=================================================================
// ⚠️ CHAQUE MONDE DE TEST OBTIENT UNE `LayoutVersion` UNIQUE DANS LE PROCESSUS
//=================================================================
// Ce n'est pas de la cosmétique, c'est une CONTAMINATION CROISÉE réelle entre tests, et
// elle n'était jusqu'ici masquée que par un accident.
//
// `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs
// rendaient tous les deux **1**. Or les caches par chunk de `GetDensityAt` sont clés sur
// `(ChunkCoord, LayoutVersion)` : deux mondes différents, même version, même chunk ⇒ le
// second se voit servir les params — ET le drapeau `CP_UseOpStack` — du premier.
// Personne ne l'a vu parce que `bUseOperatorStack` valait false partout : les deux
// mondes étaient d'accord par défaut. Le premier monde qui coche la case fait tomber
// cette coïncidence, dans les DEUX sens (il contamine, et il est contaminé).
//
// Un compteur de processus donne à chaque monde une version distincte, donc tout cache
// survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe
// (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler
// ne change pas le layout — seulement le compteur.
//
// Each test world gets a process-unique LayoutVersion. Two worlds both reporting 1 made
// GetDensityAt's per-chunk caches serve the previous world's params — and its
// CP_UseOpStack flag — for the same chunk coord. Invisible while every world agreed that
// the flag was false.
static int32 GWorldSerial = 0;
const int32 Bumps = ++GWorldSerial;
for (int32 b = 0; b < Bumps; ++b)
{
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