diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8cafe77..52e2c16 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,6 +33,13 @@ StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/Float On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a strate via a window-invariant XY field — currently wired into SurfaceWorld. +⚠️ **The `switch` above is no longer the only density path.** 6 of the 8 archetypes (all but +TunnelNetwork and Underwater) also exist as **operator stacks**, selected per strate by +`bUseOperatorStack` and evaluated instead of the `switch`; each is bit-identical to the function in +its row. The design lives in `OPSTACK-PLAN.md` / `OPSTACK-DECOMPOSITION.md`, the symbol index in +`CODEMAP §3.2d` — not repeated here. What matters for *this* document: the archetype table describes +what the world IS, and both paths compute it. + ### 8.2 (0,0) spine & hybrid connections - `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical column at XY (0,0) in every strate's **interior** (seals untouched). Radius = diff --git a/AUDIT-2026-07.md b/AUDIT-2026-07.md index 4de5aa2..04baef8 100644 --- a/AUDIT-2026-07.md +++ b/AUDIT-2026-07.md @@ -117,6 +117,42 @@ again.** --- +#### ⚠️ REOPENED AND RE-CLOSED 2026-07-28 — the sweep missed one site, and the search pattern is why + +`§C1` was reported fixed on 2026-07-27 (85 sites, *"0 left behind"*), and +`VoxelForge.Determinism.LargeSeedSurvives` went green on seeds up to 2e9. **One site had survived**, +found by reading `GetFloatingIslandDensity` line by line to port it: + +```cpp +// VoxelGenerator.cpp — the floating-island domain warp +const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, ...)); +``` + +**Why the sweep missed it:** the sweep matched the `SeedF * K` spelling. This site spells the same +thing `(float)S * K`, where `S` is the archetype's salted seed. A textual sweep finds a *spelling*, +not a *bug* — and the green property test could not compensate, because `LargeSeedSurvives` asserts +that the **heightfield** still varies, and this site perturbs an **island outline**. Neither the +comparison oracle nor the property oracle covered it; a human read did. + +**Impact, before the fix:** at `Seed = 2e9` the term reaches ~1.4e6, where the float ULP is 0.125 +against a per-voxel step of 0.04 — the warp flattens and every island silhouette snaps back to a +perfect circle. Cosmetic rather than catastrophic (the C1 failure mode for a *heightfield* is a flat +world), which is exactly why nothing screamed. + +**Fixed** in both paths in one pass (`VoxelHash::SeedOffset(S, 0.0007f)` in `GetFloatingIslandDensity` +and in `FIslandBlobSource`), so `FloatingIslandEquivalence` stays a valid oracle. + +**One sharp edge recorded:** `SeedOffset` quantises the site key by `×100 + 0.5`, so `0.0007f` maps to +site **0**. That is unique today — every other key in the plugin is ≥ 0.19 — but the next sub-`0.005` +key will collide silently. Two sites sharing an offset is a correlation, not a collapse; still, it is +a footgun in a helper whose whole job is decorrelation. + +**Lesson, and it is the session's third instance of the same one:** *verify the premise before +reasoning from it.* "C1 is closed" was load-bearing for two days and was 1 site short. `grep` over a +spelling is evidence about the spelling. + +--- + ### C2 — Per-chunk parameter caches have no layout key (stale after live-edit) ⚠️ **real, reproducible** `VoxelGenerator.cpp:503-524`: diff --git a/CODEMAP.md b/CODEMAP.md index 611719b..7a2447d 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -121,8 +121,8 @@ stack share ONE copy. `VoxelGenerator.cpp` keeps same-named `static FORCEINLINE` ⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack in its per-chunk refetch block and evaluates it *instead of* the `switch` only when `UVoxelStrateManager::UsesOperatorStackForChunk` says so — strate ticked `bUseOperatorStack` **and** -archetype in the ported list (**Maze, FlatPlain, CrystalChamber**). Everything else still takes the -`switch`, unchanged. +archetype in the ported list (**Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, +FloatingIslands** — 6 of 8). Everything else still takes the `switch`, unchanged. **`ClassifyTile` is NOT wired** — still hand-written guards, not `ClassifyBox`. That is Phase 2. ⛔ Never run both paths in one world, and never compare them for equality: the ~1 ULP residue is inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6). @@ -131,16 +131,20 @@ inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6). |--------|------|-------| | `FVoxelOpStack` | — | Ordered `TUniquePtr` list. `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold, with an early-out when both hypotheses die). | | `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → seal → passage **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. | -| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. | +| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). | +| `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. | | `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). Its `EffectOverBox` answers for the source+carve **pair** (Phase 1 simplification) so it must be told the downstream `ExtraReach`. | | `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. | -| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. | +| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. | +| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. | | `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. | | `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. | | `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. | | `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. | +| `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`. | ### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp` @@ -320,7 +324,7 @@ Maps depth→strate at runtime; owns passages. | `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. | | `GetStrateForChunk` | 466 | Chunk → definition. | | `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | -| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list (Maze, FlatPlain, CrystalChamber). **That list is written down here and nowhere else** — an unported archetype ignores the flag, so ticking the box anywhere is harmless. Add a row here when you port one. | +| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands — 6 of 8; missing: TunnelNetwork, Underwater). **That list is written down here and nowhere else** — an unported archetype ignores the flag, so ticking the box anywhere is harmless. Add a row here when you port one. | | `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | | `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. | | `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). | @@ -406,6 +410,8 @@ 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. | +| `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. | ## 4. The density pipeline (most-edited hot path) diff --git a/OPSTACK-DECOMPOSITION.md b/OPSTACK-DECOMPOSITION.md index 9afbf35..9ce8553 100644 --- a/OPSTACK-DECOMPOSITION.md +++ b/OPSTACK-DECOMPOSITION.md @@ -445,6 +445,30 @@ untouched — and since a floating-island strate is *mostly* empty void, that is with `FConstantVoidSource`'s `ClassifyBox → AllAir`, a FloatingIslands strate could go from skipping zero tiles to skipping the large majority of them. +#### ✅ PORTED 2026-07-28 — three deviations from the sketch above, all deliberate + +1. **`FConstantVoidSource` and `FSdfFill` are not new classes.** Each is the class it mirrors, with + the opposite **sign**: `FConstantFieldSource(±Base)` and `FSdfConvertOp(Sign = ±1)`, two factories + each. The table above listed them as separate ops; writing them separately would have duplicated + the classifier and the six-line formula for nothing. Multiplying by ±1 is exact in IEEE-754, so + the three already-green ports are bit-for-bit untouched by the generalisation. + **This is the port's actual result:** reuse **by inversion** rather than by identity — evidence + that the abstract axis (the sign of the internal density) is the right one, not just that two + archetypes happened to look alike. +2. **The warp stays inside the source; no `FRAME` op was built.** Frames are worth building at the + second real user, and two of the three (`TunnelNetwork`'s cave warp, its tunnel warp) are not + ported yet. Designing the abstraction against a single example is what this refactor has avoided + throughout — cf. `IVoxelBiomeField`, which was born from a concrete second need. Revisit with + TunnelNetwork. +3. **`AUDIT §C1`'s last surviving site was in this archetype** and was fixed in both paths in the + same pass (the warp's `(float)S * 0.0007f`; the 2026-07-27 sweep matched `SeedF * K` and missed + the `(float)S` spelling). + +**The `Identity` bound is one-sided, and that matters:** `Sdf ≥ WorldZ − TopSurf` bounds an island +from **above** only. Below `BotZ` the SDF degenerates to ≈ `DistXY`, so a hairline thread of matter +hangs down each island's axis to the strate floor. Rejecting a box because it sits below an island +would be a hole. Only the top rejects. + --- ## 8. Underwater diff --git a/OPSTACK-PLAN.md b/OPSTACK-PLAN.md index 2c96455..38d035a 100644 --- a/OPSTACK-PLAN.md +++ b/OPSTACK-PLAN.md @@ -400,11 +400,19 @@ Port each archetype **the next time a feature makes you open it anyway**. The sw Suggested order when there's a free choice — cheapest and least risky first: ✅ `Maze` (P1) → ✅ `FlatPlain`/`CrystalChamber` (one op, two default sets — the first real win: two -archetypes collapse into one; **done**, `BuildSlabStack`, 8 archetypes → 7) → ✅ `SurfaceWorld` (**done**, incl. biomes — needed a whole second op family, `VoxelHeightOp.h`) → ✅ `VerticalShafts` (**done**, 3 ops reused from Maze unchanged) (biggest payoff, biggest care: the T1.a column cache and the exact- -lattice `ClassifyTile` bound must both survive) → `VerticalShafts` → `FloatingIslands` → `TunnelNetwork` +archetypes collapse into one; **done**, `BuildSlabStack`, 8 archetypes → 7) → ✅ `SurfaceWorld` +(**done**, incl. biomes — needed a whole second op family, `VoxelHeightOp.h`; biggest payoff, biggest +care: the T1.a column cache and the exact-lattice `ClassifyTile` bound both survived) → +✅ `VerticalShafts` (**done**, 3 ops reused from Maze unchanged) → +✅ `FloatingIslands` (**done**, `BuildFloatingIslandStack` — the stack that runs **backwards**: void +source + fill instead of rock source + carve, the *same* classes with the opposite sign; only the +blob source is new) → `Underwater` (TunnelNetwork + a flag) → `TunnelNetwork` (**last** — it owns `BuildChunkCache`'s two-region window-invariance discipline, §8.4, the most delicate code in the plugin). +**6 of 8 ported.** The two that remain are really one: `Underwater` *is* TunnelNetwork plus +`WaterLevelRelative` (§8), so the switch loses its last two cases in a single port. + Along the way, `FStrateGenerationParams`' 74 fields decompose into per-op structs, which retires the `VF_STRATE_PARAM_FIELDS` X-macro drift problem for free. diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 76d1cad..35968d2 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -1797,3 +1797,109 @@ things Phase 2 invented (height space, `IVoxelBiomeField`), and the method lesso in build cycles. --- + +## 2026-07-28 — FloatingIslands ported. 6 of 8. The stack runs BACKWARDS, and §C1 was NOT closed. + +**The portage that tests the AXIS, not the fidelity.** `VerticalShafts` measured reuse *by identity* +— three of Maze's ops, not a line changed. This one measures something stronger and riskier for the +abstraction: **reuse by INVERSION**. + +The four archetypes ported so far all start from ROC and CARVE. FloatingIslands starts from the VOID +and FILLS. If the abstract axis chosen back in §0.1 — the *sign* of the internal density — is the +right one, then both ends of the pile must be the same operators negated: + +``` +FConstantFieldSource(+Base) ←→ FConstantFieldSource(-Base) ClassifyBox: AllSolid ←→ AllAir +FSdfConvertOp(Sign = -1) ←→ FSdfConvertOp(Sign = +1) carve ←→ fill +FSdfRoughnessMod ←→ FSdfRoughnessMod 4ᵉ archétype, inchangé +``` + +And it holds: **the only new operator in this port is the island blob.** 7 ops total. + +Two classes were merged rather than duplicated (`FConstantRockSource` → `FConstantFieldSource`, +`FSdfCarveOp` → `FSdfConvertOp`), each with two factories so the *authoring* vocabulary keeps saying +"rock"/"void" and "carve"/"fill". Multiplying by ±1 is exact in IEEE-754, so the three green ports +are bit-for-bit untouched — that claim is load-bearing and the next run tests it. + +### `ClassifyBox` can say **AllAir** for the first time + +No cave archetype has ever proved "all air"; `FConstantVoidSource` can, trivially and exactly, and a +floating-island strate is *by construction* mostly empty. That is `OPSTACK-DECOMPOSITION §7`'s claim, +and the test counts AllSolid and AllAir **separately** — an aggregate "N proved" would have hidden +precisely the number that matters. If AllAir comes back 0, the stack is still sound and the perf +argument simply did not fire (the VerticalShafts situation); the test says so out loud rather than +looking green. + +### ⚠️ The bound is ONE-SIDED, and assuming otherwise would have been a hole + +`Sdf ≥ WorldZ − TopSurf` bounds an island from above. **There is no bound from below:** under an +island the SDF degenerates to ≈ `DistXY`, so a hairline thread of matter hangs down the axis to the +strate floor. Rejecting a box for sitting below an island would be a hole in the original's own +geometry. Only the top rejects. + +Second trap, caught by doing the arithmetic rather than eyeballing it: the domain warp displaces X +and Y with **two independent** noise samples, so the point moves along the diagonal — the pad needs +`WarpAmp·√2`, not `WarpAmp`. A 1× pad is wrong by 41 % exactly where both noises saturate together: +rare, plausible-looking, and effectively unreachable by random testing. + +### ⚠️⚠️ `AUDIT §C1` was reported closed on 2026-07-27. It was one site short. + +Found by reading `GetFloatingIslandDensity` line by line to port it: + +```cpp +const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, ...)); +``` + +**The sweep matched `SeedF * K`; this site spells it `(float)S * K`.** A textual sweep finds a +spelling, not a bug. And the property test could not compensate — `LargeSeedSurvives` asserts the +*heightfield* still varies, while this site perturbs an *island outline*: at seed 2e9 the term hits +~1.4e6, ULP 0.125 against a 0.04 voxel step, so the warp flattens and every island snaps back to a +perfect circle. Cosmetic, not catastrophic, which is exactly why nothing screamed for two days. + +Fixed in **both** paths in one pass so `FloatingIslandEquivalence` stays a valid oracle. Recorded in +`AUDIT §C1` with the sharp edge that came with it: `SeedOffset` quantises its site key by ×100, so +`0.0007f` lands on site **0** — unique today (every other key is ≥ 0.19), silently collidable +tomorrow. + +**This is the third time this session that a confident premise failed a check.** C1's *documented* +fix was wrong; "C9 is gone after FPSemantics" was wrong; now "C1 is closed, 0 left behind" was wrong. +The pattern is stable enough to plan around: **a claim about the code is evidence about whatever was +actually examined, and nothing else.** + +### Deviation from `§7`, stated + +`§7` sketched a `FRAME IslandWarp` wrapping the source. The warp stays **inside** the op. Frames are +worth building at the *second* real user, and two of the three (TunnelNetwork's cave warp, its tunnel +warp) are not ported. Designing an abstraction against one example is what this refactor has avoided +throughout — `IVoxelBiomeField` exists because a second, concrete need appeared. Revisit at +TunnelNetwork. + +Also carried over from the shaft port: the 3×3 memo key includes `BoundarySealThickness`, **which the +original omits** although `SpreadZ` reads it. Same family as `§C2` and as the overhang regression of +2026-07-27. Adding a field to a cache key can only cost a recompute; leaving one out costs a wrong +world, invisibly. + +**Ported: Maze · FlatPlain · CrystalChamber · SurfaceWorld (biomes incl.) · VerticalShafts · +FloatingIslands — 6 of 8.** The two remaining are really one: `Underwater` *is* TunnelNetwork plus +`WaterLevelRelative` (§8), so the `switch` loses both cases in a single port. + +**UNVERIFIED:** nothing here is compiled. Likely spots, in order: the two class renames +(`FConstantRockSource` / `FSdfCarveOp` no longer exist — every reference should go through a factory, +but a missed one is a clean C2065); `FFloatingIslandParams` reaching `VoxelDensityOpStack.cpp` +(it comes via `VoxelStrateTypes.h`, already included, so this should be free); the nested `FCells` +declared before its returning functions (the `FShaftFieldSource` C4430 trap, avoided deliberately); +and `MakeUnique` being called from the factory namespace, which is fine only +because the class sits above the end-of-anonymous-namespace line. + +**Next single action:** build, run the `VoxelForge` filter — **12 tests** now, the new one is +`VoxelForge.OpStack.FloatingIslandEquivalence`. Watch three numbers in its output: samples inside +island rock (0 ⇒ the equivalence proved only that two voids agree), the AllAir verdict count (0 ⇒ the +perf argument did not fire), and of course the diff count. + +**⚠️ EXPECT ISLAND SILHOUETTES TO CHANGE** wherever the seed is large — that is the C1 fix, it is +intended, and §2.6.1 covers it. + +Then `Underwater` + `TunnelNetwork` (§8 / §2, **last**, with §8.4's window-invariance discipline). +Perf still parked by Jahni until the transition is complete. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp new file mode 100644 index 0000000..5a8b21d --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp @@ -0,0 +1,311 @@ +// VoxelForgeOpStackIslandTest.cpp +// FloatingIslands — le portage qui fait tourner la pile À L'ENVERS. +// FloatingIslands — the port that runs the stack BACKWARDS. +// +// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES +// `VerticalShaftEquivalence` a mesuré la réutilisation À L'IDENTIQUE : trois opérateurs de Maze +// repris sans une ligne de changement. Celui-ci mesure quelque chose de plus fort, et de plus +// risqué pour l'abstraction : **la réutilisation PAR INVERSION**. +// +// Les quatre archétypes déjà portés partent tous de ROC et CREUSENT. FloatingIslands part du VIDE +// et REMPLIT. Si l'axe abstrait choisi (le SIGNE de la densité, convention interne positif = solide) +// est le bon, alors les deux extrémités de la pile doivent être les MÊMES opérateurs au signe près : +// +// FConstantFieldSource(+Base) ←→ FConstantFieldSource(-Base) +// FSdfConvertOp(Sign = -1) ←→ FSdfConvertOp(Sign = +1) +// +// Et c'est le cas : le seul opérateur neuf de ce portage est le blob d'île. Un archétype qui se +// réutilise en s'INVERSANT est une preuve plus forte qu'un archétype qui se réutilise à l'identique +// — le premier dit que l'abstraction a trouvé le bon axe, le second seulement que deux archétypes +// se ressemblaient. +// +// ET LE VERDICT DE BOÎTE : c'est ici que `ClassifyBox` peut rendre **AllAir** pour la première fois +// de tout le plugin. Une strate d'îles flottantes est, par construction, surtout vide ; aucun +// archétype de grotte n'a jamais su prouver « tout air » (`OPSTACK-DECOMPOSITION §7`). Le test +// compte les deux verdicts SÉPARÉMENT, parce qu'un total agrégé masquerait exactement ce gain-là. +// +// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10). + +#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( + FVoxelForgeOpStackIslandTest, + "VoxelForge.OpStack.FloatingIslandEquivalence", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + constexpr int32 NumIslandSamples = 20000; + + /** + * Les défauts génèrent bien des îles, mais un test qui les prend tels quels laisse la question + * « les échantillons sont-ils VRAIMENT tombés dedans ? » à la chance du seed. On force donc une + * densité d'îles haute, et surtout un `TopFlatten < 1` — la branche du dôme de bord est le seul + * endroit où `TopHalf` et `Edge²` interviennent, et elle est silencieusement morte à 1.0. + * (Même piège que `WaterLevelRelative` et la fenêtre d'overhang : un paramètre au repos est un + * opérateur non testé.) + */ + void EnableIslandFeatures(FFloatingIslandParams& P) + { + P.IslandDensity = 0.75f; // des îles dans presque chaque cellule du 3×3 + P.TopFlatten = 0.55f; // < 1 ⇒ la branche du dôme de bord s'exécute + P.SurfaceRoughness = 4.0f; // la rugosité SDF partagée avec Maze et VerticalShafts + P.VerticalJitter = 0.6f; // des îles à des hauteurs différentes + P.ThicknessRatio = 0.7f; + } +} + +bool FVoxelForgeOpStackIslandTest::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::SlotFloatingIsland, TopVoxelZ, BottomVoxelZ)) + { + AddError(TEXT("The fixture layout has no FloatingIslands slot. Check FTestWorld::Build's ") + TEXT("Archetypes[] against FTestWorld::SlotFloatingIsland.")); + return false; + } + + const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE; + FFloatingIslandParams P = World.StrateManager->GetFloatingIslandParamsForChunk( + FIntVector(0, 0, MidChunkZ)); + + if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f) + { + AddError(TEXT("The FloatingIslands strate has degenerate Z bounds, which sends ") + TEXT("GetFloatingIslandDensity down its early-out. The op stack has none by design.")); + return false; + } + + EnableIslandFeatures(P); + + FVoxelOpStack Stack; + VoxelDensityOps::BuildFloatingIslandStack(Stack, P, World.Settings->Seed, + Gen->OriginSpineRadius, World.StrateManager.Get()); + + // void + blobs + roughness + fill + 3 structurels. + TestEqual(TEXT("the island stack is decomposed into 7 ops"), Stack.Num(), 7); + + FVoxelOpContext Ctx; + Ctx.Seed = (uint32)World.Settings->Seed; + Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion(); + Ctx.StrateTopWorldZ = P.StrateTopWorldZ; + Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ; + Stack.PrepareChunk(Ctx); + + TArray Points; + Points.Reserve(NumIslandSamples); + { + FRandomStream Rng(60186); + for (int32 i = 0; i < NumIslandSamples; ++i) + { + Points.Add(FVector( + (float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE), + (float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE), + (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ))); + } + } + + //========================================================================= + // 1. ÉQUIVALENCE + //========================================================================= + // On compte SÉPARÉMENT le solide d'intérieur et le solide de seal : sur cet archétype la + // quasi-totalité du volume est de l'air, donc un « N solides » agrégé serait dominé par les + // deux bandes de seal et ne dirait RIEN sur les îles elles-mêmes. + const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness; + const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness; + + int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1; + int32 NumInsideIsland = 0, NumOpenVoid = 0; + float WorstDelta = 0.0f; + + for (int32 i = 0; i < NumIslandSamples; ++i) + { + const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; + + const float Old = Gen->GetFloatingIslandDensity(X, Y, Z, P); + const float New = Stack.EvalMC(X, Y, Z); + + const bool bInterior = (Z > InnerBot && Z < InnerTop); + if (bInterior && Old < 0.0f) { ++NumInsideIsland; } // solide loin des seals ⇒ une île + if (bInterior && Old >= 0.0f) { ++NumOpenVoid; } + + 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("FloatingIslands: bit-identical across %d samples (%d inside island rock away from ") + TEXT("the seal bands, %d in open void, so the void source, the blobs, the roughness and ") + TEXT("the fill were all exercised). The stack runs BACKWARDS -- void source + fill ") + TEXT("instead of rock source + carve -- using the SAME operators with the opposite ") + TEXT("sign. Only the blob source is new (OPSTACK-PLAN 2.5)."), + NumIslandSamples, NumInsideIsland, NumOpenVoid)); + } + else + { + AddError(FString::Printf( + TEXT("FloatingIslands: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ") + TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ") + TEXT("this is a real port error. Check, in order: the C1 warp fix (BOTH paths must now ") + TEXT("use VoxelHash::SeedOffset(S, 0.0007f) -- if only one was changed, EVERY warped ") + TEXT("sample differs), then the SdfConvert SIGN (+1 fills, -1 carves), then the 'Isld' ") + TEXT("salt (0x49736C64), the roughness frequency (0.08 / 4 octaves here, NOT Maze's ") + TEXT("0.12 / 3), the per-island TaperEnd and TopFlatten dome branch, and the ") + TEXT("SmoothMin blend K = max(SDFBlendRadius, 0.01)."), + NumDiff, NumIslandSamples, 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 (NumInsideIsland == 0) + { + AddWarning(TEXT("No sample landed inside island rock away from the seal bands, so the blob ") + TEXT("source and the fill were never meaningfully exercised -- the equivalence ") + TEXT("above then only proves that two empty voids agree. Raise IslandDensity or ") + TEXT("IslandMaxRadius.")); + } + + //========================================================================= + // 2. INVARIANCE DE FENÊTRE + //========================================================================= + // La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params — et cette clé + // inclut délibérément `BoundarySealThickness`, que l'original omet alors que `SpreadZ` le lit + // (voir la note dans FIslandBlobSource::GetCells). + { + std::atomic Impure{ 0 }; + const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); + + TArray Ref; + Ref.SetNumUninitialized(NumIslandSamples); + for (int32 i = 0; i < NumIslandSamples; ++i) + { + Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + } + + ParallelFor(NumBlocks, [&](int32 Block) + { + TArray LocalOrder; + BuildShuffledOrder(NumIslandSamples, 3300 + 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 island stack is window-invariant across order and threads"), + Impure.load(), 0); + } + + //========================================================================= + // 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin + //========================================================================= + { + int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0; + FRandomStream Rng(24680); + + for (int32 t = 0; t < 60; ++t) + { + const int32 Step = 1, Cells = 8; + const int32 Extent = Step * Cells; + const FIntVector Origin( + Rng.RandRange(-6, 6) * Extent, + Rng.RandRange(-6, 6) * 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)); + + const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx); + if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; } + + const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); + if (bClaimsSolid) { ++NumProvedSolid; } else { ++NumProvedAir; } + + for (int32 gz = -1; gz <= GridDim; ++gz) + for (int32 gy = -1; gy <= GridDim; ++gy) + for (int32 gx = -1; gx <= GridDim; ++gx) + { + const float X = (float)(Origin.X + gx * Step); + const float Y = (float)(Origin.Y + gy * Step); + const float Z = (float)(Origin.Z + gz * Step); + const float D = Stack.EvalMC(X, Y, Z); + if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) + { + if (NumUnsound == 0) + { + AddError(FString::Printf( + TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ") + TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ") + TEXT("order: the blob source's Pad (does it cover the WARP amplitude ") + TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ") + TEXT("then the Z bound -- note there is NO lower bound, a thin thread ") + TEXT("of matter hangs below each island down the axis, so only the ") + TEXT("TOP may be used to reject."), + bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), + Origin.X, Origin.Y, Origin.Z, X, Y, Z, D, + (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID"))); + } + ++NumUnsound; + gz = gy = gx = GridDim + 1; + } + } + } + + TestEqual(TEXT("every box verdict the island stack emits survives brute force"), NumUnsound, 0); + + AddInfo(FString::Printf( + TEXT("Box verdicts over 60 FloatingIslands tiles: %d proved AllSolid, %d proved AllAir, ") + TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ") + TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-") + TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."), + NumProvedSolid, NumProvedAir, NumMixed)); + + if (NumProvedAir == 0) + { + AddWarning(TEXT("Zero tiles proved AllAir. The stack is still SOUND, but the whole perf ") + TEXT("argument for this archetype rests on that verdict, so it is worth ") + TEXT("knowing it did not fire. Most likely the blob source's Pad is so wide ") + TEXT("that every box finds an island within reach -- the same pessimism ") + TEXT("VerticalShafts has (0 of 60), for the same reason.")); + } + } + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index f7cdc91..2677ac1 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -42,14 +42,24 @@ namespace } //========================================================================= - // RÔLE 1 — SOURCE : ROC CONSTANT / CONSTANT ROCK + // RÔLE 1 — SOURCE : CHAMP CONSTANT / CONSTANT FIELD (roc ET vide) //========================================================================= // `float Density = Params.BaseDensity; // start solid` — la première ligne de TunnelNetwork, - // de Maze ET de VerticalShafts. Trois archétypes, une ligne, désormais un opérateur. - class FConstantRockSource final : public IVoxelDensityOp + // de Maze ET de VerticalShafts. Et `float Density = -Params.BaseDensity; // open air (void)` — + // la première ligne de FloatingIslands. **C'est le MÊME opérateur au signe près**, et le signe + // n'est pas un détail : il décide du verdict de boîte de départ (AllSolid contre AllAir), donc + // de ce que la strate saura sauter. + // + // Quatre archétypes, une ligne, un opérateur. Deux fabriques (`MakeConstantRockSource` / + // `MakeConstantVoidSource`) parce que « roc » et « vide » sont ce que l'auteur veut DIRE ; la + // classe, elle, n'a aucune raison d'exister en deux exemplaires. + // + // One operator, two factories: rock and void are the same constant field with opposite signs, + // and the sign is what decides the starting box verdict (AllSolid vs AllAir). + class FConstantFieldSource final : public IVoxelDensityOp { public: - explicit FConstantRockSource(float InBaseDensity) : BaseDensity(InBaseDensity) {} + explicit FConstantFieldSource(float InValue) : Value(InValue) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -57,23 +67,28 @@ namespace void Eval(float, float, float, FVoxelOpSample& InOut) const override { - InOut.Density = BaseDensity; // Replace : racine de pile, ignore l'entrée + InOut.Density = Value; // Replace : racine de pile, ignore l'entrée } - // Exact et gratuit : une constante positive est solide partout. C'est ce qui donne aux - // strates de grotte une hypothèse AllSolid de départ — elles n'en ont jamais eu. + // Exact et gratuit, dans les DEUX sens (convention interne : positif = solide). + // Positif ⇒ AllSolid : c'est ce qui donne aux strates de grotte une hypothèse de départ + // qu'elles n'ont jamais eue. Négatif ⇒ AllAir : c'est ce qui rend une strate d'îles + // flottantes — un grand vide surtout vide — sautable là où aucune île n'arrive. EVoxelTileClass ClassifyBox(const FBox&, const FVoxelOpContext&) const override { - return (BaseDensity > 0.0f) ? EVoxelTileClass::AllSolid : EVoxelTileClass::Mixed; + if (Value > 0.0f) { return EVoxelTileClass::AllSolid; } + if (Value < 0.0f) { return EVoxelTileClass::AllAir; } + return EVoxelTileClass::Mixed; // exactement 0 : le mesher le compte du côté AIR, + // mais un champ nul n'est pas une hypothèse utile. } EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant + return EVoxelOpEffect::Both; // jamais atteint sauf Value == 0 : ClassifyBox répond avant } private: - float BaseDensity; + float Value; }; //========================================================================= @@ -1003,13 +1018,26 @@ namespace }; //========================================================================= - // RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE) + // RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE et FILL) //========================================================================= - // Les six mêmes lignes dans TunnelNetwork, Maze et VerticalShafts. Une fois ici, plus jamais. - class FSdfCarveOp final : public IVoxelDensityOp + // Les six mêmes lignes dans TunnelNetwork, Maze, VerticalShafts — et FloatingIslands, où le + // SEUL changement est `Density += Fill·Base·2` au lieu de `Density -= Carve·Base·2`. + // + // Un archétype qui CREUSE dans du roc et un archétype qui REMPLIT du vide sont donc le même + // opérateur au signe près, exactement comme la source constante au-dessus. C'est la symétrie + // que le `switch` ne pouvait pas montrer : les deux blocs y sont à 900 lignes l'un de l'autre. + // + // ⚠️ `Sign` vaut ±1.0f et rien d'autre. La multiplication par ±1 est EXACTE en IEEE-754, donc + // `D += (-1·F)·B·2` rend bit pour bit ce que `D -= F·B·2` rendait — l'égalité binaire des trois + // portages déjà verts en dépend. + // + // Same operator, opposite sign. Multiplying by ±1 is exact in IEEE-754, so the carve path is + // bit-for-bit what it was before this generalisation — the three green ports depend on that. + class FSdfConvertOp final : public IVoxelDensityOp { public: - FSdfCarveOp(float InBlend, float InBaseDensity) : Blend(InBlend), BaseDensity(InBaseDensity) {} + FSdfConvertOp(float InBlend, float InBaseDensity, float InSign) + : Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -1017,9 +1045,9 @@ namespace void Eval(float, float, float, FVoxelOpSample& InOut) const override { if (InOut.Sdf >= Blend) { return; } - float Carve = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f); - Carve = SmoothStep01(Carve); - InOut.Density -= Carve * BaseDensity * 2.0f; // interne : baisser = vers l'air + float T = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f); + T = SmoothStep01(T); + InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide } EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override @@ -1028,7 +1056,7 @@ namespace } private: - float Blend, BaseDensity; + float Blend, BaseDensity, Sign; }; //========================================================================= @@ -1429,6 +1457,240 @@ namespace const FShaftFieldSource* Field; // NON possédant : la pile possède la source }; + //========================================================================= + // RÔLE 1 — SOURCE : ÎLES FLOTTANTES / FLOATING ISLAND BLOBS + //========================================================================= + // Le SEUL archétype dont la source est de l'AIR : `FConstantFieldSource(-BaseDensity)` pose un + // grand vide, et cet opérateur y suspend des blobs. C'est ce qui en fait le bon test de + // composition — tous les autres portages partent de roc et creusent. + // + // FORME D'UNE ÎLE : une dalle assez plate au-dessus du centre (`TopHalf = 0.20·Rxy`) et un + // dessous qui s'effile vers une pointe (`ThicknessRatio·Rxy`). C'est l'asymétrie qui se lit + // comme une île flottante plutôt que comme une sphère. + // + // ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §7`, qui décrivait un `FRAME IslandWarp` enveloppant + // la source. Le warp reste À L'INTÉRIEUR de l'opérateur, et c'est délibéré : `§7` compte trois + // usages de frames (îles, caves de TunnelNetwork, tunnels), mais **deux d'entre eux ne sont pas + // encore portés**. Inventer l'infrastructure de frame pour son unique utilisateur actuel, c'est + // la concevoir contre un seul exemple — précisément ce que ce refactor a évité jusqu'ici en + // n'abstrayant qu'à la deuxième occurrence (cf. `IVoxelBiomeField`, né d'un besoin réel). + // À reprendre quand TunnelNetwork arrivera avec le deuxième usage réel. + // + // The warp stays INSIDE the op against §7's FRAME suggestion: two of the three frame users are + // not ported yet, and designing the abstraction against a single example is what this refactor + // has deliberately avoided. Revisit when TunnelNetwork brings the second real use. + class FIslandBlobSource final : public IVoxelDensityOp + { + public: + FIslandBlobSource(const FFloatingIslandParams& InP, int32 Seed, float InExtraReach) + : P(InP), Salt((uint32)Seed ^ 0x49736C64u) // 'Isld' — identique à GetFloatingIslandDensity + , ExtraReach(InExtraReach) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + void PrepareChunk(const FVoxelOpContext&) override {} + + struct FIsland { float X, Y, Rxy, TopHalf, TopZ, BotZ, TaperEnd; }; + + // ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie — même piège que `FShaftFieldSource` + // (C4430 : les corps de méthodes sont différés, les types de retour non). + struct FCells { TArray> Islands; }; + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f); + const FCells& C = GetCells(WorldX, WorldY); + + // CONTOUR IRRÉGULIER : on déforme la requête HORIZONTALE pour que les bords des îles + // soient lobés au lieu d'être des cercles parfaits. Calculé une fois par voxel et + // partagé par toutes les îles proches — chacune échantillonne une autre partie du champ, + // d'où des silhouettes distinctes. + const float WarpAmp = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f; + const float WX = WorldX + HFractal3D(FVector(WorldX * 0.04f + VoxelHash::SeedOffset(Salt, 0.0007f), + WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) + * VOXEL_NOISE_SCALE * WarpAmp; + const float WY = WorldY + HFractal3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, + WorldZ * 0.012f), VoxelGenLOD::Eff(3)) + * VOXEL_NOISE_SCALE * WarpAmp; + + float IslandSDF = FLT_MAX; + for (const FIsland& Isl : C.Islands) + { + // Distance horizontale dans le repère DÉFORMÉ, donc le contour n'est pas un cercle. + const float Dxw = WX - Isl.X, Dyw = WY - Isl.Y; + const float DistXY = FMath::Sqrt(Dxw * Dxw + Dyw * Dyw); + + // Enveloppe de rayon par la hauteur : pleine largeur en haut, resserrée jusqu'à une + // pointe en bas (taper SmoothStep). + const float Hgt = FMath::Clamp((WorldZ - Isl.BotZ) / FMath::Max(Isl.TopZ - Isl.BotZ, 1.0f), + 0.0f, 1.0f); + const float Taper = SmoothStep01(FMath::Clamp(Hgt / Isl.TaperEnd, 0.0f, 1.0f)); + const float Env = Isl.Rxy * Taper; + + // Surface du dessus : plate par défaut ; les bords retombent en dôme si TopFlatten < 1. + float TopSurf = Isl.TopZ; + if (P.TopFlatten < 1.0f) + { + const float Edge = FMath::Clamp(DistXY / FMath::Max(Isl.Rxy, 1.0f), 0.0f, 1.0f); + TopSurf = Isl.TopZ - (1.0f - P.TopFlatten) * Isl.TopHalf * 2.0f * Edge * Edge; + } + + // Pseudo-SDF : dehors si au-delà de l'enveloppe radiale OU au-dessus du dessus. + const float Sdf = FMath::Max(DistXY - Env, WorldZ - TopSurf); + + IslandSDF = VoxelSDF::SmoothMin(IslandSDF, Sdf, BlendK); + } + + InOut.Sdf = IslandSDF; + } + + /** + * `FillOnly` si une île peut atteindre la boîte, `Identity` sinon — et sur une strate d'îles + * `Identity` est le cas COURANT, ce qui est tout l'intérêt : combiné à l'`AllAir` de la + * source constante, c'est la première fois qu'un archétype de grotte peut prouver « tout air » + * (`OPSTACK-DECOMPOSITION §7`). + * + * BORNE, et pourquoi elle est sûre dans les deux directions : + * • en XY, `Sdf ≥ DistXY − Rxy` (l'enveloppe ne dépasse jamais `Rxy`), et le warp déplace + * le POINT de `WarpAmp · VOXEL_NOISE_SCALE · √2` au plus (FBM ∈ [−1,1] sur DEUX axes + * indépendants — voir la note √2 dans le corps) ; + * • en Z, `Sdf ≥ WorldZ − TopSurf ≥ WorldZ − TopZ`, donc au-dessus du sommet + marge il + * n'y a plus rien à faire. **En dessous, il n'y a PAS de borne** : sous une île, le SDF + * vaut ≈ `DistXY` à toute profondeur, donc un mince fil de matière descend le long de + * l'axe. C'est le comportement de l'original ; le confondre avec « rien en dessous » + * serait un TROU, et c'est pourquoi seule la borne HAUTE est testée. + * • `ExtraReach` couvre l'aval (rugosité, blend du fill, creux du SmoothMin ≤ K/6). + */ + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + // ⚠️ √2, PAS 1×. Le warp déplace X et Y par DEUX échantillons de bruit INDÉPENDANTS, + // chacun borné par `WarpAmp · VOXEL_NOISE_SCALE`. Le déplacement du POINT est donc la + // diagonale, `WarpMax·√2`, et non `WarpMax`. Une marge à 1× serait fausse de 41 % dans + // le pire cas — c'est-à-dire un trou dans le coin exact où les deux bruits saturent + // ensemble. Rare, et c'est précisément ce qui rendrait le bug injoignable en test. + // TWO independent noise samples ⇒ the point displacement is the diagonal, not one axis. + constexpr float Sqrt2 = 1.4142136f; + const float WarpMax = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f + * VOXEL_NOISE_SCALE * Sqrt2; + const float Pad = ExtraReach + FMath::Abs(WarpMax); + const float MaxR = FMath::Max(P.IslandMinRadius, P.IslandMaxRadius); + + const float Spacing = FMath::Max(P.IslandSpacing, 1.0f); + const FBox Padded = VoxelBox.ExpandBy(MaxR + Pad); + const int32 CX0 = FMath::FloorToInt((float)Padded.Min.X / Spacing); + const int32 CX1 = FMath::FloorToInt((float)Padded.Max.X / Spacing); + const int32 CY0 = FMath::FloorToInt((float)Padded.Min.Y / Spacing); + const int32 CY1 = FMath::FloorToInt((float)Padded.Max.Y / Spacing); + + for (int32 cy = CY0; cy <= CY1; ++cy) + for (int32 cx = CX0; cx <= CX1; ++cx) + { + FIsland Isl; + if (!RollIsland(cx, cy, Isl)) { continue; } + + // Entièrement au-dessus du sommet de l'île (+ marge) ⇒ hors d'atteinte. + if ((float)VoxelBox.Min.Z > Isl.TopZ + Pad) { continue; } + + const float R = Isl.Rxy + Pad; + const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Isl.X, + Isl.X - (float)VoxelBox.Max.X)); + const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Isl.Y, + Isl.Y - (float)VoxelBox.Max.Y)); + if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::FillOnly; } + } + + return EVoxelOpEffect::Identity; + } + + private: + /** Tirage d'une cellule. PURE en (cellule, seed, params) ⇒ `Eval` et `EffectOverBox` ne + * peuvent pas voir des îles différentes. Transcription littérale du bloc de cuisson de + * `GetFloatingIslandDensity`. */ + bool RollIsland(int32 nx, int32 ny, FIsland& Out) const + { + const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ; + const float Spacing = FMath::Max(P.IslandSpacing, 1.0f); + const float MidZ = (P.StrateTopWorldZ + P.StrateBottomWorldZ) * 0.5f; + + const uint32 Hh = VoxelHash::Cell(nx, ny, Salt); + if (VoxelHash::ToFloat01(Hh) > P.IslandDensity) { return false; } + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); + + Out.X = (nx + 0.15f + JX * 0.7f) * Spacing; + Out.Y = (ny + 0.15f + JY * 0.7f) * Spacing; + Out.Rxy = FMath::Lerp(P.IslandMinRadius, P.IslandMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au))); + + // PROFIL ASYMÉTRIQUE : dalle de terre au-dessus, dessous qui s'effile en pointe. + Out.TopHalf = Out.Rxy * 0.20f; + const float UnderDepth = Out.Rxy * FMath::Max(P.ThicknessRatio, 0.25f); + + const float SpreadZ = FMath::Max(H * 0.5f - FMath::Max(Out.TopHalf, UnderDepth) + - P.BoundarySealThickness, 0.0f) * P.VerticalJitter; + const float Cz = MidZ + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ; + Out.TopZ = Cz + Out.TopHalf; + Out.BotZ = Cz - UnderDepth; + + // Netteté du taper par île (point d'arrivée du SmoothStep) → silhouettes variées. + Out.TaperEnd = FMath::Lerp(0.45f, 0.7f, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu))); + return true; + } + + /** + * Le voisinage 3×3, mémoïsé par worker — la même cuisson `thread_local` que l'original. + * + * ⚠️ LA CLÉ INCLUT `BoundarySealThickness`, QUE L'ORIGINAL OMET. `SpreadZ` s'en sert + * (`H·0.5 − max(TopHalf, UnderDepth) − Seal`), donc dans `GetFloatingIslandDensity` une + * édition à chaud qui ne change QUE l'épaisseur de seal sert des îles périmées. Même famille + * que `AUDIT §C2` et que la régression d'overhang du 2026-07-27 : une clé de cache + * incomplète ne se voit pas, elle produit du terrain plausible. Ajouter le champ ne coûte + * qu'un recalcul, jamais une valeur différente — donc l'égalité binaire tient. + * + * The key includes BoundarySealThickness, which the original omits although SpreadZ reads it. + * Adding it can only cost a recompute, never change a value — bit-equality is unaffected. + */ + const FCells& GetCells(float WorldX, float WorldY) const + { + const float Spacing = FMath::Max(P.IslandSpacing, 1.0f); + const int32 CX = FMath::FloorToInt(WorldX / Spacing); + const int32 CY = FMath::FloorToInt(WorldY / Spacing); + + thread_local FCells Cache; + thread_local int32 FI_CX = INT32_MAX, FI_CY = INT32_MAX; + thread_local uint32 FI_Salt = 0xFFFFFFFFu; + thread_local float FI_Spacing = -1.0f, FI_Dens = -1.0f, FI_MinR = -1.0f, FI_MaxR = -1.0f, + FI_Thick = -1.0f, FI_VJit = -1.0f, FI_Seal = -1.0f, + FI_BotZ = FLT_MAX, FI_TopZ = FLT_MAX; + + if (CX != FI_CX || CY != FI_CY || Salt != FI_Salt || Spacing != FI_Spacing || + P.IslandDensity != FI_Dens || P.IslandMinRadius != FI_MinR || + P.IslandMaxRadius != FI_MaxR || P.ThicknessRatio != FI_Thick || + P.VerticalJitter != FI_VJit || P.BoundarySealThickness != FI_Seal || + P.StrateBottomWorldZ != FI_BotZ || P.StrateTopWorldZ != FI_TopZ) + { + FI_CX = CX; FI_CY = CY; FI_Salt = Salt; FI_Spacing = Spacing; + FI_Dens = P.IslandDensity; FI_MinR = P.IslandMinRadius; FI_MaxR = P.IslandMaxRadius; + FI_Thick = P.ThicknessRatio; FI_VJit = P.VerticalJitter; + FI_Seal = P.BoundarySealThickness; + FI_BotZ = P.StrateBottomWorldZ; FI_TopZ = P.StrateTopWorldZ; + Cache.Islands.Reset(); + + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + FIsland Isl; + if (RollIsland(CX + dx, CY + dy, Isl)) { Cache.Islands.Add(Isl); } + } + } + return Cache; + } + + FFloatingIslandParams P; + uint32 Salt; + float ExtraReach; + }; + } // ⚠️ 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 @@ -1459,7 +1721,14 @@ namespace VoxelDensityOps { TUniquePtr MakeConstantRockSource(float BaseDensity) { - return MakeUnique(BaseDensity); + return MakeUnique(BaseDensity); + } + + TUniquePtr MakeConstantVoidSource(float BaseDensity) + { + // `float Density = -Params.BaseDensity; // start as open air (void)` — la négation unaire + // est exacte, donc c'est littéralement la première ligne de GetFloatingIslandDensity. + return MakeUnique(-BaseDensity); } TUniquePtr MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float ExtraReach) @@ -1475,7 +1744,12 @@ namespace VoxelDensityOps TUniquePtr MakeSdfCarve(float Blend, float BaseDensity) { - return MakeUnique(Blend, BaseDensity); + return MakeUnique(Blend, BaseDensity, -1.0f); + } + + TUniquePtr MakeSdfFill(float Blend, float BaseDensity) + { + return MakeUnique(Blend, BaseDensity, +1.0f); } TUniquePtr MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) @@ -1563,6 +1837,39 @@ namespace VoxelDensityOps P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); } + void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + { + // ⚠️ LA PILE QUI S'INVERSE, et c'est la mesure que ce portage-ci ajoute : les quatre autres + // archétypes partent de ROC et CREUSENT ; celui-ci part du VIDE et REMPLIT. Aucune des deux + // extrémités n'a demandé un opérateur neuf — la source constante et la conversion SDF→densité + // sont les MÊMES classes, au signe près (`FConstantFieldSource`, `FSdfConvertOp`). Un + // opérateur qui se réutilise en s'inversant est une preuve plus forte qu'un opérateur qui se + // réutilise à l'identique : ça veut dire que l'axe abstrait (le signe de la densité) est le + // bon, pas seulement que deux archétypes se ressemblaient. + // + // The stack that runs BACKWARDS: four archetypes start from rock and carve, this one starts + // from void and fills — and neither end needed a new operator, only the opposite sign. + const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f); + + // Portée que la source doit déclarer pour la paire source+fill : la rugosité peut abaisser + // le SDF de `Rough·VOXEL_NOISE_SCALE` (FBM ∈ [-1,1]), le SmoothMin de `K/6` de plus, et le + // fill s'applique dès `Sdf < BlendK`. Sur-estimer coûte du CPU ; sous-estimer serait un trou. + const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE + + BlendK * 2.0f + 1.0f; + + OutStack.Add(MakeConstantVoidSource(P.BaseDensity)); + OutStack.Add(MakeUnique(P, Seed, ExtraReach)); + // Fréquence 0.08 et 4 octaves — les constantes de `GetFloatingIslandDensity`. Quatrième + // archétype à réutiliser cet opérateur (Maze 0.12/3, VerticalShafts 0.1/3). + OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.08f, 4, + P.SurfaceRoughness + BlendK + 2.0f)); + OutStack.Add(MakeSdfFill(BlendK, P.BaseDensity)); + + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + } + void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) { diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index e8078a8..65a579d 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -664,6 +664,20 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, Seed, OriginSpineRadius, StrateManager); break; + + case ECaveGeneratorType::FloatingIslands: + // Même garde de strate dégénérée : GetFloatingIslandDensity court-circuite sur + // `return 1.0f` (= air) quand la hauteur est nulle ou négative. + if (CP_Float.StrateTopWorldZ - CP_Float.StrateBottomWorldZ <= 0.0f) + { + CP_UseOpStack = false; + break; + } + OpCtx.StrateTopWorldZ = CP_Float.StrateTopWorldZ; + OpCtx.StrateBottomWorldZ = CP_Float.StrateBottomWorldZ; + VoxelDensityOps::BuildFloatingIslandStack(CP_OpStack, CP_Float, Seed, + OriginSpineRadius, StrateManager); + break; default: // UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc // on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch` @@ -3428,7 +3442,16 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa // organic instead of perfect circles. Computed once per voxel and shared by all nearby // islands (each samples a different part of the field → distinct silhouettes). const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f; - const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) + // ⚠️ AUDIT §C1 — DERNIER SITE DU PLUGIN, trouvé en portant cet archétype (2026-07-28). Le + // balayage du 2026-07-27 cherchait le motif `SeedF * K` et celui-ci s'écrit `(float)S * K`, donc + // il a survécu : à Seed = 2e9 le terme atteint ~1.4e6, où l'ULP du float vaut 0.125 contre un pas + // de 0.04 par voxel — le warp s'aplatit et les îles redeviennent des cercles parfaits. Corrigé + // dans les DEUX chemins (ici et FIslandBlobSource) en une passe, pour que le test d'équivalence + // reste un oracle valable. + // NOTE : `SeedOffset` quantifie la clé de site par ×100, donc 0.0007 → site 0. Unique aujourd'hui + // (toutes les autres clés du plugin sont ≥ 0.19) ; la prochaine clé sous 0.005 devra en choisir + // une autre plutôt que de collisionner en silence. + const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + VoxelHash::SeedOffset(S, 0.0007f), WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * WarpAmp; const float WY = WorldY + FractalNoise3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * WarpAmp; diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 12fab3b..59bf322 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -585,6 +585,11 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord case ECaveGeneratorType::VerticalShafts: return true; // Phase 2 — 3 ops repris de Maze tels quels + case ECaveGeneratorType::FloatingIslands: + // Phase 2 — la pile qui tourne à l'ENVERS : source de VIDE + fill, au lieu de source de ROC + // + carve, avec les MÊMES opérateurs au signe près. 6 des 8 portés. + return true; + default: return false; } } diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index d8910fe..e8711b9 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -5,18 +5,24 @@ // ⚠️ CECI ALIMENTE LE JEU, MAIS SEULEMENT SUR OPT-IN (depuis OPSTACK-PLAN §4, Phase 1, point 3). // `UVoxelGenerator::GetDensityAt` construit la pile par chunk et l'évalue à la place du `switch` // UNIQUEMENT quand `UVoxelStrateManager::UsesOperatorStackForChunk` rend true — c.-à-d. quand la -// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés (Maze -// seul aujourd'hui). Toute autre strate passe encore par le `switch`, inchangé. +// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés : +// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands (6 sur 8)**. +// Toute autre strate passe encore par le `switch`, inchangé. // `ClassifyTile` n'est PAS branché : il utilise toujours ses gardes écrites à la main, pas // `ClassifyBox`. C'est la Phase 2. // // THIS FEEDS THE GAME, BUT ONLY BEHIND AN OPT-IN. GetDensityAt builds the stack per chunk and -// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate -// ticked bUseOperatorStack AND its archetype is ported — Maze only, today). ClassifyTile is NOT -// wired: it still uses its hand-written guards rather than ClassifyBox. That is Phase 2. +// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate ticked +// bUseOperatorStack AND its archetype ported — 6 of 8). ClassifyTile is NOT wired: it still uses its +// hand-written guards rather than ClassifyBox. That is Phase 2. // -// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde, ni les comparer pour l'égalité : -// le résidu de ~1 ULP est INHÉRENT et documenté (AUDIT-2026-07 §C10). La barre est visuelle (§2.6). +// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde. +// ⚠️ EN REVANCHE, LES COMPARER EST DEVENU LÉGITIME — cette ligne disait l'inverse et elle est +// périmée. `AUDIT §C10` (le résidu ~1 ULP) est CLOS depuis `FPSemantics = Precise` : les cinq tests +// d'équivalence comparent bit à bit et sont verts. Ils ne sont plus des contrôles de FIDÉLITÉ (la +// barre `§2.6.1` n'exige aucune ressemblance avec l'ancien monde) mais des oracles de +// CORRECTION DE PORTAGE — une faute de transcription reste un vrai bug, et l'ancienne fonction est +// le moyen le moins cher de l'attraper. // // POURQUOI CETTE FORME / WHY THIS SHAPE // La question à laquelle la Phase 1 doit répondre n'est pas « est-ce que ça marche ? » mais @@ -161,6 +167,12 @@ namespace VoxelDensityOps * Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */ VOXELFORGE_API TUniquePtr MakeConstantRockSource(float BaseDensity); + /** Rôle 1 — le MÊME opérateur au signe près : `Density = -BaseDensity`, un grand vide ouvert. + * `ClassifyBox` → **AllAir**, ce qu'aucune source n'avait encore su rendre — c'est ce qui rend + * une strate d'îles flottantes (surtout vide) sautable là où aucune île n'arrive. Racine de + * FloatingIslands. */ + VOXELFORGE_API TUniquePtr MakeConstantVoidSource(float BaseDensity); + /** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D. * Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux * chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région @@ -184,6 +196,11 @@ namespace VoxelDensityOps * Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */ VOXELFORGE_API TUniquePtr MakeSdfCarve(float Blend, float BaseDensity); + /** 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 + * étant exacte en IEEE-754, le chemin carve reste bit pour bit ce qu'il était. */ + VOXELFORGE_API TUniquePtr MakeSdfFill(float Blend, float BaseDensity); + /** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis * OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un * `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z @@ -243,6 +260,22 @@ namespace VoxelDensityOps int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager); + /** + * FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** : + * ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3] + * + * Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et + * REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et + * `FSdfConvertOp` sont les mêmes classes au signe près, et `FSdfRoughnessMod` est repris sans + * une ligne de changement (4ᵉ archétype). Seul le blob d'île est nouveau. + * + * The stack that runs backwards: void source + fill instead of rock source + carve, using the + * SAME operators with the opposite sign. + */ + VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager); + /** * La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique : * ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post]