test: pits and chimneys never ran — ask the bake, not the density

Check 3b earned its keep on its first run: 0 of 1500 points moved when
PitDensity was zeroed, because BuildChunkCache bakes pits inside
`if (!CR.RoomOp) continue;` and then reads a FRESH param struct with only
that room's terrain op applied. FStrateGenerationParams::PitDensity is
never read by the bake. Pits, chimneys and columns exist only through a
per-room UVoxelTerrainOpDefinition, and the fixture had none.

Not a product bug: those fields carry no UPROPERTY, so no editor surface
offers a setting that quietly does nothing. My reading was wrong.

Three attempts, and the second is the instructive one. Zeroing the params
tests fields nothing reads. Building a second stack with an empty op pool
would have LIED — the op pool is not in the SDF cache key (LayoutVersion
covers pool edits in production), so both stacks share the thread_local
cache and report "no contribution" for a third wrong reason. So: ask the
bake what it baked. Rooms > 0, pits > 0, chimneys > 0, columns == 0.

The test now attaches a real Pit/Chimney op pool. Safe at stage A because
ApplyTo(Pit) writes only pit fields, so none of the 13 unported detail
modifiers wake up — a Terrace op there would break it, which is exactly
what stage B adds.

Also reworded the green equivalence message, which claimed pits and
chimneys were exercised while zero existed. A success message that asserts
coverage instead of reporting it reads as evidence while measuring nothing.

Cave coverage 1.1% -> 21%, fingerprint 0.75% -> 95.8%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 19:22:07 +02:00
parent 5bf61c1815
commit 68e43238bd
2 changed files with 207 additions and 54 deletions
@@ -33,6 +33,8 @@
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelTerrainOpDefinition.h" // pits/chimneys n'existent QUE via un op par salle
#include "VoxelCaveMorphology.h" // FChunkSDFCache — le contrôle 3b regarde la cuisson
#include <atomic>
@@ -71,30 +73,85 @@ namespace
}
/**
* Pits et cheminées sont à 0 par défaut — or ce sont précisément les deux boucles que `§2`
* annonçait comme « le plus retors de toute la décomposition » (coordonnées NON warpées
* mélangées au SDF warpé). Les laisser au repos testerait tout sauf le morceau difficile.
* ⚠️ DENSIFIÉ après le premier run vert. Aux défauts (`RoomSpacing = 80`, `RoomDensity = 0.35`)
* le premier passage a rendu **65 échantillons en grotte sur 6000, soit 1,1 %** : bit-identique,
* oui, mais en comparant surtout du roc plein à du roc plein, là où le carve et les vers ne
* s'exécutent même pas. Le compteur avait été écrit exactement pour dire ça, et il l'a dit ;
* l'avertissement, lui, ne se déclenchait qu'à ZÉRO. Les deux sont corrigés. → 21 %.
*
* ⚠️ ET ON DENSIFIE LE RÉSEAU, corrigé après le premier run vert. Aux défauts
* (`RoomSpacing = 80`, `RoomDensity = 0.35`) le premier passage a rendu **65 échantillons en
* grotte sur 6000, soit 1,1 %** : bit-identique, oui, mais en comparant surtout du roc plein à
* du roc plein, là où le carve, les pits, les cheminées et les vers ne s'exécutent même pas.
* Le compteur avait été écrit exactement pour dire ça, et il l'a dit ; l'avertissement, lui, ne
* se déclenchait qu'à ZÉRO — trop tard pour être utile. Les deux sont corrigés ici.
* ⚠️⚠️ CE QUI N'EST **PAS** ICI : `PitDensity` et `ChimneyDensity`. Les y mettre était une
* ERREUR, et le contrôle 3b l'a attrapée (0 point sur 1500 bougeait en les remettant à zéro).
* `BuildChunkCache` ouvre sa cuisson par `if (!CR.RoomOp) continue;` puis lit `OpParams`, un
* `FStrateGenerationParams` NEUF sur lequel seul l'op de la salle a été appliqué. **Les champs
* pit/cheminée/colonne de la strate ne sont donc jamais lus** — ces primitives n'existent QUE
* via un `UVoxelTerrainOpDefinition` tiré par salle. (Ils n'ont pas d'`UPROPERTY` sur
* `FStrateGenerationParams`, donc ce n'est pas un piège d'éditeur : c'est un struct de transport,
* pas un réglage. Rien à corriger côté produit — c'était ma lecture qui était fausse.)
*
* Densified after the first green run: 65 of 6000 samples in open cave (1.1%) means the port was
* mostly compared solid-rock-to-solid-rock. The counter said so; the warning threshold (only at
* zero) did not. Both fixed.
* NOT here: PitDensity / ChimneyDensity. Setting them was a mistake that check 3b caught — the
* bake reads a FRESH param struct with only the room's op applied, so those fields are never
* read. Pits exist only through a per-room terrain-op asset. See MakeShaftOpPool below.
*/
void EnableTunnelFeatures(FStrateGenerationParams& P)
{
P.RoomSpacing = 42.0f; // 80 → 42 : des salles à portée de chaque chunk échantillonné
P.RoomDensity = 0.85f; // 0.35 → 0.85
P.PitDensity = 0.55f;
P.ChimneyDensity = 0.55f;
P.VerticalScale = 1.35f; // ≠ 1 ⇒ le Z « effectif » diverge du Z monde partout
}
/**
* Le SEUL moyen d'obtenir des pits et des cheminées : donner à la strate un pool d'ops de
* terrain, que `BuildChunkCache` tire par salle. Deux entrées à `Probability = 0.5` ⇒ le pool
* est entièrement réclamé, donc **chaque salle reçoit un op** (moitié pits, moitié cheminées).
*
* ⚠️ SÛR POUR L'ÉTAPE A, et ce n'est pas une évidence : l'override par salle du chemin d'origine
* applique l'op de la salle sur une COPIE des params, laquelle pilote les 13 modificateurs de
* détail — ceux que l'étape A n'a pas portés. Mais `ApplyTo` n'écrit, pour `Pit`, que les quatre
* champs de pit (idem `Chimney`). Aucun champ de détail n'est touché, donc aucun modificateur ne
* s'allume et l'équivalence de l'étape A tient. Un op `Terrace` ici la casserait — c'est
* exactement ce que l'étape B ajoutera, exprès.
*
* Safe at stage A because ApplyTo(Pit) writes only the four pit fields: no detail modifier wakes
* up. A Terrace op here WOULD break stage A — which is precisely what stage B will add.
*
* @param OutKeepAlive les assets transitoires, à garder vivants pour la durée du test.
*/
void MakeShaftOpPool(UVoxelStrateDefinition* Def,
TArray<TStrongObjectPtr<UVoxelTerrainOpDefinition>>& OutKeepAlive)
{
UVoxelTerrainOpDefinition* PitOp = NewObject<UVoxelTerrainOpDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
PitOp->Type = EVoxelTerrainOpType::Pit;
PitOp->PitDensity = 0.9f; // par salle, pas par cellule de hash : on en veut vraiment
PitOp->PitMinRadius = 5.0f;
PitOp->PitMaxRadius = 11.0f;
PitOp->PitDepth = 22.0f;
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(PitOp));
UVoxelTerrainOpDefinition* ChimOp = NewObject<UVoxelTerrainOpDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
ChimOp->Type = EVoxelTerrainOpType::Chimney;
ChimOp->ChimneyDensity = 0.9f;
ChimOp->ChimneyMinRadius = 3.0f;
ChimOp->ChimneyMaxRadius = 6.0f;
ChimOp->ChimneyHeight = 18.0f;
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(ChimOp));
FStrateTerrainOpEntry PitEntry;
PitEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(PitOp);
PitEntry.Weight = 1.0f;
PitEntry.Probability = 0.5f;
FStrateTerrainOpEntry ChimEntry;
ChimEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(ChimOp);
ChimEntry.Weight = 1.0f;
ChimEntry.Probability = 0.5f;
Def->TerrainOperations.Reset();
Def->TerrainOperations.Add(PitEntry);
Def->TerrainOperations.Add(ChimEntry);
}
/** Fraction minimale d'échantillons devant tomber en grotte ouverte pour que l'équivalence
* signifie quelque chose. 10 % est modeste et très au-dessus du 1,1 % observé. */
constexpr float MinCaveFraction = 0.10f;
@@ -114,6 +171,23 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
const UVoxelGenerator* Gen = World.Generator.Get();
// ⚠️ AVANT LA MOINDRE ÉVALUATION. Le pool d'ops est lu au moment où `BuildChunkCache` construit
// le cache ; le muter après coup laisserait un cache `thread_local` déjà chaud, bâti sans pits,
// et les deux chemins ne seraient plus comparables. On le pose donc pendant que rien n'est chaud.
TArray<TStrongObjectPtr<UVoxelTerrainOpDefinition>> OpAssets;
if (World.Definitions.IsValidIndex(FTestWorld::SlotTunnelNetwork)
&& World.Definitions[FTestWorld::SlotTunnelNetwork].IsValid())
{
MakeShaftOpPool(World.Definitions[FTestWorld::SlotTunnelNetwork].Get(), OpAssets);
}
else
{
AddError(TEXT("The fixture has no definition object for the TunnelNetwork slot, so no ")
TEXT("terrain-op pool could be attached -- pits and chimneys would silently not ")
TEXT("exist and check 3b would report zero for the wrong reason."));
return false;
}
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotTunnelNetwork, TopVoxelZ, BottomVoxelZ))
{
@@ -211,9 +285,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
TEXT("TunnelNetwork STAGE A: bit-identical across %d samples in %d chunks (%d in open ")
TEXT("cave, %d in rock, away from the seal bands). Exercised: vertical scale (1.35, so ")
TEXT("effective Z differs from world Z everywhere), cave warp, the room/tunnel SDF via ")
TEXT("the SHARED BuildChunkCache, pits and chimneys at UNWARPED coords, the carve with ")
TEXT("its floored divisor, and the worm carve with its network mask. NOT covered: the ")
TEXT("13 detail modifiers, the per-room op override, and any tile verdict."),
TEXT("the SHARED BuildChunkCache, the carve with its floored divisor, and the worm carve ")
TEXT("with its network mask. Pits and chimneys are covered only if the bake-coverage ")
TEXT("line below reports non-zero -- this message used to claim them outright, and was ")
TEXT("wrong for a whole run. NOT covered at all: the 13 detail modifiers, the per-room ")
TEXT("op override, and any tile verdict."),
NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock));
AddInfo(FString::Printf(
@@ -385,54 +461,71 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
// activer dans les params ne prouve pas qu'elles ont changé quoi que ce soit — un test peut très
// bien être vert avec `SDFCache.Pits` vide.
//
// On le MESURE : la même pile avec `PitDensity = ChimneyDensity = 0`, et on compte les points où
// la densité bouge. Zéro ⇒ les deux boucles n'ont rien fait et le morceau difficile n'est pas
// couvert, quelle que soit la couleur du test.
// ⚠️ ON INTERROGE LA CUISSON, PAS LA DENSITÉ — et le premier essai a échoué exactement là.
// La v1 de ce contrôle reconstruisait la pile avec `PitDensity = ChimneyDensity = 0` et comptait
// les points qui bougent : **0 sur 1500**. Cause : ces champs-là ne sont jamais lus. La v2 par
// « pile sans pits » ne marche pas non plus, pour une raison plus subtile — le pool d'ops n'est
// PAS dans la clé du cache SDF (en production c'est `LayoutVersion` qui couvre son édition), donc
// deux piles ne différant que par le pool se serviraient le même cache `thread_local`.
//
// Enabling a feature in the params is not evidence it fired. This measures it.
// On demande donc directement à `BuildChunkCache` ce qu'elle a cuit. C'est la structure dont la
// vacuité était la cause suspectée : autant la regarder plutôt que d'en inférer l'existence
// depuis une densité. Aucun risque de clé, aucun ordre à respecter.
//
// Ask the bake what it baked, instead of inferring it from a density: the suspected cause was an
// empty SDFCache.Pits, and that is a structure this test can simply look at. (v1 zeroed params
// nothing reads; a "stack without pits" would share the thread_local cache, since the op pool is
// not in its key — LayoutVersion covers pool edits in production.)
{
FStrateGenerationParams PNoShafts = P;
PNoShafts.PitDensity = 0.0f;
PNoShafts.ChimneyDensity = 0.0f;
FVoxelOpStack StackNoShafts;
VoxelDensityOps::BuildTunnelNetworkStack(StackNoShafts, PNoShafts, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
StackNoShafts.PrepareChunk(Ctx);
const int32 Probe = FMath::Min(1500, NumTunnelSamples);
// Deux passes SOLO (pas d'alternance) : on ne teste pas une clé de cache ici, seulement une
// contribution — et alterner ferait reconstruire le cache SDF à chaque point pour rien.
TArray<float> WithShafts;
WithShafts.SetNumUninitialized(Probe);
for (int32 i = 0; i < Probe; ++i)
int32 StrateIdx = 0;
{
WithShafts[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
const int32 QZ = FMath::FloorToInt((float)((TopVoxelZ + BottomVoxelZ) / 2) / (float)CHUNK_SIZE);
StrateIdx = World.StrateManager->GetStrateIndex(
((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
}
int32 NumMoved = 0;
for (int32 i = 0; i < Probe; ++i)
const UVoxelStrateDefinition* Def = World.Definitions[FTestWorld::SlotTunnelNetwork].Get();
int32 TotalPits = 0, TotalChimneys = 0, TotalRooms = 0, TotalColumns = 0;
for (int32 c = 0; c < 6; ++c)
{
const float V = StackNoShafts.EvalMC((float)Points[i].X, (float)Points[i].Y,
(float)Points[i].Z);
if (!BitEqual(V, WithShafts[i])) { ++NumMoved; }
const float Expansion = P.CaveWarpStrength + 2.0f;
const float MinX = (c - 3) * (float)CHUNK_SIZE - Expansion;
const float MinY = (c - 3) * (float)CHUNK_SIZE - Expansion;
FChunkSDFCache ProbeCache;
VoxelCaveMorphology::BuildChunkCache(
ProbeCache, MinX, MinY, MinX + CHUNK_SIZE + 2.0f * Expansion,
MinY + CHUNK_SIZE + 2.0f * Expansion,
P, (uint32)World.Settings->Seed, StrateIdx, &Def->TerrainOperations);
TotalRooms += ProbeCache.Rooms.Num();
TotalPits += ProbeCache.Pits.Num();
TotalChimneys += ProbeCache.Chimneys.Num();
TotalColumns += ProbeCache.Columns.Num();
}
AddInfo(FString::Printf(
TEXT("Pit/chimney contribution: %d of %d probe points move when PitDensity and ")
TEXT("ChimneyDensity are zeroed. These are the two loops OPSTACK-DECOMPOSITION 2 calls ")
TEXT("the fiddliest thing in the decomposition (unwarped coords SmoothMin'd into the ")
TEXT("warped room SDF), so this number is the difference between covering them and ")
TEXT("merely having switched them on."),
NumMoved, Probe));
TEXT("Bake coverage over 6 search boxes: %d rooms, %d pits, %d chimneys, %d columns. ")
TEXT("Pits and chimneys are the two loops OPSTACK-DECOMPOSITION 2 calls the fiddliest ")
TEXT("thing in the decomposition (unwarped coords SmoothMin'd into the warped room SDF); ")
TEXT("a zero here means the 6000-sample equivalence above says NOTHING about them, ")
TEXT("whatever colour it reports."),
TotalRooms, TotalPits, TotalChimneys, TotalColumns));
if (NumMoved == 0)
TestTrue(TEXT("the bake produced rooms at all"), TotalRooms > 0);
TestTrue(TEXT("the bake produced pits, so the pit loop has data to run on"), TotalPits > 0);
TestTrue(TEXT("the bake produced chimneys, so the chimney loop has data to run on"),
TotalChimneys > 0);
if (TotalColumns > 0)
{
AddWarning(TEXT("Zeroing PitDensity and ChimneyDensity changed nothing, so those two ")
TEXT("loops never contributed a single voxel and the equivalence says ")
TEXT("NOTHING about them. Most likely BuildChunkCache baked no pits at this ")
TEXT("RoomSpacing/strate height -- check SDFCache.Pits, not the params."));
AddError(FString::Printf(
TEXT("The bake produced %d columns, but stage A has NOT ported the column loop ")
TEXT("(STEP 4d). The equivalence above should have failed; if it did not, the ")
TEXT("sample points simply missed every column. Remove the column op from the pool ")
TEXT("until stage B."),
TotalColumns));
}
}