feat: TunnelNetwork stage A — the SDF spine, wrapping BuildChunkCache

The last archetype is ~1080 lines with 13 detail modifiers, a two-region
cache and a per-room op override. Porting it whole before anything can be
verified is ~600 unverified lines on top of ~200 — the pattern this
refactor has dodged six times. So: three stages.

Stage A = vertical scale, base rock, cave warp, room graph (+ pits and
chimneys), carve, worms, structural post. 6 ops. It is verifiable NOW
because every detail modifier is amplitude-gated and FStrateGenerationParams
already defaults them all to zero — zeroing SurfaceRoughness sends the
ORIGINAL down exactly the path stage A ported.

TunnelNetwork stays OFF in UsesOperatorStackForChunk until stage C.

The decision that matters: FRoomGraphSource CALLS BuildChunkCache and
EvaluateSDFCached rather than transcribing them. That is where §8.4's
two-region window-invariance discipline lives; a transcription would fork
it, and the fork would be "validated" by a test comparing it to the
original. Only the ~60 lines of glue are transcribed.

FRAME ops are retired. All three candidates are now ported and none needed
one: CaveWarp's scope is exactly one operator (pits/chimneys read unwarped
coords), VerticalScale is a one-line pure function, and the island warp was
already local. Not missing infrastructure — one idea seen three times from
a distance.

Also: check 3 was going to compare two interleaved param sets against the
original, which would have FAILED — the original's SDF cache key has no
params, so it serves B the rooms it built for A. Comparing there measures
its bug, not the port. Rewritten against each stack evaluated alone. The
same reasoning suggests a live production staleness across Gradient
transitions; filed in AUDIT §C2 as SUSPECTED with the check that would
confirm it, since it rests on a premise I have not verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 18:28:06 +02:00
parent 96e75abe57
commit ef5bda3d8a
7 changed files with 963 additions and 9 deletions
+24
View File
@@ -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, `ChangeSeed` mostly survives; **live-edit is where this bites**. Symptom: "I tweaked the strate asset,
regenerated, and one patch kept the old shape." 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 — **Fix:** the getter already exists and is already used elsewhere —
```cpp ```cpp
const uint32 LV = StrateManager->GetLayoutVersion(); const uint32 LV = StrateManager->GetLayoutVersion();
+4
View File
@@ -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`. | | `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::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. | | `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. | | `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::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** (topbottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. | | `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** (topbottom ≤ 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. | | `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. | | `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. | | `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). | | `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. | | `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. | | `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. |
+16
View File
@@ -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 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. 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 ### A fifth thing the plan doesn't name: FRAME OPS
Two archetypes transform the *query coordinates* rather than the field: Two archetypes transform the *query coordinates* rather than the field:
+89
View File
@@ -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. 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).
---
@@ -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 <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackTunnelTest,
"VoxelForge.OpStack.TunnelNetworkSpineEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumTunnelChunks = 24;
constexpr int32 PointsPerChunk = 250;
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
/**
* 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<FVector> 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<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumTunnelSamples);
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumTunnelSamples, 4400 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(TEXT("the tunnel stack is window-invariant across order and threads"),
Impure.load(), 0);
}
//=========================================================================
// 3. LE CACHE NE PEUT PAS SERVIR LES PARAMS DU VOISIN
//=========================================================================
// La régression d'overhang du 2026-07-27 : deux piles dans la MÊME strate, au MÊME seed, ne
// différant QUE par des params, partageaient un cache `thread_local` dont la clé ignorait les
// params — et la seconde lisait les salles de la première. La pile clé donc aussi sur une
// empreinte CRC des params. Ce bloc le vérifie en ALTERNANT A, B, A, B au même point, le motif
// qui fait mentir une clé incomplète.
//
// ⚠️⚠️ 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<float> SoloA, SoloB;
SoloA.SetNumUninitialized(Probe);
SoloB.SetNumUninitialized(Probe);
for (int32 i = 0; i < Probe; ++i)
{
SoloA[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
for (int32 i = 0; i < Probe; ++i)
{
SoloB[i] = Stack2.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
int32 NumWrong = 0, NumActuallyDifferent = 0;
for (int32 i = 0; i < Probe; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float GotA = Stack.EvalMC(X, Y, Z);
const float GotB = Stack2.EvalMC(X, Y, Z);
if (!BitEqual(GotA, SoloA[i]) || !BitEqual(GotB, SoloB[i])) { ++NumWrong; }
if (!BitEqual(SoloA[i], SoloB[i])) { ++NumActuallyDifferent; }
}
TestEqual(TEXT("two tunnel stacks with different params never serve each other's rooms"),
NumWrong, 0);
AddInfo(FString::Printf(
TEXT("Params-fingerprint check: %d of %d probe points 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
@@ -25,6 +25,7 @@
#include "VoxelGenerator.h" // VoxelGenLOD::Eff #include "VoxelGenerator.h" // VoxelGenLOD::Eff
#include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks #include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks
#include "VoxelNoise.h" // VoxelNoise::FBM #include "VoxelNoise.h" // VoxelNoise::FBM
#include "VoxelStrateDefinition.h" // TerrainOperations — le pool que BuildChunkCache tire par salle
#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox #include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE #include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
@@ -1036,8 +1037,8 @@ namespace
class FSdfConvertOp final : public IVoxelDensityOp class FSdfConvertOp final : public IVoxelDensityOp
{ {
public: public:
FSdfConvertOp(float InBlend, float InBaseDensity, float InSign) FSdfConvertOp(float InBlend, float InBaseDensity, float InSign, float InMinDivisor)
: Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign) {} : Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign), MinDivisor(InMinDivisor) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; }
void PrepareChunk(const FVoxelOpContext&) override {} void PrepareChunk(const FVoxelOpContext&) override {}
@@ -1045,7 +1046,14 @@ namespace
void Eval(float, float, float, FVoxelOpSample& InOut) const override void Eval(float, float, float, FVoxelOpSample& InOut) const override
{ {
if (InOut.Sdf >= Blend) { return; } 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); T = SmoothStep01(T);
InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide
} }
@@ -1056,7 +1064,7 @@ namespace
} }
private: private:
float Blend, BaseDensity, Sign; float Blend, BaseDensity, Sign, MinDivisor;
}; };
//========================================================================= //=========================================================================
@@ -1691,6 +1699,370 @@ namespace
float ExtraReach; 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<FStrateTerrainOpEntry>* 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. } // ⚠️ 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 // 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 // (« FVoxelOpStack », « FABRIQUES ») insère la classe HORS du namespace anonyme, et l'accolade
@@ -1742,14 +2114,14 @@ namespace VoxelDensityOps
return MakeUnique<FSdfRoughnessMod>(Strength, Frequency, BaseOctaves, ApplyWithin); return MakeUnique<FSdfRoughnessMod>(Strength, Frequency, BaseOctaves, ApplyWithin);
} }
TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity) TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity, float MinDivisor)
{ {
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, -1.0f); return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, -1.0f, MinDivisor);
} }
TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity) TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity)
{ {
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, +1.0f); return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, +1.0f, 0.0f);
} }
TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
@@ -1837,6 +2209,52 @@ namespace VoxelDensityOps
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); 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<FRoomGraphSource>(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<FWormFieldSource>(P, Seed));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
}
void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P, void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{ {
+25 -2
View File
@@ -193,8 +193,13 @@ namespace VoxelDensityOps
int32 BaseOctaves, float ApplyWithin); int32 BaseOctaves, float ApplyWithin);
/** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur. /** 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. */ * Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts.
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity); * @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<IVoxelDensityOp> 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. /** 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 * 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, int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager); 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 (4b4h) 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** : * FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3] * ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]