Files
VoxelForge/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp
T
Fr0zka 91585ea493 test(tunnel): a warning that always means "this is fine" is noise -- demote it
Jahni asked whether a "warning stage" he had been seeing for several sessions
meant something was broken. It did not: the only Warning in that run is the
tunnel test's "[dense fixture] No tile was proved", whose own text explains that
0 proved is the EXPECTED and correct result there -- cull spheres cover the
dense fixture ~3.6x, so no box can be outside all of them. Test passes, density
bit-identical, all coverage non-zero, violations 0, and production defaults
reports 11 proved / 14641 voxels / 0 violations right below it.

But a warning that fires every run and always means "fine" trains the reader to
ignore warnings, and this one quietly worried him across several sessions.

RunTileScan now takes bZeroProvedIsExpected: AddInfo on the dense fixture where
zero is the only possible answer, AddWarning on production defaults where zero
would be a real regression from 11. Same message, severity now carries meaning.

Same defect as the lumped bail counter one layer out: a signal that always says
the same thing measures nothing.

Test-only. Not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 03:20:07 +02:00

1559 lines
92 KiB
C++

// VoxelForgeOpStackTunnelTest.cpp
// TunnelNetwork ET Underwater — étapes A + B + C : les deux archétypes, entiers.
// TunnelNetwork AND Underwater — stages A + B + C: both archetypes, whole.
//
// POURQUOI UN TEST D'UNE PILE INCOMPLÈTE
// `GetDensityWithParams` fait ~1080 lignes et douze modificateurs de détail (le chiffre « treize »
// traînait dans les notes ; il y en a douze, et onze seulement lisent la copie de params par salle).
// Tout porter avant de pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées —
// exactement le motif que `AUDIT §P3` documente et que ce refactor a évité six fois de suite.
//
// La sortie : **tous les modificateurs de détail sont pilotés par une amplitude**, et
// `FStrateGenerationParams` les laisse déjà TOUS à zéro par défaut (`BuildParamsFromDefinition` ne
// les fusionne plus globalement — ils viennent d'ops par salle). Une seule exception,
// `SurfaceRoughness = 5`. Les mettre à zéro fait passer l'ORIGINAL par exactement le chemin que
// l'étape A a porté, donc l'étape A est vérifiable AUJOURD'HUI, bit à bit, contre la vraie fonction.
// Même discipline que la passe « défauts puis tous les ops ON » du test de pile de hauteur, prise
// dans l'autre sens.
//
// L'ÉTAPE B A REMONTÉ CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté
// sortait de la liste des amplitudes éteintes pour entrer dans `EnableTunnelFeatures`, avec (i) une
// sonde de couverture prouvant qu'il a réellement bougé quelque chose, et (ii) le compte d'ops qui monte.
// • B1 : rugosité de paroi, STEP 4b.
// • B2 : terrasses, lignes de strates, nervures — STEP 4c.
// • B3 : surplombs, falaise, festons, arches — STEP 4c.
// • B4 : colonnes (4d), dômes (4g), pincement (4h), biais de sol. La liste des amplitudes
// éteintes est vide : les douze modificateurs sont portés.
// • B5 : le GATE lui-même. Aucun opérateur ajouté — la décision (early-out répété par opérateur
// plutôt que conteneur de portée) est écrite dans `VF_NearCaveSurface`, et le contrôle 1d la
// paie : hors gate, la pile complète doit être BIT À BIT celle sans modificateurs.
// • C1 : l'override d'op PAR SALLE. Aucun opérateur ajouté non plus — onze des douze
// modificateurs lisent désormais `FRoomGraphSource::LocalParams()`. Le pool du test reçoit un op
// `Terrace` dont les valeurs écrasent celles de la strate : **une pile qui ignorerait l'override
// ne peut plus être bit-identique**, ce qu'aucun test de l'étape B ne pouvait exiger.
//
// • C2 (ce commit) : `Underwater`. Aucun opérateur, aucun paramètre — le même rocher atteint
// depuis un second `case`. Le contrôle 5 l'échantillonne dans SON slot, ce qui exerce en prime
// un second index de strate (donc un second `StrateSeed` de cuisson et une seconde entrée de la
// mémo d'index) — ce que six chunks d'un seul slot ne peuvent pas faire.
//
// CE QUE CE TEST NE PROUVE TOUJOURS PAS : le saut de tuile — `FRoomGraphSource::EffectOverBox` rend
// `Both` et le ver `CarveOnly` partout, donc zéro verdict prouvé, ce que le contrôle 4 ASSERTE.
//
// ⚠️ ÉCHANTILLONNAGE PAR GRAPPES, PAS UNIFORME. Le cache SDF se reconstruit quand la requête sort de
// sa boîte de recherche ; 20 000 points uniformément aléatoires feraient ~20 000 `BuildChunkCache`
// par chemin, et un test qui dure trois minutes est un test qu'on finit par ne plus lancer. On tire
// donc N chunks et M points DANS chacun — ce qui est aussi plus représentatif du vrai motif d'accès
// (un mesher parcourt une tuile, il ne saute pas au hasard).
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#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>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackTunnelTest,
"VoxelForge.OpStack.TunnelNetworkSpineEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumTunnelChunks = 24;
constexpr int32 PointsPerChunk = 250;
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
/**
* L'empreinte de params que `GetDensityWithParams` exige depuis le correctif d'`AUDIT §C2`.
*
* ⚠️ CE N'EST PAS DU REMPLISSAGE D'ARGUMENT. Avant ce correctif, l'original clé son cache SDF
* sans les params, et le contrôle 3 plus bas explique en détail pourquoi il fallait alors
* comparer chaque pile à ELLE-MÊME plutôt qu'à l'original : l'oracle partageait le défaut
* testé. En passant la même empreinte que la production, l'oracle ne le partage plus.
*
* `LayoutVersion = 0` partout dans ce test : le monde de test ne rebâtit jamais son layout en
* cours de route, donc la version est constante — ce qui compte ici, c'est que l'empreinte
* DIFFÈRE entre deux jeux de params, et c'est exactement ce que la CRC donne.
*/
FORCEINLINE uint32 VF_FP(const FStrateGenerationParams& InP)
{
return FCrc::MemCrc32(&InP, sizeof(InP));
}
//=========================================================================
// ⚠️ `DisableStageBModifiers` A DISPARU, ET SA DISPARITION EST LE RÉSULTAT DE L'ÉTAPE B
//=========================================================================
// Cette fonction mettait à zéro tout ce que le portage n'avait pas encore atteint, pour que
// l'original emprunte le même chemin que la pile. À chaque groupe (B1…B4) une ligne en sortait
// pour entrer dans `EnableTunnelFeatures` ET dans `FeatureProbes` ; à B4 il n'en restait plus.
// Sa liste vide EST la mesure de l'avancement — d'où le fait de le dire ici plutôt que de
// laisser une fonction vide qu'on continuerait d'appeler sans y penser.
//
// Une ligne y était de toute façon inopérante et mérite d'être notée : `ColumnDensity = 0`
// n'éteignait rien. La cuisson des colonnes lit `OpParams` (un struct NEUF où seul l'op de la
// salle a été appliqué), jamais le champ de la strate — même mécanisme que pour les pits. Ce qui
// tenait les colonnes éteintes à l'étape A, c'était l'ABSENCE d'op `Column` dans le pool, et le
// garde-fou qui ERREUR-ait si la cuisson en produisait quand même. B4 ajoute l'op au pool et
// retourne ce garde-fou : il EXIGE maintenant des colonnes.
/**
* ⚠️ 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 %.
*
* ⚠️⚠️ 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.)
*
* 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 MakeRoomOpPool 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.VerticalScale = 1.35f; // ≠ 1 ⇒ le Z « effectif » diverge du Z monde partout
// ── ÉTAPE B1 : rugosité de paroi (4b) ────────────────────────────────────────────────
// Écrits EXPLICITEMENT, pas laissés au défaut : un test qui dépend d'un défaut se casse en
// silence le jour où le défaut change. `SurfaceRoughness` était le seul de ces champs non nul
// par défaut (5.0), et l'étape A le remettait à zéro — c'est ce zéro qui disparaît ici.
P.SurfaceRoughness = 5.0f;
P.RoughnessFrequency = 0.1f;
P.RoughnessNoiseType = EVoxelNoiseType::FBM; // les 4 types sont balayés au contrôle 1c
P.DomainWarpStrength = 3.0f; // ≠ 0 ⇒ le chemin de warp de domaine est pris
P.DomainWarpFrequency = 0.03f;
// ── ÉTAPE B2 : les trois remaniements « sédimentaires » (4c) ──────────────────────────
// Espacements CHOISIS PETITS devant la fenêtre d'échantillonnage : `LineRange` vaut
// `Spacing · 1.5`, donc un espacement de 30 ne laisserait presque aucun point dans la
// fenêtre et la sonde de couverture rapporterait un quasi-zéro pour la mauvaise raison.
P.TerraceStepHeight = 6.0f;
P.TerraceHardness = 0.6f;
P.TerraceNoiseDisplacement = 0.5f; // ≠ 0 ⇒ le bruit de déplacement du palier est pris
P.LayerLineSpacing = 5.0f;
P.LayerLineDepth = 0.35f;
P.RibbingSpacing = 4.0f;
P.RibbingDepth = 0.4f;
// ── ÉTAPE B3 : surplombs · falaise · festons · arches (4c) ───────────────────────────
P.OverhangStrength = 0.6f;
P.OverhangDepth = 5.0f;
P.OverhangFrequency= 0.06f;
P.CliffStrength = 0.5f;
P.ScallopStrength = 0.8f;
P.ScallopFrequency = 0.1f;
// ⚠️ ArchDensity BEAUCOUP plus haut que le défaut (0.1) : chaque salle tire 3 arches et
// n'en garde que celles dont `hash01 <= ArchDensity`. À 0.1, ~0.3 arche par salle et une
// fenêtre d'influence de 2 voxels autour d'une capsule — la sonde de couverture rapporterait
// zéro sans que rien ne soit faux. La couverture d'un test n'est pas la valeur de prod.
P.ArchDensity = 0.9f;
P.ArchMinRadius = 3.0f;
P.ArchMaxRadius = 6.0f;
// ── ÉTAPE B4 : dômes · pincement · biais de sol (4g, 4h) ─────────────────────────────
// ⚠️ LES COLONNES (4d) NE SONT PAS ICI, et ce n'est pas un oubli : elles n'ont AUCUN
// paramètre de strate. Ni la cuisson ni la boucle par voxel ne lisent `ColumnDensity` ;
// elles n'existent que par un op `Column` dans le pool (voir `MakeRoomOpPool`) et leur
// couverture se prouve au contrôle 3b, pas par une sonde de params.
P.DomeDensity = 0.9f; // 2 tirages par salle ⇒ il en faut beaucoup pour couvrir
P.DomeMinRadius = 8.0f;
P.DomeMaxRadius = 15.0f;
P.DomeHeightRatio = 0.8f;
P.PinchDensity = 0.9f; // idem, 3 tirages par salle
P.PinchStrength = 5.0f;
P.PinchLength = 12.0f;
P.FloorBias = 0.8f; // ne s'applique que dans l'air certain (CaveSDF < 0)
}
/**
* Le SEUL moyen d'obtenir des pits, des cheminées et des colonnes : donner à la strate un pool
* d'ops de terrain, que `BuildChunkCache` tire par salle. Trois entrées à `Probability = 1.0` ⇒
* `TotalOpProb = 3`, `NormFactor = 1/3`, curseurs à 1/3, 2/3, 1 : **chaque salle reçoit un op**,
* un tiers de chaque sorte.
*
* ⚠️⚠️ CE POOL DOIT RESTER Pit / Chimney / Column JUSQU'À L'ÉTAPE C1, ET LA RAISON EST SUBTILE.
* L'override par salle du chemin d'origine applique l'op de la salle sur une COPIE des params,
* et ONZE des douze modificateurs de détail lisent cette copie (la rugosité 4b, non : le shadow
* est déclaré après elle). L'étape B les a portés contre les params de la STRATE. Les deux ne
* coïncident que tant qu'aucune salle ne porte un op de type DÉTAIL — or `ApplyTo(Pit)` n'écrit
* que les quatre champs de pit, `ApplyTo(Chimney)` que les quatre de cheminée, `ApplyTo(Column)`
* que les trois de colonne. Aucun champ de détail touché ⇒ aucun modificateur ne diverge.
*
* **Mettre un op `Terrace` ici casserait l'équivalence — et c'est exactement le test que C1
* ajoutera**, parce que c'est la seule preuve possible de l'override par salle.
*
* The pool must stay Pit/Chimney/Column until C1: eleven of the twelve modifiers read the
* per-room param copy in the original and the strate params here, and those agree only while no
* room carries a detail-type op. A Terrace entry is what C1 adds, deliberately.
*
* @param OutKeepAlive les assets transitoires, à garder vivants pour la durée du test.
*/
void MakeRoomOpPool(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));
// ⚠️⚠️ AJOUTÉ À L'ÉTAPE C1, ET C'EST *LE* TEST DE L'OVERRIDE PAR SALLE.
// Jusqu'ici le pool était volontairement limité aux types Pit / Chimney / Column, dont les
// `ApplyTo` n'écrivent AUCUN champ de détail : c'est ce qui rendait l'étape B vérifiable
// alors même que les onze modificateurs lisaient les params de la strate au lieu de ceux de
// la salle. Un op `Terrace` change exactement ça — les salles qui le tirent voient
// `TerraceStepHeight/Hardness/NoiseDisplacement` ÉCRASÉS par ceux de l'op. Une pile qui
// ignorerait l'override rendrait donc une densité différente **sur ces salles-là**, et
// l'équivalence tomberait. C'est la seule preuve possible de C1.
//
// Valeurs délibérément TRÈS différentes de celles de la strate (6.0 / 0.6 / 0.5) : un
// override qui n'écrase qu'avec des valeurs proches serait indétectable au bit près sur peu
// de points, et « peu de points » est une couverture qu'on ne saurait pas lire.
UVoxelTerrainOpDefinition* TerraceOp = NewObject<UVoxelTerrainOpDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
TerraceOp->Type = EVoxelTerrainOpType::Terrace;
TerraceOp->TerraceStepHeight = 3.0f; // strate : 6.0
TerraceOp->TerraceHardness = 0.95f; // strate : 0.6
TerraceOp->TerraceNoiseDisplacement = 1.4f; // strate : 0.5
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(TerraceOp));
// ⚠️ AJOUTÉ À L'ÉTAPE B4 : sans cette entrée, `SDFCache.Columns` reste VIDE et l'opérateur
// de colonnes ne s'exécute sur rien — ce que le contrôle 3b exigeait à l'étape A et qu'il
// exige désormais dans l'autre sens.
UVoxelTerrainOpDefinition* ColOp = NewObject<UVoxelTerrainOpDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
ColOp->Type = EVoxelTerrainOpType::Column;
ColOp->ColumnDensity = 0.9f;
ColOp->ColumnMinRadius = 2.0f;
ColOp->ColumnMaxRadius = 5.0f;
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(ColOp));
// `Probability = 1.0` sur les quatre : la somme dépasse 1, donc la sélection est normalisée
// et chaque salle tire exactement un op parmi les quatre, à parts égales.
FStrateTerrainOpEntry PitEntry;
PitEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(PitOp);
PitEntry.Weight = 1.0f;
PitEntry.Probability = 1.0f;
FStrateTerrainOpEntry ChimEntry;
ChimEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(ChimOp);
ChimEntry.Weight = 1.0f;
ChimEntry.Probability = 1.0f;
FStrateTerrainOpEntry ColEntry;
ColEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(ColOp);
ColEntry.Weight = 1.0f;
ColEntry.Probability = 1.0f;
// ⚠️ `Weight = 1.0` et pas autre chose : `ApplyTo` multiplie le champ d'ACTIVATION par le
// poids (`TerraceStepHeight * Weight`) et copie les autres tels quels. Un poids ≠ 1 rendrait
// la valeur attendue moins lisible dans un diff sans rien prouver de plus.
FStrateTerrainOpEntry TerraceEntry;
TerraceEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(TerraceOp);
TerraceEntry.Weight = 1.0f;
TerraceEntry.Probability = 1.0f;
Def->TerrainOperations.Reset();
Def->TerrainOperations.Add(PitEntry);
Def->TerrainOperations.Add(ChimEntry);
Def->TerrainOperations.Add(ColEntry);
Def->TerrainOperations.Add(TerraceEntry);
}
/** 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;
//=========================================================================
// COUVERTURE PAR GROUPE — une entrée par groupe de l'étape B
//=========================================================================
// ⚠️ LA LEÇON DES PITS, GÉNÉRALISÉE : **activer une fonctionnalité n'est pas une preuve qu'elle
// a tiré.** `PitDensity = 0.55` n'a rien fait pendant tout un run (mauvais struct) et le test
// restait vert. Ici la question « le groupe B_n a-t-il changé quelque chose ? » se pose de la
// seule façon qui ne puisse répondre juste par hasard : reconstruire la pile avec CE groupe
// éteint et COMPTER LES POINTS QUI BOUGENT.
//
// ⚠️⚠️ POURQUOI CE DIFF-DE-PILES EST LÉGITIME ICI ALORS QU'IL AURAIT MENTI POUR LES PITS :
// le pool d'ops de terrain n'est PAS dans la clé du cache SDF, donc deux piles n'en différant
// que par lui se servaient le même cache `thread_local` et rendaient exactement la même chose.
// Les champs ci-dessous, eux, sont des params — et l'empreinte CRC des params EST dans la clé.
// Deux piles qui n'en diffèrent que par un de ces champs se reconstruisent donc bien chacune.
//
// Enabling a feature is not evidence it fired. Each entry rebuilds the stack with that group
// OFF and counts moved points. Legitimate here (unlike for the op pool) because these are
// params, and the params CRC is part of the SDF cache key.
struct FFeatureProbe
{
const TCHAR* Name;
void (*Disable)(FStrateGenerationParams&); // lambda sans capture ⇒ pointeur de fonction
};
const FFeatureProbe FeatureProbes[] =
{
{ TEXT("B1 surface roughness (STEP 4b)"),
[](FStrateGenerationParams& Q) { Q.SurfaceRoughness = 0.0f; } },
{ TEXT("B2 terracing (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.TerraceStepHeight = 0.0f; } },
{ TEXT("B2 layer lines (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.LayerLineSpacing = 0.0f; } },
{ TEXT("B2 ribbing (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.RibbingSpacing = 0.0f; } },
{ TEXT("B3 cave overhangs (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.OverhangStrength = 0.0f; } },
{ TEXT("B3 cliff sharpening (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.CliffStrength = 0.0f; } },
{ TEXT("B3 scallop (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.ScallopStrength = 0.0f; } },
{ TEXT("B3 arches (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.ArchDensity = 0.0f; } },
{ TEXT("B4 domes (STEP 4g)"),
[](FStrateGenerationParams& Q) { Q.DomeDensity = 0.0f; } },
{ TEXT("B4 pinch (STEP 4h)"),
[](FStrateGenerationParams& Q) { Q.PinchDensity = 0.0f; } },
{ TEXT("B4 floor bias"),
[](FStrateGenerationParams& Q) { Q.FloorBias = 0.0f; } },
// ⚠️ PAS DE SONDE POUR LES COLONNES (STEP 4d), et ce n'est pas un oubli : elles n'ont aucun
// paramètre de strate qu'on puisse éteindre. Leur couverture se prouve au contrôle 3b, en
// demandant à la cuisson combien elle en a produit. Voir la note là-bas.
// ⚠️ UNE SONDE PAR OPÉRATEUR, PAS UNE PAR GROUPE. Le groupe B2 en contient trois ; une seule
// sonde « B2 » serait verte tant qu'UN des trois tire, et les deux autres pourraient être
// faux sans que rien ne le dise. La granularité de la sonde est la granularité de la preuve.
};
//=========================================================================
// LE `switch` SUR LE TYPE DE BRUIT — quatre branches, et une seule serait testée
//=========================================================================
// L'équivalence principale tourne en FBM. Les trois autres branches (Ridged, Mixed, Cellular)
// et les deux chemins de warp de domaine ne seraient JAMAIS exécutés — une transcription fausse
// dans `case Cellular:` passerait tout l'étage B sans un mot. Ce balayage les prend une par une.
struct FRoughVariant
{
const TCHAR* Name;
EVoxelNoiseType Type;
float WarpStrength;
};
const FRoughVariant RoughVariants[] =
{
{ TEXT("FBM, no domain warp"), EVoxelNoiseType::FBM, 0.0f },
{ TEXT("FBM, domain warp"), EVoxelNoiseType::FBM, 3.0f },
{ TEXT("Ridged, no domain warp"), EVoxelNoiseType::Ridged, 0.0f },
{ TEXT("Ridged, domain warp"), EVoxelNoiseType::Ridged, 3.0f },
{ TEXT("Mixed, no domain warp"), EVoxelNoiseType::Mixed, 0.0f },
{ TEXT("Mixed, domain warp"), EVoxelNoiseType::Mixed, 3.0f },
{ TEXT("Cellular, no domain warp"), EVoxelNoiseType::Cellular, 0.0f },
{ TEXT("Cellular, domain warp"), EVoxelNoiseType::Cellular, 3.0f },
};
/**
* ⚠️ DIVISION ENTIÈRE **PLANCHER**, ET C'EST UN VRAI BUG QU'ELLE CORRIGE, PAS UNE PRÉCAUTION.
*
* En C++ `/` TRONQUE vers zéro. Pour la strate TunnelNetwork, qui est en HAUT du layout et donc
* en Z positif, tronquer et plancher donnent le même résultat — le bug ne pouvait pas se voir.
* La strate Underwater est en BAS, en Z NÉGATIF : `-1 / 32` vaut `0` par troncature contre `-1`
* par plancher, donc la borne haute de chunk part un cran trop haut, et le `Clamp` qui suit
* entasse les points en trop sur le tout dernier voxel de la strate — en pleine bande de seal,
* c'est-à-dire dans du roc plein.
*
* C'est un candidat pour le « 0 de 2000 échantillons en grotte ouverte » qu'a rapporté le premier
* run vert du contrôle 5. Candidat, pas conclusion : le bloc de diagnostic du contrôle 5
* INTERROGE désormais la cuisson et l'intervalle réellement échantillonné plutôt que de l'inférer.
* (Même famille que `DivideAndRoundDown` dans les notes de projet : la troncature coûte des
* cycles de build à chaque fois qu'on la suppose être un plancher.)
*/
FORCEINLINE int32 FloorDivChunk(int32 A)
{
const int32 Q = A / CHUNK_SIZE, R = A % CHUNK_SIZE;
return (R != 0 && R < 0) ? Q - 1 : Q;
}
/** Le balayage ne relit pas les 6000 points : les branches de bruit sont par-voxel et sans
* état, donc un sous-ensemble les couvre autant. Ce qui coûte, c'est la reconstruction du
* cache SDF à chaque changement de chunk, et elle est proportionnelle aux chunks visités. */
constexpr int32 RoughSweepPoints = 1500;
}
bool FVoxelForgeOpStackTunnelTest::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();
// ⚠️ 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())
{
MakeRoomOpPool(World.Definitions[FTestWorld::SlotTunnelNetwork].Get(), OpAssets);
// ÉTAPE C2 — le slot Underwater reçoit le MÊME pool, pour la même raison et au même
// moment. Sans lui, le contrôle 5 comparerait deux chemins sur une strate sans aucune
// primitive par salle : vert, et vide de sens.
if (World.Definitions.IsValidIndex(FTestWorld::SlotUnderwater)
&& World.Definitions[FTestWorld::SlotUnderwater].IsValid())
{
MakeRoomOpPool(World.Definitions[FTestWorld::SlotUnderwater].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))
{
AddError(TEXT("The fixture layout has no TunnelNetwork slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotTunnelNetwork."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
FStrateGenerationParams P = World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
{
AddError(TEXT("The TunnelNetwork strate has degenerate Z bounds."));
return false;
}
EnableTunnelFeatures(P);
FVoxelOpStack Stack;
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + roomgraph + carve + **les 12 modificateurs de détail** + worms + 3 structurels = 19.
// L'étape C n'ajoute AUCUN opérateur — elle change ce que onze d'entre eux LISENT — donc ce
// nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une
// formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté.
TestEqual(TEXT("the stage-A+B tunnel stack is decomposed into 19 ops"), Stack.Num(), 19);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
// Grappes : N chunks, M points dans chacun. Voir l'en-tête — un tirage uniforme ferait
// reconstruire le cache SDF à presque chaque point, sur les DEUX chemins.
TArray<FVector> Points;
Points.Reserve(NumTunnelSamples);
{
FRandomStream Rng(1080601);
const int32 ChunkZ0 = FloorDivChunk(BottomVoxelZ);
const int32 ChunkZ1 = FMath::Max(ChunkZ0, FloorDivChunk(TopVoxelZ) - 1);
for (int32 c = 0; c < NumTunnelChunks; ++c)
{
const int32 CX = Rng.RandRange(-3, 3);
const int32 CY = Rng.RandRange(-3, 3);
const int32 CZ = Rng.RandRange(ChunkZ0, ChunkZ1);
for (int32 i = 0; i < PointsPerChunk; ++i)
{
Points.Add(FVector(
(float)(CX * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1)),
(float)(CY * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1)),
(float)FMath::Clamp(CZ * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1),
BottomVoxelZ, TopVoxelZ)));
}
}
}
//=========================================================================
// 1. ÉQUIVALENCE
//=========================================================================
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
int32 NumInCave = 0, NumInRock = 0;
float WorstDelta = 0.0f;
// Gardé pour le contrôle 1b : la référence « pile complète » que chaque sonde de couverture
// compare à une pile dont UN groupe est éteint. Rempli ici pour ne pas repayer une passe.
TArray<float> FullVals;
FullVals.SetNumUninitialized(NumTunnelSamples);
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetDensityWithParams(X, Y, Z, P, VF_FP(P), 0);
const float New = Stack.EvalMC(X, Y, Z);
FullVals[i] = New;
const bool bInterior = (Z > InnerBot && Z < InnerTop);
if (bInterior && Old >= 0.0f) { ++NumInCave; } // air loin des seals ⇒ salle/tunnel/ver
if (bInterior && Old < 0.0f) { ++NumInRock; }
if (!BitEqual(Old, New))
{
++NumDiff;
const float D = FMath::Abs(Old - New);
if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; }
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("TunnelNetwork STAGE A+B: bit-identical across %d samples in %d chunks (%d in ")
TEXT("open cave, %d in rock, away from the seal bands). Exercised: vertical scale (1.35, ")
TEXT("so effective Z differs from world Z everywhere), cave warp, the room/tunnel SDF via ")
TEXT("the SHARED BuildChunkCache, the carve with its floored divisor, the ported detail ")
TEXT("modifiers of 4b-4c, and the worm carve with its network mask. Every one of those ")
TEXT("is covered ONLY insofar as the bake-coverage and group-coverage lines below report ")
TEXT("non-zero -- this message used to CLAIM coverage outright, and was wrong for a ")
TEXT("whole run. NOT covered at all: whatever is still listed in ")
TEXT("the per-room op override (stage C1), and any tile verdict."),
NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock));
AddInfo(FString::Printf(
TEXT("Cave coverage: %.1f%% of samples are in open cave (floor %.0f%%). This is the ")
TEXT("number that says whether the equivalence MEANS anything -- the carve, the pits, ")
TEXT("the chimneys and the worm carve only execute near the network, so a run dominated ")
TEXT("by deep rock would be green while proving almost nothing."),
100.0f * (float)NumInCave / (float)NumTunnelSamples, 100.0f * MinCaveFraction));
}
else
{
AddError(FString::Printf(
TEXT("TunnelNetwork STAGE A+B: %d of %d samples differ (largest |delta| %.9g at ")
TEXT("(%.0f, %.0f, %.0f)); %d cross the isosurface. Check, in order: the carve's ")
TEXT("MinDivisor (TunnelNetwork floors Blend*2 at 1.0 and the other archetypes do NOT ")
TEXT("-- getting this wrong only shows up when SDFBlendRadius*2 < 1), then EffectiveZ ")
TEXT("(VerticalScale must divide BEFORE the warp and the worms, and must NOT touch pit ")
TEXT("or chimney Z), then the pit/chimney loops reading UNWARPED coords while the room ")
TEXT("SDF reads warped ones, then the SDF cache key (it now includes a params ")
TEXT("fingerprint the original lacks -- that can cost a rebuild, never a wrong room), ")
TEXT("then the worm early-out on N1 >= threshold. NEW AT B1, so suspect these first: ")
TEXT("the roughness gate is bNearCaveSurface (SDF < BlendRadius*3) AND ")
TEXT("|SDF| < SurfaceRoughness*2 -- two different windows; the domain warp adds ONE ")
TEXT("shared offset to BOTH noise positions (two independent warps would be tidier and ")
TEXT("wrong); the fine octave set is frequency*3 with +2000/+2500/+3000 offsets; the ")
TEXT("anti-fill clamp is min(TotalRough, 0) only where SDF < 0; and the fade is ")
TEXT("quadratic in DistFromSurface/RoughnessDepth. Roughness also reads EffectiveZ, ")
TEXT("not WorldZ."),
NumDiff, NumTunnelSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSideDisagree));
}
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
// ⚠️ SEUIL EN FRACTION, PAS « > 0 ». La version « == 0 » de ce garde-fou a laissé passer un run
// à 1,1 % en silence. Un test qui ne se plaint qu'au zéro absolu ne mesure pas la couverture,
// il constate seulement qu'elle n'est pas vide.
if ((float)NumInCave < MinCaveFraction * (float)NumTunnelSamples)
{
AddWarning(FString::Printf(
TEXT("Only %.1f%% of samples landed in open cave (want >= %.0f%%): the carve, the pits, ")
TEXT("the chimneys and the worm carve were barely exercised, so the equivalence above ")
TEXT("mostly compares solid rock to solid rock. Lower RoomSpacing or raise RoomDensity ")
TEXT("in EnableTunnelFeatures."),
100.0f * (float)NumInCave / (float)NumTunnelSamples, 100.0f * MinCaveFraction));
}
//=========================================================================
// 1b. CHAQUE GROUPE DE L'ÉTAPE B A-T-IL RÉELLEMENT TIRÉ ?
//=========================================================================
// Voir `FeatureProbes` : on rebâtit la pile avec un groupe éteint et on compte les points qui
// BOUGENT. Zéro ⇒ l'équivalence ci-dessus ne dit rien de ce groupe, quelle que soit sa couleur.
// C'est une ERREUR, pas un avertissement : un groupe porté et jamais exécuté est exactement
// l'état dans lequel une faute de transcription traverse tout un run sans se faire voir.
for (const FFeatureProbe& Probe : FeatureProbes)
{
FStrateGenerationParams PWithout = P;
Probe.Disable(PWithout);
FVoxelOpStack StackWithout;
VoxelDensityOps::BuildTunnelNetworkStack(StackWithout, PWithout, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
StackWithout.PrepareChunk(Ctx);
int32 NumMoved = 0;
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
const float V = StackWithout.EvalMC((float)Points[i].X, (float)Points[i].Y,
(float)Points[i].Z);
if (!BitEqual(V, FullVals[i])) { ++NumMoved; }
}
if (NumMoved > 0)
{
AddInfo(FString::Printf(
TEXT("Group coverage -- %s: %d of %d samples (%.1f%%) move when this group is ")
TEXT("switched off, so the equivalence above genuinely covers it."),
Probe.Name, NumMoved, NumTunnelSamples,
100.0f * (float)NumMoved / (float)NumTunnelSamples));
}
else
{
AddError(FString::Printf(
TEXT("Group coverage -- %s: ZERO of %d samples move when this group is switched ")
TEXT("off. The group contributed NOTHING to the 6000-sample equivalence, so that ")
TEXT("equivalence says nothing about it. Either its params never reach the op (the ")
TEXT("PitDensity mistake, second time), or its gate never opens at these sample ")
TEXT("points. Do not read the green equivalence as covering this group."),
Probe.Name, NumTunnelSamples));
}
}
//=========================================================================
// 1c. LES QUATRE BRANCHES DU `switch` DE BRUIT, ET LES DEUX CHEMINS DE WARP
//=========================================================================
// L'équivalence principale ne prend QU'UNE branche (FBM). Une transcription fausse dans
// `case Cellular:` ou `case Mixed:` la traverserait sans un mot. Chaque variante est donc
// comparée à l'original sur un sous-ensemble des mêmes points.
{
int32 TotalVariantDiffs = 0;
for (const FRoughVariant& V : RoughVariants)
{
FStrateGenerationParams PV = P;
PV.RoughnessNoiseType = V.Type;
PV.DomainWarpStrength = V.WarpStrength;
FVoxelOpStack VStack;
VoxelDensityOps::BuildTunnelNetworkStack(VStack, PV, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
VStack.PrepareChunk(Ctx);
int32 VDiff = 0;
for (int32 i = 0; i < RoughSweepPoints; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV, VF_FP(PV), 0),
VStack.EvalMC(X, Y, Z)))
{
++VDiff;
}
}
TotalVariantDiffs += VDiff;
if (VDiff > 0)
{
AddError(FString::Printf(
TEXT("Roughness variant '%s': %d of %d samples differ from the original. Only ")
TEXT("this branch of the noise switch is implicated -- the other variants and ")
TEXT("the main equivalence use the same code either side of it."),
V.Name, VDiff, RoughSweepPoints));
}
}
if (TotalVariantDiffs == 0)
{
AddInfo(FString::Printf(
TEXT("Roughness noise sweep: all %d variants (FBM / Ridged / Mixed / Cellular, each ")
TEXT("with and without the domain warp) are bit-identical over %d samples. This is ")
TEXT("what makes the three unused branches of the 4b switch mean anything -- the ")
TEXT("main equivalence only ever takes the FBM one."),
(int32)UE_ARRAY_COUNT(RoughVariants), RoughSweepPoints));
}
}
//=========================================================================
// 1d. LE GATE `bNearCaveSurface` — UN VOXEL HORS PORTÉE EST-IL VRAIMENT INTACT ? (ÉTAPE B5)
//=========================================================================
// ⚠️ LA DÉCISION DE L'ÉTAPE B5, RAPPELÉE ICI PARCE QUE C'EST CE CONTRÔLE QUI LA PAIE.
// Dans l'original les douze modificateurs vivent dans UN SEUL `if (bNearCaveSurface)`. La pile
// est une LISTE PLATE, donc chaque opérateur re-teste le gate lui-même (`VF_NearCaveSurface`,
// VoxelDensityOpStack.cpp, où le raisonnement complet est écrit). Un opérateur conteneur aurait
// dû replier ses enfants à la place de `ClassifyBox` et les lui aurait cachés.
//
// Le risque que ce choix introduit est précis : **douze occasions d'oublier le gate au lieu
// d'une**. Un oubli ne se verrait pas forcément dans l'équivalence globale (le modificateur
// s'appliquerait aussi dans l'original si son propre range était large), d'où un contrôle dédié.
//
// MÉTHODE : on classe chaque échantillon par son SDF final (aucun opérateur en aval du graphe de
// salles n'écrit ce canal), puis on compare la pile COMPLÈTE à une pile dont les onze amplitudes
// pilotables sont à zéro. **Hors gate, les deux doivent être bit à bit identiques** ; dedans,
// elles doivent différer souvent — sinon le contrôle ne compare rien.
//
// ⚠️ CE QUE CE CONTRÔLE NE PEUT PAS COUVRIR : l'opérateur de COLONNES, qui n'a aucune amplitude
// à éteindre (cf. la note du contrôle 3b). Son gate à lui n'est couvert que par la bit-identité
// globale du contrôle 1 face à l'original — ce qui suffit, mais ne se voit pas ici.
{
FStrateGenerationParams PNoMods = P;
PNoMods.SurfaceRoughness = 0.0f;
PNoMods.TerraceStepHeight = 0.0f;
PNoMods.LayerLineSpacing = 0.0f;
PNoMods.RibbingSpacing = 0.0f;
PNoMods.OverhangStrength = 0.0f;
PNoMods.CliffStrength = 0.0f;
PNoMods.ScallopStrength = 0.0f;
PNoMods.ArchDensity = 0.0f;
PNoMods.DomeDensity = 0.0f;
PNoMods.PinchDensity = 0.0f;
PNoMods.FloorBias = 0.0f;
FVoxelOpStack NoModStack;
VoxelDensityOps::BuildTunnelNetworkStack(NoModStack, PNoMods, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
NoModStack.PrepareChunk(Ctx);
TArray<float> NoModVals;
NoModVals.SetNumUninitialized(NumTunnelSamples);
TArray<uint8> bOutsideGate;
bOutsideGate.SetNumUninitialized(NumTunnelSamples);
const float DetailThreshold = P.SDFBlendRadius * 3.0f;
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const FVoxelOpSample S = NoModStack.EvalSample(X, Y, Z);
NoModVals[i] = -S.Density; // même négation MC que EvalMC
// Le gate, écrit exactement comme l'original et comme VF_NearCaveSurface.
bOutsideGate[i] = ((S.Sdf < DetailThreshold) && (S.Sdf < FLT_MAX)) ? 0 : 1;
}
int32 NumOutside = 0, NumLeaked = 0, NumInsideMoved = 0;
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
if (bOutsideGate[i])
{
++NumOutside;
if (!BitEqual(NoModVals[i], FullVals[i])) { ++NumLeaked; }
}
else if (!BitEqual(NoModVals[i], FullVals[i]))
{
++NumInsideMoved;
}
}
TestEqual(TEXT("no detail modifier touches a voxel outside the bNearCaveSurface gate"),
NumLeaked, 0);
AddInfo(FString::Printf(
TEXT("Gate check (stage B5): %d of %d samples (%.1f%%) sit OUTSIDE the gate ")
TEXT("(SDF >= SDFBlendRadius*3), and %d of them leaked. Inside the gate, %d samples ")
TEXT("move when the eleven amplitudes are zeroed -- that second number is what says ")
TEXT("the comparison is not vacuous: if it were 0, 'nothing leaked' would only mean ")
TEXT("'nothing happened anywhere'. The column operator (STEP 4d) is NOT covered here, ")
TEXT("having no amplitude to zero; its gate rides on check 1's bit-identity instead."),
NumOutside, NumTunnelSamples, 100.0f * (float)NumOutside / (float)NumTunnelSamples,
NumLeaked, NumInsideMoved));
if (NumOutside < NumTunnelSamples / 10)
{
AddWarning(FString::Printf(
TEXT("Only %d of %d samples are outside the gate, so this check barely asked its ")
TEXT("question. Deep rock is the common case in production -- a sampling set that ")
TEXT("almost never leaves the cave is not representative of what the gate skips."),
NumOutside, NumTunnelSamples));
}
if (NumInsideMoved == 0)
{
AddError(TEXT("Inside the gate, zeroing all eleven modifier amplitudes changed NOTHING. ")
TEXT("The gate check above is therefore vacuous, and so, probably, is a large ")
TEXT("part of check 1b. Suspect the params never reaching the operators."));
}
}
//=========================================================================
// 2. INVARIANCE DE FENÊTRE — le test qui compte le plus sur cet archétype
//=========================================================================
// `BuildChunkCache` porte la discipline à deux régions de `ARCHITECTURE §8.4` : c'est LE endroit
// du plugin où un cache mal clé produit une couture visible entre deux tuiles. La pile ajoute sa
// propre clé par-dessus (boîte + strate + seed + empreinte de params + version de layout), donc
// c'est cette clé-là que ce bloc met à l'épreuve : mêmes points, ordre mélangé, N threads.
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumTunnelSamples);
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumTunnelSamples, 4400 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(TEXT("the tunnel stack is window-invariant across order and threads"),
Impure.load(), 0);
}
//=========================================================================
// 3. LE CACHE NE PEUT PAS SERVIR LES PARAMS DU VOISIN
//=========================================================================
// La régression d'overhang du 2026-07-27 : deux piles dans la MÊME strate, au MÊME seed, ne
// différant QUE par des params, partageaient un cache `thread_local` dont la clé ignorait les
// params — et la seconde lisait les salles de la première. La pile clé donc aussi sur une
// empreinte CRC des params. Ce bloc le vérifie en ALTERNANT A, B, A, B au même point, le motif
// qui fait mentir une clé incomplète.
//
// ⚠️⚠️ HISTORIQUE, ET LE DÉNOUEMENT EST DANS LE PARAGRAPHE SUIVANT — À LIRE EN ENTIER.
// Ce bloc a été écrit quand `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed),
// **sans les params** : en alternance il rendait, pour B, les salles de A, donc l'original
// ÉCHOUAIT ce contrôle. Le comparer à lui ici n'aurait pas mesuré l'opérateur, ça aurait mesuré
// son bug — d'où le choix de comparer chaque pile à ELLE-MÊME évaluée seule.
//
// ✅ **CE N'ÉTAIT PAS QU'UN ARTEFACT DE TEST, ET C'EST MAINTENANT CORRIGÉ** (2026-07-28). Le
// soupçon écrit ici s'est confirmé : `GetGenerationParams` blende les params À L'INTÉRIEUR
// d'une strate (`Alpha` = f(chunk Z) en `Gradient`, le défaut), donc deux chunks de Z différents
// partageaient boîte XY, index de strate et seed ⇒ aucune reconstruction ⇒ le deuxième chunk
// évalué contre les salles du premier. Et comme l'ordre des workers décide lequel est « le
// premier », **deux pairs divergeaient depuis la même seed**, ce que §2.6.1 interdit.
// `GetDensityWithParams` prend désormais une empreinte de params et une `LayoutVersion`
// OBLIGATOIRES (calculées une fois par chunk côté production, `VF_FP` ici).
//
// ⚠️ ON GARDE POURTANT L'ORACLE « CHAQUE PILE CONTRE ELLE-MÊME », et ce n'est pas de la
// paresse : il teste la clé de la PILE, qui est une clé distincte de celle de l'original. Les
// faire dépendre l'une de l'autre remettrait exactement le couplage qu'on vient de défaire.
//
// The suspicion recorded here was CONFIRMED and is now fixed: the params fingerprint and layout
// version are required arguments. The self-comparison oracle stays, because it tests the STACK's
// key, which is a different key from the original's.
{
FStrateGenerationParams P2 = P;
P2.RoomSpacing = P.RoomSpacing * 0.6f; // une autre disposition de salles
P2.RoomDensity = FMath::Min(P.RoomDensity * 1.7f, 1.0f);
FVoxelOpStack Stack2;
VoxelDensityOps::BuildTunnelNetworkStack(Stack2, P2, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
Stack2.PrepareChunk(Ctx);
// Chaque pile compte 2 reconstructions de cache par point en alternance (elles partagent le
// `thread_local`), donc on reste modeste sur le nombre de sondes : `BuildChunkCache` est la
// fonction la plus chère du plugin.
const int32 Probe = FMath::Min(400, NumTunnelSamples);
TArray<float> SoloA, SoloB;
SoloA.SetNumUninitialized(Probe);
SoloB.SetNumUninitialized(Probe);
for (int32 i = 0; i < Probe; ++i)
{
SoloA[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
for (int32 i = 0; i < Probe; ++i)
{
SoloB[i] = Stack2.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
int32 NumWrong = 0, NumActuallyDifferent = 0;
for (int32 i = 0; i < Probe; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float GotA = Stack.EvalMC(X, Y, Z);
const float GotB = Stack2.EvalMC(X, Y, Z);
if (!BitEqual(GotA, SoloA[i]) || !BitEqual(GotB, SoloB[i])) { ++NumWrong; }
if (!BitEqual(SoloA[i], SoloB[i])) { ++NumActuallyDifferent; }
}
TestEqual(TEXT("two tunnel stacks with different params never serve each other's rooms"),
NumWrong, 0);
AddInfo(FString::Printf(
TEXT("Params-fingerprint check: %d of %d probe points (%.1f%%) genuinely differ between ")
TEXT("the two param sets, and %d were served wrong under A/B interleaving. The FIRST ")
TEXT("number is the check's real strength: only those points could ever reveal a stale ")
TEXT("cache, so it is a count of how many times the question was actually asked."),
NumActuallyDifferent, Probe, 100.0f * (float)NumActuallyDifferent / (float)Probe,
NumWrong));
// Le premier run a donné 3 sur 400 (0,75 %) : 397 sondes ne pouvaient RIEN distinguer. Même
// correction que la couverture de grotte — un seuil en fraction, pas « non nul ».
if ((float)NumActuallyDifferent < 0.05f * (float)Probe)
{
AddWarning(FString::Printf(
TEXT("Only %d of %d probe points differ between the two param sets (want >= 5%%), so ")
TEXT("this check asked its question %d times, not %d. A stale cache would go ")
TEXT("unnoticed at every other point. Make P2 differ more, or probe nearer the ")
TEXT("network."),
NumActuallyDifferent, Probe, NumActuallyDifferent, Probe));
}
}
//=========================================================================
// 3b. LES PITS ET LES CHEMINÉES ONT-ILS RÉELLEMENT CONTRIBUÉ ?
//=========================================================================
// `§2` désigne ces deux boucles comme « le plus retors de toute la décomposition » : elles
// écrivent le MÊME canal SDF que le graphe de salles mais à des coordonnées NON warpées. Les
// 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 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`.
//
// 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.)
{
int32 StrateIdx = 0;
{
const int32 QZ = FMath::FloorToInt((float)((TopVoxelZ + BottomVoxelZ) / 2) / (float)CHUNK_SIZE);
StrateIdx = World.StrateManager->GetStrateIndex(
((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
}
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 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("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));
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);
// ⚠️ CE GARDE-FOU S'EST RETOURNÉ À L'ÉTAPE B4, ET C'EST LE SEUL MOYEN DE COUVRIR LES
// COLONNES. Il ERREUR-ait si la cuisson produisait des colonnes (l'étape A n'avait pas porté
// `STEP 4d`) ; il EXIGE maintenant qu'elle en produise. Il n'y a pas d'alternative par
// paramètre : ni la cuisson ni la boucle par voxel ne lisent `FStrateGenerationParams::
// ColumnDensity`, donc aucune sonde de `FeatureProbes` ne peut éteindre les colonnes — et
// une pile bâtie sur un pool SANS op `Column` partagerait le cache `thread_local` (le pool
// n'est pas dans sa clé) et rendrait exactement la même densité, c'est-à-dire mentirait.
// Reste à demander à la cuisson ce qu'elle a cuit. Troisième application de la même leçon.
TestTrue(TEXT("the bake produced columns, so the STEP 4d loop has data to run on ")
TEXT("(no params probe can cover this one -- see the comment above)"),
TotalColumns > 0);
}
//=========================================================================
// 3c. L'OVERRIDE D'OP PAR SALLE A-T-IL EU L'OCCASION DE COMPTER ? (ÉTAPE C1)
//=========================================================================
// Le pool contient depuis C1 un op `Terrace` dont les valeurs (3.0 / 0.95 / 1.4) écrasent celles
// de la strate (6.0 / 0.6 / 0.5) sur les salles qui le tirent. Une pile qui ignorerait l'override
// rendrait donc une densité DIFFÉRENTE près de ces salles, et le contrôle 1 tomberait.
//
// ⚠️ MAIS SEULEMENT SI DE TELLES SALLES EXISTENT ET SI ON A ÉCHANTILLONNÉ PRÈS D'ELLES. C'est
// exactement le piège des pits, une quatrième fois : une équivalence verte prouverait alors que
// deux chemins s'accordent là où l'override ne s'applique pas. Il n'y a pas de sonde de params
// possible (le pool n'est pas dans la clé du cache), donc on interroge encore la STRUCTURE :
// (1) combien de salles portent un op `Terrace` ?
// (2) combien d'échantillons tombent dans le rayon d'influence d'une de ces salles ?
// Zéro à l'une ou l'autre ⇒ le contrôle 1 ne dit RIEN de C1, quelle que soit sa couleur.
//
// (2) est approximatif — il mesure en coordonnées NON warpées, alors que le voxel choisit sa
// salle en coordonnées warpées. C'est un compteur de COUVERTURE, pas un oracle : sur-estimer
// légèrement ne rend rien faux, ça rend seulement le seuil un peu généreux.
{
int32 StrateIdx = 0;
{
const int32 QZ = FMath::FloorToInt((float)((TopVoxelZ + BottomVoxelZ) / 2) / (float)CHUNK_SIZE);
StrateIdx = World.StrateManager->GetStrateIndex(
((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
}
const UVoxelStrateDefinition* Def = World.Definitions[FTestWorld::SlotTunnelNetwork].Get();
// UNE grande boîte couvrant tout le domaine échantillonné (chunks XY de -3 à +3), plutôt que
// 49 boîtes par chunk : les salles sont hachées par cellule, donc leurs positions ne
// dépendent pas du découpage — seule la décision de STOCKAGE change, et on ne lit ici que
// des centres et des ops.
const float Expansion = P.CaveWarpStrength + 2.0f;
const float BigMinX = -3.0f * CHUNK_SIZE - Expansion;
const float BigMinY = -3.0f * CHUNK_SIZE - Expansion;
const float BigMaxX = 4.0f * CHUNK_SIZE + Expansion;
const float BigMaxY = 4.0f * CHUNK_SIZE + Expansion;
FChunkSDFCache BigCache;
VoxelCaveMorphology::BuildChunkCache(
BigCache, BigMinX, BigMinY, BigMaxX, BigMaxY,
P, (uint32)World.Settings->Seed, StrateIdx, &Def->TerrainOperations);
TArray<const FCachedRoom*> TerraceRooms;
int32 NumRoomsWithOp = 0;
for (const FCachedRoom& R : BigCache.Rooms)
{
if (R.RoomOp == nullptr) { continue; }
++NumRoomsWithOp;
if (R.RoomOp->Type == EVoxelTerrainOpType::Terrace) { TerraceRooms.Add(&R); }
}
int32 NumSamplesNearTerrace = 0;
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
const FVector Pt = Points[i];
for (const FCachedRoom* R : TerraceRooms)
{
if ((float)FVector::DistSquared(Pt, R->Center) < R->CullRadiusSq)
{
++NumSamplesNearTerrace;
break;
}
}
}
AddInfo(FString::Printf(
TEXT("Per-room override coverage (stage C1): %d of %d baked rooms carry a terrain op, ")
TEXT("%d of them a Terrace op whose params (3.0/0.95/1.4) overwrite the strate's ")
TEXT("(6.0/0.6/0.5); %d of %d samples fall inside one of those rooms' influence radius. ")
TEXT("The bit-identity in check 1 means C1 works ONLY insofar as those last two numbers ")
TEXT("are non-zero -- otherwise it proves that two paths agree where the override never ")
TEXT("applies, which is what stage B already proved."),
NumRoomsWithOp, BigCache.Rooms.Num(), TerraceRooms.Num(),
NumSamplesNearTerrace, NumTunnelSamples));
TestTrue(TEXT("the bake rolled at least one Terrace op onto a room (stage C1's premise)"),
TerraceRooms.Num() > 0);
TestTrue(TEXT("at least one sample lands inside a Terrace-op room, so the per-room override ")
TEXT("is actually exercised by the equivalence above"),
NumSamplesNearTerrace > 0);
}
//=========================================================================
// 4. LE VERDICT DE BOÎTE — plus attendu nul, et CHAQUE VERDICT EST BRUTE-FORCÉ
//=========================================================================
// ⚠️ CE BLOC A CHANGÉ DE NATURE LE 2026-07-28, ET IL FAUT SAVOIR POURQUOI.
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que `FRoomGraphSource::EffectOverBox`
// rendait `Both` inconditionnellement : « zéro » était alors une description honnête de l'état
// du portage. Depuis que la source répond SPATIALEMENT, asserter zéro reviendrait à interdire
// le gain qu'on vient de construire — et pire, ça transformerait le test en gardien du bug.
//
// Ce qui le remplace n'est PAS « on enlève l'assertion » : c'est l'assertion qui compte
// vraiment, la SOUNDNESS. Un verdict faux ne se voit pas — pas de géométrie, **pas de
// collision** — jusqu'à ce qu'un joueur traverse le sol. Donc chaque tuile déclarée prouvée est
// ré-évaluée voxel par voxel, et le test échoue si UN seul échantillon contredit le verdict.
// Le nombre de tuiles prouvées, lui, est REPORTÉ, pas asserté : c'est une mesure, pas un
// contrat (la leçon « coverage is a number, not a boolean »).
//
// Was: assert zero proved. That was honest while the source answered Both unconditionally; it
// would now forbid the very gain this change makes. What replaces it is the assertion that
// actually matters — every proved tile is brute-forced voxel by voxel, because a false verdict
// means no geometry and NO COLLISION until a player falls through it.
// ⚠️ UNE SEULE DÉFINITION DU BALAYAGE, DEUX MONDES. Voir le commentaire d'appel plus bas :
// la densité de la fixture rend ce verdict STRUCTURELLEMENT impossible, donc mesurer sur elle
// seule ne dit rien de la production. Copier-coller le balayage aurait donné deux critères qui
// divergent ; c'est un paramètre, pas un doublon.
auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
const FVoxelOpContext& TCtx, const TCHAR* Label,
bool bZeroProvedIsExpected)
{
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
int32 NumBruteSamples = 0, NumViolations = 0;
float WorstViolation = 0.0f;
TMap<FString, int32> SolidKillerCounts;
int32 NumRoomKilled = 0, NumTilesAwayFromSpine = 0;
// ±320 voxels = 4 x RoomSpacing. Hors de la boucle : la ligne de rapport en a besoin, et
// une étendue d'échantillonnage qu'on ne peut pas citer est une étendue qu'on ne surveille pas.
const int32 SpanCells = 40;
const int32 SpanVoxelsReported = SpanCells * 8; // Extent = Step * Cells = 1 * 8
int32 TilesHitByRooms = 0, TilesHitByTunnels = 0, TilesHitByPits = 0, TilesHitByChimneys = 0;
int32 SumHitRooms = 0, SumNumRooms = 0, SumHitTunnels = 0, SumNumTunnels = 0;
int32 SumHitRoomsNoWarp = 0, SumHitTunnelsNoWarp = 0;
float LastWarpDilation = 0.0f;
FRandomStream Rng(97531);
for (int32 t = 0; t < 40; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
// ⚠️ L'ÉTENDUE XY ÉTAIT ±32 VOXELS, ET C'EST CE QUI RENDAIT CE BLOC INEXPLOITABLE.
// `RandRange(-4, 4) * 8` échantillonnait 40 tuiles dans un cube de ±32 voxels autour de
// (0,0) — c'est-à-dire l'endroit le PLUS creusé du monde entier, et de loin :
// • `RoomSpacing = 80`, donc ±32 ne couvre même pas la moitié d'UNE cellule de salle ;
// • `OriginRoomRadius = 20` garantit une grosse salle exactement à (0,0), de rayon de
// cull `max(20·1.5, 8) + 3·4 = 42` — qui avale la quasi-totalité de la fenêtre ;
// • la spine (0,0) descend précisément là.
// La mesure « 4.9 salles sur 7.2 atteignent la boîte » ne décrivait donc pas la densité
// de grottes du monde, elle décrivait le hub de la spine. Aucune conclusion sur la
// prouvabilité du roc profond ne pouvait sortir de cet échantillon.
//
// ⚠️ CE N'EST PAS « ÉLARGIR JUSQU'À CE QUE ÇA PASSE ». Le verdict de chaque tuile reste
// brute-forcé voxel par voxel juste en dessous : un échantillonneur plus large qui
// produirait un verdict FAUX échoue exactement comme avant. On corrige ce que la mesure
// REGARDE, pas ce qu'elle exige.
//
// The XY extent was ±32 voxels around (0,0) -- with RoomSpacing = 80 and a guaranteed
// OriginRoomRadius = 20 room at the origin, that samples the single most cave-dense spot
// in the world and says nothing about deep rock. Widening changes what the measurement
// LOOKS AT, not what it demands: every verdict is still brute-forced below.
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
// Combien de tuiles échappent vraiment au hub de la spine : sans ce compte, un futur
// resserrement de l'étendue redeviendrait invisible.
if (FMath::Square((float)Origin.X) + FMath::Square((float)Origin.Y)
> FMath::Square(3.0f * TP.OriginRoomRadius))
{
++NumTilesAwayFromSpine;
}
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
// ATTRIBUTION — le même pliage, mais il dit QUI tue chaque hypothèse. Le premier build
// de l'`EffectOverBox` spatial est revenu vert avec 0 tuile prouvée, et le rapport ne
// savait nommer aucun coupable : les deux causes que la mise en garde proposait étaient
// toutes les deux fausses, la vraie étant un troisième opérateur. On ne redevine pas.
int32 SolidKiller = INDEX_NONE, AirKiller = INDEX_NONE;
const EVoxelTileClass Verdict = S.ClassifyBoxAttributed(Box, TCtx, SolidKiller, AirKiller);
if (SolidKiller != INDEX_NONE)
{
const FString KillerName = S.GetOpDebugName(SolidKiller);
SolidKillerCounts.FindOrAdd(KillerName)++;
// VENTILATION PAR CLASSE DE PRIMITIVE. Quand c'est la source de salles qui tue,
// « les tuiles traversent une grotte » n'est pas une réponse : les salles, les
// tunnels, les pits et les cheminées ont chacun leur borne, de finesse très
// différente (une sphère englobante de capsule est un très mauvais tunnel). On lit
// ce que l'opérateur a RÉELLEMENT calculé plutôt que de rejouer le critère ici.
if (KillerName == TEXT("RoomGraphSource"))
{
const VoxelDensityOps::FRoomBoxDiagnostic D =
VoxelDensityOps::GetLastRoomBoxDiagnostic();
++NumRoomKilled;
if (D.HitRooms > 0) { ++TilesHitByRooms; }
if (D.HitTunnels > 0) { ++TilesHitByTunnels; }
if (D.HitPits > 0) { ++TilesHitByPits; }
if (D.HitChimneys > 0) { ++TilesHitByChimneys; }
SumHitRooms += D.HitRooms; SumNumRooms += D.NumRooms;
SumHitTunnels += D.HitTunnels; SumNumTunnels += D.NumTunnels;
SumHitRoomsNoWarp += D.HitRoomsNoWarp;
SumHitTunnelsNoWarp += D.HitTunnelsNoWarp;
LastWarpDilation = D.WarpDilation;
}
}
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved;
const bool bClaimSolid = (Verdict == EVoxelTileClass::AllSolid);
if (bClaimSolid) { ++NumSolid; } else { ++NumAir; }
// BRUTE FORCE — la boîte entière, pas un échantillonnage. `EvalMC` rend la convention
// du mesher (négatif = solide), donc « tout solide » veut dire qu'aucun échantillon
// n'est du côté air. On teste le SIGNE, c'est-à-dire l'existence d'une traversée
// d'isosurface : c'est exactement la propriété sur laquelle le mesher est sauté.
for (float Z = (float)Box.Min.Z; Z <= (float)Box.Max.Z; Z += 1.0f)
for (float Y = (float)Box.Min.Y; Y <= (float)Box.Max.Y; Y += 1.0f)
for (float X = (float)Box.Min.X; X <= (float)Box.Max.X; X += 1.0f)
{
const float D = S.EvalMC(X, Y, Z);
++NumBruteSamples;
const bool bViolates = bClaimSolid ? (D > 0.0f) : (D < 0.0f);
if (bViolates)
{
++NumViolations;
WorstViolation = FMath::Max(WorstViolation, FMath::Abs(D));
}
}
}
AddInfo(FString::Printf(
TEXT("[%s] Tile sampler: 40 tiles of 10 voxels, XY drawn from +/-%d voxels (= %.1f x ")
TEXT("RoomSpacing %.0f), Z across the strate; %d of 40 landed further than 3 x ")
TEXT("OriginRoomRadius from the (0,0) spine. THIS LINE EXISTS BECAUSE THE SAMPLER WAS ")
TEXT("THE BUG ONCE: it drew XY from +/-32 voxels, i.e. entirely inside the origin room's ")
TEXT("cull sphere, so every number below described the spine hub rather than the world. ")
TEXT("If the last count is low, nothing below says anything about deep rock."),
Label, SpanVoxelsReported, (float)SpanVoxelsReported / FMath::Max(TP.RoomSpacing, 1.0f),
TP.RoomSpacing, NumTilesAwayFromSpine));
AddInfo(FString::Printf(
TEXT("[%s] Box verdicts over 40 TunnelNetwork tiles: %d proved (%d AllSolid, %d AllAir), ")
TEXT("%d Mixed -- brute-forced over %d voxels, %d violations. This number was 0 proved / ")
TEXT("40 Mixed until FRoomGraphSource::EffectOverBox learned to answer spatially, and it ")
TEXT("is the single largest perf item of the whole plan (OPSTACK-DECOMPOSITION 0.2): a ")
TEXT("proved tile skips GenerateMesh entirely, so it trades one BuildChunkCache against ")
TEXT("30000+ density evaluations. Read the PROVED count as a measurement, never as a ")
TEXT("contract -- what is asserted below is that none of them is WRONG, because a false ")
TEXT("verdict leaves no geometry and no collision behind it."),
Label, NumProved, NumSolid, NumAir, NumMixed, NumBruteSamples, NumViolations));
// QUI TUE `AllSolid`, ET COMBIEN DE FOIS. Toujours imprimé, pas seulement en cas d'échec :
// c'est aussi la ligne qui dit, quand des tuiles SONT prouvées, ce qui bloque les autres.
{
SolidKillerCounts.ValueSort([](int32 A, int32 B) { return A > B; });
FString Breakdown;
for (const TPair<FString, int32>& Kv : SolidKillerCounts)
{
if (!Breakdown.IsEmpty()) { Breakdown += TEXT(", "); }
Breakdown += FString::Printf(TEXT("%s x%d"), *Kv.Key, Kv.Value);
}
if (Breakdown.IsEmpty()) { Breakdown = TEXT("nothing -- AllSolid survived every tile"); }
AddInfo(FString::Printf(
TEXT("[%s] AllSolid killed by: %s. Names the first operator to kill the ")
TEXT("hypothesis, counted per tile. (Why this line exists, and the wrong guesses ")
TEXT("that preceded it, live in OPSTACK-PROGRESS and are deliberately NOT ")
TEXT("repeated here: a diagnostic that carries narrative gets its live numbers ")
TEXT("read as history and its history read as live numbers.)"),
Label, *Breakdown));
if (NumRoomKilled > 0)
{
AddInfo(FString::Printf(
TEXT("[%s] ...and when RoomGraphSource is the killer (%d tiles), WHICH primitive class ")
TEXT("reaches the box: rooms %d, tunnels %d, pits %d, chimneys %d (tiles, not ")
TEXT("primitives -- a tile can be hit by several). Averages per killed tile: ")
TEXT("%.1f of %.1f rooms reach, %.1f of %.1f tunnels reach. ")
TEXT("WARP SHARE: the query box is dilated by +/-%.1f voxels per axis for the ")
TEXT("warped room/tunnel query; with that dilation set to ZERO the same tests ")
TEXT("would keep only %.1f rooms and %.1f tunnels. The gap between those ")
TEXT("pairs is blocking caused by MY BOX rather than by geometry -- the term ")
TEXT("that went unmeasured while three rounds of tightening happened around ")
TEXT("it. If the gap dominates, tighten the warp bound, not the primitives."),
Label, NumRoomKilled, TilesHitByRooms, TilesHitByTunnels, TilesHitByPits, TilesHitByChimneys,
(float)SumHitRooms / (float)NumRoomKilled, (float)SumNumRooms / (float)NumRoomKilled,
(float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled,
LastWarpDilation,
(float)SumHitRoomsNoWarp / (float)NumRoomKilled,
(float)SumHitTunnelsNoWarp / (float)NumRoomKilled));
}
}
if (NumProved == 0)
{
// ⚠️ UN AVERTISSEMENT QUI SE DÉCLENCHE À CHAQUE RUN ET VEUT DIRE « tout va bien »
// N'EST PAS UN AVERTISSEMENT — c'est du bruit qui apprend à ignorer les vrais.
// Sur la fixture dense, 0 prouvé est la SEULE réponse arithmétiquement possible
// (les sphères de cull couvrent ce monde 3,6x) : c'est une info. En production
// défauts, 0 prouvé serait une VRAIE régression (11 aujourd'hui) : ça reste un
// avertissement.
// A warning that fires every run and always means "this is fine" is noise that
// trains the reader to ignore warnings. Zero proved is the only possible answer on
// the dense fixture (info); on production defaults it would be a real regression
// from 11 (warning).
const FString ZeroMsg = FString::Printf(
TEXT("[%s] No tile was proved, so the brute force verified nothing -- it has no ")
TEXT("verdict to contradict. Do NOT re-derive the cause: read the two lines above, ")
TEXT("which name the operator and then the primitive class. ⚠️ On the DENSE ")
TEXT("FIXTURE this is the EXPECTED and correct result, not a defect: room cull ")
TEXT("radius (1.5R + 3*SDFBlendRadius, mean ~42) equals RoomSpacing 42 at 85%% ")
TEXT("occupancy, so cull spheres cover that world ~3.6x over and no box can be ")
TEXT("outside all of them. It is the 'production defaults' run that answers ")
TEXT("whether real worlds have skippable rock."),
Label);
if (bZeroProvedIsExpected) { AddInfo(ZeroMsg); }
else { AddWarning(ZeroMsg); }
}
TestEqual(FString::Printf(
TEXT("[%s] every proved TunnelNetwork tile survives brute force (worst ")
TEXT("|density| on the wrong side: %.9g)"), Label, WorstViolation),
NumViolations, 0);
};
//-------------------------------------------------------------------------
// LES DEUX MONDES, ET POURQUOI IL EN FAUT DEUX
//-------------------------------------------------------------------------
// ⚠️ LA FIXTURE REND CE VERDICT STRUCTURELLEMENT IMPOSSIBLE, ET CE N'EST PAS UN DÉFAUT DE LA
// FIXTURE. `EnableTunnelFeatures` densifie délibérément (`RoomSpacing` 80 → 42,
// `RoomDensity` 0.35 → 0.85) parce qu'aux défauts le premier run n'avait que 1,1 % des
// échantillons en grotte — l'équivalence comparait du roc plein à du roc plein. Cette
// densification est ce qui rend le contrôle 1 SIGNIFIANT.
//
// Mais elle est exactement ANTAGONISTE de la prouvabilité, et l'arithmétique le dit sans
// ambiguïté : le rayon de cull d'une salle vaut `max(R·1.5, R·HeightRatio) + 3·SDFBlendRadius`,
// soit `1.5R + 12` ⇒ entre 27 et 57 pour `R ∈ [10, 30]`, moyenne ≈ 42 — c'est-à-dire
// **exactement le pas du réseau**, à 85 % d'occupation. Des sphères de cull de rayon égal au pas
// du réseau recouvrent l'espace ~3,6 fois. **Aucune boîte de ce monde ne peut être hors de
// toutes les sphères de cull.** Le « 6.3 salles sur 8.3 atteignent la boîte » mesuré est
// exactement ça, et élargir l'échantillonneur n'y a rien changé (39 tuiles sur 40 étaient déjà
// loin de la spine, et le compte est resté 40 sur 40).
//
// Donc on mesure les DEUX : la fixture dense, où `0 prouvé` est le résultat CORRECT et
// informatif (le gain disparaît quand les grottes saturent), et un jeu de params aux DÉFAUTS
// DE PRODUCTION, qui est le monde dont la question « combien de tuiles peut-on sauter » parle
// réellement. Ce n'est pas « élargir jusqu'à ce que ça passe » : les deux sont rapportés, les
// deux sont brute-forcés, et le dense DOIT continuer à rendre ~0.
//
// Two worlds on purpose: the fixture is deliberately densified so the equivalence check means
// something, and that same densification makes tile-proving structurally impossible (room cull
// radius ~= the lattice spacing, at 85% occupancy). Both are measured and both are brute-forced;
// the dense one reporting ~0 is the correct answer, not a failure.
RunTileScan(Stack, P, Ctx, TEXT("dense fixture"), /*bZeroProvedIsExpected*/ true);
{
FStrateGenerationParams SparseP = P;
SparseP.RoomSpacing = 80.0f; // le défaut d'`UVoxelStrateDefinition`
SparseP.RoomDensity = 0.35f; // idem — voir `EnableTunnelFeatures`
FVoxelOpStack SparseStack;
VoxelDensityOps::BuildTunnelNetworkStack(SparseStack, SparseP, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
SparseStack.PrepareChunk(Ctx);
// ⚠️ Les deux piles partagent les caches `thread_local` de `FRoomGraphSource`. C'est VOULU,
// et c'est exactement ce que le contrôle 3 vérifie : l'empreinte de params est dans la clé,
// donc l'une ne peut pas se servir les salles de l'autre. Le jour où ce contrôle tombe,
// cette ligne-ci devient fausse en même temps — elles se surveillent mutuellement.
RunTileScan(SparseStack, SparseP, Ctx, TEXT("production defaults"), /*bZeroProvedIsExpected*/ false);
}
//=========================================================================
// 5. UNDERWATER — LE MÊME ROCHER, DEPUIS UN AUTRE SLOT (ÉTAPE C2)
//=========================================================================
// `OPSTACK-DECOMPOSITION §8` affirme qu'il n'y a **aucune différence de densité** entre
// `Underwater` et `TunnelNetwork` : `GetDensityAt` les met dans le MÊME `case`, et
// `WaterLevelRelative` n'est lu que par `UVoxelStrateManager` (nappe d'eau, côté rendu), jamais
// par la densité. Les deux affirmations ont été RELUES dans le code avant d'écrire ce contrôle,
// pas supposées — c'est la leçon « vérifier la prémisse » et elle a déjà été payée quatre fois.
//
// Ce que ce contrôle ajoute par-dessus, et qui n'est PAS tautologique : le slot Underwater est
// une AUTRE strate, donc un autre `StrateIndex`, donc un autre `StrateSeed` dans la cuisson et
// une autre entrée de la mémo d'index de strate. Il exerce ce que six chunks du slot 0 ne
// peuvent pas : que la pile suive le bon jeu de salles quand deux strates du même archétype
// coexistent dans le même monde.
{
int32 UWTop = 0, UWBottom = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotUnderwater, UWTop, UWBottom))
{
AddError(TEXT("The fixture layout has no Underwater slot, so stage C2 is untested. ")
TEXT("Check FTestWorld::Build's Archetypes[] against SlotUnderwater."));
}
else
{
const int32 UWMidChunkZ = ((UWTop + UWBottom) / 2) / CHUNK_SIZE;
FStrateGenerationParams UP =
World.StrateManager->GetGenerationParams(FIntVector(0, 0, UWMidChunkZ));
EnableTunnelFeatures(UP);
FVoxelOpStack UWStack;
VoxelDensityOps::BuildTunnelNetworkStack(UWStack, UP, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
FVoxelOpContext UWCtx;
UWCtx.Seed = (uint32)World.Settings->Seed;
UWCtx.LayoutVersion = World.StrateManager->GetLayoutVersion();
UWCtx.StrateTopWorldZ = UP.StrateTopWorldZ;
UWCtx.StrateBottomWorldZ = UP.StrateBottomWorldZ;
UWStack.PrepareChunk(UWCtx);
TestEqual(TEXT("the Underwater stack is the tunnel stack: same 19 ops"), UWStack.Num(), 19);
// 8 → 24 chunks, comme le balayage principal. Le premier run vert a rapporté **0 de 2000
// échantillons en grotte ouverte** là où le slot TunnelNetwork en avait 16,7 % : trop peu de
// grappes est l'une des explications possibles, la troncature de la division en est une
// autre, et le bloc de diagnostic plus bas dit laquelle.
const int32 UWChunks = 24, UWPerChunk = 250;
const int32 UWSamples = UWChunks * UWPerChunk;
TArray<FVector> UWPoints;
UWPoints.Reserve(UWSamples);
{
FRandomStream Rng(20260728);
const int32 CZ0 = FloorDivChunk(UWBottom);
const int32 CZ1 = FMath::Max(CZ0, FloorDivChunk(UWTop) - 1);
for (int32 c = 0; c < UWChunks; ++c)
{
const int32 CX = Rng.RandRange(-3, 3);
const int32 CY = Rng.RandRange(-3, 3);
const int32 CZ = Rng.RandRange(CZ0, CZ1);
for (int32 i = 0; i < UWPerChunk; ++i)
{
UWPoints.Add(FVector(
(float)(CX * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1)),
(float)(CY * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1)),
(float)FMath::Clamp(CZ * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1),
UWBottom, UWTop)));
}
}
}
const float UWInnerBot = UP.StrateBottomWorldZ + UP.BoundarySealThickness;
const float UWInnerTop = UP.StrateTopWorldZ - UP.BoundarySealThickness;
int32 UWDiff = 0, UWInCave = 0;
float UWWorst = 0.0f;
for (int32 i = 0; i < UWSamples; ++i)
{
const float X = (float)UWPoints[i].X, Y = (float)UWPoints[i].Y, Z = (float)UWPoints[i].Z;
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP, VF_FP(UP), 0);
const float New = UWStack.EvalMC(X, Y, Z);
if (Z > UWInnerBot && Z < UWInnerTop && Old >= 0.0f) { ++UWInCave; }
if (!BitEqual(Old, New))
{
++UWDiff;
UWWorst = FMath::Max(UWWorst, FMath::Abs(Old - New));
}
}
if (UWDiff == 0)
{
AddInfo(FString::Printf(
TEXT("Underwater (stage C2): bit-identical across %d samples in %d chunks of the ")
TEXT("Underwater slot, %d of them in open cave (%.1f%%). This is a DIFFERENT ")
TEXT("strate index from the TunnelNetwork slot, so it also exercises the ")
TEXT("strate-index memo and the per-strate room seed -- which six chunks of one ")
TEXT("slot cannot. No density difference between the two archetypes was found, ")
TEXT("which is what OPSTACK-DECOMPOSITION 8 predicts."),
UWSamples, UWChunks, UWInCave, 100.0f * (float)UWInCave / (float)UWSamples));
}
else
{
AddError(FString::Printf(
TEXT("Underwater (stage C2): %d of %d samples differ (largest |delta| %.9g). ")
TEXT("⚠️ READ THIS BEFORE FIXING CODE: if the TunnelNetwork equivalence above is ")
TEXT("GREEN and only this one fails, the operators are fine and the finding is a ")
TEXT("real DENSITY DIFFERENCE between Underwater and TunnelNetwork -- which ")
TEXT("would contradict OPSTACK-DECOMPOSITION 8 and is worth more written down ")
TEXT("than patched. The likeliest culprit is the strate index (the memo is keyed ")
TEXT("on chunk-Z and layout version) rather than anything archetype-specific."),
UWDiff, UWSamples, UWWorst));
}
//-----------------------------------------------------------------
// 5b. POURQUOI ? — ON INTERROGE, ON N'INFÈRE PAS (le premier run vert a dit 0,0 %)
//-----------------------------------------------------------------
// ⚠️ Trois causes possibles à « aucun échantillon en grotte ouverte », et elles se
// corrigent différemment. Plutôt que d'en choisir une au jugé — la faute que ce fichier
// documente trois fois déjà — chacune a désormais son propre nombre :
// (a) la cuisson n'a produit AUCUNE salle pour cet index de strate ⇒ `Rooms == 0` ;
// (b) les points échantillonnés ne sont pas dans l'INTÉRIEUR de la strate (bandes de
// seal, ou intervalle de chunks mal calculé) ⇒ `interior` faible ;
// (c) il y a des salles et l'intervalle est bon, mais elles ne croisent pas ces XY
// ⇒ `Rooms > 0` et `interior` élevé, et il faut alors élargir les XY.
{
const int32 UWStrateIdx = World.StrateManager->GetStrateIndex(
((float)FloorDivChunk((UWTop + UWBottom) / 2) + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
const UVoxelStrateDefinition* UWDef =
World.Definitions.IsValidIndex(FTestWorld::SlotUnderwater)
? World.Definitions[FTestWorld::SlotUnderwater].Get() : nullptr;
int32 UWRooms = 0, UWPits = 0, UWChims = 0, UWCols = 0;
if (UWDef)
{
const float Expansion = UP.CaveWarpStrength + 2.0f;
for (int32 c = 0; c < 6; ++c)
{
const float MinX = (c - 3) * (float)CHUNK_SIZE - Expansion;
const float MinY = (c - 3) * (float)CHUNK_SIZE - Expansion;
FChunkSDFCache UWProbe;
VoxelCaveMorphology::BuildChunkCache(
UWProbe, MinX, MinY, MinX + CHUNK_SIZE + 2.0f * Expansion,
MinY + CHUNK_SIZE + 2.0f * Expansion,
UP, (uint32)World.Settings->Seed, UWStrateIdx, &UWDef->TerrainOperations);
UWRooms += UWProbe.Rooms.Num();
UWPits += UWProbe.Pits.Num();
UWChims += UWProbe.Chimneys.Num();
UWCols += UWProbe.Columns.Num();
}
}
int32 UWInterior = 0;
for (int32 i = 0; i < UWSamples; ++i)
{
const float Z = (float)UWPoints[i].Z;
if (Z > UWInnerBot && Z < UWInnerTop) { ++UWInterior; }
}
AddInfo(FString::Printf(
TEXT("Underwater diagnosis: strate index %d, voxel Z range [%d, %d], seal-free ")
TEXT("interior (%.0f, %.0f); %d of %d samples are inside that interior; the bake ")
TEXT("produced %d rooms, %d pits, %d chimneys, %d columns over 6 search boxes. ")
TEXT("Read it as: 0 rooms means the bake, a low interior count means the sampled ")
TEXT("Z range, and rooms>0 with a high interior count means the XY spread."),
UWStrateIdx, UWBottom, UWTop, UWInnerBot, UWInnerTop,
UWInterior, UWSamples, UWRooms, UWPits, UWChims, UWCols));
if (UWInCave == 0)
{
AddWarning(FString::Printf(
TEXT("No Underwater sample landed in open cave (%d in the seal-free interior, ")
TEXT("%d rooms baked), so check 5 compared solid rock to solid rock and proves ")
TEXT("almost nothing about this slot. Same failure mode as the 1.1%% run of ")
TEXT("stage A -- and the equivalence being bit-identical is NOT evidence ")
TEXT("against it, it is exactly what two agreeing voids look like. The ")
TEXT("diagnosis line above says which of the three causes it is."),
UWInterior, UWRooms));
}
}
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS