diff --git a/CODEMAP.md b/CODEMAP.md index f55b270..42c4778 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -124,8 +124,15 @@ in its per-chunk refetch block and evaluates it *instead of* the `switch` only w archetype in the ported list, which is now **all 8 of 8**: Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater. A strate that has not ticked the box still takes the `switch`, unchanged — the flag is the only thing that switches paths. -**`ClassifyTile` is NOT wired** — still hand-written guards, not `ClassifyBox`. That is the next -piece, and it is where the measured tile-skipping turns into frames. +**`ClassifyTile` IS wired now**, for CAVE archetypes only and behind the same per-strate opt-in: +where it used to `return Mixed` without a call, it builds the strate's stack through the *same* +factory `GetDensityAt` uses (`VF_BuildOpStackForChunk`) and folds `ClassifyBox`. SurfaceWorld and +bedrock gaps keep their hand-written proofs — the exact-lattice column test is better than any box +bound. Guards, all failing to `Mixed`: one cave slot per tile, no mixed cave/surface/gap tile, the +opt-in true on *every* chunk the box touches, the params **bit-identical** across every chunk coord +the box touches (blended transition bands make one stack unable to represent the tile — `AUDIT §C2`), +a 27-chunk-coord cap, and the disturbances folded in by hand since they are applied after the stack. +Brute-forced end to end by `VoxelForge.OpStack.ClassifyTileSoundness`. ⛔ Never run both paths in one world. **Comparing them IS legitimate now** — the ~1 ULP residue of AUDIT §C10 is gone since `FPSemantics = Precise`, and all eight equivalence tests compare bit for bit. They are port-correctness oracles, not fidelity checks: the acceptance bar is §2.6.1 (same seed @@ -259,7 +266,8 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). | **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. | | `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. | | `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. | -| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. | +| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. | +| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. | | `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. | | `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. | | `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. | diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp index 6dcdbbc..353c839 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp @@ -233,6 +233,169 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters) return true; } +//============================================================================= +// LE CHEMIN PILE D'OPÉRATEURS DE ClassifyTile — MÊME FORCE BRUTE, MONDE OPT-IN +//============================================================================= +// `ClassifyTile` rendait `Mixed` sans appel pour tout archétype de CAVE. Il consulte désormais +// `FVoxelOpStack::ClassifyBox` quand la strate a coché `bUseOperatorStack` — donc **un tout nouveau +// chemin peut faire sauter le maillage d'une tuile**, et son erreur est un TROU : pas de triangles, +// pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers. +// +// Ce test est le même oracle par force brute que `ClassifyTileSoundness`, sur un monde dont TOUTES +// les strates ont coché la case. Il ne vérifie pas le pliage (c'est `BoxVerdictFold`) ni les +// opérateurs (ce sont les huit tests d'équivalence) : il vérifie le **câblage** — que la pile +// interrogée par le classifieur est bien celle qui produit la densité, params, drapeau et +// disturbances compris. +// +// ⚠️ LE COMPTEUR À LIRE EN PREMIER est le nombre de tuiles réellement brute-forcées. Un run vert +// avec zéro verdict non-Mixed ne prouverait RIEN — exactement le piège que ce fichier documente +// depuis sa première version, et la raison pour laquelle l'absence de verdict est une ERREUR ici. +// +// Same brute-force oracle as ClassifyTileSoundness, on a world where every strate has opted in. +// It checks the WIRING, not the fold and not the operators. + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeOpStackClassifyTileTest, + "VoxelForge.OpStack.ClassifyTileSoundness", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeOpStackClassifyTileTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + // ⚠️ `bUseOperatorStack = true` sur toutes les strates : c'est LE point du test. La fixture + // donne à ce monde une `LayoutVersion` unique dans le processus, sans quoi les caches par chunk + // de `GetDensityAt` — dont `CP_UseOpStack` — pourraient encore porter ceux d'un autre test. + FTestWorld World; + World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + const UVoxelGenerator* Gen = World.Generator.Get(); + + FRandomStream Rng(20260728); + TArray ToVerify; + int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0; + + const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE; + const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE; + + // Tirage uniforme sur tout le layout, PAS biaisé vers SurfaceWorld comme l'autre test : ici ce + // sont précisément les strates de cave qui intéressent, puisque ce sont elles qui passent par le + // nouveau chemin. SurfaceWorld continue d'être prouvé par le code écrit à la main. + for (int32 t = 0; t < NumTilesScanned; ++t) + { + FTileSpec Spec; + Spec.Step = 1 << Rng.RandRange(0, 2); + Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16; + const int32 Extent = Spec.Step * Spec.Cells; + + auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; }; + const int32 LoTile = FloorDiv(BottomVoxelZ, Extent); + const int32 HiTile = FMath::Max(LoTile, FloorDiv(TopVoxelZ, Extent)); + + Spec.Origin = FIntVector( + Rng.RandRange(-4, 4) * Extent, + Rng.RandRange(-4, 4) * Extent, + Rng.RandRange(LoTile, HiTile) * Extent); + + const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells); + switch (Verdict) + { + case EVoxelTileClass::Mixed: ++NumMixed; break; + case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break; + case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break; + } + } + + AddInfo(FString::Printf( + TEXT("ClassifyTile ON THE OPERATOR-STACK PATH, %d scanned tiles: Mixed %d, AllSolid %d, ") + TEXT("AllAir %d (brute-forcing %d). Compare with VoxelForge.Determinism.ClassifyTileSoundness, ") + TEXT("which runs the SAME scan on a world that has NOT opted in: every verdict beyond what ") + TEXT("that test reports is a tile the mesher now skips and did not before. That difference ") + TEXT("IS the T1.d prize OPSTACK-PLAN has been aiming at -- and every one of those tiles is a ") + TEXT("hole if the wiring is wrong, which is what the brute force below is for."), + NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num())); + + if (ToVerify.Num() == 0) + { + AddError(TEXT("VACUOUS: not one tile got a non-Mixed verdict on the operator-stack path, so ") + TEXT("this test verified NOTHING about the new wiring. Either no strate actually ") + TEXT("opted in (check FTestWorld::Build's bUseOperatorStack), or every guard in the ") + TEXT("cave branch of ClassifyTile bailed to Mixed -- the params-identical check and ") + TEXT("the 27-chunk-coord cap are the likeliest. Do NOT read a green run as proof.")); + return false; + } + + int32 NumHoles = 0; + for (const FTileSpec& Spec : ToVerify) + { + const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells); + if (Verdict == EVoxelTileClass::Mixed) { continue; } + + const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE); + const int32 GridDim = CPA + 1; + const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); + + bool bTileBad = false; + for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz) + for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy) + for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx) + { + const float X = (float)(Spec.Origin.X + gx * Spec.Step); + const float Y = (float)(Spec.Origin.Y + gy * Spec.Step); + const float Z = (float)(Spec.Origin.Z + gz * Spec.Step); + const float D = Gen->GetDensityAt(X, Y, Z); + + const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f); + if (!bAgrees) + { + bTileBad = true; + ++NumHoles; + AddError(FString::Printf( + TEXT("HOLE ON THE OPERATOR-STACK PATH: ClassifyTile said %s for tile (%d,%d,%d) ") + TEXT("Step=%d Cells=%d, but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s ") + TEXT("side. Check, in order: (1) does GetDensityAt for this chunk actually take ") + TEXT("the stack (CP_UseOpStack), or did the classifier judge a field the mesher ") + TEXT("will not produce; (2) the params-identical check -- a blended transition ") + TEXT("band means one stack cannot represent the whole tile (AUDIT C2); (3) the ") + TEXT("disturbance fold, since disturbances are applied AFTER the stack and are ") + TEXT("not part of it; (4) an operator's EffectOverBox claiming Identity where it ") + TEXT("can act -- the per-room op override can ENABLE a modifier the strate had ") + TEXT("switched off, which makes a box bound too optimistic."), + bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), + Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells, + X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID"))); + } + } + } + + TestEqual(TEXT("no tile was classified uniform on the operator-stack path while containing a ") + TEXT("surface (a false verdict is a hole)"), NumHoles, 0); + + // Même contrôle de stabilité que sur l'autre chemin : la pile est reconstruite à chaque appel, + // et `FRoomGraphSource` partage un cache `thread_local` avec le chemin densité — deux appels + // successifs doivent malgré tout rendre le même verdict. + for (const FTileSpec& Spec : ToVerify) + { + const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells); + const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells); + if (A != B) + { + AddError(FString::Printf( + TEXT("UNSTABLE VERDICT on the operator-stack path at tile (%d,%d,%d) Step=%d: %d vs ") + TEXT("%d. The classifier builds a fresh stack per call, so a difference means an ") + TEXT("operator is reading thread_local state the density path mutates."), + Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B)); + } + } + + return true; +} + //============================================================================= // LE FOLD DE LA PILE D'OPÉRATEURS / the op-stack fold //============================================================================= diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h index 518b1bd..79b803d 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h +++ b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h @@ -73,7 +73,7 @@ namespace VoxelForgeTest * dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones * (up to 2e9) to prove it stays that way. */ - void Build(int32 InSeed = 1337, int32 InGapChunks = 2) + void Build(int32 InSeed = 1337, int32 InGapChunks = 2, bool bUseOperatorStack = false) { Settings = TStrongObjectPtr( NewObject(GetTransientPackage(), NAME_None, RF_Transient)); @@ -104,6 +104,10 @@ namespace VoxelForgeTest GetTransientPackage(), NAME_None, RF_Transient); Def->GeneratorType = Archetypes[i]; Def->StrateHeightInChunks = 4; + // L'OPT-IN de la pile d'opérateurs. Faux par défaut : les treize tests existants + // doivent continuer à exercer le `switch`, qui reste le comportement de référence. + // Seul le test de solidité de ClassifyTie côté pile le passe à vrai. + Def->bUseOperatorStack = bUseOperatorStack; // Hard transitions: param blending across a boundary would make "which archetype // owns this chunk" ambiguous, and these tests want an unambiguous mapping. Def->TransitionType = EVoxelStrateTransition::Hard; @@ -117,7 +121,36 @@ namespace VoxelForgeTest StrateManager = TStrongObjectPtr( NewObject(GetTransientPackage(), NAME_None, RF_Transient)); - StrateManager->Initialize(Settings.Get(), Settings->Seed); + + //================================================================= + // ⚠️ CHAQUE MONDE DE TEST OBTIENT UNE `LayoutVersion` UNIQUE DANS LE PROCESSUS + //================================================================= + // Ce n'est pas de la cosmétique, c'est une CONTAMINATION CROISÉE réelle entre tests, et + // elle n'était jusqu'ici masquée que par un accident. + // + // `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs + // rendaient tous les deux **1**. Or les caches par chunk de `GetDensityAt` sont clés sur + // `(ChunkCoord, LayoutVersion)` : deux mondes différents, même version, même chunk ⇒ le + // second se voit servir les params — ET le drapeau `CP_UseOpStack` — du premier. + // Personne ne l'a vu parce que `bUseOperatorStack` valait false partout : les deux + // mondes étaient d'accord par défaut. Le premier monde qui coche la case fait tomber + // cette coïncidence, dans les DEUX sens (il contamine, et il est contaminé). + // + // Un compteur de processus donne à chaque monde une version distincte, donc tout cache + // survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe + // (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler + // ne change pas le layout — seulement le compteur. + // + // Each test world gets a process-unique LayoutVersion. Two worlds both reporting 1 made + // GetDensityAt's per-chunk caches serve the previous world's params — and its + // CP_UseOpStack flag — for the same chunk coord. Invisible while every world agreed that + // the flag was false. + static int32 GWorldSerial = 0; + const int32 Bumps = ++GWorldSerial; + for (int32 b = 0; b < Bumps; ++b) + { + StrateManager->Initialize(Settings.Get(), Settings->Seed); + } DiffLayer = TStrongObjectPtr( NewObject(GetTransientPackage(), NAME_None, RF_Transient)); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 2c278b9..9d1b6f4 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -423,6 +423,124 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z, } } +//============================================================================= +// LE MAPPING « ARCHÉTYPE → PILE D'OPÉRATEURS » — UNE SEULE DÉFINITION +//============================================================================= +// ⚠️ EXTRAIT DE `GetDensityAt` PARCE QUE `ClassifyTile` EN A BESOIN AUSSI, ET QU'UNE DEUXIÈME COPIE +// SERAIT LA PIRE FORME DE BUG DISPONIBLE ICI. +// +// `ClassifyTile` décide si une tuile est maillée DU TOUT. Si son verdict venait d'une pile +// construite autrement que celle qui produit la densité — ne serait-ce qu'un paramètre de +// construction différent — la tuile serait sautée sur la foi d'un champ qui n'est pas celui que le +// mesher aurait vu. C'est-à-dire un TROU : pas de géométrie, pas de collision, invisible. +// Un commentaire « garder les deux en phase » n'aurait pas suffi ; il fallait qu'il n'y en ait +// qu'une. +// +// Les params ne sont PAS cherchés ici : les deux appelants les ont déjà (le chemin densité les tient +// dans ses `CP_*`, le classifieur les cherche pour son slot). On ne passe que des pointeurs. +// +// ONE definition of the archetype → stack mapping, because ClassifyTile decides whether a tile is +// meshed at all: a verdict from a differently-built stack would be a hole. Params are passed in, +// never fetched here — both callers already have them. +namespace +{ + struct FVoxelStackParamRefs + { + const FSlabGenerationParams* Slab = nullptr; + const FMazeGenerationParams* Maze = nullptr; + const FVerticalShaftParams* Vert = nullptr; + const FFloatingIslandParams* Float = nullptr; + const FStrateGenerationParams* Tunnel = nullptr; + + // SurfaceWorld uniquement. `Surface == nullptr` ⇒ la fabrique REFUSE cet archétype, ce qui + // est exactement ce que veut `ClassifyTile` : il prouve SurfaceWorld lui-même, sur le + // treillis exact du mesher, et n'a aucune raison de passer par la pile pour ça. + const FSurfaceGenerationParams* Surface = nullptr; + const TArray* SurfaceBiomeParams = nullptr; + TUniquePtr BiomeField; + }; + + /** + * Construit la pile de cet archétype dans `OutStack` et remplit les bornes Z de `OutCtx`. + * + * @return false quand la pile NE DOIT PAS être utilisée — archétype non porté, params absents, + * ou **strate dégénérée**. Ce dernier cas n'est pas de la prudence : cinq fonctions + * d'archétype court-circuitent sur `return 1.0f` (= air) quand la hauteur est nulle, + * et la pile n'a pas cet early-out, par conception. L'appelant retombe sur le `switch`, + * qui EST le comportement de référence. (`GetDensityWithParams`, lui, n'a aucun + * early-out de ce genre : TunnelNetwork/Underwater n'ont donc pas cette garde.) + */ + bool VF_BuildOpStackForChunk(ECaveGeneratorType Type, FVoxelStackParamRefs& Refs, + int32 Seed, float SpineRadius, const UVoxelStrateManager* SM, + FVoxelOpStack& OutStack, FVoxelOpContext& OutCtx) + { + switch (Type) + { + case ECaveGeneratorType::Maze: + if (!Refs.Maze) { return false; } + if (Refs.Maze->StrateTopWorldZ - Refs.Maze->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Maze->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Maze->StrateBottomWorldZ; + VoxelDensityOps::BuildMazeStack(OutStack, *Refs.Maze, Seed, SpineRadius, SM); + return true; + + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + // UN SEUL cas pour les deux, comme le `switch` de production : `GetSlabDensity` ne les + // distingue pas non plus. Voir BuildSlabStack. + if (!Refs.Slab) { return false; } + if (Refs.Slab->StrateTopWorldZ - Refs.Slab->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Slab->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Slab->StrateBottomWorldZ; + VoxelDensityOps::BuildSlabStack(OutStack, *Refs.Slab, Seed, SpineRadius, SM); + return true; + + case ECaveGeneratorType::SurfaceWorld: + if (!Refs.Surface) { return false; } + if (Refs.Surface->StrateTopWorldZ - Refs.Surface->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Surface->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Surface->StrateBottomWorldZ; + VoxelDensityOps::BuildSurfaceStack( + OutStack, *Refs.Surface, Seed, SpineRadius, SM, + Refs.SurfaceBiomeParams ? *Refs.SurfaceBiomeParams : TArray(), + MoveTemp(Refs.BiomeField)); + return true; + + case ECaveGeneratorType::VerticalShafts: + if (!Refs.Vert) { return false; } + if (Refs.Vert->StrateTopWorldZ - Refs.Vert->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Vert->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Vert->StrateBottomWorldZ; + VoxelDensityOps::BuildVerticalShaftStack(OutStack, *Refs.Vert, Seed, SpineRadius, SM); + return true; + + case ECaveGeneratorType::FloatingIslands: + if (!Refs.Float) { return false; } + if (Refs.Float->StrateTopWorldZ - Refs.Float->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Float->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Float->StrateBottomWorldZ; + VoxelDensityOps::BuildFloatingIslandStack(OutStack, *Refs.Float, Seed, SpineRadius, SM); + return true; + + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + // Underwater EST TunnelNetwork plus un drapeau d'eau consommé côté rendu. Pas de garde + // de strate dégénérée : `GetDensityWithParams` n'a pas d'early-out à reproduire. + if (!Refs.Tunnel) { return false; } + OutCtx.StrateTopWorldZ = Refs.Tunnel->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Tunnel->StrateBottomWorldZ; + VoxelDensityOps::BuildTunnelNetworkStack(OutStack, *Refs.Tunnel, Seed, SpineRadius, SM); + return true; + + default: + // `UsesOperatorStackForChunk` ne rend true que pour les archétypes portés (les 8), donc + // on ne devrait jamais arriver ici. Si ça arrive : retomber sur le `switch` plutôt que + // générer du vide — un monde faux est pire qu'un monde non porté. + return false; + } + } +} + void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) { // Seul le seed est copié ici. Tout le reste (params de cave, transitions, @@ -537,124 +655,35 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co OpCtx.Seed = (uint32)Seed; OpCtx.LayoutVersion = LayoutVersion; - switch (CP_GenType) + // ⚠️ LE MAPPING VIT DANS `VF_BuildOpStackForChunk` (haut de ce fichier) ET NULLE + // PART AILLEURS — `ClassifyTile` appelle la MÊME fabrique. Un verdict de tuile issu + // d'une pile construite autrement serait un trou. Ici on ne fait que fournir les + // params déjà cherchés juste au-dessus. + FVoxelStackParamRefs Refs; + Refs.Slab = &CP_Slab; + Refs.Maze = &CP_Maze; + Refs.Vert = &CP_Vert; + Refs.Float = &CP_Float; + Refs.Tunnel = &CP_Tunnel; + + // SurfaceWorld : le champ de biomes est fabriqué ICI, du côté qui connaît le + // générateur, et TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField`, + // ce qui lui permet de devenir un asset en Phase 3 sans traîner le générateur. + TArray PerBiome; + if (CP_GenType == ECaveGeneratorType::SurfaceWorld) { - case ECaveGeneratorType::Maze: - // GARDE DE STRATE DÉGÉNÉRÉE : GetMazeDensity court-circuite sur `return 1.0f` - // (= air) quand la hauteur est nulle ou négative ; cette garde appartient à la - // fonction d'archétype et la pile n'en a pas, par conception. Sans ce test, une - // strate dégénérée donnerait de l'air sur un chemin et de la géométrie sur - // l'autre. On retombe sur le `switch`, qui EST le comportement de référence. - // Degenerate-strate guard: the archetype early-outs to air, the stack has no - // such early-out by design. Fall back to the switch, which is the reference. - if (CP_Maze.StrateTopWorldZ - CP_Maze.StrateBottomWorldZ <= 0.0f) - { - CP_UseOpStack = false; - break; - } - OpCtx.StrateTopWorldZ = CP_Maze.StrateTopWorldZ; - OpCtx.StrateBottomWorldZ = CP_Maze.StrateBottomWorldZ; - VoxelDensityOps::BuildMazeStack(CP_OpStack, CP_Maze, Seed, - OriginSpineRadius, StrateManager); - break; - - case ECaveGeneratorType::FlatPlain: - case ECaveGeneratorType::CrystalChamber: - // UN SEUL cas pour les deux, comme le `switch` de production juste en dessous : - // `GetSlabDensity` ne les distingue pas non plus. Voir BuildSlabStack. - // Même garde de strate dégénérée : GetSlabDensity court-circuite sur `1.0f`. - if (CP_Slab.StrateTopWorldZ - CP_Slab.StrateBottomWorldZ <= 0.0f) - { - CP_UseOpStack = false; - break; - } - OpCtx.StrateTopWorldZ = CP_Slab.StrateTopWorldZ; - OpCtx.StrateBottomWorldZ = CP_Slab.StrateBottomWorldZ; - VoxelDensityOps::BuildSlabStack(CP_OpStack, CP_Slab, Seed, - OriginSpineRadius, StrateManager); - break; - - case ECaveGeneratorType::SurfaceWorld: - { - if (CP_Surface.StrateTopWorldZ - CP_Surface.StrateBottomWorldZ <= 0.0f) - { - CP_UseOpStack = false; - break; - } - OpCtx.StrateTopWorldZ = CP_Surface.StrateTopWorldZ; - OpCtx.StrateBottomWorldZ = CP_Surface.StrateBottomWorldZ; - - // Le champ de biomes est fabriqué ICI, du côté qui connaît le générateur, et - // TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField` : c'est ce - // qui lui permet de devenir un asset en Phase 3 sans traîner le générateur. - // Built here, on the side that knows the generator, and handed to the stack. - TUniquePtr Field; - TArray PerBiome; + Refs.Surface = &CP_Surface; if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0) { PerBiome = CP_SurfaceBiomeParams; - Field = MakeUnique( + Refs.SurfaceBiomeParams = &PerBiome; + Refs.BiomeField = MakeUnique( this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z); } - - VoxelDensityOps::BuildSurfaceStack(CP_OpStack, CP_Surface, Seed, - OriginSpineRadius, StrateManager, - PerBiome, MoveTemp(Field)); - break; } - case ECaveGeneratorType::VerticalShafts: - if (CP_Vert.StrateTopWorldZ - CP_Vert.StrateBottomWorldZ <= 0.0f) - { - CP_UseOpStack = false; - break; - } - OpCtx.StrateTopWorldZ = CP_Vert.StrateTopWorldZ; - OpCtx.StrateBottomWorldZ = CP_Vert.StrateBottomWorldZ; - VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, Seed, - OriginSpineRadius, StrateManager); - break; - - case ECaveGeneratorType::FloatingIslands: - // Même garde de strate dégénérée : GetFloatingIslandDensity court-circuite sur - // `return 1.0f` (= air) quand la hauteur est nulle ou négative. - if (CP_Float.StrateTopWorldZ - CP_Float.StrateBottomWorldZ <= 0.0f) - { - CP_UseOpStack = false; - break; - } - OpCtx.StrateTopWorldZ = CP_Float.StrateTopWorldZ; - OpCtx.StrateBottomWorldZ = CP_Float.StrateBottomWorldZ; - VoxelDensityOps::BuildFloatingIslandStack(CP_OpStack, CP_Float, Seed, - OriginSpineRadius, StrateManager); - break; - - case ECaveGeneratorType::Underwater: - case ECaveGeneratorType::TunnelNetwork: - // ⚠️ UN SEUL CAS POUR LES DEUX, exactement comme le `switch` de production - // vingt lignes plus bas : `GetDensityAt` route déjà `Underwater` vers - // `GetDensityWithParams`, et `WaterLevelRelative` n'est lu que par - // `UVoxelStrateManager` (côté rendu / requête), JAMAIS par la densité — vérifié - // par recherche, pas supposé. Underwater EST TunnelNetwork plus un drapeau d'eau. - // - // ⚠️ PAS DE GARDE DE STRATE DÉGÉNÉRÉE ICI, et c'est une différence RÉELLE avec - // les cinq cas au-dessus : eux la portent parce que leur fonction d'archétype - // court-circuite sur `return 1.0f` quand la hauteur est nulle. Lu ligne à ligne : - // `GetDensityWithParams` n'a AUCUN early-out de ce genre. Ajouter la garde ici - // ferait diverger la pile du `switch` sur les strates dégénérées, dans le sens - // exact que la garde était censée empêcher ailleurs. - OpCtx.StrateTopWorldZ = CP_Tunnel.StrateTopWorldZ; - OpCtx.StrateBottomWorldZ = CP_Tunnel.StrateBottomWorldZ; - VoxelDensityOps::BuildTunnelNetworkStack(CP_OpStack, CP_Tunnel, Seed, - OriginSpineRadius, StrateManager); - break; - default: - // UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc - // on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch` - // plutôt que générer du vide — un monde faux est pire qu'un monde non porté. - CP_UseOpStack = false; - break; - } + CP_UseOpStack = VF_BuildOpStackForChunk(CP_GenType, Refs, Seed, OriginSpineRadius, + StrateManager, CP_OpStack, OpCtx); // Le test appelle PrepareChunk, pas la production : c'est exactement la divergence // qui rend un opérateur vert en test et faux en jeu. Les sept `PrepareChunk` @@ -2666,8 +2695,17 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in } int32 NumSlots = 0; + // ── T1.d GÉNÉRIQUE : la pile d'opérateurs classe les archétypes de CAVE ── + // Ces trois-là suivent le slot de cave que la tuile touche. Le verdict de la pile porte sur la + // BOÎTE ENTIÈRE, pas sur un z, donc il ne peut être calculé qu'après la boucle — et il n'est + // valable que si la tuile ne touche QUE ce slot-là (voir la garde `bAnyNonCave`). + int32 CaveBotChunkZ = INT32_MAX; // identité du slot de cave (borne basse, en chunks) + int32 CaveRepChunkZ = 0; + bool bAnyCave = false; + bool bAnyNonCave = false; // gap ou SurfaceWorld dans la même tuile ⇒ on abandonne + int32 MemoChunkZ = INT32_MAX; - int32 MemoCat = -1; // 0 = gap, 1 = surface + int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile d'opérateurs) int32 MemoSlotIdx = -1; for (int32 g = -1; g <= GridDim; ++g) { @@ -2680,6 +2718,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (StrateManager->IsGapChunk(CC)) { MemoCat = 0; + bAnyNonCave = true; } else if (StrateManager->GetGeneratorTypeForChunk(CC) == ECaveGeneratorType::SurfaceWorld) { @@ -2721,14 +2760,42 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false; } MemoCat = 1; + bAnyNonCave = true; } else { - return EVoxelTileClass::Mixed; // archétype cave / hors layout : pas prouvable en v1 + // ── ARCHÉTYPE DE CAVE ── Jusqu'ici : `return Mixed`, sans appel. Désormais on tente + // le pliage générique de la pile — mais SEULEMENT sous des conditions vérifiables, + // parce qu'un faux verdict ici est un trou (pas de géométrie, pas de collision). + // + // Condition 1 : la strate doit RÉELLEMENT être générée par la pile. Sinon on + // classerait un champ que le mesher ne produira pas. C'est le même drapeau, lu au + // même endroit, que `GetDensityAt`. + if (!StrateManager->UsesOperatorStackForChunk(CC)) { return EVoxelTileClass::Mixed; } + + // Condition 2 : un seul slot de cave par tuile. Deux slots = deux jeux de params = + // deux piles, et une pile ne sait répondre que pour SA strate. + int32 CaveTopCZ = 0, CaveBotCZ = 0; + if (!StrateManager->GetStrateChunkZBounds(ChunkZ, CaveTopCZ, CaveBotCZ)) + { + return EVoxelTileClass::Mixed; // hors layout + } + if (CaveBotChunkZ != INT32_MAX && CaveBotChunkZ != CaveBotCZ) + { + return EVoxelTileClass::Mixed; + } + CaveBotChunkZ = CaveBotCZ; + CaveRepChunkZ = ChunkZ; + bAnyCave = true; + MemoCat = 2; } } - if (MemoCat == 0) + if (MemoCat == 2) + { + // Rien par z : la pile répond pour la boîte entière, après la boucle. + } + else if (MemoCat == 0) { bCanAir = false; // bedrock du gap = solide (le carve des passages est déjà gardé) } @@ -2749,6 +2816,165 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (!bCanSolid && !bCanAir) return EVoxelTileClass::Mixed; } + //========================================================================= + // ── LE PLIAGE DE LA PILE D'OPÉRATEURS, POUR LES ARCHÉTYPES DE CAVE ── + //========================================================================= + // ⚠️ ERREUR ICI = TROU, PAS RÉGRESSION. Un verdict non-Mixed fait SAUTER `GenerateMesh` : pas + // de triangles, pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers. Toutes les + // gardes ci-dessous ÉCHOUENT EN MIXED ; aucune ne donne le bénéfice du doute. + // + // Le verdict lui-même ne peut pas être meilleur que la pile : `FVoxelOpStack::ClassifyBox` plie + // chaque opérateur avec `VF_FoldOp` et rend `Mixed` dès que les deux hypothèses meurent. Ce que + // ce bloc ajoute, c'est la vérification que la pile interrogée est bien CELLE QUI PRODUIRA LA + // DENSITÉ de cette tuile — même fabrique, mêmes params, même drapeau. + if (bAnyCave) + { + // Une tuile mi-cave mi-surface (ou mi-gap) n'est pas classable ainsi : la pile de cave ne + // répond que pour SA strate, et sa boîte couvrirait des z appartenant à une autre. + if (bAnyNonCave) { return EVoxelTileClass::Mixed; } + + const FIntVector RepCC(0, 0, CaveRepChunkZ); + const ECaveGeneratorType CaveType = StrateManager->GetGeneratorTypeForChunk(RepCC); + + //--------------------------------------------------------------------- + // ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA TUILE + //--------------------------------------------------------------------- + // `GetGenerationParams` et ses homologues BLENDENT les params dans les bandes de transition : + // `Alpha` dépend du chunk Z pour `Gradient`, et du chunk XY EN PLUS pour `Interleaved`. Deux + // chunks d'une même tuile peuvent donc porter des params différents — c'est le constat de + // `AUDIT §C2`, confirmé par lecture le 2026-07-28 — et UNE pile ne peut pas représenter DEUX + // champs. On construit donc les params pour CHAQUE coordonnée de chunk que la boîte touche et + // on exige qu'ils soient identiques bit à bit. + // + // `Memcmp` sur un POD : un padding différent ne peut produire qu'un FAUX ÉCART, donc un + // `Mixed` de trop. On se trompe du côté du CPU, jamais du côté du trou. + const int32 CX0 = FloorDivC(MinX, CHUNK_SIZE), CX1 = FloorDivC(MaxX, CHUNK_SIZE); + const int32 CY0 = FloorDivC(MinY, CHUNK_SIZE), CY1 = FloorDivC(MaxY, CHUNK_SIZE); + const int32 CZ0 = FloorDivC(MinZ, CHUNK_SIZE), CZ1 = FloorDivC(MaxZ, CHUNK_SIZE); + + // Une tuile très étalée (Step élevé) toucherait trop de chunks pour que cette vérification + // reste bon marché. Au-delà, `Mixed` — on renonce au gain, jamais à la sûreté. + const int64 NumChunkCoords = (int64)(CX1 - CX0 + 1) * (int64)(CY1 - CY0 + 1) * (int64)(CZ1 - CZ0 + 1); + if (NumChunkCoords > 27) { return EVoxelTileClass::Mixed; } + + FSlabGenerationParams TileSlab; + FMazeGenerationParams TileMaze; + FVerticalShaftParams TileVert; + FFloatingIslandParams TileFloat; + FStrateGenerationParams TileTunnel; + bool bFirst = true; + + for (int32 cz = CZ0; cz <= CZ1; ++cz) + for (int32 cy = CY0; cy <= CY1; ++cy) + for (int32 cx = CX0; cx <= CX1; ++cx) + { + const FIntVector CC(cx, cy, cz); + if (StrateManager->GetGeneratorTypeForChunk(CC) != CaveType) + { + return EVoxelTileClass::Mixed; // la boîte déborde sur un autre archétype + } + + // Le drapeau doit tenir sur TOUS les chunks de la boîte, pas seulement sur celui qui a + // déclenché la tentative : un seul chunk hors pile invaliderait le verdict. + if (!StrateManager->UsesOperatorStackForChunk(CC)) { return EVoxelTileClass::Mixed; } + + switch (CaveType) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + { + const FSlabGenerationParams Q = StrateManager->GetSlabParamsForChunk(CC); + if (bFirst) { TileSlab = Q; } + else if (FMemory::Memcmp(&Q, &TileSlab, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; } + break; + } + case ECaveGeneratorType::Maze: + { + const FMazeGenerationParams Q = StrateManager->GetMazeParamsForChunk(CC); + if (bFirst) { TileMaze = Q; } + else if (FMemory::Memcmp(&Q, &TileMaze, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; } + break; + } + case ECaveGeneratorType::VerticalShafts: + { + const FVerticalShaftParams Q = StrateManager->GetVerticalShaftParamsForChunk(CC); + if (bFirst) { TileVert = Q; } + else if (FMemory::Memcmp(&Q, &TileVert, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; } + break; + } + case ECaveGeneratorType::FloatingIslands: + { + const FFloatingIslandParams Q = StrateManager->GetFloatingIslandParamsForChunk(CC); + if (bFirst) { TileFloat = Q; } + else if (FMemory::Memcmp(&Q, &TileFloat, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; } + break; + } + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + { + const FStrateGenerationParams Q = StrateManager->GetGenerationParams(CC); + if (bFirst) { TileTunnel = Q; } + else if (FMemory::Memcmp(&Q, &TileTunnel, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; } + break; + } + default: + return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas arriver ici (bAnyNonCave) + } + + bFirst = false; + } + + //--------------------------------------------------------------------- + // La pile — construite par la MÊME fabrique que `GetDensityAt`. + //--------------------------------------------------------------------- + FVoxelStackParamRefs Refs; + Refs.Slab = &TileSlab; + Refs.Maze = &TileMaze; + Refs.Vert = &TileVert; + Refs.Float = &TileFloat; + Refs.Tunnel = &TileTunnel; + // `Refs.Surface` reste nul — la fabrique refuse alors SurfaceWorld, et c'est voulu : cette + // fonction le prouve elle-même sur le treillis EXACT du mesher, ce qu'aucune borne de boîte + // ne fera mieux. + + FVoxelOpContext OpCtx; + OpCtx.ChunkCoord = RepCC; + OpCtx.Seed = (uint32)Seed; + OpCtx.LayoutVersion = TC_LayoutVersion; + OpCtx.Step = Step; + + FVoxelOpStack TileStack; + if (!VF_BuildOpStackForChunk(CaveType, Refs, Seed, OriginSpineRadius, + StrateManager, TileStack, OpCtx)) + { + // Strate dégénérée ou archétype non porté : `GetDensityAt` retomberait sur le `switch`, + // donc la pile ne décrit pas ce que le mesher verra. Aucun verdict. + return EVoxelTileClass::Mixed; + } + TileStack.PrepareChunk(OpCtx); + + const FBox TileBox(FVector((float)MinX, (float)MinY, (float)MinZ), + FVector((float)MaxX, (float)MaxY, (float)MaxZ)); + const EVoxelTileClass StackVerdict = TileStack.ClassifyBox(TileBox, OpCtx); + if (StackVerdict == EVoxelTileClass::Mixed) { return EVoxelTileClass::Mixed; } + + if (StackVerdict == EVoxelTileClass::AllSolid) { bCanAir = false; } + else { bCanSolid = false; } + + //--------------------------------------------------------------------- + // ⚠️ LES DISTURBANCES NE SONT PAS DANS LA PILE (`OPSTACK-DECOMPOSITION §10.2`) : + // `GetDensityAt` les applique APRÈS, sur la densité déjà négatée. Un verdict qui les + // ignorerait serait faux exactement là où elles agissent. Mêmes inégalités que la branche + // SurfaceWorld plus haut, pour la même raison. + //--------------------------------------------------------------------- + const FStrateDisturbanceParams D = StrateManager->GetDisturbanceParamsForChunk(RepCC); + if (D.ChasmDensity > 0.0f) { bCanSolid = false; } + if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) { bCanAir = false; } + + if (bCanSolid == bCanAir) { return EVoxelTileClass::Mixed; } + return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; + } + // ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne // tranche chaque z intérieur : air côté MC ⇔ TerrainZ ≤ z ≤ CeilSurf (D = −interne ≥ 0, // cf. SurfaceDensityFromColumn ; spine/passages ne font QUE de l'air → gardés plus haut).