diff --git a/AUDIT-2026-07.md b/AUDIT-2026-07.md index 04baef8..5202e54 100644 --- a/AUDIT-2026-07.md +++ b/AUDIT-2026-07.md @@ -182,6 +182,30 @@ Seed) **are** seed-guarded, and the strate-index memo **is** version-guarded (`S `ChangeSeed` mostly survives; **live-edit is where this bites**. Symptom: "I tweaked the strate asset, regenerated, and one patch kept the old shape." +#### ⚠️ SUSPECTED, NOT PROVEN, 2026-07-28 — the SDF cache may serve stale rooms *within* a strate + +Found while porting TunnelNetwork, and stated as a suspicion on purpose: I have reasoned it, not +measured it. + +`GetDensityWithParams`' SDF cache key is `(XY search box, StrateIndex, Seed)`. It contains **no +params and no chunk Z**. Meanwhile `GetGenerationParams` *blends* params between neighbouring strates +across a Gradient transition — so two chunks at different Z **inside the same strate** can carry +different `RoomSpacing`/`RoomDensity`/… while sharing an XY box, a strate index and a seed. + +If that is right, a worker descending a transition band gets **no rebuild** and evaluates the lower +chunk against the upper chunk's room layout. Same family as `§C2` above and as the overhang +regression of 2026-07-27; the difference is that this one needs no live edit to trigger. + +**What would confirm it:** call `GetGenerationParams` for two adjacent chunk Zs inside a +Gradient-transitioned strate and compare the room-placement fields. If they differ, the cache is +being reused across a real param change. **Do that before acting** — the whole thing rests on +"Gradient blending actually varies within a strate", which I have not verified. + +The operator-stack port does **not** inherit this: `FRoomGraphSource` folds a `FCrc::MemCrc32` +fingerprint of the params (plus `LayoutVersion`) into its key, so differing params force a rebuild. +`VoxelForge.OpStack.TunnelNetworkSpineEquivalence` check 3 exercises exactly that — and deliberately +does *not* compare against the original there, because the original would fail it. + **Fix:** the getter already exists and is already used elsewhere — ```cpp const uint32 LV = StrateManager->GetLayoutVersion(); diff --git a/CODEMAP.md b/CODEMAP.md index 7a2447d..0556b4b 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -143,6 +143,9 @@ inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6). | `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. | | `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. | | `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. | +| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion** that the original lacks — see the suspected staleness note in AUDIT §C2. `EffectOverBox` → `Both` for now (a real answer means building the cache for the queried box; only pays once `ClassifyTile` consumes `ClassifyBox`). | +| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). `EffectOverBox` → **`CarveOnly` everywhere** — no spatial bound, so it kills `AllSolid` on every tile of every strate with worms on. `MaxCarveAmplitude()` holds the bound from DECOMPOSITION §0.2 that would recover it, waiting for a fold that carries numbers. | +| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **STAGE A of three — incomplete on purpose.** 6 ops: rock → room graph → carve → worms → structural ×3. The 13 detail modifiers (stage B) and the per-room op override (stage C) are missing, which is why TunnelNetwork is still **off** in `UsesOperatorStackForChunk`. Testable now because every detail modifier is amplitude-gated and defaults to 0. | | `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. `EffectOverBox` → `FillOnly` when a blob reaches the box, `Identity` otherwise; its pad must cover warp·**√2** (two independent noise axes), roughness, fill blend and the `SmoothMin` dip. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. | | `VoxelDensityOps::BuildFloatingIslandStack` | — | 7 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. | | `VoxelDensityOps::BuildMazeStack` | — | The 7-op Maze stack. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. | @@ -410,6 +413,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeHeightStackTest.cpp` | `VoxelForge.OpStack.SurfaceHeightEquivalence` | The height-space stack vs `ComputeSurfaceTerrainZ`, in **altitudes**. Runs twice: defaults, then **all F20 terrain ops ON** — the load-bearing pass, since the ops are off by default and the defaults pass exercises only the structural source. Also brute-forces `MaxDisplacement` (a false bound would be a hole). Bar is bit-identity; a height delta is a visibly different world, not rounding. | | `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. | | `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. | +| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. | | `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. Known-pessimistic: proves **0 of 60** tiles (its `EffectOverBox` rejects on a `Spacing*1.6` halo instead of real connector capsules — lost CPU, never a hole). | | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | diff --git a/OPSTACK-DECOMPOSITION.md b/OPSTACK-DECOMPOSITION.md index 9ce8553..1b1f513 100644 --- a/OPSTACK-DECOMPOSITION.md +++ b/OPSTACK-DECOMPOSITION.md @@ -170,6 +170,22 @@ The order is load-bearing and is the order the code already uses: spine carves t the seal then re-solidifies its bands (the spine deliberately never touches them), passages punch through everything including the seal, and the player wins last. +> ### ⛔ RETIRED 2026-07-28 — frames were never a fifth thing. Porting all three candidates killed it. +> +> The section below argues for a `FRAME` op family from three examples. All three are now ported, +> and none of them turned out to need one: +> +> - **`CaveWarp` wraps exactly ONE operator.** Pits and chimneys explicitly read *unwarped* coords +> while writing the same SDF channel — the thing this document called "the single fiddliest thing +> in the whole decomposition". Inside one operator the difficulty evaporates: the warp is a local +> variable, not an inherited context. A transform whose scope is one op is not a frame. +> - **`VerticalScale` is `Z / Scale`** — a pure function of a scalar and a param, recomputed in one +> line by each op that needs it. A frame would add a channel to avoid a division. +> - **The island warp (§7)** was kept local for the same reason, before the other two were even read. +> +> **Zero frames out of three candidates.** It was not missing infrastructure; it was one idea seen +> three times from a distance. Kept below as the reasoning that was superseded, not as a plan. + ### A fifth thing the plan doesn't name: FRAME OPS Two archetypes transform the *query coordinates* rather than the field: diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 35968d2..9125764 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -1903,3 +1903,92 @@ Then `Underwater` + `TunnelNetwork` (§8 / §2, **last**, with §8.4's window-in Perf still parked by Jahni until the transition is complete. --- + +## 2026-07-28 — 12 tests green. TunnelNetwork STAGE A (of three) written. + +Jahni: *"everything's green."* FloatingIslands is bit-identical and wired; 6 of 8 ported. +**Worth asking for explicitly next run:** the three coverage numbers that test prints — samples +inside island rock, the AllAir verdict count, and the diff count. Green with zero samples inside +island rock would mean the equivalence proved that two empty voids agree. + +### The last archetype, staged deliberately + +`GetDensityWithParams` is ~1080 lines, 13 detail modifiers, a two-region cache and a per-room op +override. Porting all of it before anything can be verified would be ~600 unverified lines on top of +~200 unverified ones — the `AUDIT §P3` pattern this refactor has dodged six times. + +**The way in:** every detail modifier is amplitude-gated, and `FStrateGenerationParams` already +leaves all of them at **zero** by default (`BuildParamsFromDefinition` stopped merging them globally +— they arrive as per-room ops). One exception, `SurfaceRoughness = 5`. So zeroing those amplitudes +sends the ORIGINAL down exactly the path stage A ported, and stage A is verifiable **today**, bit for +bit, against the real function. Same discipline as the height-stack test's "defaults, then all ops +ON", taken in the other direction. + +- **Stage A (this commit):** vertical scale · base rock · cave warp · room graph (+ pits + chimneys) + · carve · worms · structural post. 6 ops. +- **Stage B:** the 13 detail modifiers, gated on `Sdf < SDFBlendRadius·3`. +- **Stage C:** the per-room op override (`§2`'s option (a)), the `Underwater` water flag, and only + then does `UsesOperatorStackForChunk` return true for either. + +### ⚠️ The one decision that matters: the op CALLS `BuildChunkCache`, it does not transcribe it + +Every other port is a literal transcription. This one must not be. `BuildChunkCache` carries the +two-region window-invariance discipline (`ARCHITECTURE §8.4`) and is the most delicate code in the +plugin; transcribing it would **fork** it — two copies of one invariant, drifting, with the copy +"validated" by a test that compares it to the original. What *is* transcribed is the ~60 lines of +glue around it (strate-index memo, search-box key, warp, pit/chimney loops). + +### `FRAME` ops are retired, and porting is what retired them + +`§2` described two nested frames (`VerticalScale`, `CaveWarp`) and `§7` a third (the island warp). +Porting all three dissolved all three: + +- **`CaveWarp` wraps exactly ONE operator.** Pits and chimneys explicitly read *unwarped* coords. + A transform whose scope is one op is a local variable, not a frame. +- **`VerticalScale` is `Z / Scale`** — a pure function of a scalar and a param, one line wherever it + is needed. A frame would add a channel to avoid a division. +- **The island warp** was already kept local for the same reason. + +**Zero frames out of three candidates.** It was never missing infrastructure; it was the same thing +seen three times from a distance. Recorded in the builder rather than left as a permanent TODO. + +Also found: TunnelNetwork's carve divides by `Max(SDFBlendRadius·2, 1)` where Maze/Shafts/Islands +divide by `Blend·2`. The formulas diverge once `Blend·2 < 1`, so `FSdfConvertOp` gained an explicit +`MinDivisor` instead of letting the two look interchangeable. `Max(x, 0) == x` for positive Blend, so +the green ports are untouched. + +### ⚠️ A test that compares against the original would have measured the ORIGINAL'S bug + +Check 3 (two stacks, different params, evaluated A/B/A/B at the same point) was written comparing +both against `GetDensityWithParams`. **It would have failed** — and not because of the port. The +original's SDF cache key is `(XY box, strate, seed)` with **no params**, so under interleaving it +serves B the rooms it built for A. Comparing to it there measures its staleness, not my operator. +Rewritten to compare each stack against **itself evaluated alone**: an oracle that does not share the +defect under test. + +That in turn raises a **suspicion, filed as a suspicion**: `GetGenerationParams` blends params across +Gradient transitions, so two chunk Zs inside one strate can hold different params with the same XY +box, strate index and seed ⇒ no rebuild ⇒ the lower chunk gets the upper chunk's rooms. In +production, with no live edit needed. Recorded in `AUDIT §C2` **with the check that would confirm +it**, because the whole chain rests on "Gradient blending actually varies within a strate", which I +have not verified. The port does not inherit it — the params fingerprint forces the rebuild. + +### What stage A deliberately does NOT prove + +Box verdicts: **zero proved, and the test asserts zero.** `FRoomGraphSource` answers `Both` (its +bounds are in the SDF cache, which it would have to build for the queried box — worth doing only +once `ClassifyTile` actually consumes `ClassifyBox`), and `FWormFieldSource` answers `CarveOnly` +*everywhere*, because a fielded-noise carve has no spatial bound. That is `§0.2`'s point, and the +amplitude cap that fixes it (`t ∈ [0,1]`, `Mask ∈ [0,1]` ⇒ at most `WormStrength` toward air) now has +a home in `FWormFieldSource::MaxCarveAmplitude()`, waiting for a fold that carries numbers. + +**UNVERIFIED:** not compiled. Likely spots: `FStrateTerrainOpEntry` / `UVoxelStrateDefinition` newly +reachable from `VoxelDensityOpStack.cpp` (added the include); `MakeSdfCarve`'s new defaulted third +parameter (declared in the header, so the three existing call sites still compile); `FCrc` needing +`Misc/Crc.h` (it comes via `CoreMinimal.h`, and `FSurfaceColumnSource` already uses it in this file); +and the test's `GetGenerationParams` signature. + +**Next single action:** build, run the filter — **13 tests**, the new one is +`VoxelForge.OpStack.TunnelNetworkSpineEquivalence`. Then stage B (the 13 modifiers). + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp new file mode 100644 index 0000000..0726fc7 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -0,0 +1,380 @@ +// VoxelForgeOpStackTunnelTest.cpp +// TunnelNetwork — ÉTAPE A : le squelette SDF, sans les modificateurs de détail. +// TunnelNetwork — STAGE A: the SDF spine, without the detail modifiers. +// +// POURQUOI UN TEST D'UNE PILE INCOMPLÈTE +// `GetDensityWithParams` fait ~1080 lignes et treize modificateurs de détail. 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. +// +// CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur les 13 modificateurs, rien sur l'override d'op +// par salle, et rien sur le saut de tuile — `FRoomGraphSource::EffectOverBox` rend `Both`, donc +// aucun verdict n'est prouvable à ce stade. Ces trois manques sont l'étape B et l'étape C. +// +// ⚠️ É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 + +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; + + /** + * Met à zéro tout ce que l'étape A n'a pas encore porté, pour que l'original prenne le même + * chemin. Tout sauf `SurfaceRoughness` est DÉJÀ à zéro par défaut ; on l'écrit quand même, parce + * qu'un test qui dépend d'un défaut se casse le jour où le défaut change, et silencieusement. + */ + void DisableStageBModifiers(FStrateGenerationParams& P) + { + P.SurfaceRoughness = 0.0f; // le seul non nul par défaut (5.0) + P.DomainWarpStrength = 0.0f; + P.TerraceStepHeight = 0.0f; + P.TerraceNoiseDisplacement = 0.0f; + P.LayerLineSpacing = 0.0f; + P.RibbingSpacing = 0.0f; + P.OverhangStrength = 0.0f; + P.CliffStrength = 0.0f; + P.ScallopStrength = 0.0f; + P.ArchDensity = 0.0f; + P.ColumnDensity = 0.0f; // ⚠️ celui-ci se cuit dans SDFCache.Columns, pas un `if` + P.DomeDensity = 0.0f; + P.PinchDensity = 0.0f; + P.FloorBias = 0.0f; + } + + /** Pits et cheminées sont à 0 par défaut — or ce sont précisément les deux boucles que `§2` + * annonçait comme « le plus retors de toute la décomposition » (coordonnées NON warpées + * mélangées au SDF warpé). Les laisser au repos testerait tout sauf le morceau difficile. */ + void EnableTunnelFeatures(FStrateGenerationParams& P) + { + P.PitDensity = 0.55f; + P.ChimneyDensity = 0.55f; + P.VerticalScale = 1.35f; // ≠ 1 ⇒ le Z « effectif » diverge du Z monde partout + } +} + +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(); + + 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; + } + + DisableStageBModifiers(P); + EnableTunnelFeatures(P); + + FVoxelOpStack Stack; + VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed, + Gen->OriginSpineRadius, World.StrateManager.Get()); + + // rock + roomgraph + carve + worms + 3 structurels. Les 13 modificateurs de détail viendront + // s'insérer entre le carve et les vers — ce nombre DOIT bouger à l'étape B. + TestEqual(TEXT("the stage-A tunnel stack is decomposed into 6 ops"), Stack.Num(), 6); + + 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 Points; + Points.Reserve(NumTunnelSamples); + { + FRandomStream Rng(1080601); + const int32 ChunkZ0 = BottomVoxelZ / CHUNK_SIZE; + const int32 ChunkZ1 = FMath::Max(ChunkZ0, (TopVoxelZ / CHUNK_SIZE) - 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; + + 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); + const float New = Stack.EvalMC(X, Y, Z); + + 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: bit-identical across %d samples in %d chunks (%d in open ") + TEXT("cave, %d in rock, away from the seal bands). Exercised: vertical scale (1.35, so ") + TEXT("effective Z differs from world Z everywhere), cave warp, the room/tunnel SDF via ") + TEXT("the SHARED BuildChunkCache, pits and chimneys at UNWARPED coords, the carve with ") + TEXT("its floored divisor, and the worm carve with its network mask. NOT covered: the ") + TEXT("13 detail modifiers, the per-room op override, and any tile verdict."), + NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock)); + } + else + { + AddError(FString::Printf( + TEXT("TunnelNetwork STAGE A: %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."), + 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); + + if (NumInCave == 0) + { + AddWarning(TEXT("No sample landed in open cave away from the seal bands: the room graph, ") + TEXT("the carve, the pits and the worms were never meaningfully exercised, so ") + TEXT("the equivalence above mostly compares solid rock to solid rock. Raise ") + TEXT("RoomDensity or lower RoomSpacing.")); + } + + //========================================================================= + // 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 Impure{ 0 }; + const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); + + TArray 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 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. + // + // ⚠️⚠️ ON NE COMPARE **PAS** À L'ORIGINAL ICI, ET C'EST LE POINT LE PLUS IMPORTANT DE CE TEST. + // `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed) — **sans les params**. En + // alternance il rendrait donc, pour B, les salles de A : l'original ÉCHOUERAIT ce contrôle. Le + // comparer à lui ici ne mesurerait pas mon opérateur, ça mesurerait son bug. On compare donc + // chaque pile à ELLE-MÊME évaluée seule — un oracle qui ne partage pas le défaut testé. + // + // ⚠️ ET CE N'EST PEUT-ÊTRE PAS QU'UN ARTEFACT DE TEST — à vérifier, pas à croire. En production + // `GetGenerationParams` MÉLANGE les params entre strates voisines (transitions Gradient), donc + // deux chunks de Z différents dans la même strate peuvent avoir des params différents, avec la + // même boîte XY, le même index de strate et le même seed ⇒ aucune reconstruction. Si c'est + // exact, un worker qui descend une bande de transition sert les salles du chunk précédent. + // Noté dans `AUDIT §C2` comme SUSPECTÉ, avec le test qui le confirmerait — pas comme prouvé. + // + // We compare each stack to ITSELF evaluated alone, not to the original: the original keys its + // SDF cache without the params and would fail this check, so comparing against it would measure + // its bug rather than this operator. + { + 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 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 genuinely differ between the two ") + TEXT("param sets, and %d were served wrong under A/B interleaving. A zero in the FIRST ") + TEXT("number would mean the check proved nothing -- the two param sets must actually ") + TEXT("produce different rock for a stale cache to be detectable."), + NumActuallyDifferent, Probe, NumWrong)); + + if (NumActuallyDifferent == 0) + { + AddWarning(TEXT("The two param sets produced identical density at every probe point, so ") + TEXT("this check cannot distinguish a correct cache from a stale one. Make ") + TEXT("P2 differ more.")); + } + } + + //========================================================================= + // 4. LE VERDICT DE BOÎTE — attendu NUL, et c'est le point + //========================================================================= + { + int32 NumProved = 0, NumMixed = 0; + FRandomStream Rng(97531); + for (int32 t = 0; t < 40; ++t) + { + const int32 Step = 1, Cells = 8; + const int32 Extent = Step * Cells; + const FIntVector Origin( + Rng.RandRange(-4, 4) * Extent, + Rng.RandRange(-4, 4) * Extent, + FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); + 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)); + + if (Stack.ClassifyBox(Box, Ctx) == EVoxelTileClass::Mixed) { ++NumMixed; } + else { ++NumProved; } + } + + AddInfo(FString::Printf( + TEXT("Box verdicts over 40 TunnelNetwork tiles: %d proved, %d Mixed. %d proved is the ") + TEXT("EXPECTED result at stage A and not a defect: the room source answers Both (its ") + TEXT("bounds live in the SDF cache, which it would have to build for the queried box), ") + TEXT("and the worm source answers CarveOnly EVERYWHERE because a fielded noise carve ") + TEXT("has no spatial bound at all. Recovering these needs the numeric amplitude cap in ") + TEXT("OPSTACK-DECOMPOSITION 0.2 -- the largest single perf item in the whole plan, and ") + TEXT("the reason this archetype currently skips zero tiles."), + NumProved, NumMixed, NumProved)); + + TestEqual(TEXT("stage A emits no unsound verdict (it emits none at all)"), NumProved, 0); + } + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 2677ac1..83c9cac 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -25,6 +25,7 @@ #include "VoxelGenerator.h" // VoxelGenLOD::Eff #include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks #include "VoxelNoise.h" // VoxelNoise::FBM +#include "VoxelStrateDefinition.h" // TerrainOperations — le pool que BuildChunkCache tire par salle #include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox #include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE @@ -1036,8 +1037,8 @@ namespace class FSdfConvertOp final : public IVoxelDensityOp { public: - FSdfConvertOp(float InBlend, float InBaseDensity, float InSign) - : Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign) {} + FSdfConvertOp(float InBlend, float InBaseDensity, float InSign, float InMinDivisor) + : Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign), MinDivisor(InMinDivisor) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -1045,7 +1046,14 @@ namespace void Eval(float, float, float, FVoxelOpSample& InOut) const override { if (InOut.Sdf >= Blend) { return; } - float T = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f); + // ⚠️ `MinDivisor` n'est PAS une précaution ajoutée : TunnelNetwork écrit + // `/ FMath::Max(SDFBlendRadius * 2, 1.0f)` là où Maze/Shafts/Islands écrivent `/ (Blend*2)`. + // Les deux formules DIVERGENT dès que `Blend·2 < 1`, donc les confondre serait une faute + // de portage silencieuse. Avec `MinDivisor = 0` et un Blend positif, `Max(x, 0) == x` + // exactement — les trois portages déjà verts ne bougent pas d'un bit. + // Not a safety tweak: TunnelNetwork genuinely floors this divisor at 1 and the others + // do not. Max(x, 0) is exactly x for positive Blend, so existing ports are untouched. + float T = FMath::Clamp((Blend - InOut.Sdf) / FMath::Max(Blend * 2.0f, MinDivisor), 0.0f, 1.0f); T = SmoothStep01(T); InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide } @@ -1056,7 +1064,7 @@ namespace } private: - float Blend, BaseDensity, Sign; + float Blend, BaseDensity, Sign, MinDivisor; }; //========================================================================= @@ -1691,6 +1699,370 @@ namespace float ExtraReach; }; + //========================================================================= + // RÔLE 1 — SOURCE : GRAPHE DE SALLES / ROOM GRAPH (TunnelNetwork) + //========================================================================= + // ⚠️⚠️ CET OPÉRATEUR N'A PAS RÉÉCRIT `BuildChunkCache` / `EvaluateSDFCached` : IL LES APPELLE. + // + // C'est LA décision de ce portage, et elle mérite d'être dite explicitement parce que la + // tentation inverse est forte : les six autres portages sont des transcriptions littérales. + // Celui-ci ne peut pas l'être. `BuildChunkCache` porte la discipline d'invariance de fenêtre à + // deux régions (ARCHITECTURE §8.4) — la région COLLECT (plus large, décide QUELLES primitives + // existent) et la région STORE (ce qu'on garde) — et c'est le code le plus délicat du plugin. + // Le transcrire, ce serait le FORKER : deux copies d'un invariant qui dérivent, dont l'une n'est + // testée que par un test d'équivalence qui compare... la copie à l'original. + // + // Ce qui EST transcrit ici, c'est la glu autour : la mémo d'index de strate, la clé de cache par + // BOÎTE DE RECHERCHE (pas par chunk — voir plus bas), le warp, et les boucles pits/cheminées. + // ~60 lignes déjà relues, contre ~400 lignes d'algorithme qu'on ne touche pas. + // + // This op CALLS the morphology cache rather than transcribing it: BuildChunkCache carries the + // two-region window-invariance discipline (§8.4) and forking it would be the worst possible + // outcome of a refactor whose whole point is to have ONE definition of each idea. + class FRoomGraphSource final : public IVoxelDensityOp + { + public: + FRoomGraphSource(const FStrateGenerationParams& InP, int32 InSeed, + const UVoxelStrateManager* InManager) + : P(InP), Seed(InSeed), SeedU((uint32)InSeed), Manager(InManager) + , ParamsFingerprint(FCrc::MemCrc32(&InP, sizeof(InP))) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + + void PrepareChunk(const FVoxelOpContext& Ctx) override + { + // La seule chose vraiment constante par chunk ET dépendante du contexte. Le reste + // (index de strate, pool d'ops) est résolu paresseusement dans `Eval` comme l'original, + // parce que le cache SDF se ré-clé sur une BOÎTE, pas sur un chunk. + LayoutVersion = Ctx.LayoutVersion; + } + + /** Le Z « effectif » : `VerticalScale` étire le monde AVANT le bruit. Pure fonction de Z et + * d'un param — c'est pourquoi ce portage n'a PAS eu besoin d'un opérateur « frame » + * (voir la note de conception dans BuildTunnelNetworkStack). */ + FORCEINLINE float EffZ(float WorldZ) const + { + return (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) ? (WorldZ / P.VerticalScale) + : WorldZ; + } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return; } // Sdf reste FLT_MAX + + const float EffectiveZ = EffZ(WorldZ); + + //--------------------------------------------------------------- + // WARP DE CAVE — coordonnées de REQUÊTE uniquement + //--------------------------------------------------------------- + // ⚠️ Le warp ne s'applique QU'À la requête du graphe de salles. Les pits et les cheminées + // plus bas lisent les coordonnées RÉELLES, et c'est délibéré dans l'original : leurs + // ancres viennent de centres de salles NON warpés. C'est aussi pourquoi le warp n'est pas + // un « frame » : sa portée est exactement UN opérateur, donc elle appartient à cet + // opérateur. + float WarpedX = WorldX, WarpedY = WorldY, WarpedZ = EffectiveZ; + if (P.CaveWarpStrength > 0.0f) + { + const float WF = P.CaveWarpFrequency; + const float WS = P.CaveWarpStrength; + WarpedX += VoxelNoise::Perlin3D(FVector( + WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f), + WorldY * WF + 1.3f, + EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS; + WarpedY += VoxelNoise::Perlin3D(FVector( + WorldX * WF + 7.1f, + WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f), + EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS; + WarpedZ += VoxelNoise::Perlin3D(FVector( + WorldX * WF + 11.3f, + WorldY * WF + 9.7f, + EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f))) * VOXEL_NOISE_SCALE * WS; + } + + //--------------------------------------------------------------- + // LE CACHE PAR BOÎTE DE RECHERCHE + //--------------------------------------------------------------- + // ⚠️ CLÉ PAR BOÎTE, PAS PAR CHUNK, et c'est un INVARIANT DE PERF (§8.10) : les + // échantillons de gradient interrogent `WorldX ± 1` et le warp déplace encore, donc une + // clé « égalité de chunk » se retournait à chaque cellule de bord et reconstruisait le + // cache (coûteux) en boucle. Comme le cache couvre la boîte + MaxInfluence, toute requête + // DANS la boîte est correcte. Ne pas « simplifier » en clé de chunk. + thread_local FChunkSDFCache SDFCache; + thread_local float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // invalide au départ + thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f; + thread_local int32 CachedStrate = INT32_MIN; + thread_local uint32 CachedSeed = 0; + // ⚠️ AJOUTÉ PAR RAPPORT À L'ORIGINAL — la leçon du 2026-07-27 (régression d'overhang). + // L'original ne clé QUE sur (boîte, strate, seed) : deux jeux de params différents dans + // la MÊME strate au MÊME seed se servent mutuellement leur cache. En production + // `RebuildStrates` masque le trou en bougeant la strate ; en test, deux piles construites + // côte à côte le déclenchent immédiatement. Empreinte CRC des params + LayoutVersion. + // `FStrateGenerationParams` est du POD pur (aucun TArray/FString/pointeur), donc une CRC + // mémoire ne peut pas donner un FAUX POSITIF ; au pire un padding donne un faux MANQUE, + // c'est-à-dire un recalcul. On se trompe du côté du CPU, jamais du côté d'une salle fausse. + thread_local uint32 CachedFingerprint = 0xFFFFFFFFu; + thread_local uint32 CachedLayout = 0xFFFFFFFFu; + + // Index de strate — mémo (chunk-Z, version de layout), transcrit tel quel. La requête + // vise le CENTRE de la bande, donc le résultat est une fonction pure de la clé. + int32 StrateIdx = 0; + if (Manager) + { + thread_local int32 SI_ChunkZ = INT32_MAX; + thread_local uint32 SI_Version = 0xFFFFFFFFu; + thread_local int32 SI_Index = 0; + const int32 QZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + const uint32 LV = Manager->GetLayoutVersion(); + if (QZ != SI_ChunkZ || LV != SI_Version) + { + SI_ChunkZ = QZ; + SI_Version = LV; + SI_Index = Manager->GetStrateIndex(((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE); + } + StrateIdx = SI_Index; + } + + const bool bNeedRebuild = + StrateIdx != CachedStrate || SeedU != CachedSeed || + ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout || + WarpedX < CachedSMinX || WarpedX > CachedSMaxX || + WarpedY < CachedSMinY || WarpedY > CachedSMaxY; + + if (bNeedRebuild) + { + const int32 CacheChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE); + const int32 CacheChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE); + const float ChunkMinX = CacheChunkX * (float)CHUNK_SIZE; + const float ChunkMinY = CacheChunkY * (float)CHUNK_SIZE; + const float ChunkMaxX = ChunkMinX + (float)CHUNK_SIZE; + const float ChunkMaxY = ChunkMinY + (float)CHUNK_SIZE; + const float Expansion = P.CaveWarpStrength + 2.0f; + + const float SMinX = ChunkMinX - Expansion; + const float SMinY = ChunkMinY - Expansion; + const float SMaxX = ChunkMaxX + Expansion; + const float SMaxY = ChunkMaxY + Expansion; + + const TArray* TerrainOps = nullptr; + if (Manager) + { + const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + UVoxelStrateDefinition* Def = Manager->GetStrateForChunk( + FIntVector(CacheChunkX, CacheChunkY, ChunkZ)); + if (Def) { TerrainOps = &Def->TerrainOperations; } + } + + VoxelCaveMorphology::BuildChunkCache( + SDFCache, SMinX, SMinY, SMaxX, SMaxY, P, SeedU, StrateIdx, TerrainOps); + + CachedSMinX = SMinX; CachedSMaxX = SMaxX; + CachedSMinY = SMinY; CachedSMaxY = SMaxY; + CachedStrate = StrateIdx; + CachedSeed = SeedU; + CachedFingerprint = ParamsFingerprint; + CachedLayout = LayoutVersion; + } + + int32 NearestRoom = -1; + float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( + WarpedX, WarpedY, WarpedZ, SDFCache, P.SDFBlendRadius, &NearestRoom); + + //--------------------------------------------------------------- + // PITS & CHEMINÉES — coordonnées RÉELLES, SmoothMin dans le même canal SDF + //--------------------------------------------------------------- + // C'est le point que `OPSTACK-DECOMPOSITION §2` annonçait comme « le plus retors de toute + // la décomposition » : deux primitives qui écrivent le MÊME canal que le graphe de salles + // mais à des coordonnées NON warpées. Sous un modèle de frames il aurait fallu les sortir + // du frame tout en gardant le canal — exprimable, mais tordu. Dans un opérateur unique la + // difficulté disparaît : le warp est une variable locale, pas un contexte hérité. + for (const FCachedPit& Pit : SDFCache.Pits) + { + const float DZ = WorldZ - Pit.TopZ; + if (DZ >= Pit.BlendK) { continue; } + if (-DZ > Pit.Depth + Pit.BlendK) { continue; } + + const float DX = WorldX - Pit.CenterX; + const float DY = WorldY - Pit.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Pit.BoundXYRadiusSq) { continue; } + + float PitSDF; + if (DZ <= 0.0f) + { + const float DepthBelow = -DZ; + float FlareFactor = FMath::Clamp(1.0f - DepthBelow / Pit.FlareDist, 0.0f, 1.0f); + FlareFactor = FlareFactor * FlareFactor; + const float EffRadius = Pit.Radius + Pit.FlareExtra * FlareFactor; + PitSDF = FMath::Sqrt(XYDistSq) - EffRadius; + } + else + { + PitSDF = FMath::Sqrt(XYDistSq) - (Pit.Radius + Pit.FlareExtra); + } + + CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK); + } + + for (const FCachedChimney& Chim : SDFCache.Chimneys) + { + const float DZ = WorldZ - Chim.BottomZ; + if (-DZ >= Chim.BlendK) { continue; } + if (DZ > Chim.Height + Chim.BlendK) { continue; } + + const float DX = WorldX - Chim.CenterX; + const float DY = WorldY - Chim.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Chim.BoundXYRadiusSq) { continue; } + + float ChmSDF; + if (DZ >= 0.0f) + { + float FlareFactor = FMath::Clamp(1.0f - DZ / Chim.FlareDist, 0.0f, 1.0f); + FlareFactor = FlareFactor * FlareFactor; + const float EffRadius = Chim.Radius + Chim.FlareExtra * FlareFactor; + ChmSDF = FMath::Sqrt(XYDistSq) - EffRadius; + } + else + { + ChmSDF = FMath::Sqrt(XYDistSq) - (Chim.Radius + Chim.FlareExtra); + } + + CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK); + } + + InOut.Sdf = CaveSDF; + } + + /** + * ⚠️ `Both` POUR L'INSTANT, ET C'EST UNE DETTE ASSUMÉE, PAS UN OUBLI. + * + * Les bornes existent pourtant : `FCachedRoom` / `FCachedTunnel` portent déjà leurs + * `Bound*` (c'est ce dont `§2` dit qu'il rend le bedrock profond prouvable, « le plus gros + * poste de perf de tout le plan »). Ce qui manque, c'est que répondre honnêtement demande de + * consulter le cache — donc de le CONSTRUIRE pour la boîte interrogée, sur le thread qui + * interroge, ce qui n'est raisonnable qu'une fois `ClassifyBox` réellement branché dans + * `ClassifyTile` (il ne l'est toujours pas). Rendre `Both` coûte du CPU et ne peut pas faire + * de trou ; rendre le mauvais en ferait un. + * + * Conservative placeholder: the room/tunnel bounds needed for a real answer are already in + * the cache, but answering means building that cache for the queried box, which only pays + * once ClassifyTile actually consumes ClassifyBox. Both is always safe. + */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f) ? EVoxelOpEffect::Both + : EVoxelOpEffect::Identity; + } + + private: + FStrateGenerationParams P; + int32 Seed; + uint32 SeedU; + const UVoxelStrateManager* Manager; // NON possédant + uint32 ParamsFingerprint; + uint32 LayoutVersion = 0; + }; + + //========================================================================= + // RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork) + //========================================================================= + // Un carve par SEUIL sur du bruit 3D, masqué par la distance au réseau de salles. Il écrit la + // DENSITÉ directement (pas le canal SDF) : c'est une source « fieldée », pas une primitive + // placée — la distinction que `AUDIT §6.2` pose et que `OPSTACK-DECOMPOSITION §0.2` chiffre. + // + // ⚠️ IL LIT `InOut.Sdf` : le masque de réseau est une fonction de `CaveSDF` APRÈS pits et + // cheminées. C'est encore le canal SDF utilisé comme ce pour quoi il existe — transporter une + // information géométrique entre deux opérateurs au lieu de la recalculer. + class FWormFieldSource final : public IVoxelDensityOp + { + public: + FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed) + : P(InP), SeedU((uint32)Seed) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return; } + + const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) + ? (WorldZ / P.VerticalScale) : WorldZ; + const float CaveSDF = InOut.Sdf; + + float NetworkMask = 1.0f; + if (P.WormNetworkRange > 0.0f) + { + if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX) + { + NetworkMask = 0.0f; + } + else if (CaveSDF > 0.0f) + { + NetworkMask = 1.0f - SmoothStep01(CaveSDF / P.WormNetworkRange); + } + } + + if (NetworkMask <= 0.0f) { return; } + + const float WormZFreq = P.WormFrequency * P.WormHorizontalBias; + + const float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f), + WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f), + EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) + )) * VOXEL_NOISE_SCALE); + + // N2 ≥ 0, donc si N1 dépasse déjà le seuil la somme ne peut plus creuser — on saute le + // second Perlin (le cas courant ; sortie bit-identique). Transcrit tel quel. + if (N1 >= P.WormThreshold) { return; } + + const float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f) + 137.0f, + WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f) + 259.0f, + EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) + 431.0f + )) * VOXEL_NOISE_SCALE); + + const float WormValue = N1 + N2; + if (WormValue < P.WormThreshold) + { + const float t = 1.0f - (WormValue / P.WormThreshold); + InOut.Density -= t * P.WormStrength * NetworkMask; + } + } + + /** + * ⚠️ `CarveOnly` PARTOUT quand les vers sont actifs — et c'est exactement le problème que + * `OPSTACK-DECOMPOSITION §0.2` isole : un carve fieldé n'a AUCUNE borne spatiale, donc il tue + * l'hypothèse `AllSolid` sur CHAQUE tuile de CHAQUE strate à vers. La direction seule ne peut + * pas le récupérer. + * + * **Mais l'amplitude, elle, est bornée et triviale** : `t ∈ [0,1]`, `NetworkMask ∈ [0,1]`, + * donc ce ver ne peut déplacer la densité vers l'air que de `WormStrength` au plus. Dès que + * le pliage saura porter un INTERVALLE numérique et pas seulement une direction, « le rocher + * est solide de plus que la somme des carves restants » redevient prouvable — et c'est le + * plus gros poste de perf du plan. Noté ici, au point exact où la borne manque. + */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? EVoxelOpEffect::CarveOnly + : EVoxelOpEffect::Identity; + } + + /** L'amplitude max de carve, en unités de densité. Pas encore consommée par le pliage — + * posée ici pour que la borne de `§0.2` ait déjà un domicile quand les intervalles + * arriveront. / The bound §0.2 needs, given a home before it has a consumer. */ + float MaxCarveAmplitude() const + { + return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f; + } + + private: + FStrateGenerationParams P; + uint32 SeedU; + }; + } // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE. // Même piège que dans VoxelHeightOpStack.cpp : s'ancrer sur une bannière située plus bas // (« FVoxelOpStack », « FABRIQUES ») insère la classe HORS du namespace anonyme, et l'accolade @@ -1742,14 +2114,14 @@ namespace VoxelDensityOps return MakeUnique(Strength, Frequency, BaseOctaves, ApplyWithin); } - TUniquePtr MakeSdfCarve(float Blend, float BaseDensity) + TUniquePtr MakeSdfCarve(float Blend, float BaseDensity, float MinDivisor) { - return MakeUnique(Blend, BaseDensity, -1.0f); + return MakeUnique(Blend, BaseDensity, -1.0f, MinDivisor); } TUniquePtr MakeSdfFill(float Blend, float BaseDensity) { - return MakeUnique(Blend, BaseDensity, +1.0f); + return MakeUnique(Blend, BaseDensity, +1.0f, 0.0f); } TUniquePtr MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) @@ -1837,6 +2209,52 @@ namespace VoxelDensityOps P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); } + void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + { + // ⚠️ ÉTAPE A SUR TROIS — LA PILE EST INCOMPLÈTE, ET DÉLIBÉRÉMENT. + // Sont portés : l'échelle verticale, le roc de base, le warp, le graphe de salles (+ pits + // + cheminées), le carve, les vers, le post structurel. **NE SONT PAS ENCORE PORTÉS** les + // treize modificateurs de détail de l'étape 4b-4h (rugosité, terrasses, lignes de strates, + // nervures, surplombs, falaise, festons, arches, colonnes, dômes, pincement, biais de sol), + // ni l'override d'op PAR SALLE. + // + // C'est pour cela que `UsesOperatorStackForChunk` rend encore **false** pour TunnelNetwork : + // brancher une pile incomplète sur le monde en retirerait tout le détail. Le test compare + // avec ces amplitudes MISES À ZÉRO, donc l'étape A est entièrement vérifiable dès + // maintenant au lieu d'attendre ~600 lignes de plus — c'est la même discipline que la passe + // « défauts puis tous les ops ON » du test de la pile de hauteur. + // + // STAGE A OF THREE, deliberately incomplete: the 13 detail modifiers and the per-room op + // override are not ported yet, which is why the archetype is still off in + // UsesOperatorStackForChunk. The test zeroes those amplitudes so stage A is verifiable now. + // + //--------------------------------------------------------------------- + // ⚠️ CE PORTAGE RETIRE L'IDÉE DE « FRAME OPS » (OPSTACK-DECOMPOSITION §1) + //--------------------------------------------------------------------- + // `§2` décrivait deux frames imbriqués : `VerticalScale` et `CaveWarp`. En les portant pour + // de vrai, les deux se sont dissous : + // • `CaveWarp` a une portée d'EXACTEMENT UN opérateur (le graphe de salles — pits et + // cheminées lisent explicitement les coordonnées non warpées). Une transformation qui + // n'enveloppe qu'un opérateur n'est pas un frame, c'est une variable locale. + // • `VerticalScale` est `Z / Scale` : une fonction PURE d'un scalaire et d'un param, que + // chaque opérateur qui en a besoin recalcule en une ligne. Un frame ne ferait + // qu'ajouter un canal pour éviter une division. + // Il restait le warp d'îles (§7), déjà gardé local pour la même raison. **Zéro frame sur + // trois candidats** : ce n'était pas une infrastructure manquante, c'était trois fois la + // même chose vue de loin. Noté ici plutôt que laissé en TODO permanent. + constexpr float CarveMinDivisor = 1.0f; // TunnelNetwork plancher son diviseur, cf. FSdfConvertOp + + OutStack.Add(MakeConstantRockSource(P.BaseDensity)); + OutStack.Add(MakeUnique(P, Seed, StrateManager)); + OutStack.Add(MakeSdfCarve(P.SDFBlendRadius, P.BaseDensity, CarveMinDivisor)); + // [ÉTAPE B ira ici : les 13 modificateurs de détail, gated sur `Sdf < SDFBlendRadius·3`] + OutStack.Add(MakeUnique(P, Seed)); + + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + } + void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P, int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) { diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index e8711b9..4da2585 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -193,8 +193,13 @@ namespace VoxelDensityOps int32 BaseOctaves, float ApplyWithin); /** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur. - * Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */ - VOXELFORGE_API TUniquePtr MakeSdfCarve(float Blend, float BaseDensity); + * Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. + * @param MinDivisor plancher du diviseur `Blend·2`. **TunnelNetwork passe 1.0** (son original + * écrit `FMath::Max(SDFBlendRadius·2, 1)`) ; Maze/Shafts laissent 0, où + * `Max(x,0) == x` exactement. Les deux formules divergent si `Blend·2 < 1`, + * donc ce paramètre est une vraie différence, pas une précaution. */ + VOXELFORGE_API TUniquePtr MakeSdfCarve(float Blend, float BaseDensity, + float MinDivisor = 0.0f); /** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur. * C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1 @@ -260,6 +265,24 @@ namespace VoxelDensityOps int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager); + /** + * TunnelNetwork — **ÉTAPE A SUR TROIS, PILE INCOMPLÈTE** : + * ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → Worms → [structural ×3] + * + * ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C : les 13 + * modificateurs de détail (4b–4h) et l'override d'op par salle ne sont pas portés, donc le monde + * y perdrait tout son détail. Le test compare avec ces amplitudes à zéro. + * + * ⚠️ `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit + * pas : c'est là que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE + * §8.4`), et en faire une copie serait le pire résultat possible pour un refactor dont le but est + * d'avoir UNE définition de chaque idée. + */ + VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, + const FStrateGenerationParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager); + /** * FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** : * ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]