Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41ba3c34a9 | |||
| e002bd4e2e | |||
| 861dc8e109 | |||
| b2938d38f1 | |||
| 9733179723 | |||
| 87a0b996ec | |||
| f2fefade4c | |||
| 8043a613c3 | |||
| f537648867 | |||
| 03ddcde334 |
+27
-6
@@ -221,12 +221,33 @@ evaluates the second chunk against **the room list baked from the first chunk's
|
||||
(so "which archetype owns this chunk" stays unambiguous), which switches the blend off entirely.
|
||||
The one configuration the tests never build is the default one.
|
||||
|
||||
**Fix (unimplemented, deliberately — this is the `switch` path, not the port):** fold the params into
|
||||
the SDF cache key exactly as `FRoomGraphSource` already does — `FCrc::MemCrc32` over the params
|
||||
struct, plus `LayoutVersion`. `FStrateGenerationParams` is pure POD, so a memory CRC cannot produce a
|
||||
false *match*; at worst padding causes a needless rebuild. Err on CPU, never on a wrong room.
|
||||
Alternatively add chunk Z to the key, which is coarser (it rebuilds on every Z step even outside a
|
||||
blend band) but needs no CRC.
|
||||
#### ✅ FIXED 2026-07-28 (the SDF-cache half) — pending build
|
||||
|
||||
The params now travel into the key, and the shape of the fix is worth recording because the obvious
|
||||
version of it was the wrong one.
|
||||
|
||||
`GetDensityWithParams` takes **two new required arguments**, `ParamsFingerprint` and `LayoutVersion`,
|
||||
and both go into the `bNeedRebuild` test next to the existing `(box, strate, seed)`.
|
||||
|
||||
- **Required, not defaulted.** A caller that forgets must fail to compile rather than silently
|
||||
inherit the hole — the same discipline that put `LayoutVersion` inside `FVoxelOpContext`.
|
||||
- **The CRC is computed once per chunk, not per voxel.** `FCrc::MemCrc32` over ~300 bytes on the
|
||||
hottest path in the plugin would have been a real regression; instead it is taken where the params
|
||||
memo already lives (`CP_TunnelFP`, refreshed in the same block that refetches `CP_Tunnel`), so the
|
||||
per-voxel cost is two integer compares.
|
||||
- **Why not "add chunk Z to the key" (the alternative this section used to offer):** it is not
|
||||
actually cheaper *and* it is not sufficient. `Interleaved` makes `Alpha` depend on chunk **XY**
|
||||
too, and chunk XY is *not* pinned by the existing key — the box deliberately outlives the chunk so
|
||||
that `WorldX ± 1` gradient probes don't thrash it (`ARCHITECTURE §8.10`). Pinning chunk XY to fix
|
||||
the params would have destroyed that invariant. The fingerprint fixes the cause and leaves the box
|
||||
reuse intact: what forces a rebuild now is a *real* params change, once per chunk in a transition
|
||||
band, which is the number of rebuilds this cache should always have done.
|
||||
|
||||
The three test call sites pass `VF_FP(P)` so the **oracle no longer shares the defect under test** —
|
||||
see the rewritten note at check 3 of `VoxelForgeOpStackTunnelTest.cpp`.
|
||||
|
||||
**Still open in this section:** the `OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache` sites listed above.
|
||||
Those are the live-edit staleness half, not the determinism half, and they are untouched.
|
||||
|
||||
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.
|
||||
|
||||
+4
-4
@@ -154,8 +154,8 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
|
||||
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. |
|
||||
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
|
||||
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
|
||||
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion** that the original lacks — see the suspected staleness note in AUDIT §C2. `EffectOverBox` → `Both` for now (a real answer means building the cache for the queried box; only pays once `ClassifyTile` consumes `ClassifyBox`). |
|
||||
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). `EffectOverBox` → **`CarveOnly` everywhere** — no spatial bound, so it kills `AllSolid` on every tile of every strate with worms on. `MaxCarveAmplitude()` holds the bound from DECOMPOSITION §0.2 that would recover it, waiting for a fold that carries numbers. |
|
||||
| `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**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)` — **any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `−K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|A−B\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. |
|
||||
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. |
|
||||
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). |
|
||||
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
|
||||
| `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. |
|
||||
@@ -262,7 +262,7 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
||||
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. |
|
||||
| `InitializeSettings` | 211 | Copies seed from settings. |
|
||||
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. |
|
||||
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. |
|
||||
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. |
|
||||
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
|
||||
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
|
||||
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
|
||||
@@ -433,7 +433,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
|
||||
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
|
||||
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
|
||||
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
|
||||
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. Known-pessimistic: proves **0 of 60** tiles (its `EffectOverBox` rejects on a `Spacing*1.6` halo instead of real connector capsules — lost CPU, never a hole). |
|
||||
| `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. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. |
|
||||
| `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. |
|
||||
|
||||
|
||||
+146
-129
@@ -1,10 +1,10 @@
|
||||
# Handoff — VoxelForge operator stack, 2026-07-28 (Phase 2 complete and green)
|
||||
# Handoff — VoxelForge operator stack, 2026-07-29 (T1.d delivered and measured)
|
||||
|
||||
> Paste the block below into a fresh session. Everything it refers to is on disk and in git.
|
||||
>
|
||||
> **State:** Phase 2 is **DONE — 8 of 8 archetypes ported, built, and green** (14 tests, two
|
||||
> consecutive green builds). `ClassifyTile` consumes `ClassifyBox`. **Everything in git is compiled
|
||||
> and tested.** One question to confirm in the first run, then one clear next task.
|
||||
> **State:** 8 of 8 archetypes ported and green. **Tile-skipping now actually works and is measured:
|
||||
> 11 of 40 tiles proved `AllSolid` at production defaults, 14641 voxels brute-forced, 0 violations.**
|
||||
> `AUDIT §C2` is fixed. One commit is written but **not yet built** — see "First action".
|
||||
|
||||
---
|
||||
|
||||
@@ -17,142 +17,128 @@ re-derive them.
|
||||
1. **`CLAUDE.md`** — project rules. **Rule #1 is absolute: never build, compile, or run the editor.**
|
||||
I build everything myself. When code is done, stop, say "ready to build", list the likely
|
||||
compile-error spots, and wait.
|
||||
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point. The last
|
||||
entry is the green build with every measured number in it.
|
||||
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point.
|
||||
3. **`OPSTACK-PLAN.md`** — the plan. **§2.6.1 is the acceptance bar** and supersedes §2.6.
|
||||
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is the
|
||||
live one; §2 TunnelNetwork and §8 Underwater are now history, not instructions.
|
||||
5. **`AUDIT-2026-07.md`** — **§C2 has a CONFIRMED sub-item as of 2026-07-28, read it**; §C10 is
|
||||
SOLVED, don't reopen; §C9's library half is the top open theoretical risk with 0 measured
|
||||
exposure.
|
||||
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is now
|
||||
*implemented*, not pending; §2 TunnelNetwork and §8 Underwater are history, not instructions.
|
||||
5. **`AUDIT-2026-07.md`** — **§C2's SDF-cache half is FIXED (2026-07-28)**, its live-edit half
|
||||
(`OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache`) is still open; §C10 is SOLVED, don't reopen;
|
||||
§C9's library half is the top open theoretical risk with 0 measured exposure.
|
||||
6. **`CODEMAP.md`** — navigation. Trust symbol names over line numbers.
|
||||
|
||||
## Where things stand — the transition is COMPLETE and VERIFIED
|
||||
## Where things stand
|
||||
|
||||
All 8 archetypes have an operator-stack twin, per-strate opt-in, each equivalence-tested **bit for
|
||||
bit** against its original density function. The `switch` and the stack are now two complete,
|
||||
bit** against its original density function. The `switch` and the stack are two complete,
|
||||
interchangeable implementations.
|
||||
|
||||
| Archetype | State |
|
||||
|---|---|
|
||||
| `Maze` | ✅ ported, bit-identical, wired |
|
||||
| `FlatPlain` + `CrystalChamber` | ✅ **one op for both**, bit-identical, wired |
|
||||
| `SurfaceWorld` | ✅ ported incl. biome blending, bit-identical, wired |
|
||||
| `VerticalShafts` | ✅ ported, bit-identical, wired |
|
||||
| `FloatingIslands` | ✅ ported, bit-identical, wired — the stack that runs **backwards** |
|
||||
| `TunnelNetwork` | ✅ **19 ops**, bit-identical incl. all 12 detail modifiers + per-room override |
|
||||
| `Underwater` | ✅ same builder, second `case` — confirm its coverage number once, see below |
|
||||
|
||||
Everything sits behind `UVoxelStrateDefinition::bUseOperatorStack`; the ported list lives **only** in
|
||||
`UVoxelStrateManager::UsesOperatorStackForChunk` (now all 8). **No strate asset has the box ticked**
|
||||
— that is my call and I haven't made it. But the flag is no longer a no-op anywhere: ticking it now
|
||||
really switches that strate onto the stack, for density *and* for tile classification.
|
||||
`UVoxelStrateManager::UsesOperatorStackForChunk` (all 8). **No strate asset has the box ticked** —
|
||||
that is my call and I still haven't made it. `GetDensityAt` and `ClassifyTile` build the stack
|
||||
through the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole, not a bug.
|
||||
|
||||
`ClassifyTile` **consumes `ClassifyBox`** for cave archetypes (SurfaceWorld and bedrock gaps keep
|
||||
their hand-written exact-lattice proofs). `GetDensityAt` and `ClassifyTile` build the stack through
|
||||
the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole, not a bug.
|
||||
### ✅ T1.d — the tile-skipping prize — is real, and it is measured
|
||||
|
||||
## The one number to confirm, and it takes one run
|
||||
|
||||
The first green build reported this, and it is the one result worth understanding before trusting
|
||||
anything about `Underwater`:
|
||||
`FRoomGraphSource::EffectOverBox` answers **spatially**. The result, brute-forced voxel by voxel:
|
||||
|
||||
```
|
||||
Underwater (stage C2): bit-identical across 2000 samples — 0 of them in open cave (0.0%)
|
||||
[production defaults] 11 of 40 tiles proved AllSolid — 14641 voxels checked, 0 violations
|
||||
[dense fixture] 0 of 40 — correct, and structurally inevitable
|
||||
```
|
||||
|
||||
**A green bit-identity over 2000 samples of solid rock is not evidence** — it is exactly what two
|
||||
agreeing voids look like. Same failure as stage A's 1.1 % run, in a different slot, caught by a
|
||||
counter written for it.
|
||||
One function's verdict is inherited by `FSdfConvertOp`, the twelve detail modifiers (via
|
||||
`VF_NoCaveOverBox`) **and** `FWormFieldSource` — fourteen operators from one place. That is what the
|
||||
C1 wiring was built for.
|
||||
|
||||
A real bug surfaced while diagnosing it: the sampled chunk-Z range used `Z / CHUNK_SIZE`, and C++
|
||||
integer division **truncates toward zero**. TunnelNetwork is at the top of the layout in positive Z
|
||||
where truncation == floor, so it could not show there; Underwater is at the **bottom, in negative
|
||||
Z**, where it shifts the upper chunk bound a notch high and the `Clamp` piles samples into the top
|
||||
seal band. Fixed (`FloorDivChunk`), sampling widened 8 → 24 clusters, **and not trusted**: check 5b
|
||||
gives each of the three possible causes (the bake / the sampled Z range / the XY spread) its own
|
||||
number and prints how to read them.
|
||||
### ⚠️⚠️ THE ONE INVARIANT THAT CAN DELETE COLLISION — read before touching any op
|
||||
|
||||
**That fix is built — the second build was green too. What I did not see is the number.** So:
|
||||
**`FRoomGraphSource::EffectOverBox` returning `Identity` now means `Sdf ≥ T`, NOT `Sdf == FLT_MAX`**,
|
||||
where `T = max(3·SDFBlendRadius, WormNetworkRange)`. That is sound only because all three consumers
|
||||
of the SDF channel were read one by one:
|
||||
|
||||
> **First action: run the `VoxelForge` filter and read the `Underwater diagnosis` line, plus the
|
||||
> cave-coverage percentage on the line above it.**
|
||||
| consumer | threshold |
|
||||
|---|---|
|
||||
| `FSdfConvertOp::Eval` | `Sdf >= Blend`, and the tunnel stack passes `MakeSdfCarve(P.SDFBlendRadius, …)` ⇒ **K** |
|
||||
| the twelve modifiers | `VF_NearCaveSurface` ⇒ **3K** |
|
||||
| `FWormFieldSource::Eval` | `CaveSDF >= WormNetworkRange` ⇒ **WormNetworkRange** |
|
||||
|
||||
**Any new consumer of `InOut.Sdf` must have a threshold ≤ `T`, or be added to that `max`.** An op
|
||||
reading `Sdf < 100` would see false `Identity` verdicts and produce tiles with no geometry **and no
|
||||
collision**. The warning is written at the site you land on when you add one.
|
||||
|
||||
(The `−K` slack covers *any* number of primitives because `SmoothMin`'s penalty is exactly zero once
|
||||
`|A−B| ≥ K`, so the running minimum saturates at `K` below the smallest term. Without that
|
||||
observation the slack would scale with the ~88 tunnels in a cache and the criterion would be dead.)
|
||||
|
||||
## First action: build, then read ONE line
|
||||
|
||||
**The last commit (`e002bd4`, VerticalShafts) is written and NOT built.** Everything before it is
|
||||
built and green.
|
||||
|
||||
> Build, run the `VoxelForge` filter, and read
|
||||
> **`Box verdicts over 60 VerticalShafts tiles`**.
|
||||
>
|
||||
> - **Non-zero cave coverage** ⇒ the truncation *was* the cause, `Underwater` is genuinely covered,
|
||||
> and this whole section is closed. Say so in `OPSTACK-PROGRESS.md` and move on to the next
|
||||
> section — do not go looking for a bug that no longer exists.
|
||||
> - **Still 0.0 %** ⇒ the diagnosis line names which of the three causes it is, and the fix follows
|
||||
> from that rather than from a guess.
|
||||
> **0 was the number for the whole project's life.** Its `EffectOverBox` used to return `CarveOnly`
|
||||
> because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at
|
||||
> `ShaftSpacing 55 / ShaftDensity 0.6`. It now rebuilds the connectors the way `GetCells` does and
|
||||
> tests the real capsules, with **Z exact** and XY conservative.
|
||||
>
|
||||
> Either way it is one run, and the answer is printed. Do not infer it from the fact that the suite
|
||||
> is green: a bit-identity over solid rock is green for the wrong reason, which is the entire point
|
||||
> of that counter existing.
|
||||
> - **Non-zero, and `violations` still 0** ⇒ it worked; record it and move on.
|
||||
> - **Still 0** ⇒ the warning in that test names what to check **first**: `ExtraReach` inflates both
|
||||
> remaining tests, so compare it against `ShaftMaxRadius` before touching either test. **Do not
|
||||
> re-derive from scratch** — that is exactly what cost three rounds on TunnelNetwork.
|
||||
|
||||
## Then the one task everything is waiting on
|
||||
## Then, in order
|
||||
|
||||
**Make `FRoomGraphSource::EffectOverBox` answer spatially.**
|
||||
|
||||
TunnelNetwork proves **0 of 40** tiles today, and the test asserts that. The chain dies at the room
|
||||
source, which returns `Both` with unknown amplitude before anything downstream is reached. Its room
|
||||
and tunnel bounds (`FCachedRoom::CullRadiusSq`, `FCachedTunnel::BoundRadiusSq`) are **already in the
|
||||
SDF cache**; what it costs is building that cache for the *queried box*, on the querying thread.
|
||||
|
||||
That cost is now clearly worth paying, and every other piece is already built to receive it:
|
||||
|
||||
- `ClassifyTile` consumes `ClassifyBox` in production, so a proved tile skips `GenerateMesh` —
|
||||
30 000+ density evaluations saved against one `BuildChunkCache`;
|
||||
- the fold carries **numbers** (`MaxCarveOverBox` / `MaxFillOverBox` / `ForcedMarginOverBox`), so a
|
||||
bounded worm no longer kills `AllSolid` on rock that is solid by more than it can carve;
|
||||
- the twelve detail modifiers already **inherit** the room source's verdict via `VF_NoCaveOverBox` —
|
||||
the day the source says `Identity` for a box, all twelve follow, in one place rather than thirteen.
|
||||
|
||||
**Keep the brute-force check.** `VoxelForge.OpStack.ClassifyTileSoundness` verifies verdicts against
|
||||
`GetDensityAt` on a world where every strate opted in. A false verdict is an invisible hole: no
|
||||
geometry, **no collision**, until a player falls through it.
|
||||
|
||||
## ⚠️ Debts that must be paid BEFORE that lands, not after
|
||||
|
||||
Both were introduced knowingly and are written at the exact site a reader would land on.
|
||||
|
||||
1. **Box bounds read STRATE params, but a per-room op can raise them.** `EffectOverBox` and the new
|
||||
amplitude bounds are computed from strate params, because a box spans many rooms. But `ApplyTo`
|
||||
writes the op's value **even where the strate's was 0**, so a room op can enable a modifier the
|
||||
strate had switched off, or give it a bigger amplitude. A box verdict on a strate with a
|
||||
terrain-op pool can therefore be **too optimistic** — the dangerous direction. Harmless while the
|
||||
room source answers `Both` (nothing is provable anyway); **not harmless the moment it doesn't.**
|
||||
Noted at `FLayerLineMod::EffectOverBox` and `FRoomGraphSource::LocalParams()`.
|
||||
2. **`AUDIT §C2` is confirmed and unfixed on the `switch` path.** `GetGenerationParams` blends params
|
||||
*within* a strate (`Alpha` depends on chunk Z for `Gradient`, and on chunk XY too for
|
||||
`Interleaved`), and `Gradient` + `TransitionBlendChunks = 2` are the **defaults**. The original's
|
||||
SDF cache key has neither params nor chunk Z, so a worker evaluates the second chunk it builds
|
||||
against the first chunk's rooms — and *which* chunk came first depends on worker order, so two
|
||||
peers can diverge from the same seed. The op stack does **not** inherit it (params CRC in the
|
||||
key), and `ClassifyTile`'s new path guards against it explicitly (params must be bit-identical
|
||||
across every chunk coord the box touches). The fix on the `switch` path is a params CRC in its
|
||||
key — a live-generation change that wants a build in front of it.
|
||||
|
||||
## After that, in order
|
||||
|
||||
1. **PERF — unparked.** The op path is measurably slower. One cause found and fixed (the column memo
|
||||
discarded itself every chunk). Remaining suspects in order: the hashed column lookup vs
|
||||
`GSurfColCache`'s direct-indexed box, then per-voxel virtual dispatch. Also measured and stated:
|
||||
the gate is now tested twelve times per voxel instead of once (stage B5's deliberate trade).
|
||||
**Measure before optimising** — that is the §C10 lesson.
|
||||
2. **`VerticalShafts` proves 0 of 60 tiles.** Pessimistic, not wrong: `EffectOverBox` returns
|
||||
`CarveOnly` whenever any shaft is within a `Spacing*1.6` halo instead of testing real connector
|
||||
capsules. Lost CPU, never a hole.
|
||||
1. **PERF — still unparked, and now the biggest open item.** The op path is measurably slower. One
|
||||
cause found and fixed (the column memo discarded itself every chunk). Remaining suspects in order:
|
||||
the hashed column lookup vs `GSurfColCache`'s direct-indexed box, then per-voxel virtual dispatch.
|
||||
Also measured and stated: the gate is tested twelve times per voxel instead of once (stage B5's
|
||||
deliberate trade). **Measure before optimising** — that is the §C10 lesson, and this session
|
||||
re-learned it the hard way.
|
||||
2. **The warp squeeze — PARKED with its ceiling measured, my recommendation is leave it.** The
|
||||
`WARP SHARE` line says over half the remaining blocking is the query-box dilation, not geometry
|
||||
(production: rooms 0.9 → 0.4, tunnels 2.4 → 1.1 with the dilation zeroed). The only remaining
|
||||
route is proving `sup|Perlin3D|` down from the proved **1.5** toward its apparent ~1.0–1.1, worth
|
||||
~27 % of the dilation. Spot-checking a grid is **not** a proof and a wrong sup is a hole.
|
||||
**A negative result is already recorded so nobody repeats it:** bounding the warp *locally*
|
||||
(evaluate at the box centre, shift, dilate by the variation) is **worse** — a rigorous per-axis
|
||||
Lipschitz bound is `4·1.875 + 1 = 8.5` per unit cell, and `8.5 × 0.206` (the half-box in noise
|
||||
units) `= 1.75` exceeds the global range bound of 1.5.
|
||||
3. **`AUDIT §C9` library half** — `sinf`/`cosf` are not IEEE-754 specified, so MSVC's CRT and glibc's
|
||||
libm can differ. Currently **0 samples within 1e-6 of the isosurface**, i.e. no measured risk. Run
|
||||
`CrossPlatformDigest` on Linux, compare the SHAPE digest, pin it. The real fix if ever needed is a
|
||||
deterministic in-house sin/cos.
|
||||
4. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
|
||||
4. **`AUDIT §C2`'s remaining half** — `OC_Chunk`, `BM_Chunk`, `FChunkBiomeCache` are still keyed
|
||||
without the layout version. That is the live-edit staleness class ("I tweaked the asset and one
|
||||
patch kept the old shape"), not the determinism class, which is fixed.
|
||||
5. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
|
||||
unprompted. What makes it possible is already in place: ops depend on capabilities
|
||||
(`IVoxelBiomeField`), never on `UVoxelGenerator`.
|
||||
|
||||
## Debts — status changed, read this before acting on the old text
|
||||
|
||||
1. **"Box bounds read STRATE params but a per-room op can raise them" — DORMANT, not urgent.**
|
||||
Checked rather than paid, and the check reversed the premise: when the source proves `Identity`
|
||||
the twelve modifiers are `Identity` **soundly** (their `bNearCaveSurface` gate never opens, so no
|
||||
room op can enable anything), and when it answers `Both` it supplies no `MaxCarveOverBox`, so the
|
||||
default `FLT_MAX` kills every hypothesis regardless of what the modifiers claim. **It goes live
|
||||
the day `FRoomGraphSource` gains a `MaxCarveOverBox`** — bounding the converter's `2·BaseDensity`
|
||||
would make the modifiers' own numbers matter for the first time. Written at the site.
|
||||
2. **`AUDIT §C2` — FIXED on the `switch` path.** `GetDensityWithParams` now takes **required**
|
||||
`ParamsFingerprint` + `LayoutVersion`. Required, not defaulted, so a caller that forgets fails to
|
||||
compile. The CRC is taken **once per chunk** where the params memo already lives (`CP_TunnelFP`) —
|
||||
a `MemCrc32` per voxel on the hottest path would have been a real regression. Note the audit's own
|
||||
suggested alternative ("add chunk Z to the key") is both insufficient (`Interleaved` makes `Alpha`
|
||||
depend on chunk **XY** too) and destructive (chunk XY is deliberately absent so `WorldX ± 1`
|
||||
gradient probes don't thrash the box — `ARCHITECTURE §8.10`).
|
||||
|
||||
## Hard rules that prevent real bugs
|
||||
|
||||
- **Density sign:** negative = solid at the mesher. Inside the op stack the convention is INTERNAL
|
||||
(**positive = solid**), negated once by the caller. The SDF channel uses standard SDF convention.
|
||||
- **`Identity` from the room source means `Sdf ≥ T`.** See the boxed invariant above. This is the
|
||||
single most dangerous thing in the current code.
|
||||
- **Never run both density paths in one world.** **Comparing them is legitimate** — §C10 is closed
|
||||
since `FPSemantics = Precise`, and all eight equivalence tests compare bit for bit. They are
|
||||
**port-correctness oracles**, not fidelity checks: §2.6.1 requires *same seed ⇒ same world on every
|
||||
@@ -160,49 +146,80 @@ Both were introduced knowingly and are written at the exact site a reader would
|
||||
- **Every cache key includes `LayoutVersion` AND the params.** See §C2 and the overhang regression of
|
||||
2026-07-27, where omitting the params silently deleted the overhang and only 1 sample in 20 000
|
||||
crossed the isosurface.
|
||||
- **A bound in a box verdict must be PROVED, not observed.** `|Perlin3D| ≤ 1.5` is derived from
|
||||
`GradDot`'s two-distinct-axes form and the per-axis weighted bound of 0.5 — *not* from the header's
|
||||
"~[-1,1]". Over-estimating costs CPU; under-estimating deletes collision.
|
||||
- `ProcessQueue` stays `EQueueMode::Mpsc`; `Epoch` carries through every async path; don't "optimize"
|
||||
the `ARCHITECTURE §8.10` invariants.
|
||||
- Commit per coherent unit with a real message. **Never push.** `main` is the known-good fallback.
|
||||
- Update `CODEMAP §3`, `ARCHITECTURE §8`, tick `OPSTACK-PLAN`, append to `OPSTACK-PROGRESS.md`.
|
||||
- **When inserting a class into `VoxelDensityOpStack.cpp` / `VoxelHeightOpStack.cpp`, put it ABOVE
|
||||
the labelled end of the anonymous namespace.** Anchoring on the FACTORIES banner puts it outside,
|
||||
and the brace added with it closes nothing. Made that mistake twice; both files say so at the
|
||||
exact line.
|
||||
and the brace added with it closes nothing. Made that mistake twice; both files say so.
|
||||
- **Match the codebase's spelling of engine macros.** `KINDA_SMALL_NUMBER`, not
|
||||
`UE_KINDA_SMALL_NUMBER` — the plugin uses the unprefixed form everywhere.
|
||||
|
||||
## Method lessons this refactor actually paid for
|
||||
|
||||
Ordered by how much they cost.
|
||||
|
||||
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses, then was
|
||||
solved for free by a build setting changed for an unrelated reason. Park a question whose
|
||||
consequences are measured and benign.
|
||||
- **Verify the premise before reasoning from it.** Five times now a confident chain rested on an
|
||||
unchecked assumption and the check reversed it: C1's *documented* fix was wrong; "C9's risk is gone
|
||||
after FPSemantics" was wrong; "C1 is closed, 0 sites left behind" was wrong (the sweep matched a
|
||||
*spelling*); "PitDensity enables pits" was wrong; "there are 13 detail modifiers" was wrong (twelve,
|
||||
and only eleven read the per-room copy). **A grep over a spelling is evidence about the spelling.**
|
||||
- **⭐ Instrument what you ASSUMED, not just what you changed.** This is the expensive one, learned
|
||||
over four rounds in one session. The warp dilation — `CaveWarpStrength · VOXEL_NOISE_SCALE ·
|
||||
PerlinAbsBound`, a constant chosen in the first commit — inflated a 10-voxel tile into a 50-voxel
|
||||
query box, **125× the volume**. Four separate tightenings (the worm, the columns, the sampler, the
|
||||
tunnel disjunction) were each individually correct and each landed *around* that untouched term.
|
||||
The tunnel fix, predicted "an order of magnitude", delivered 25 % — **and the instrument said so,
|
||||
and I credited the tunnels.** *When a fix under-delivers against its predicted size, suspect the
|
||||
constant you never measured.*
|
||||
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses. In this
|
||||
session the attribution line (`AllSolid killed by: …`) was written after *two* wrong guesses and
|
||||
immediately named a third operator nobody had looked at. **A diagnostic that lists candidate causes
|
||||
without measuring them is still a guess wearing rigour** — my "either the tiles straddle cave or
|
||||
the source isn't reaching Identity" warning offered two causes and both were wrong.
|
||||
- **Verify the premise before reasoning from it.** Six times now a confident chain rested on an
|
||||
unchecked assumption and the check reversed it. Latest three: `RoomSpacing` was **42** (the fixture
|
||||
overrides it) while I did three rounds of arithmetic with the header default of 80 — *the number
|
||||
was printing in the report I kept quoting*; "the plugin bets on `|Perlin3D| ≤ 0.8`" was wrong (the
|
||||
cache **rebuilds** when the warped query leaves the box, so that expansion is a perf heuristic);
|
||||
and the per-room-op debt "must be paid first" was wrong (it is dormant). **Include the premises you
|
||||
are confident enough about not to look up — especially a default, when a fixture exists whose whole
|
||||
job is overriding defaults.**
|
||||
- **A sampler must cover at least one period of what it samples.** The tunnel test drew tile XY from
|
||||
**±32 voxels** with `RoomSpacing 80` and a guaranteed origin room at (0,0) — it measured the spine
|
||||
hub and called it the world. The shaft test had the identical bug (±48 against `ShaftSpacing 55`).
|
||||
Both now print their own extent **in units of the pattern's period**.
|
||||
- **A test fixture tuned for coverage can be antagonistic to the thing you are measuring.**
|
||||
`EnableTunnelFeatures` densifies (`RoomSpacing` 80→42, `RoomDensity` 0.35→0.85) so the equivalence
|
||||
check isn't comparing solid rock to solid rock — and at that density the room cull radius *equals*
|
||||
the lattice spacing, so **no box can ever be proved**. `0 proved` there is the correct answer. The
|
||||
box verdict is therefore measured on **both** densities, and the dense run must stay at 0.
|
||||
- **Diagnostics report THIS run; history goes in the log.** The test output had accumulated hardcoded
|
||||
numbers from previous runs beside live ones ("32 of 34 tiles" printed while the live figure was 21
|
||||
of 28). Unreadable, and self-inflicted.
|
||||
- **Read the code, not the comment.** The cliff modifier's comment promises a sampled Z±1 gradient;
|
||||
the code samples nothing and uses a Z-stretched Perlin it *calls* `VertGrad`. Ported as written —
|
||||
and written down, so nobody "fixes" it from the comment.
|
||||
- **A perf change can be a correctness change.** The column-memo optimisation silently deleted the
|
||||
overhang; the tests caught it the same day. Invisible to inspection, and it produced plausible
|
||||
terrain.
|
||||
- **Coverage is a number, not a boolean.** Four related traps, each of which produced a green run
|
||||
that proved almost nothing:
|
||||
- **Coverage is a number, not a boolean.** Four related traps, each producing a green run that proved
|
||||
almost nothing:
|
||||
- *A test that prints nothing on success is indistinguishable from one that never ran.*
|
||||
- *A guard that only trips at zero notices absence, it does not measure coverage.* Use fractions.
|
||||
- *A success message that **asserts** coverage instead of reporting it reads as evidence while
|
||||
measuring nothing.*
|
||||
- *A check can be vacuous as well as a counter.* "Nothing leaked" is worthless unless something
|
||||
happened — so the gate check also reports how many samples move when the modifiers are zeroed.
|
||||
happened.
|
||||
- **Enabling a feature is not evidence it fired — ask the structure, not the output.** Setting
|
||||
`PitDensity` did nothing (wrong struct). Diffing two stacks with/without the op pool would have
|
||||
*lied* (the pool is not in the SDF cache key, so both share the `thread_local` cache). What worked:
|
||||
call `BuildChunkCache` and look at `Pits.Num()`. **Prefer the check that can fail for exactly one
|
||||
reason** — and when a zero has three possible causes, give each one its own number.
|
||||
`PitDensity` did nothing (wrong struct). **Prefer the check that can fail for exactly one reason**
|
||||
— and when a zero has several possible causes, give each one its own number.
|
||||
- **An oracle that shares the defect under test proves nothing.** The stale-cache check compares each
|
||||
stack against *itself evaluated alone*, never against the original — which keys its SDF cache
|
||||
without the params and would fail it.
|
||||
stack against *itself evaluated alone*. (Since §C2 was fixed, the test call sites now pass a real
|
||||
params fingerprint, so the original no longer shares the defect either.)
|
||||
- **One definition, not two kept in sync.** `VF_BuildOpStackForChunk` exists because a tile skipped on
|
||||
the verdict of a stack that is not the one producing its density is a hole. A "keep these in sync"
|
||||
comment would not have been enough.
|
||||
the verdict of a stack that is not the one producing its density is a hole. The same reasoning is
|
||||
why `GetLastRoomBoxDiagnostic` **reads back** what the operator computed instead of letting the
|
||||
test re-derive the criterion, and why the two-density tile scan is one lambda called twice.
|
||||
- **Don't assert a number you want to improve.** Check 4 asserted `0 proved` — honest when written,
|
||||
and it would have forbidden the entire T1.d gain. What it asserts now is that **no proved tile is
|
||||
wrong** (brute force, every voxel); the proved count is *reported*.
|
||||
|
||||
@@ -2467,3 +2467,802 @@ building that cache for the queried box, and that is now clearly worth it — a
|
||||
30 000+ density evaluations, `ClassifyTile` consumes `ClassifyBox` in production, the numeric
|
||||
amplitude fold is in place, and the twelve modifiers already inherit the source's verdict. Every
|
||||
piece is built to receive it and nothing else moves until it lands.
|
||||
|
||||
## 2026-07-28 — the Underwater 0% is EXPLAINED, and `EffectOverBox` answers spatially. **UNBUILT.**
|
||||
|
||||
Two things, in the order the handoff asked for them. The first cost one file read; the second is the
|
||||
piece everything has been waiting on since stage A.
|
||||
|
||||
### 1. The one number — read, not inferred
|
||||
|
||||
`Saved/Automation/Automation2026.07.28-14.30.37.csv`, the run Jahni had already made:
|
||||
|
||||
```
|
||||
Underwater (C2) ..... bit-identical, 6000 samples in 24 chunks — 94 in open cave (1.6%) was 0.0%
|
||||
Underwater diagnosis strate index 7, voxel Z [-1440, -1313], seal-free interior (-1436, -1316);
|
||||
5600 of 6000 samples inside that interior;
|
||||
bake: 54 rooms, 28 pits, 17 chimneys, 25 columns over 6 search boxes
|
||||
```
|
||||
|
||||
**The truncation WAS the cause.** All three counters of check 5b came back the way the "it's the Z
|
||||
range" hypothesis predicts: the bake is alive (54 rooms — never the problem), and the interior count
|
||||
is now 93 % where the whole point of the bug was that `Z / CHUNK_SIZE` truncating toward zero in
|
||||
negative Z shoved samples into the top seal band. `FloorDivChunk` moved them back. **Section closed.**
|
||||
|
||||
One honest caveat, because the number deserves to be read rather than celebrated: **1.6 % is not
|
||||
16.7 %.** TunnelNetwork's cave coverage carries a 10 % floor; Underwater's guard trips only at
|
||||
**zero** (`if (UWInCave == 0)`), which is the exact shape the project's own lesson warns about — *a
|
||||
guard that only trips at zero notices absence, it does not measure coverage.* 94 samples genuinely
|
||||
exercise the carve, so the bit-identity now means something; it means about a tenth as much as
|
||||
TunnelNetwork's. Per the diagnosis line's own reading key (rooms > 0, interior high) the residual
|
||||
thinness is the **XY spread**. Not chased — the criterion for closing was non-zero, and it is a
|
||||
counter to tighten when someone is next in this file, not a bug.
|
||||
|
||||
### 2. `FRoomGraphSource::EffectOverBox` answers spatially — the debt is paid
|
||||
|
||||
The chain no longer dies at the room source. The criterion is the **per-voxel cull lifted from point
|
||||
to box**, which is the one formulation that can fail for exactly one reason:
|
||||
|
||||
> `Eval` starts at `MinSDF = FLT_MAX` and only lowers it through a primitive that survives its own
|
||||
> cull. So if no cached primitive can survive its cull *anywhere in the box*, `Sdf` stays `FLT_MAX`
|
||||
> across the whole box — the source is the identity, and so is everything behind it.
|
||||
|
||||
"Everything behind it" is not a figure of speech: `FSdfConvertOp` already returns `Identity` ("la
|
||||
source a répondu pour la paire") and the twelve detail modifiers already inherit through
|
||||
`VF_NoCaveOverBox`. **One function learned to answer and fourteen operators became provable.** That
|
||||
is what the C1 wiring was for, and it is the first time the "one place, not thirteen" bet has paid.
|
||||
|
||||
What it does, in order: reject boxes spanning two strates or two op pools (`Both`); memoise the
|
||||
verdict (all twelve modifiers ask the *same* question about the *same* box — without the memo a tile
|
||||
would cost thirteen `BuildChunkCache` instead of one); build the cache for the queried box into a
|
||||
**second per-worker cache**, never `FState::Cache`, so classification cannot disturb a live
|
||||
generation; then test rooms and tunnels as spheres against the warp-dilated box, pits and chimneys
|
||||
against the **undilated** box (they are queried in real coordinates — dilating them would be merely
|
||||
cautious, *not* dilating them would be wrong), and columns as infinite cylinders.
|
||||
|
||||
Three things chosen deliberately, all erring toward CPU:
|
||||
|
||||
- **`|Perlin3D| <= 2`, not `<= 1`.** The header says "~[-1,1] (typically [-0.7,0.7])" — that `~` is an
|
||||
observation, and a box verdict resting on an observation is the hole this file spends its life
|
||||
avoiding. What is *provable* from reading `GradDot`: it returns `ru + rv` with both in `[-1,1]`, and
|
||||
a trilinear lerp never leaves the hull of its inputs. So the warp dilation uses 2, roughly 3x the
|
||||
real displacement. Costs a wider search box; cannot cost a verdict.
|
||||
- **The op pool is passed to `BuildChunkCache`, not `nullptr`.** Tempting to skip it "since room
|
||||
geometry doesn't depend on it" — and false. The bake reads `OpParams` to place **pits and
|
||||
chimneys**. Passing `nullptr` would under-bound the cache and could return `Identity` over a real
|
||||
pit. That is precisely a hole, and it is the same trap as `ColumnDensity = 0` not disabling columns.
|
||||
- **The search box is wider than `Eval`'s**, which yields a *superset* of primitives — so "nothing
|
||||
reaches the box here" implies "nothing reaches it there".
|
||||
|
||||
### 3. `AUDIT §C2` fixed on the `switch` path — and the obvious fix was the wrong one
|
||||
|
||||
`GetDensityWithParams` now takes **required** `ParamsFingerprint` + `LayoutVersion`. Details and the
|
||||
reasoning are in `AUDIT §C2`; the part worth repeating here is why the alternative that section used
|
||||
to recommend ("just add chunk Z to the key") is both insufficient and destructive: `Interleaved`
|
||||
makes `Alpha` depend on chunk **XY** as well, and chunk XY is deliberately *not* in the key — the box
|
||||
outlives the chunk so `WorldX ± 1` gradient probes don't thrash it (`§8.10`). Pinning XY to fix params
|
||||
would have traded a determinism bug for a perf regression. The CRC is taken **once per chunk** where
|
||||
the params memo already lives, so the per-voxel cost is two integer compares.
|
||||
|
||||
The three test call sites now pass `VF_FP(P)`, so the oracle stops sharing the defect it tests.
|
||||
|
||||
### 4. The other debt, `FLayerLineMod` / `LocalParams` — **dormant, and the premise was slightly off**
|
||||
|
||||
The handoff said this had to be paid before `EffectOverBox` landed, because a per-room op can raise a
|
||||
modifier's amplitude above what the strate params claim. Checked before paying it, and the check
|
||||
reverses the conclusion — the fifth time this refactor that has happened:
|
||||
|
||||
- when the source proves `Identity`, all twelve modifiers return `Identity` **soundly**: their
|
||||
per-voxel gate is `bNearCaveSurface`, which is false everywhere in the box no matter what any room
|
||||
op says. The room op cannot enable a gate that never opens.
|
||||
- when the source answers `Both`, it supplies **no** `MaxCarveOverBox`, so the default `FLT_MAX`
|
||||
removes the whole margin and nothing is provable regardless of what the modifiers claim.
|
||||
|
||||
So there is no reachable path today where an over-optimistic modifier bound changes a verdict. **The
|
||||
debt goes live the day `FRoomGraphSource` gains a `MaxCarveOverBox`** — bounding the converter's
|
||||
`2·BaseDensity` would make the modifiers' own numbers matter for the first time. That is now written
|
||||
at the site rather than in a handoff.
|
||||
|
||||
### Ready to build. Likely compile-error spots, worst first
|
||||
|
||||
1. `GetDensityWithParams` signature — 2 production call sites + 3 in `VoxelForgeOpStackTunnelTest.cpp`
|
||||
are updated; **any other caller I missed will fail to compile, which is the intent.**
|
||||
2. `FCrc::MemCrc32` in `VoxelGenerator.cpp` and the test — reachable transitively in
|
||||
`VoxelDensityOpStack.cpp` today, so it should resolve, but `#include "Misc/Crc.h"` is the fix.
|
||||
3. `FBoxState` / `BoxState()` / `PerlinAbsBound` are new members of `FRoomGraphSource`, inserted
|
||||
INSIDE the class (not near the FACTORIES banner — the mistake this file warns about twice).
|
||||
4. `FBox::operator==` on `B.KeyBox == VoxelBox`.
|
||||
5. The rewritten check 4 uses `EVoxelTileClass::AllSolid` / `AllAir` and a triple `float` loop.
|
||||
|
||||
### What to read in the results
|
||||
|
||||
- **`Box verdicts over 40 TunnelNetwork tiles`** — this line no longer asserts `0 proved`. It asserts
|
||||
that **no proved tile is wrong under brute force**, and reports the count. A non-zero proved count
|
||||
is the T1.d prize arriving; a zero count now emits a *warning* saying the check verified nothing,
|
||||
because a soundness check with no verdicts to contradict is vacuous.
|
||||
- Everything else should be **unchanged and green**. The §C2 fix changes generated terrain only
|
||||
inside transition bands on the `switch` path (where it was previously order-dependent, i.e. not
|
||||
well-defined), so an equivalence test that moves is a real signal, not expected noise.
|
||||
|
||||
## 2026-07-28 — GREEN, and `0 proved of 40`. The blocker was the **worm**, not the room source.
|
||||
|
||||
The build was green and every number in the previous entry held. One line did not:
|
||||
|
||||
```
|
||||
Box verdicts over 40 TunnelNetwork tiles: 0 proved (0 AllSolid, 0 AllAir), 40 Mixed
|
||||
-- brute-forced over 0 voxels, 0 violations.
|
||||
WARNING: No TunnelNetwork tile was proved ... this check verified nothing.
|
||||
```
|
||||
|
||||
**The warning I wrote for exactly this case fired, and then it was not good enough.** It offered two
|
||||
candidate causes — "the tiles genuinely straddle cave" or "the source isn't reaching its `Identity`
|
||||
branch" — and **both were wrong**. The real cause was a third operator that neither candidate
|
||||
mentioned. That is the failure worth recording, more than the bug itself.
|
||||
|
||||
### The cause, found by reading two default values
|
||||
|
||||
`FWormFieldSource::EffectOverBox` answered `CarveOnly` unconditionally, with a *provably correct*
|
||||
amplitude bound of `WormStrength`. And in `VoxelStrateTypes.h`:
|
||||
|
||||
```
|
||||
float BaseDensity = 8.0f;
|
||||
float WormStrength = 10.0f; // "Must exceed BaseDensity to create air." <- the field's own comment
|
||||
```
|
||||
|
||||
So `SolidMargin = 8 − 10 = −2 < 0`, on **every tile of every strate with worms on**, before the fold
|
||||
ever reached anything the room source had proved. The worm's bound is not loose by accident — the
|
||||
defaults *require* it to exceed `BaseDensity`, or worms could never carve. A numerically correct
|
||||
bound that is structurally always fatal.
|
||||
|
||||
### The fix was already written in the worm's own `Eval`, three lines up
|
||||
|
||||
```cpp
|
||||
if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX)
|
||||
{ NetworkMask = 0.0f; }
|
||||
...
|
||||
if (NetworkMask <= 0.0f) { return; }
|
||||
```
|
||||
|
||||
**The worm IS spatially bounded** — not by a bound of its own, but by the room source's, exactly like
|
||||
the twelve detail modifiers. Where `FRoomGraphSource` proves `Identity`, `Sdf` stays `FLT_MAX`
|
||||
(verified: `FVoxelOpSample::Sdf = FLT_MAX` is the initialiser), so `NetworkMask` is 0 at every voxel
|
||||
and `Eval` returns before touching `Density`. The worm is the *identity* there, not "a bounded
|
||||
carve". It simply never asked the question.
|
||||
|
||||
So it now inherits the verdict — thirteen inheritors instead of twelve. Two details:
|
||||
|
||||
- **Deliberately NOT `VF_NoCaveOverBox`.** That helper returns `true` when `Rooms == nullptr`, which
|
||||
is right for the twelve modifiers (they only ever exist in a stack where the room source is the
|
||||
sole SDF writer) and **wrong** for the worm, which a future assembly could place behind a different
|
||||
SDF writer — `FLatticeCorridorSource` writes that channel too. No room source ⇒ we don't know ⇒
|
||||
`CarveOnly`. Not knowing must cost CPU, never a hole.
|
||||
- `MaxCarveOverBox` now calls `EffectOverBox` rather than re-testing the condition, so the two cannot
|
||||
drift. The room source's verdict memo makes the second call free.
|
||||
|
||||
### The lesson, and the instrument that came out of it
|
||||
|
||||
**A diagnostic that lists candidate causes without measuring them is still a guess** — it just looks
|
||||
like rigour. My warning named two causes and had a number for neither, so a green run with a real
|
||||
defect in it produced a message that sent the reader to the wrong two places.
|
||||
|
||||
`FVoxelOpStack::ClassifyBoxAttributed` now exists: the same fold, verdict-identical to `ClassifyBox`
|
||||
(same loop, same early-out — a diagnostic that takes a different path than the thing it explains is
|
||||
worse than none), reporting the **index of the first operator that kills each hypothesis**.
|
||||
`IVoxelDensityOp::DebugName()` gives them readable names; it touches no cache key and no generation
|
||||
decision, so it cannot change the world. Check 4 prints:
|
||||
|
||||
```
|
||||
AllSolid killed by: <op> x<count>, <op> x<count>, ...
|
||||
```
|
||||
|
||||
Always printed, not only on failure — when tiles *are* proved, that line is what says why the rest
|
||||
are not. And the `0 proved` warning now says **"do not re-derive the cause, read the attribution
|
||||
line"**, because the next person's guess would be as good as mine was.
|
||||
|
||||
### Ready to build. Likely compile-error spots
|
||||
|
||||
1. `FWormFieldSource` gained a third ctor arg (defaulted) and a `Rooms` member; its single
|
||||
construction site in `BuildTunnelNetworkStack` passes `RoomPtr`, which is already in scope there.
|
||||
2. `IVoxelDensityOp::DebugName()` is a new virtual with a default — six overrides added
|
||||
(`ConstantFieldSource`, `RoomGraphSource`, `SdfConvertOp`, `OriginSpineOp`, `BoundarySealOp`,
|
||||
`PassageCarveOp`, `WormFieldSource`). Everything else inherits `"(unnamed op)"` and reports by index.
|
||||
3. `ClassifyBoxAttributed` / `GetOpDebugName` are new inline methods on `FVoxelOpStack` (header).
|
||||
4. The test uses `TMap<FString,int32>::ValueSort` and `FindOrAdd` on a `const TCHAR*` key.
|
||||
|
||||
### What to read, in order
|
||||
|
||||
1. **`AllSolid killed by:`** — the new line. If it is empty ("AllSolid survived every tile") the
|
||||
prize has landed. If it names `RoomGraphSource`, the tiles genuinely straddle cave and the sampler
|
||||
is what to look at. If it names anything else, that operator's box answer is more pessimistic than
|
||||
its `Eval`, and it is now named rather than guessed at.
|
||||
2. **`Box verdicts over 40`** — proved count is a measurement; the assertion is only that no proved
|
||||
tile is *wrong*.
|
||||
3. Everything else should be unchanged. The worm change cannot alter density: `EffectOverBox` and
|
||||
`MaxCarveOverBox` are box-verdict methods, and `Eval` is untouched.
|
||||
|
||||
## 2026-07-28 — the worm fix WORKED. `AllSolid killed by: RoomGraphSource x40` — and that is not an answer.
|
||||
|
||||
The attribution line did its job on its first run:
|
||||
|
||||
```
|
||||
AllSolid killed by: RoomGraphSource x40
|
||||
```
|
||||
|
||||
`WormFieldSource` is gone from that list, so the previous entry's fix landed exactly as reasoned. The
|
||||
blocker moved one operator upstream, to the room source itself.
|
||||
|
||||
**And my own warning is now the thing to distrust.** It said: *"If it names RoomGraphSource, the tiles
|
||||
genuinely straddle cave."* That is a **hypothesis wearing the costume of a conclusion** — the same
|
||||
mistake as the previous warning, one level down. `RoomGraphSource` has FOUR primitive classes behind
|
||||
it (rooms, tunnels, pits, chimneys) whose bounds differ enormously in quality, and "the tiles straddle
|
||||
cave" is only one of the things a `Both` from it can mean. So: no third guess. Two changes, one of
|
||||
them proved, and an instrument for the rest.
|
||||
|
||||
### Proved, not guessed: the columns test is gone
|
||||
|
||||
The first version treated columns as **infinite cylinders in Z** (the cache gives them no vertical
|
||||
bound), so a box hundreds of voxels below the owning room answered `Both` because it shared an XY
|
||||
circle with a column. That was the loosest test in the function — and it was **redundant**, not
|
||||
conservative:
|
||||
|
||||
```cpp
|
||||
void FRoomColumnMod::Eval(...) const
|
||||
{
|
||||
if (!VF_NearCaveSurface(InOut.Sdf, P.SDFBlendRadius)) { return; } // <- the only consumer
|
||||
```
|
||||
|
||||
Columns are read by exactly one operator, and it gates on `Sdf` being near a cave surface. In a box
|
||||
no room, tunnel, pit or chimney reaches, `Sdf` stays `FLT_MAX` at every voxel, so **no column can
|
||||
execute regardless of where it sits in XY**. Removing the test tightens the verdict without touching
|
||||
its correctness. That is a proof, not a relaxation.
|
||||
|
||||
### The instrument: which primitive class actually reaches the box
|
||||
|
||||
`EffectOverBox` no longer early-outs on the first hit. It **counts all four classes**, because
|
||||
stopping at the first gives the right verdict and no information — which is precisely why
|
||||
`RoomGraphSource x40` told us nothing actionable. The cost is nil at the scale that matters: we have
|
||||
just run `BuildChunkCache`, which dwarfs a walk over ~100 structs, and the verdict is memoised so the
|
||||
walk happens once per box rather than thirteen times.
|
||||
|
||||
`VoxelDensityOps::GetLastRoomBoxDiagnostic()` exposes it. **Deliberately a read-back of what the
|
||||
operator computed, not a re-derivation in the test** — the test has everything needed to replay the
|
||||
criterion, and replaying it would create a second definition that drifts from the real one and lies
|
||||
on the day it is believed. Same reason `VF_BuildOpStackForChunk` exists.
|
||||
|
||||
The report now prints, per killed tile, how many rooms and tunnels of those in the cache actually
|
||||
reach the box. **The hypothesis it is built to kill or confirm:** a tunnel is culled per voxel by its
|
||||
**bounding sphere**, and for a long thin capsule that sphere is an enormous over-estimate, while a
|
||||
room's cull sphere is a fair fit for a roughly spherical room. If tunnels ≫ rooms, the box test is
|
||||
losing to capsule bounding spheres rather than to real cave, and the fix is a segment-vs-box distance
|
||||
— nothing to do with the sampler or with cave density.
|
||||
|
||||
### What was deliberately NOT done, and why
|
||||
|
||||
Tightening tunnels to a real capsule test is **not** free correctness: the per-voxel cull *is* the
|
||||
bounding sphere, so a capsule test would be tighter than the cull and would break the stated criterion
|
||||
("no primitive survives its cull"). Making it sound needs the stronger criterion — *no primitive can
|
||||
bring `Sdf` below `max(Blend, SDFBlendRadius·3, WormNetworkRange)`* — which in turn needs a bound on
|
||||
how far `SmoothMin` of N primitives can dip below `min`. That is real §0.2 design work, and doing it
|
||||
blind, in the same build, before knowing whether tunnels are even the problem, is the §C10 mistake
|
||||
verbatim. **Measure, then tighten what the numbers name.**
|
||||
|
||||
### Ready to build. Likely compile-error spots
|
||||
|
||||
1. `VoxelDensityOps::FRoomBoxDiagnostic` + `GetLastRoomBoxDiagnostic()` — new declaration in the
|
||||
header, defined in the .cpp *after* the anonymous namespace closes (it reads
|
||||
`FRoomGraphSource::BoxState()`, whose type lives in that namespace; legal, since the type does not
|
||||
appear in the function's signature).
|
||||
2. `FBoxState` gained eight `int32` counters.
|
||||
3. The test accumulates into new locals and calls `VoxelDensityOps::GetLastRoomBoxDiagnostic()`.
|
||||
|
||||
### What to read
|
||||
|
||||
1. **`...and when RoomGraphSource is the killer`** — the new second line. `tunnels 40 / rooms 3` says
|
||||
capsule bounding spheres; `rooms 40` says the tiles really are near rooms and the sampler is what
|
||||
to look at; pits or chimneys leading would be a surprise worth stopping on.
|
||||
2. Whether removing the columns test alone moved `proved` off zero. If it did, that number is the
|
||||
first real T1.d saving in the plugin.
|
||||
|
||||
## 2026-07-28 — the breakdown refuted MY hypothesis, and found the sampler was the bug
|
||||
|
||||
```
|
||||
rooms 40, tunnels 40, pits 14, chimneys 0
|
||||
Averages per killed tile: 4.9 of 7.2 rooms reach, 69.2 of 80.4 tunnels reach
|
||||
```
|
||||
|
||||
I predicted "tunnels ≫ rooms ⇒ capsule bounding spheres". **Wrong, or rather insufficient:** tunnels
|
||||
*are* wildly over-counted (69 of 80), but **rooms hit all 40 tiles too**, so fixing tunnels alone
|
||||
would have moved the number by exactly zero. Fourth hypothesis this refactor has reversed on contact
|
||||
with a measurement. The instrument paid for itself on its first run.
|
||||
|
||||
### The real finding: the tile sampler never left the (0,0) spine
|
||||
|
||||
```cpp
|
||||
const int32 Extent = Step * Cells; // 1 * 8 = 8
|
||||
Rng.RandRange(-4, 4) * Extent // XY ∈ [-32, +32] voxels
|
||||
```
|
||||
|
||||
Against the defaults:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `RoomSpacing` | **80** — ±32 does not cover half of one room cell |
|
||||
| `OriginRoomRadius` | **20**, guaranteed at (0,0); cull radius `max(20·1.5, 8) + 3·4` = **42** |
|
||||
| the (0,0) spine | descends exactly there |
|
||||
|
||||
So all 40 tiles sat inside the origin room's cull sphere, in the most cave-riddled cubic
|
||||
metre of the entire world. `4.9 of 7.2 rooms reach` was not measuring the world's cave density — it
|
||||
was measuring the spine hub. **Every conclusion about "is deep rock provable" drawn from that sample
|
||||
was answering a different question.**
|
||||
|
||||
Widened to ±320 voxels (4 × `RoomSpacing`), and the report now **prints its own sampling extent** plus
|
||||
how many tiles landed clear of the spine — because a sampler whose extent you cannot quote is one
|
||||
nobody is watching. The brute-force soundness assertion is untouched: this changes what the
|
||||
measurement *looks at*, never what it *demands*.
|
||||
|
||||
### Why rooms can't be tightened and tunnels can — the arithmetic, before writing any code
|
||||
|
||||
With `SDFBlendRadius K = 4`, `WormNetworkRange = 24`, mods gating at `3K = 12`, the strongest useful
|
||||
threshold is `T = max(Blend, 3K, WormNetworkRange) = 24`.
|
||||
|
||||
`SmoothMin(A,B,K) = min(A,B) − H³K/6`, `H = max(K−|A−B|,0)/K`. Two consequences worth writing down:
|
||||
the penalty is **exactly zero** once `|A−B| ≥ K`, and the running minimum therefore **saturates at
|
||||
`K` below the true minimum** — so `Sdf ≥ min_i(SDF_i) − K` for **any** number of primitives. That is
|
||||
the bound that makes an "`Sdf ≥ T`" criterion possible at all.
|
||||
|
||||
Now compare the two possible criteria per primitive:
|
||||
|
||||
- **rooms** — cull rejects at `dist > Rmax + 3K` (= 57 worst case). The `Sdf ≥ T+K` criterion rejects
|
||||
only at `dist ≥ Rmax + T + K` (= 73). Since `T + K = 28 > 3K = 12`, **the cull is strictly the
|
||||
better test for rooms.** Nothing to gain; leave them alone.
|
||||
- **tunnels** — the cull is the capsule's **bounding sphere**, and with `MaxTunnelLength = 200` that
|
||||
sphere has radius up to ~107 for a tube of radius 7. The `Sdf ≥ T+K` criterion uses the real
|
||||
distance to the segment, so it rejects at `dist(box, segment) ≥ 7 + 28 = 35`. **Order of magnitude
|
||||
tighter**, and sound because `TaperedCapsule` is a genuine distance function
|
||||
(`Dist(P, ClosestOnSegment) − Lerp(Ra,Rb,T)`), verified rather than assumed.
|
||||
|
||||
So the eventual fix is a **per-primitive disjunction** — a primitive cannot matter if it fails its
|
||||
cull *or* its own SDF stays ≥ `T + K` over the box — applied **only to tunnels**, where the SDF is
|
||||
exact. Mixing is sound: primitives failing the cull contribute nothing, the rest are all ≥ `T+K`, so
|
||||
the fold is ≥ `T`, and every consumer (converter at `Blend`, twelve mods at `3K`, worm at
|
||||
`WormNetworkRange`) is identity at `Sdf ≥ T`.
|
||||
|
||||
### NOT done in this build, deliberately
|
||||
|
||||
The tunnel disjunction changes what `Identity` *means* here — from "`Sdf` stays `FLT_MAX`" to
|
||||
"`Sdf ≥ T`" — and that is only sound if **every** consumer's threshold is ≤ `T`. Three premises still
|
||||
need reading rather than assuming: `VF_NearCaveSurface`'s exact constant, the `Blend` actually passed
|
||||
to `FSdfConvertOp` by `BuildTunnelNetworkStack`, and whether any modifier re-probes the SDF *outside*
|
||||
the box (`FCaveTerraceMod` samples Z±1) before its gate. Any one of those wrong is a hole with no
|
||||
collision behind it, and this session has already produced four confident chains that reversed on
|
||||
checking. **Fix the measurement first; it costs nothing and it is wrong today.**
|
||||
|
||||
### The prediction, stated so the next run can refute it
|
||||
|
||||
With the widened sampler: **rooms should stop hitting every tile** (cull spheres of mean radius ~42
|
||||
on an 80-lattice cover roughly 60 % of space, so ~40 % of tiles should be clear of all rooms), and
|
||||
**tunnels should now be the binding constraint on nearly all of them.** If instead tunnels stay at
|
||||
~100 % *and* rooms drop, the tunnel disjunction above is the whole remaining job. If rooms also stay
|
||||
at 100 %, my model of the room lattice is wrong and that is the thing to look at next — not the
|
||||
tunnels.
|
||||
|
||||
### Ready to build. Compile-error spots
|
||||
|
||||
`SpanCells` / `SpanVoxelsReported` / `NumTilesAwayFromSpine` are new locals hoisted **outside** the
|
||||
tile loop (the report line needs them); `P.OriginRoomRadius` and `P.RoomSpacing` are read in check 4.
|
||||
|
||||
## 2026-07-28 — the sampler fix worked, the prediction failed, and the fixture is the reason
|
||||
|
||||
The widened sampler did exactly what it was meant to: **39 of 40 tiles now land clear of the (0,0)
|
||||
spine.** And the verdict did not move.
|
||||
|
||||
```
|
||||
[before] rooms 40, tunnels 40 | 4.9 of 7.2 rooms, 69.2 of 80.4 tunnels (XY ±32)
|
||||
[after ] rooms 40, tunnels 40 | 6.3 of 8.3 rooms, 78.0 of 87.7 tunnels (XY ±320, 39/40 off-spine)
|
||||
```
|
||||
|
||||
My stated prediction was *"rooms should stop hitting every tile."* **They did not.** By my own
|
||||
pre-registered rule that means the room-lattice model was wrong — and it was, for a reason printed in
|
||||
the report I had been reading past for three runs:
|
||||
|
||||
> `= 7.6 x RoomSpacing 42`
|
||||
|
||||
**`RoomSpacing` is 42 here, not the 80 I computed with.** I read the `UPROPERTY` default out of the
|
||||
header instead of the fixture, which overrides it. Fifth premise this refactor that reversed on being
|
||||
checked, and the plainest one: *the number was on screen.*
|
||||
|
||||
### Why the fixture cannot ever prove a tile — arithmetic, not opinion
|
||||
|
||||
`EnableTunnelFeatures` densifies **on purpose**, and says so: `RoomSpacing` 80 → 42,
|
||||
`RoomDensity` 0.35 → 0.85, because at the defaults the first run had **1.1 %** of samples in open
|
||||
cave and the equivalence was comparing solid rock to solid rock. That densification is what makes
|
||||
check 1 mean anything.
|
||||
|
||||
It is also exactly antagonistic to provability:
|
||||
|
||||
```
|
||||
room cull radius = max(R·1.5, R·HeightRatio) + 3·SDFBlendRadius
|
||||
= 1.5R + 12 for R ∈ [10,30] ⇒ 27 … 57, mean ≈ 42
|
||||
lattice spacing = 42, occupancy 0.85
|
||||
```
|
||||
|
||||
**The mean cull radius equals the lattice spacing.** Sphere volume over cell volume gives ~3.6×
|
||||
redundant coverage — *no box in that world can be outside all room cull spheres.* `6.3 of 8.3 rooms
|
||||
reach` is not a symptom of a loose test; it is the world being saturated. No sampler change and no
|
||||
tightening of tunnels can move it, which is why widening the sampler correctly changed nothing.
|
||||
|
||||
**So `0 proved` on this fixture is the right answer**, and it is informative: the tile-skipping prize
|
||||
shrinks to nothing as caves saturate. What it is *not* is an answer about production.
|
||||
|
||||
### Both worlds, one criterion
|
||||
|
||||
Check 4 is now a lambda run twice — **parameterised, not copy-pasted**, because two copies of the
|
||||
criterion would drift and the second one would lie:
|
||||
|
||||
- **`[dense fixture]`** — as before. Expected to keep reporting ~0, and that is now written down as
|
||||
the correct result rather than read as a failure.
|
||||
- **`[production defaults]`** — the same 40 tiles against `RoomSpacing 80 / RoomDensity 0.35`, the
|
||||
actual `UVoxelStrateDefinition` defaults, which is the world the question "how many tiles can we
|
||||
skip" is really about. At that spacing, cull spheres of mean radius 42 on an 80-lattice at 35 %
|
||||
occupancy cover roughly a fifth of space.
|
||||
|
||||
Both are brute-forced voxel by voxel. This is **not** "widen it until it passes": the dense run stays
|
||||
in the report and must stay ~0, and a false verdict in either run still fails the assertion. The two
|
||||
stacks deliberately share `FRoomGraphSource`'s `thread_local` caches — the params fingerprint in the
|
||||
key is what keeps them apart, so this run and check 3 now watch each other.
|
||||
|
||||
### The lesson, and it is not the one I expected to write
|
||||
|
||||
Three runs in a row I reasoned from `RoomSpacing = 80` while the test printed 42 at me. The
|
||||
instrument was right, the arithmetic done on it was right, and the *input to the arithmetic* was
|
||||
taken from the wrong file. **"Verify the premise" has to include the premises you are confident
|
||||
enough about not to look up** — especially a default, when a fixture exists whose entire job is to
|
||||
override defaults.
|
||||
|
||||
### Ready to build. Compile-error spots
|
||||
|
||||
1. Check 4 is now `auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
|
||||
const FVoxelOpContext& TCtx, const TCHAR* Label)`, called twice. Verified mechanically: zero bare
|
||||
`Stack` / `P.` / `Ctx` references survive inside the lambda body, braces and parens balance.
|
||||
2. Five `FString::Printf` sites gained a leading `%s` **and** the matching `Label` argument — the
|
||||
mismatch was caught and fixed before commit; worth re-checking if the log looks scrambled.
|
||||
3. `SparseStack` is a second `FVoxelOpStack` built by `BuildTunnelNetworkStack` and `PrepareChunk`ed.
|
||||
|
||||
### What to read
|
||||
|
||||
**`[production defaults] Box verdicts`** — the only new number that matters. Non-zero is the first
|
||||
real T1.d saving in the plugin. If it is *also* zero, then the per-class line under it says whether
|
||||
rooms still saturate at 80 spacing (my model is wrong again, look there) or whether tunnels are
|
||||
finally alone (the segment-vs-box disjunction from the previous entry is then the whole remaining
|
||||
job, and its three unverified premises are listed there).
|
||||
|
||||
## 2026-07-28 — ✅ **THE PRIZE LANDED.** 6 of 40 tiles proved, 7986 voxels brute-forced, 0 violations.
|
||||
|
||||
```
|
||||
[production defaults] Box verdicts over 40 tiles: 6 proved (6 AllSolid, 0 AllAir), 34 Mixed
|
||||
-- brute-forced over 7986 voxels, 0 violations
|
||||
[dense fixture] 0 proved, 40 Mixed <- correct, and predicted
|
||||
```
|
||||
|
||||
**This is the first real T1.d saving in the plugin.** 15 % of tiles skip `GenerateMesh` entirely —
|
||||
one `BuildChunkCache` traded against 30 000+ density evaluations each — and not one of those verdicts
|
||||
is asserted: all 7986 voxels were re-evaluated through the stack and every single one came back on
|
||||
the claimed side. The two-world split also did its job: the dense fixture reported 0, exactly as the
|
||||
arithmetic said it must, so the number above is a measurement of *production*, not of a test that was
|
||||
widened until it agreed.
|
||||
|
||||
Five runs to get here, and the honest summary of them is that **every single one of my hypotheses was
|
||||
wrong and every single instrument was right**:
|
||||
|
||||
| run | my hypothesis | what the instrument said |
|
||||
|---|---|---|
|
||||
| 1 | "the tiles straddle cave" / "Identity unreachable" | **the worm**, unbounded `CarveOnly`, `8 < 10` by default |
|
||||
| 2 | "tunnels ≫ rooms ⇒ bounding spheres" | rooms hit 40/40 too — fixing tunnels alone changes nothing |
|
||||
| 3 | "widen the sampler and rooms will drop" | they did not — `RoomSpacing` was **42**, not the 80 I computed with |
|
||||
| 4 | — | the fixture is *deliberately* saturated; `0 proved` is its correct answer |
|
||||
| 5 | "production defaults will prove tiles" | **6 of 40**, and now tunnels really are alone (32 vs 21) |
|
||||
|
||||
### The tunnel disjunction — the deferred piece, now justified by its own number
|
||||
|
||||
The breakdown finally isolated it: of the 34 unproved tiles, **32 were blocked by a tunnel and 21 by
|
||||
a room**, so ≥13 were blocked by tunnels *alone*. That is what made the deferred work worth doing, and
|
||||
it is why it was deferred until now rather than guessed at three runs ago.
|
||||
|
||||
A primitive now fails to matter under a **disjunction**:
|
||||
|
||||
> it misses its cull entirely **OR** its own SDF stays ≥ `T + K` over the whole box.
|
||||
|
||||
Each branch wins on a different class, and the arithmetic says which:
|
||||
|
||||
- **rooms** — cull rejects at `Rmax + 3K`, threshold only at `Rmax + T + K`. Cull is strictly tighter,
|
||||
so rooms keep the cull alone. Not an omission; a calculation.
|
||||
- **tunnels** — the cull is the capsule's *bounding sphere*: radius ~107 for a 200-long tube of
|
||||
radius 7. The threshold uses the true distance to the segment. Order of magnitude.
|
||||
|
||||
**The bound is exact, not cautious.** `TaperedCapsule` was *read* (`Dist(P, ClosestOnSegment) −
|
||||
Lerp(Ra,Rb,t)`), so `SDF ≥ dist(P, segment) − max(Ra,Rb)`; and `dist(box, segment) ≥ dist(centre,
|
||||
segment) − half-diagonal` by triangle inequality. `VF_DistPointSegment` is written locally rather than
|
||||
taken from `FMath` — five lines, and "I think that function does that" is not good enough under a
|
||||
correctness bound.
|
||||
|
||||
### ⚠️ `Identity` CHANGED MEANING, and that is the one real debt this creates
|
||||
|
||||
It used to mean "`Sdf` stays `FLT_MAX`". It now means "`Sdf ≥ T`", with
|
||||
`T = max(3·SDFBlendRadius, WormNetworkRange)`. That is only sound because all three consumers of the
|
||||
SDF channel were **read one by one**, not assumed:
|
||||
|
||||
| consumer | threshold | verified at |
|
||||
|---|---|---|
|
||||
| `FSdfConvertOp::Eval` | `Sdf >= Blend`, and `BuildTunnelNetworkStack` passes `MakeSdfCarve(P.SDFBlendRadius, …)` ⇒ **K** | call site |
|
||||
| the twelve modifiers | `VF_NearCaveSurface` ⇒ **3K** | its body |
|
||||
| `FWormFieldSource::Eval` | `CaveSDF >= WormNetworkRange` ⇒ **WormNetworkRange** | its body |
|
||||
|
||||
Plus the one that could have bitten: `FCaveTerraceMod` re-probes the SDF at **Z±1, outside the box** —
|
||||
but its `VF_NearCaveSurface` gate is line 13 and the probes are lines 28–29. Gated first, so a gate
|
||||
that is false everywhere never emits a probe. Checked, not assumed.
|
||||
|
||||
**Any new consumer of the `Sdf` channel must have a threshold ≤ `T` or be added to that `max`.** An
|
||||
operator reading `Sdf < 100` would see false `Identity` verdicts, i.e. tiles with no geometry **and no
|
||||
collision**. That warning is written at the site, in the function you land in when you add one.
|
||||
|
||||
### Why `− K` is enough for any number of primitives
|
||||
|
||||
`SmoothMin(A,B,K) = min(A,B) − H³K/6` with `H = max(K−|A−B|,0)/K`. The penalty is **exactly zero**
|
||||
once `|A−B| ≥ K`, so the running minimum saturates at `K` below the smallest term — it cannot descend
|
||||
further, because at that distance `H = 0` and subsequent folds return it unchanged. Hence
|
||||
`Sdf ≥ min_i(SDF_i) − K` for **any** N, not `− N·K/6`. Without that observation the slack would scale
|
||||
with the ~88 tunnels in the cache and the criterion would be worthless.
|
||||
|
||||
### Ready to build. Compile-error spots
|
||||
|
||||
1. `VF_DistPointSegment` — new helper in the anonymous namespace, next to `VF_NearCaveSurface`
|
||||
(i.e. well above the end-of-namespace marker).
|
||||
2. `K` / `T` / `TunnelClear` / `QCenter` / `BoxHalfDiag` are new locals in `EffectOverBox`; the tunnel
|
||||
loop variable is `Tn` **specifically so it does not shadow `T`**. Braces and parens verified balanced.
|
||||
3. `(QMax - QMin).Size()` returns a double under UE5's `FVector` — cast to float.
|
||||
|
||||
### What to read
|
||||
|
||||
`[production defaults] Box verdicts` — **6 is the number to beat.** The disjunction should raise it;
|
||||
≥13 tiles were tunnel-only blocked, so somewhere near 19 of 40 is the expectation. And
|
||||
`[dense fixture]` **must stay at 0** — if the dense world starts proving tiles, the disjunction is
|
||||
wrong somewhere and the brute force is the thing that will say so.
|
||||
|
||||
## 2026-07-29 — Jahni: "are you in a loop?" **Yes.** The dominant term was a constant I set on day one and never measured.
|
||||
|
||||
11 of 40 proved, 0 violations — real, but Jahni called it: *still wrong, re-read the original and how
|
||||
you're doing it.* He was right, and the re-read found it in one line.
|
||||
|
||||
### The loop
|
||||
|
||||
`PerlinAbsBound = 2.0`, chosen in the very first commit of the spatial `EffectOverBox` and never
|
||||
revisited. The warp dilation is `CaveWarpStrength · VOXEL_NOISE_SCALE · PerlinAbsBound`, and
|
||||
**`CaveWarpStrength = 8.0` by default** (the fixture does not override it):
|
||||
|
||||
```
|
||||
dilation = 8 × 1.25 × 2.0 = 20 voxels, applied ±, on every axis
|
||||
tile = 10 voxels → query box 50 voxels per side → 125× the tile's volume
|
||||
BoxHalfDiag = 43.3
|
||||
```
|
||||
|
||||
So the tunnel test I described as "an order of magnitude tighter" actually required
|
||||
`DistToAxis ≥ 28 + 43.3 + 7 = 78`, against a bounding-sphere cull that rejects at ~107. **27 %
|
||||
tighter, not 10×** — and the run reported exactly that: tunnels reaching went 78.0 → 58.5, a 25 %
|
||||
cut. The instrument told me the truth and I credited it to the tunnels.
|
||||
|
||||
Four rounds of work — the worm, the columns, the sampler, the tunnel disjunction — every one of them
|
||||
tightening a term while **the term nobody measured stayed 125× too big**. Each round was individually
|
||||
correct and the loop was still real: *I kept instrumenting the thing I had just changed, and never
|
||||
instrumented the thing I had assumed.*
|
||||
|
||||
### What re-reading the original actually showed
|
||||
|
||||
`BuildChunkCache` is called — in `GetDensityWithParams`, in `FRoomGraphSource::Eval`, everywhere —
|
||||
with `Expansion = CaveWarpStrength + 2.0f`. That is the shipped, working, years-old code, and it is
|
||||
only correct if `|Perlin3D| · VOXEL_NOISE_SCALE ≤ CaveWarpStrength`, i.e. **`|Perlin3D| ≤ 0.8`**.
|
||||
|
||||
**The whole plugin has always bet on 0.8. I chose 2.0 — 2.5× more conservative than the assumption
|
||||
the cache's own correctness already rests on — and then optimised around my own choice for three
|
||||
builds.** That is the answer to "are you in a loop", and it is not a subtle one.
|
||||
|
||||
### The corrected bound, derived rather than guessed
|
||||
|
||||
Read out of `GradDot`: `u` and `v` are always **two distinct** components of the corner offset —
|
||||
checked on all four hash branches, not assumed. Then per axis, splitting the eight corners by `i`:
|
||||
|
||||
```
|
||||
Σ_c w_c·|dx_c| = (1−su)·fx + su·(1−fx) su = Fade(fx)
|
||||
≤ 0.5 (max at fx = 0.5; 0.302 at fx = 0.25 and 0.75)
|
||||
|Perlin3D| ≤ Σ_c w_c(|a_c|+|b_c|) ≤ S_x + S_y + S_z ≤ 1.5
|
||||
```
|
||||
|
||||
**1.5, provable, no case analysis on the hashes.** (The true max is lower still — only two of three
|
||||
axes appear per corner — and the classical `√3/2 ≈ 0.87` depends on the gradient set, so it is not
|
||||
leaned on.) Dilation drops 20 → 15 voxels. That is a 25 % cut in the dominant term, honestly
|
||||
obtained, and it is *not* the end of the story.
|
||||
|
||||
### The instrument that should have existed from the first commit
|
||||
|
||||
`EffectOverBox` now also computes every room/tunnel test **with the warp dilation set to zero**, and
|
||||
reports both. `HitRooms − HitRoomsNoWarp` is exactly the blocking caused by my own box rather than by
|
||||
geometry. The line reads:
|
||||
|
||||
```
|
||||
WARP SHARE: the query box is dilated by ±N voxels per axis; with that dilation set to ZERO
|
||||
the same tests would keep only X rooms and Y tunnels.
|
||||
```
|
||||
|
||||
If that gap dominates, the next move is the warp bound — **not** the primitives. That sentence is in
|
||||
the output so the next person (me, next run) cannot repeat this.
|
||||
|
||||
### The other thing that was wrong: the diagnostics had become narrative
|
||||
|
||||
The report was printing **hardcoded numbers from previous runs** next to live ones — "the first build
|
||||
reported 0 proved of 40", "32 of 34 tiles vs 21 for rooms" — while the live figures said 21 rooms of
|
||||
28 tiles. A reader cannot tell which is which, and I wrote every line of it. Diagnostics now report
|
||||
**this run only**; the history lives here, in the log, where it belongs.
|
||||
|
||||
### Ready to build. Compile-error spots
|
||||
|
||||
1. `PerlinAbsBound` 2.0 → 1.5 (one constant, big blast radius on the numbers, none on compilation).
|
||||
2. `FBoxState` gained `HitRoomsNoWarp` / `HitTunnelsNoWarp` / `WarpDilation`; mirrored on
|
||||
`VoxelDensityOps::FRoomBoxDiagnostic` and copied in `GetLastRoomBoxDiagnostic`.
|
||||
3. `NWMin` / `NWMax` / `NoWarpHalfDiag` are new locals in `EffectOverBox`, declared before use
|
||||
(verified mechanically, along with brace/paren balance).
|
||||
4. The per-class report line went from 10 to 13 format specifiers and 13 arguments — counted, and the
|
||||
previous round's arg-list mismatch is exactly why it was counted.
|
||||
|
||||
### What to read, and in this order
|
||||
|
||||
1. **`WARP SHARE`** — the number that has been invisible all along. `rooms 1.1 → 0.4` would mean most
|
||||
of the remaining block is my dilation and the warp bound is the whole job; `1.1 → 1.0` would mean
|
||||
the geometry really is that dense and the dilation was never the point after the 1.5 fix.
|
||||
2. `[production defaults] Box verdicts` — **11 is the number to beat.**
|
||||
3. `[dense fixture]` must still be 0.
|
||||
|
||||
## 2026-07-29 — WARP SHARE measured: **over half the blocking is my box.** And two corrections.
|
||||
|
||||
The instrument that should have existed from commit one, on its first run:
|
||||
|
||||
```
|
||||
[production defaults] 0.9 of 1.1 rooms reach, 2.4 of 3.3 tunnels reach
|
||||
WARP SHARE: dilated ±15.0 voxels/axis; with dilation ZERO
|
||||
the same tests keep only 0.4 rooms and 1.1 tunnels
|
||||
[dense fixture] 5.0 rooms / 46.3 tunnels → 2.1 / 22.4 with zero dilation
|
||||
```
|
||||
|
||||
**Rooms 0.9 → 0.4, tunnels 2.4 → 1.1.** Better than half of everything still blocking a verdict is
|
||||
caused by the query-box dilation rather than by cave geometry. Hypothesis confirmed, and the number
|
||||
is now permanent output.
|
||||
|
||||
### ✋ CORRECTION — the justification I gave last entry was wrong
|
||||
|
||||
I wrote that `BuildChunkCache`'s `Expansion = CaveWarpStrength + 2` means *"the whole plugin has
|
||||
always bet on `|Perlin3D| ≤ 0.8`"*, and used that to argue 2.0 was needlessly conservative. **That is
|
||||
false, and I should have read six lines further:**
|
||||
|
||||
```cpp
|
||||
const bool bNeedRebuild = … || WarpedX < CachedSMinX || WarpedX > CachedSMaxX || …;
|
||||
```
|
||||
|
||||
The cache **rebuilds when the warped query leaves the box.** So that expansion is a
|
||||
*rebuild-frequency heuristic*, not a correctness bound — nothing anywhere bets on 0.8, and the cache
|
||||
is self-correcting no matter how far the warp reaches. The 2.0 → 1.5 change stands on its own
|
||||
derivation and needs no support from that claim. Sixth premise this refactor that reversed on being
|
||||
checked, and this one I asserted **in the same entry where I diagnosed the habit.**
|
||||
|
||||
### ✋ NEGATIVE RESULT — "shift the box by the local warp" does **not** work here
|
||||
|
||||
The obvious next move is: the warp is smooth and low-frequency (`WF = 0.015`, wavelength ~67 voxels)
|
||||
and the tile is only 10 voxels, so evaluate the warp at the box centre, **shift** the box by it, and
|
||||
dilate only by the *variation* across the box. Worked through before writing it, and it loses:
|
||||
|
||||
A rigorous per-axis Lipschitz bound for this `Perlin3D`:
|
||||
|
||||
```
|
||||
∂V/∂fx = [Σ_c ∂W_c/∂su · G_c]·Fade'(fx) + Σ_c W_c·∂G_c/∂fx
|
||||
|Fade'| ≤ 15/8 = 1.875
|
||||
|Σ ∂W/∂su · G| ≤ 4 (difference of two convex combinations of values in [−2,2])
|
||||
|∂G/∂fx| ≤ 1 (u and v are distinct axes, so at most one of them is x)
|
||||
⇒ |∂V/∂fx| ≤ 4·1.875 + 1 = 8.5 per unit noise cell
|
||||
```
|
||||
|
||||
Half-box in noise units: `WF·5 = 0.075` for x and y, `WF·5/1.35 = 0.056` for z ⇒ 0.206 total.
|
||||
So the local variation bound is `8.5 × 0.206 = 1.75`, against a **global** range bound of **1.5**.
|
||||
|
||||
**The local bound is worse than the global one.** The crude Lipschitz constant times the box's extent
|
||||
in noise space already exceeds the whole range of the function. Recorded so nobody spends a build
|
||||
discovering it — the idea is sound in principle and simply does not pay at `WF = 0.015` with a
|
||||
Lipschitz constant this loose.
|
||||
|
||||
### Where that leaves the warp term
|
||||
|
||||
The measured ceiling for *all* remaining warp work is "about half the current blocking", i.e.
|
||||
somewhere around 11 → 16-20 tiles of 40. The only route left is tightening the sup of `|Perlin3D|`
|
||||
from the proved 1.5 toward its true value:
|
||||
|
||||
```
|
||||
|Perlin| ≤ S_x + S_y + S_z − Σ_c w_c·min(|dx_c|,|dy_c|,|dz_c|)
|
||||
spot values: 1.000 at (0.5,0.5,0.5) · 1.003 at (0.9,0.5,0.5) · 0.614 at (0.9,0.9,0.5)
|
||||
```
|
||||
|
||||
so the true sup looks like **≈ 1.0–1.1**, worth ~27 % off the dilation. But "spot-checked on a grid"
|
||||
is not a proof, and a sup proved wrong here is a tile with no geometry and **no collision**. That is
|
||||
analysis work with real hole-risk for a partial share of a bounded prize — a judgement call about
|
||||
appetite, not a technical unknown, so it goes to Jahni rather than getting done on my own initiative.
|
||||
|
||||
### State: T1.d is delivered and verified
|
||||
|
||||
**11 of 40 tiles (27.5 %) proved AllSolid at production defaults, 14641 voxels brute-forced,
|
||||
0 violations.** The dense fixture correctly proves nothing. Every verdict is checked voxel by voxel,
|
||||
so the risk of the whole feature is bounded by a test that runs on every build.
|
||||
|
||||
## 2026-07-29 — T1.d banked at 11/40. VerticalShafts: the connector branch never tested a connector.
|
||||
|
||||
Jahni's call: bank the tunnel result and take VerticalShafts rather than squeeze the Perlin sup.
|
||||
Right call — the shaft fix is a clearly-scoped defect with no hole-risk maths, on an archetype that
|
||||
was getting nothing.
|
||||
|
||||
### The defect, and it is stated in its own comment
|
||||
|
||||
`FShaftFieldSource::EffectOverBox` ends with a "connectors" branch that never looks at a connector:
|
||||
|
||||
```cpp
|
||||
const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad);
|
||||
for (cells in ConnBox)
|
||||
if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent
|
||||
```
|
||||
|
||||
It returns `CarveOnly` because a shaft **exists** somewhere in the neighbourhood. Against the
|
||||
defaults — `ShaftSpacing = 55`, `ShaftDensity = 0.6`, `Pad ≈ 11 + ExtraReach` — that expanded box
|
||||
spans roughly 4×4 cells and therefore ~10 shafts. **The condition is true essentially everywhere**,
|
||||
which is exactly the reported `0 proved of 60`. Conservative, never wrong, and completely sterile —
|
||||
the same shape as the worm's unconditional `CarveOnly`, one archetype over.
|
||||
|
||||
### The fix: rebuild the connectors the way `GetCells` does, and test the real capsule
|
||||
|
||||
Two things had to be right, and both were checked in the source rather than assumed:
|
||||
|
||||
- **The enumeration is a superset.** `Eval` reads connectors from the 3×3 neighbourhood of *its
|
||||
query's* cell. So any pair visible from a point in the box has both shafts inside the 3×3 of some
|
||||
cell the box touches ⇒ both lie in `[box cells] ± 1`, which is precisely the range swept. Pairs
|
||||
that no query ever sees may be produced — extra `CarveOnly`, never a hole.
|
||||
- **The pair order matches, so the hash matches.** `VoxelHash::Pair(A…, B…)` is fed in insertion
|
||||
order, and `GetCells` inserts over `(dy, dx)` — row-major. The sweep here is `(cy, cx)`, the same
|
||||
order, and row-major order restricted to a sub-grid preserves the relative order of any two cells.
|
||||
So the same pair gets the same hash **without assuming `Pair()` is symmetric** — which was never
|
||||
verified and now does not need to be.
|
||||
|
||||
The capsule test splits the axes because a connector is a **horizontal** capsule at height `Zc`:
|
||||
Z is exact (`RMinZ > Zc + Reach || RMaxZ < Zc - Reach` ⇒ skip), XY uses point-to-segment from the box
|
||||
centre minus the XY half-diagonal. Far tighter than a 3-D half-diagonal, which is what the tunnel
|
||||
version had to settle for.
|
||||
|
||||
### And the same sampler trap, caught this time before the build
|
||||
|
||||
The shaft test drew tile XY from `RandRange(-6,6)*8` = **±48 voxels, against `ShaftSpacing = 55`** —
|
||||
less than one period of the pattern. Identical in kind to the ±32-vs-80 bug that cost the tunnel test
|
||||
three runs. Widened to ±440 (8 periods), and the report now prints its own extent in units of
|
||||
`ShaftSpacing`, so it cannot go unnoticed again.
|
||||
|
||||
**The safety net was already there and is untouched:** this test brute-forces the full lattice of
|
||||
every proved tile against `EvalMC`, both hypotheses, and `AddError`s on the first violation. So the
|
||||
fix is checked by construction — if the superset argument or the hash-order argument is wrong, the
|
||||
existing assertion fails rather than a player falling through the floor.
|
||||
|
||||
### Ready to build. Compile-error spots
|
||||
|
||||
1. `VF_DistPointSegment2D` — new helper next to `VF_NearCaveSurface` in the anonymous namespace.
|
||||
2. Both new helpers now use `KINDA_SMALL_NUMBER`, matching the ~6 existing uses in the plugin; they
|
||||
were briefly written with the `UE_`-prefixed spelling, which nothing else here uses.
|
||||
3. The shaft test hoists `SpanCells` / `SpanVoxels` **outside** the tile loop — the report needs
|
||||
them, and `Extent` is loop-local. (That exact scoping slip happened in the tunnel test two rounds
|
||||
ago; caught here before the build rather than after.)
|
||||
4. Format string is 6 specifiers / 6 arguments — counted.
|
||||
|
||||
### What to read
|
||||
|
||||
`Box verdicts over 60 VerticalShafts tiles` — **0 is the number to beat**, and `%d violations` must
|
||||
stay 0. If it is still 0 proved, the warning now says what to check *first*: `ExtraReach` inflates
|
||||
both remaining tests, so its value against `ShaftMaxRadius` is the thing to look at before touching
|
||||
either test — not a re-derivation from scratch.
|
||||
|
||||
@@ -207,14 +207,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(13579);
|
||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||
const int32 SpanCells = 55;
|
||||
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||
|
||||
for (int32 t = 0; t < 60; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
// ⚠️ L'ÉTENDUE XY ÉTAIT ±48 VOXELS, POUR UN `ShaftSpacing` DE 55 : moins d'UNE cellule
|
||||
// de puits. C'est le même piège que celui qui a coûté trois runs au test TunnelNetwork —
|
||||
// un échantillonneur qui ne couvre pas une période du motif ne mesure pas le monde, il
|
||||
// mesure un point du motif. ±440 = 8 périodes.
|
||||
// The XY extent was ±48 voxels for a ShaftSpacing of 55 — less than one shaft cell, the
|
||||
// same trap that cost the TunnelNetwork test three runs. ±440 covers 8 periods.
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1;
|
||||
@@ -259,10 +269,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
||||
TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 60 VerticalShafts tiles: %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("ClassifyTile proves ZERO of these -- every cave archetype falls through to \"pas ")
|
||||
TEXT("prouvable en v1\"."),
|
||||
NumProved, NumMixed));
|
||||
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
|
||||
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ")
|
||||
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
|
||||
TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ")
|
||||
TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ")
|
||||
TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ")
|
||||
TEXT("because a false verdict here leaves no geometry and no collision."),
|
||||
SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing,
|
||||
NumProved, NumMixed, NumUnsound));
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ")
|
||||
TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ")
|
||||
TEXT("CAPSULE test are the only two things that can return CarveOnly here, ")
|
||||
TEXT("and ExtraReach inflates both -- check its value against ShaftMaxRadius ")
|
||||
TEXT("before touching either test."));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -70,6 +70,23 @@ namespace
|
||||
constexpr int32 PointsPerChunk = 250;
|
||||
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
|
||||
|
||||
/**
|
||||
* L'empreinte de params que `GetDensityWithParams` exige depuis le correctif d'`AUDIT §C2`.
|
||||
*
|
||||
* ⚠️ CE N'EST PAS DU REMPLISSAGE D'ARGUMENT. Avant ce correctif, l'original clé son cache SDF
|
||||
* sans les params, et le contrôle 3 plus bas explique en détail pourquoi il fallait alors
|
||||
* comparer chaque pile à ELLE-MÊME plutôt qu'à l'original : l'oracle partageait le défaut
|
||||
* testé. En passant la même empreinte que la production, l'oracle ne le partage plus.
|
||||
*
|
||||
* `LayoutVersion = 0` partout dans ce test : le monde de test ne rebâtit jamais son layout en
|
||||
* cours de route, donc la version est constante — ce qui compte ici, c'est que l'empreinte
|
||||
* DIFFÈRE entre deux jeux de params, et c'est exactement ce que la CRC donne.
|
||||
*/
|
||||
FORCEINLINE uint32 VF_FP(const FStrateGenerationParams& InP)
|
||||
{
|
||||
return FCrc::MemCrc32(&InP, sizeof(InP));
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// ⚠️ `DisableStageBModifiers` A DISPARU, ET SA DISPARITION EST LE RÉSULTAT DE L'ÉTAPE B
|
||||
//=========================================================================
|
||||
@@ -503,7 +520,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
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 Old = Gen->GetDensityWithParams(X, Y, Z, P, VF_FP(P), 0);
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
FullVals[i] = New;
|
||||
|
||||
@@ -650,7 +667,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
for (int32 i = 0; i < RoughSweepPoints; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV), VStack.EvalMC(X, Y, Z)))
|
||||
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV, VF_FP(PV), 0),
|
||||
VStack.EvalMC(X, Y, Z)))
|
||||
{
|
||||
++VDiff;
|
||||
}
|
||||
@@ -819,22 +837,28 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
// 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é.
|
||||
// ⚠️⚠️ HISTORIQUE, ET LE DÉNOUEMENT EST DANS LE PARAGRAPHE SUIVANT — À LIRE EN ENTIER.
|
||||
// Ce bloc a été écrit quand `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed),
|
||||
// **sans les params** : en alternance il rendait, pour B, les salles de A, donc l'original
|
||||
// ÉCHOUAIT ce contrôle. Le comparer à lui ici n'aurait pas mesuré l'opérateur, ça aurait mesuré
|
||||
// son bug — d'où le choix de comparer chaque pile à ELLE-MÊME évaluée seule.
|
||||
//
|
||||
// ⚠️ 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é.
|
||||
// ✅ **CE N'ÉTAIT PAS QU'UN ARTEFACT DE TEST, ET C'EST MAINTENANT CORRIGÉ** (2026-07-28). Le
|
||||
// soupçon écrit ici s'est confirmé : `GetGenerationParams` blende les params À L'INTÉRIEUR
|
||||
// d'une strate (`Alpha` = f(chunk Z) en `Gradient`, le défaut), donc deux chunks de Z différents
|
||||
// partageaient boîte XY, index de strate et seed ⇒ aucune reconstruction ⇒ le deuxième chunk
|
||||
// évalué contre les salles du premier. Et comme l'ordre des workers décide lequel est « le
|
||||
// premier », **deux pairs divergeaient depuis la même seed**, ce que §2.6.1 interdit.
|
||||
// `GetDensityWithParams` prend désormais une empreinte de params et une `LayoutVersion`
|
||||
// OBLIGATOIRES (calculées une fois par chunk côté production, `VF_FP` ici).
|
||||
//
|
||||
// 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.
|
||||
// ⚠️ ON GARDE POURTANT L'ORACLE « CHAQUE PILE CONTRE ELLE-MÊME », et ce n'est pas de la
|
||||
// paresse : il teste la clé de la PILE, qui est une clé distincte de celle de l'original. Les
|
||||
// faire dépendre l'une de l'autre remettrait exactement le couplage qu'on vient de défaire.
|
||||
//
|
||||
// The suspicion recorded here was CONFIRMED and is now fixed: the params fingerprint and layout
|
||||
// version are required arguments. The self-comparison oracle stays, because it tests the STACK's
|
||||
// key, which is a different key from the original's.
|
||||
{
|
||||
FStrateGenerationParams P2 = P;
|
||||
P2.RoomSpacing = P.RoomSpacing * 0.6f; // une autre disposition de salles
|
||||
@@ -1059,39 +1083,276 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 4. LE VERDICT DE BOÎTE — attendu NUL, et c'est le point
|
||||
// 4. LE VERDICT DE BOÎTE — plus attendu nul, et CHAQUE VERDICT EST BRUTE-FORCÉ
|
||||
//=========================================================================
|
||||
// ⚠️ CE BLOC A CHANGÉ DE NATURE LE 2026-07-28, ET IL FAUT SAVOIR POURQUOI.
|
||||
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que `FRoomGraphSource::EffectOverBox`
|
||||
// rendait `Both` inconditionnellement : « zéro » était alors une description honnête de l'état
|
||||
// du portage. Depuis que la source répond SPATIALEMENT, asserter zéro reviendrait à interdire
|
||||
// le gain qu'on vient de construire — et pire, ça transformerait le test en gardien du bug.
|
||||
//
|
||||
// Ce qui le remplace n'est PAS « on enlève l'assertion » : c'est l'assertion qui compte
|
||||
// vraiment, la SOUNDNESS. Un verdict faux ne se voit pas — pas de géométrie, **pas de
|
||||
// collision** — jusqu'à ce qu'un joueur traverse le sol. Donc chaque tuile déclarée prouvée est
|
||||
// ré-évaluée voxel par voxel, et le test échoue si UN seul échantillon contredit le verdict.
|
||||
// Le nombre de tuiles prouvées, lui, est REPORTÉ, pas asserté : c'est une mesure, pas un
|
||||
// contrat (la leçon « coverage is a number, not a boolean »).
|
||||
//
|
||||
// Was: assert zero proved. That was honest while the source answered Both unconditionally; it
|
||||
// would now forbid the very gain this change makes. What replaces it is the assertion that
|
||||
// actually matters — every proved tile is brute-forced voxel by voxel, because a false verdict
|
||||
// means no geometry and NO COLLISION until a player falls through it.
|
||||
// ⚠️ UNE SEULE DÉFINITION DU BALAYAGE, DEUX MONDES. Voir le commentaire d'appel plus bas :
|
||||
// la densité de la fixture rend ce verdict STRUCTURELLEMENT impossible, donc mesurer sur elle
|
||||
// seule ne dit rien de la production. Copier-coller le balayage aurait donné deux critères qui
|
||||
// divergent ; c'est un paramètre, pas un doublon.
|
||||
auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
|
||||
const FVoxelOpContext& TCtx, const TCHAR* Label)
|
||||
{
|
||||
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));
|
||||
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
|
||||
int32 NumBruteSamples = 0, NumViolations = 0;
|
||||
float WorstViolation = 0.0f;
|
||||
TMap<FString, int32> SolidKillerCounts;
|
||||
int32 NumRoomKilled = 0, NumTilesAwayFromSpine = 0;
|
||||
// ±320 voxels = 4 x RoomSpacing. Hors de la boucle : la ligne de rapport en a besoin, et
|
||||
// une étendue d'échantillonnage qu'on ne peut pas citer est une étendue qu'on ne surveille pas.
|
||||
const int32 SpanCells = 40;
|
||||
const int32 SpanVoxelsReported = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||
int32 TilesHitByRooms = 0, TilesHitByTunnels = 0, TilesHitByPits = 0, TilesHitByChimneys = 0;
|
||||
int32 SumHitRooms = 0, SumNumRooms = 0, SumHitTunnels = 0, SumNumTunnels = 0;
|
||||
int32 SumHitRoomsNoWarp = 0, SumHitTunnelsNoWarp = 0;
|
||||
float LastWarpDilation = 0.0f;
|
||||
|
||||
if (Stack.ClassifyBox(Box, Ctx) == EVoxelTileClass::Mixed) { ++NumMixed; }
|
||||
else { ++NumProved; }
|
||||
}
|
||||
FRandomStream Rng(97531);
|
||||
for (int32 t = 0; t < 40; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
|
||||
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));
|
||||
// ⚠️ L'ÉTENDUE XY ÉTAIT ±32 VOXELS, ET C'EST CE QUI RENDAIT CE BLOC INEXPLOITABLE.
|
||||
// `RandRange(-4, 4) * 8` échantillonnait 40 tuiles dans un cube de ±32 voxels autour de
|
||||
// (0,0) — c'est-à-dire l'endroit le PLUS creusé du monde entier, et de loin :
|
||||
// • `RoomSpacing = 80`, donc ±32 ne couvre même pas la moitié d'UNE cellule de salle ;
|
||||
// • `OriginRoomRadius = 20` garantit une grosse salle exactement à (0,0), de rayon de
|
||||
// cull `max(20·1.5, 8) + 3·4 = 42` — qui avale la quasi-totalité de la fenêtre ;
|
||||
// • la spine (0,0) descend précisément là.
|
||||
// La mesure « 4.9 salles sur 7.2 atteignent la boîte » ne décrivait donc pas la densité
|
||||
// de grottes du monde, elle décrivait le hub de la spine. Aucune conclusion sur la
|
||||
// prouvabilité du roc profond ne pouvait sortir de cet échantillon.
|
||||
//
|
||||
// ⚠️ CE N'EST PAS « ÉLARGIR JUSQU'À CE QUE ÇA PASSE ». Le verdict de chaque tuile reste
|
||||
// brute-forcé voxel par voxel juste en dessous : un échantillonneur plus large qui
|
||||
// produirait un verdict FAUX échoue exactement comme avant. On corrige ce que la mesure
|
||||
// REGARDE, pas ce qu'elle exige.
|
||||
//
|
||||
// The XY extent was ±32 voxels around (0,0) -- with RoomSpacing = 80 and a guaranteed
|
||||
// OriginRoomRadius = 20 room at the origin, that samples the single most cave-dense spot
|
||||
// in the world and says nothing about deep rock. Widening changes what the measurement
|
||||
// LOOKS AT, not what it demands: every verdict is still brute-forced below.
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
TestEqual(TEXT("stage A emits no unsound verdict (it emits none at all)"), NumProved, 0);
|
||||
// Combien de tuiles échappent vraiment au hub de la spine : sans ce compte, un futur
|
||||
// resserrement de l'étendue redeviendrait invisible.
|
||||
if (FMath::Square((float)Origin.X) + FMath::Square((float)Origin.Y)
|
||||
> FMath::Square(3.0f * TP.OriginRoomRadius))
|
||||
{
|
||||
++NumTilesAwayFromSpine;
|
||||
}
|
||||
const int32 GridDim = Cells + 1;
|
||||
const FBox Box(
|
||||
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||
|
||||
// ATTRIBUTION — le même pliage, mais il dit QUI tue chaque hypothèse. Le premier build
|
||||
// de l'`EffectOverBox` spatial est revenu vert avec 0 tuile prouvée, et le rapport ne
|
||||
// savait nommer aucun coupable : les deux causes que la mise en garde proposait étaient
|
||||
// toutes les deux fausses, la vraie étant un troisième opérateur. On ne redevine pas.
|
||||
int32 SolidKiller = INDEX_NONE, AirKiller = INDEX_NONE;
|
||||
const EVoxelTileClass Verdict = S.ClassifyBoxAttributed(Box, TCtx, SolidKiller, AirKiller);
|
||||
|
||||
if (SolidKiller != INDEX_NONE)
|
||||
{
|
||||
const FString KillerName = S.GetOpDebugName(SolidKiller);
|
||||
SolidKillerCounts.FindOrAdd(KillerName)++;
|
||||
|
||||
// VENTILATION PAR CLASSE DE PRIMITIVE. Quand c'est la source de salles qui tue,
|
||||
// « les tuiles traversent une grotte » n'est pas une réponse : les salles, les
|
||||
// tunnels, les pits et les cheminées ont chacun leur borne, de finesse très
|
||||
// différente (une sphère englobante de capsule est un très mauvais tunnel). On lit
|
||||
// ce que l'opérateur a RÉELLEMENT calculé plutôt que de rejouer le critère ici.
|
||||
if (KillerName == TEXT("RoomGraphSource"))
|
||||
{
|
||||
const VoxelDensityOps::FRoomBoxDiagnostic D =
|
||||
VoxelDensityOps::GetLastRoomBoxDiagnostic();
|
||||
++NumRoomKilled;
|
||||
if (D.HitRooms > 0) { ++TilesHitByRooms; }
|
||||
if (D.HitTunnels > 0) { ++TilesHitByTunnels; }
|
||||
if (D.HitPits > 0) { ++TilesHitByPits; }
|
||||
if (D.HitChimneys > 0) { ++TilesHitByChimneys; }
|
||||
SumHitRooms += D.HitRooms; SumNumRooms += D.NumRooms;
|
||||
SumHitTunnels += D.HitTunnels; SumNumTunnels += D.NumTunnels;
|
||||
SumHitRoomsNoWarp += D.HitRoomsNoWarp;
|
||||
SumHitTunnelsNoWarp += D.HitTunnelsNoWarp;
|
||||
LastWarpDilation = D.WarpDilation;
|
||||
}
|
||||
}
|
||||
|
||||
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||
|
||||
++NumProved;
|
||||
const bool bClaimSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
if (bClaimSolid) { ++NumSolid; } else { ++NumAir; }
|
||||
|
||||
// BRUTE FORCE — la boîte entière, pas un échantillonnage. `EvalMC` rend la convention
|
||||
// du mesher (négatif = solide), donc « tout solide » veut dire qu'aucun échantillon
|
||||
// n'est du côté air. On teste le SIGNE, c'est-à-dire l'existence d'une traversée
|
||||
// d'isosurface : c'est exactement la propriété sur laquelle le mesher est sauté.
|
||||
for (float Z = (float)Box.Min.Z; Z <= (float)Box.Max.Z; Z += 1.0f)
|
||||
for (float Y = (float)Box.Min.Y; Y <= (float)Box.Max.Y; Y += 1.0f)
|
||||
for (float X = (float)Box.Min.X; X <= (float)Box.Max.X; X += 1.0f)
|
||||
{
|
||||
const float D = S.EvalMC(X, Y, Z);
|
||||
++NumBruteSamples;
|
||||
const bool bViolates = bClaimSolid ? (D > 0.0f) : (D < 0.0f);
|
||||
if (bViolates)
|
||||
{
|
||||
++NumViolations;
|
||||
WorstViolation = FMath::Max(WorstViolation, FMath::Abs(D));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("[%s] Tile sampler: 40 tiles of 10 voxels, XY drawn from +/-%d voxels (= %.1f x ")
|
||||
TEXT("RoomSpacing %.0f), Z across the strate; %d of 40 landed further than 3 x ")
|
||||
TEXT("OriginRoomRadius from the (0,0) spine. THIS LINE EXISTS BECAUSE THE SAMPLER WAS ")
|
||||
TEXT("THE BUG ONCE: it drew XY from +/-32 voxels, i.e. entirely inside the origin room's ")
|
||||
TEXT("cull sphere, so every number below described the spine hub rather than the world. ")
|
||||
TEXT("If the last count is low, nothing below says anything about deep rock."),
|
||||
Label, SpanVoxelsReported, (float)SpanVoxelsReported / FMath::Max(TP.RoomSpacing, 1.0f),
|
||||
TP.RoomSpacing, NumTilesAwayFromSpine));
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("[%s] Box verdicts over 40 TunnelNetwork tiles: %d proved (%d AllSolid, %d AllAir), ")
|
||||
TEXT("%d Mixed -- brute-forced over %d voxels, %d violations. This number was 0 proved / ")
|
||||
TEXT("40 Mixed until FRoomGraphSource::EffectOverBox learned to answer spatially, and it ")
|
||||
TEXT("is the single largest perf item of the whole plan (OPSTACK-DECOMPOSITION 0.2): a ")
|
||||
TEXT("proved tile skips GenerateMesh entirely, so it trades one BuildChunkCache against ")
|
||||
TEXT("30000+ density evaluations. Read the PROVED count as a measurement, never as a ")
|
||||
TEXT("contract -- what is asserted below is that none of them is WRONG, because a false ")
|
||||
TEXT("verdict leaves no geometry and no collision behind it."),
|
||||
Label, NumProved, NumSolid, NumAir, NumMixed, NumBruteSamples, NumViolations));
|
||||
|
||||
// QUI TUE `AllSolid`, ET COMBIEN DE FOIS. Toujours imprimé, pas seulement en cas d'échec :
|
||||
// c'est aussi la ligne qui dit, quand des tuiles SONT prouvées, ce qui bloque les autres.
|
||||
{
|
||||
SolidKillerCounts.ValueSort([](int32 A, int32 B) { return A > B; });
|
||||
FString Breakdown;
|
||||
for (const TPair<FString, int32>& Kv : SolidKillerCounts)
|
||||
{
|
||||
if (!Breakdown.IsEmpty()) { Breakdown += TEXT(", "); }
|
||||
Breakdown += FString::Printf(TEXT("%s x%d"), *Kv.Key, Kv.Value);
|
||||
}
|
||||
if (Breakdown.IsEmpty()) { Breakdown = TEXT("nothing -- AllSolid survived every tile"); }
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("[%s] AllSolid killed by: %s. Names the first operator to kill the ")
|
||||
TEXT("hypothesis, counted per tile. (Why this line exists, and the wrong guesses ")
|
||||
TEXT("that preceded it, live in OPSTACK-PROGRESS and are deliberately NOT ")
|
||||
TEXT("repeated here: a diagnostic that carries narrative gets its live numbers ")
|
||||
TEXT("read as history and its history read as live numbers.)"),
|
||||
Label, *Breakdown));
|
||||
|
||||
if (NumRoomKilled > 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("[%s] ...and when RoomGraphSource is the killer (%d tiles), WHICH primitive class ")
|
||||
TEXT("reaches the box: rooms %d, tunnels %d, pits %d, chimneys %d (tiles, not ")
|
||||
TEXT("primitives -- a tile can be hit by several). Averages per killed tile: ")
|
||||
TEXT("%.1f of %.1f rooms reach, %.1f of %.1f tunnels reach. ")
|
||||
TEXT("WARP SHARE: the query box is dilated by +/-%.1f voxels per axis for the ")
|
||||
TEXT("warped room/tunnel query; with that dilation set to ZERO the same tests ")
|
||||
TEXT("would keep only %.1f rooms and %.1f tunnels. The gap between those ")
|
||||
TEXT("pairs is blocking caused by MY BOX rather than by geometry -- the term ")
|
||||
TEXT("that went unmeasured while three rounds of tightening happened around ")
|
||||
TEXT("it. If the gap dominates, tighten the warp bound, not the primitives."),
|
||||
Label, NumRoomKilled, TilesHitByRooms, TilesHitByTunnels, TilesHitByPits, TilesHitByChimneys,
|
||||
(float)SumHitRooms / (float)NumRoomKilled, (float)SumNumRooms / (float)NumRoomKilled,
|
||||
(float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled,
|
||||
LastWarpDilation,
|
||||
(float)SumHitRoomsNoWarp / (float)NumRoomKilled,
|
||||
(float)SumHitTunnelsNoWarp / (float)NumRoomKilled));
|
||||
}
|
||||
}
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("[%s] No tile was proved, so the brute force verified nothing -- it has no ")
|
||||
TEXT("verdict to contradict. Do NOT re-derive the cause: read the two lines above, ")
|
||||
TEXT("which name the operator and then the primitive class. ⚠️ On the DENSE ")
|
||||
TEXT("FIXTURE this is the EXPECTED and correct result, not a defect: room cull ")
|
||||
TEXT("radius (1.5R + 3*SDFBlendRadius, mean ~42) equals RoomSpacing 42 at 85%% ")
|
||||
TEXT("occupancy, so cull spheres cover that world ~3.6x over and no box can be ")
|
||||
TEXT("outside all of them. It is the 'production defaults' run that answers ")
|
||||
TEXT("whether real worlds have skippable rock."),
|
||||
Label));
|
||||
}
|
||||
|
||||
TestEqual(FString::Printf(
|
||||
TEXT("[%s] every proved TunnelNetwork tile survives brute force (worst ")
|
||||
TEXT("|density| on the wrong side: %.9g)"), Label, WorstViolation),
|
||||
NumViolations, 0);
|
||||
};
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// LES DEUX MONDES, ET POURQUOI IL EN FAUT DEUX
|
||||
//-------------------------------------------------------------------------
|
||||
// ⚠️ LA FIXTURE REND CE VERDICT STRUCTURELLEMENT IMPOSSIBLE, ET CE N'EST PAS UN DÉFAUT DE LA
|
||||
// FIXTURE. `EnableTunnelFeatures` densifie délibérément (`RoomSpacing` 80 → 42,
|
||||
// `RoomDensity` 0.35 → 0.85) parce qu'aux défauts le premier run n'avait que 1,1 % des
|
||||
// échantillons en grotte — l'équivalence comparait du roc plein à du roc plein. Cette
|
||||
// densification est ce qui rend le contrôle 1 SIGNIFIANT.
|
||||
//
|
||||
// Mais elle est exactement ANTAGONISTE de la prouvabilité, et l'arithmétique le dit sans
|
||||
// ambiguïté : le rayon de cull d'une salle vaut `max(R·1.5, R·HeightRatio) + 3·SDFBlendRadius`,
|
||||
// soit `1.5R + 12` ⇒ entre 27 et 57 pour `R ∈ [10, 30]`, moyenne ≈ 42 — c'est-à-dire
|
||||
// **exactement le pas du réseau**, à 85 % d'occupation. Des sphères de cull de rayon égal au pas
|
||||
// du réseau recouvrent l'espace ~3,6 fois. **Aucune boîte de ce monde ne peut être hors de
|
||||
// toutes les sphères de cull.** Le « 6.3 salles sur 8.3 atteignent la boîte » mesuré est
|
||||
// exactement ça, et élargir l'échantillonneur n'y a rien changé (39 tuiles sur 40 étaient déjà
|
||||
// loin de la spine, et le compte est resté 40 sur 40).
|
||||
//
|
||||
// Donc on mesure les DEUX : la fixture dense, où `0 prouvé` est le résultat CORRECT et
|
||||
// informatif (le gain disparaît quand les grottes saturent), et un jeu de params aux DÉFAUTS
|
||||
// DE PRODUCTION, qui est le monde dont la question « combien de tuiles peut-on sauter » parle
|
||||
// réellement. Ce n'est pas « élargir jusqu'à ce que ça passe » : les deux sont rapportés, les
|
||||
// deux sont brute-forcés, et le dense DOIT continuer à rendre ~0.
|
||||
//
|
||||
// Two worlds on purpose: the fixture is deliberately densified so the equivalence check means
|
||||
// something, and that same densification makes tile-proving structurally impossible (room cull
|
||||
// radius ~= the lattice spacing, at 85% occupancy). Both are measured and both are brute-forced;
|
||||
// the dense one reporting ~0 is the correct answer, not a failure.
|
||||
RunTileScan(Stack, P, Ctx, TEXT("dense fixture"));
|
||||
|
||||
{
|
||||
FStrateGenerationParams SparseP = P;
|
||||
SparseP.RoomSpacing = 80.0f; // le défaut d'`UVoxelStrateDefinition`
|
||||
SparseP.RoomDensity = 0.35f; // idem — voir `EnableTunnelFeatures`
|
||||
|
||||
FVoxelOpStack SparseStack;
|
||||
VoxelDensityOps::BuildTunnelNetworkStack(SparseStack, SparseP, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
SparseStack.PrepareChunk(Ctx);
|
||||
|
||||
// ⚠️ Les deux piles partagent les caches `thread_local` de `FRoomGraphSource`. C'est VOULU,
|
||||
// et c'est exactement ce que le contrôle 3 vérifie : l'empreinte de params est dans la clé,
|
||||
// donc l'une ne peut pas se servir les salles de l'autre. Le jour où ce contrôle tombe,
|
||||
// cette ligne-ci devient fausse en même temps — elles se surveillent mutuellement.
|
||||
RunTileScan(SparseStack, SparseP, Ctx, TEXT("production defaults"));
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
@@ -1172,7 +1433,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
for (int32 i = 0; i < UWSamples; ++i)
|
||||
{
|
||||
const float X = (float)UWPoints[i].X, Y = (float)UWPoints[i].Y, Z = (float)UWPoints[i].Z;
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP);
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP, VF_FP(UP), 0);
|
||||
const float New = UWStack.EvalMC(X, Y, Z);
|
||||
if (Z > UWInnerBot && Z < UWInnerTop && Old >= 0.0f) { ++UWInCave; }
|
||||
if (!BitEqual(Old, New))
|
||||
|
||||
@@ -79,6 +79,33 @@ namespace
|
||||
// STAGE B5 DECISION: repeated early-out in each op, NOT a scoping container — the stack is a flat
|
||||
// list that ClassifyBox folds op by op, and an op that only exists inside a container is not
|
||||
// composable. Cost stated honestly: twelve predictable compares instead of one branch.
|
||||
/** Distance d'un point à un SEGMENT (pas à une droite). Écrite ici plutôt que prise dans
|
||||
* `FMath` : cinq lignes, aucune ambiguïté d'API, et elle sert une borne de correction — le
|
||||
* genre d'endroit où « je crois que cette fonction fait ça » n'est pas suffisant. */
|
||||
FORCEINLINE float VF_DistPointSegment(const FVector& P, const FVector& A, const FVector& B)
|
||||
{
|
||||
const FVector AB = B - A;
|
||||
const double LenSq = FVector::DotProduct(AB, AB);
|
||||
const double T = (LenSq > KINDA_SMALL_NUMBER)
|
||||
? FMath::Clamp(FVector::DotProduct(P - A, AB) / LenSq, 0.0, 1.0)
|
||||
: 0.0;
|
||||
return (float)FVector::Dist(P, A + AB * T);
|
||||
}
|
||||
|
||||
/** La même chose en 2D, pour les connecteurs de puits : ce sont des capsules HORIZONTALES, donc
|
||||
* Z se teste exactement et seul XY demande une distance point-segment. */
|
||||
FORCEINLINE float VF_DistPointSegment2D(const FVector2D& P, const FVector2D& A, const FVector2D& B)
|
||||
{
|
||||
const FVector2D AB = B - A;
|
||||
const double LenSq = (double)AB.X * AB.X + (double)AB.Y * AB.Y;
|
||||
const double T = (LenSq > KINDA_SMALL_NUMBER)
|
||||
? FMath::Clamp(((double)(P.X - A.X) * AB.X + (double)(P.Y - A.Y) * AB.Y) / LenSq, 0.0, 1.0)
|
||||
: 0.0;
|
||||
const double DX = (double)P.X - ((double)A.X + AB.X * T);
|
||||
const double DY = (double)P.Y - ((double)A.Y + AB.Y * T);
|
||||
return (float)FMath::Sqrt(DX * DX + DY * DY);
|
||||
}
|
||||
|
||||
FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius)
|
||||
{
|
||||
// Transcrit tel quel, ordre des comparaisons compris :
|
||||
@@ -144,6 +171,8 @@ namespace
|
||||
return FMath::Abs(Value);
|
||||
}
|
||||
|
||||
const TCHAR* DebugName() const override { return TEXT("ConstantFieldSource"); }
|
||||
|
||||
private:
|
||||
float Value;
|
||||
};
|
||||
@@ -1119,6 +1148,8 @@ namespace
|
||||
return EVoxelOpEffect::Identity; // la source a répondu pour la paire
|
||||
}
|
||||
|
||||
const TCHAR* DebugName() const override { return TEXT("SdfConvertOp"); }
|
||||
|
||||
private:
|
||||
float Blend, BaseDensity, Sign, MinDivisor;
|
||||
};
|
||||
@@ -1159,6 +1190,8 @@ namespace
|
||||
return EVoxelOpEffect::CarveOnly;
|
||||
}
|
||||
|
||||
const TCHAR* DebugName() const override { return TEXT("OriginSpineOp"); }
|
||||
|
||||
private:
|
||||
float TopZ, BotZ, Seal, Base, Radius;
|
||||
};
|
||||
@@ -1226,6 +1259,8 @@ namespace
|
||||
return EVoxelOpEffect::FillOnly;
|
||||
}
|
||||
|
||||
const TCHAR* DebugName() const override { return TEXT("BoundarySealOp"); }
|
||||
|
||||
private:
|
||||
float TopZ, BotZ, Thickness, Base;
|
||||
};
|
||||
@@ -1258,6 +1293,8 @@ namespace
|
||||
? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
|
||||
}
|
||||
|
||||
const TCHAR* DebugName() const override { return TEXT("PassageCarveOp"); }
|
||||
|
||||
private:
|
||||
const UVoxelStrateManager* Manager;
|
||||
float Base, Seal;
|
||||
@@ -1361,20 +1398,88 @@ namespace
|
||||
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; }
|
||||
}
|
||||
|
||||
// ⚠️ Les connecteurs ne sont PAS testés ici, et c'est délibérément conservatif dans le
|
||||
// mauvais sens si on n'y prend pas garde : un connecteur ne peut exister qu'entre deux
|
||||
// puits d'un voisinage, donc si AUCUN puits n'atteint la boîte élargie de `Spacing*1.6`
|
||||
// (la portée max d'une paire), aucun connecteur ne peut l'atteindre non plus.
|
||||
const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad);
|
||||
const int32 KX0 = FMath::FloorToInt((float)ConnBox.Min.X / Spacing);
|
||||
const int32 KX1 = FMath::FloorToInt((float)ConnBox.Max.X / Spacing);
|
||||
const int32 KY0 = FMath::FloorToInt((float)ConnBox.Min.Y / Spacing);
|
||||
const int32 KY1 = FMath::FloorToInt((float)ConnBox.Max.Y / Spacing);
|
||||
for (int32 cy = KY0; cy <= KY1; ++cy)
|
||||
for (int32 cx = KX0; cx <= KX1; ++cx)
|
||||
//-----------------------------------------------------------------
|
||||
// LES CONNECTEURS — LES VRAIES CAPSULES, PLUS « un puits existe dans le coin »
|
||||
//-----------------------------------------------------------------
|
||||
// ⚠️ CE BLOC RENDAIT `CarveOnly` DÈS QU'UN PUITS **EXISTAIT** dans la boîte élargie de
|
||||
// `Spacing·1.6 + Pad`, sans jamais regarder un connecteur. Avec les défauts
|
||||
// (`ShaftSpacing = 55`, `ShaftDensity = 0.6`) cette boîte élargie couvre ~4×4 cellules,
|
||||
// donc une dizaine de puits : la condition était vraie PARTOUT et l'archétype prouvait
|
||||
// 0 tuile sur 60. Conservatif, jamais faux — et totalement stérile.
|
||||
//
|
||||
// Ce qu'on fait à la place : reconstruire les connecteurs comme `GetCells` les
|
||||
// construit, et tester la capsule réelle.
|
||||
//
|
||||
// ⚠️ POURQUOI L'ÉNUMÉRATION EST UN SUR-ENSEMBLE (donc sûre). `Eval` lit les connecteurs
|
||||
// du voisinage 3×3 de la cellule DE SA REQUÊTE. Une paire visible depuis un point de la
|
||||
// boîte a donc ses deux puits dans un même 3×3 centré sur une cellule que la boîte
|
||||
// touche ⇒ les deux sont dans [cellules de la boîte] ± 1, qui est exactement la plage
|
||||
// balayée ici. On peut produire des paires que personne ne voit jamais : c'est du
|
||||
// `CarveOnly` en trop, pas un trou.
|
||||
//
|
||||
// ⚠️ ET POURQUOI L'ORDRE (A,B) EST LE MÊME QUE CELUI DE `GetCells`. Le hash de paire est
|
||||
// pris sur (A puis B) dans l'ordre d'insertion, et `GetCells` insère en `(dy, dx)`,
|
||||
// c'est-à-dire en balayage ligne par ligne. On balaie ici `(cy, cx)`, le même ordre — et
|
||||
// un ordre ligne par ligne restreint à une sous-grille garde l'ordre relatif de deux
|
||||
// cellules. Donc la même paire reçoit le même `VoxelHash::Pair`, sans supposer que
|
||||
// celui-ci soit symétrique.
|
||||
//
|
||||
// Was: return CarveOnly as soon as any shaft EXISTED within Spacing*1.6 + Pad, which at
|
||||
// ShaftSpacing 55 / ShaftDensity 0.6 is true everywhere -- 0 of 60 tiles proved. Now it
|
||||
// rebuilds the connectors the way GetCells does and tests the real capsule. The pair
|
||||
// enumeration is a superset (safe), and the row-major cell order reproduces GetCells'
|
||||
// insertion order, so each pair gets the same hash without assuming Pair() is symmetric.
|
||||
if (P.CrossConnectChance > 0.0f)
|
||||
{
|
||||
FShaft Sh;
|
||||
if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent
|
||||
const int32 QX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1;
|
||||
const int32 QX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1;
|
||||
const int32 QY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1;
|
||||
const int32 QY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1;
|
||||
|
||||
TArray<FShaft, TInlineAllocator<32>> Near;
|
||||
for (int32 cy = QY0; cy <= QY1; ++cy)
|
||||
for (int32 cx = QX0; cx <= QX1; ++cx)
|
||||
{
|
||||
FShaft Sh;
|
||||
if (RollShaft(cx, cy, Sh)) { Near.Add(Sh); }
|
||||
}
|
||||
|
||||
const float ConnReach = P.ConnectorRadius + ExtraReach;
|
||||
const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness;
|
||||
const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness;
|
||||
|
||||
// Z est traité EXACTEMENT (le connecteur est une capsule horizontale à `Zc`), XY de
|
||||
// façon conservative. Séparer les deux est bien plus serré qu'une demi-diagonale 3D.
|
||||
const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z;
|
||||
const FVector2D CtrXY(0.5f * (float)(VoxelBox.Min.X + VoxelBox.Max.X),
|
||||
0.5f * (float)(VoxelBox.Min.Y + VoxelBox.Max.Y));
|
||||
const float HalfDiagXY = 0.5f * FMath::Sqrt(
|
||||
FMath::Square((float)(VoxelBox.Max.X - VoxelBox.Min.X)) +
|
||||
FMath::Square((float)(VoxelBox.Max.Y - VoxelBox.Min.Y)));
|
||||
|
||||
for (int32 i = 0; i < Near.Num(); ++i)
|
||||
for (int32 j = i + 1; j < Near.Num(); ++j)
|
||||
{
|
||||
const FShaft& A = Near[i];
|
||||
const FShaft& B = Near[j];
|
||||
const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y);
|
||||
if (DSq > FMath::Square(Spacing * 1.6f)) { continue; }
|
||||
|
||||
const uint32 PH = VoxelHash::Pair(
|
||||
FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y),
|
||||
FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu);
|
||||
if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) { continue; }
|
||||
|
||||
const float Zc = FMath::Lerp(BottomZ, TopZ,
|
||||
VoxelHash::ToFloat01(VoxelHash::Mix(PH)));
|
||||
if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { continue; }
|
||||
|
||||
const float DistXY = VF_DistPointSegment2D(
|
||||
CtrXY, FVector2D(A.X, A.Y), FVector2D(B.X, B.Y));
|
||||
if (DistXY - HalfDiagXY >= ConnReach) { continue; }
|
||||
|
||||
return EVoxelOpEffect::CarveOnly;
|
||||
}
|
||||
}
|
||||
|
||||
return EVoxelOpEffect::Identity;
|
||||
@@ -2124,27 +2229,447 @@ namespace
|
||||
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
|
||||
//---------------------------------------------------------------------
|
||||
// L'ÉTAT PAR BOÎTE — SÉPARÉ DE `FState`, ET DÉLIBÉRÉMENT
|
||||
//---------------------------------------------------------------------
|
||||
// `EffectOverBox` construit un cache pour la boîte INTERROGÉE, qui n'est pas la boîte de
|
||||
// recherche que `Eval` construit pour le voxel courant. Les faire partager `FState::Cache`
|
||||
// serait *correct* — la discipline d'invariance de fenêtre de §8.4 garantit qu'un cache bâti
|
||||
// sur une boîte PLUS LARGE donne le même SDF par voxel — mais ça rendrait `ClassifyTile`
|
||||
// capable de perturber le cache chaud d'une génération en cours, et un jour quelqu'un
|
||||
// paierait cette élégance très cher. Un deuxième cache par worker coûte une allocation
|
||||
// amortie ; on la paie.
|
||||
//
|
||||
// Le VERDICT est mémoïsé, et ce n'est pas du confort : `VF_NoCaveOverBox` fait poser la
|
||||
// question par les DOUZE modificateurs de détail pour la même boîte. Sans mémo, une tuile
|
||||
// coûterait treize `BuildChunkCache` au lieu d'un.
|
||||
//
|
||||
// Second per-worker cache, on purpose: sharing FState::Cache would be sound but would let
|
||||
// tile classification disturb a live generation's hot cache. The verdict is memoised because
|
||||
// all twelve detail modifiers ask the same question about the same box.
|
||||
struct FBoxState
|
||||
{
|
||||
return (P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f) ? EVoxelOpEffect::Both
|
||||
: EVoxelOpEffect::Identity;
|
||||
FChunkSDFCache Cache;
|
||||
FBox KeyBox = FBox(ForceInit);
|
||||
int32 KeyStrate = INT32_MIN;
|
||||
uint32 KeySeed = 0;
|
||||
uint32 KeyFingerprint = 0xFFFFFFFFu;
|
||||
uint32 KeyLayout = 0xFFFFFFFFu;
|
||||
bool bValid = false;
|
||||
EVoxelOpEffect Verdict = EVoxelOpEffect::Both;
|
||||
|
||||
/** DIAGNOSTIC — combien de primitives de chaque classe atteignent la dernière boîte
|
||||
* interrogée, et combien le cache en contenait. Lu par les tests via
|
||||
* `VoxelDensityOps::GetLastRoomBoxDiagnostic`. N'entre dans aucune décision. */
|
||||
int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0;
|
||||
int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0;
|
||||
/** Les mêmes comptes avec une dilatation de warp NULLE, et de combien de voxels la
|
||||
* boîte est dilatée. C'est la mesure qui manquait pendant trois builds : sans elle,
|
||||
* « les tunnels bloquent » et « ma boîte est 125x trop grosse » sont indiscernables. */
|
||||
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
|
||||
float WarpDilation = 0.0f;
|
||||
};
|
||||
|
||||
static FBoxState& BoxState()
|
||||
{
|
||||
thread_local FBoxState S;
|
||||
return S;
|
||||
}
|
||||
|
||||
/**
|
||||
* BORNE **PROUVABLE** DE `|Perlin3D|`, ET ELLE N'EST PAS 1.0.
|
||||
*
|
||||
* L'en-tête de `VoxelNoise::Perlin3D` annonce « ~[-1,1] (typiquement [-0.7,0.7]) ». Le `~`
|
||||
* est un aveu : c'est une observation, pas un théorème, et un verdict de boîte fondé sur une
|
||||
* observation est exactement le genre de trou que ce fichier passe son temps à éviter.
|
||||
*
|
||||
* Ce qui EST démontrable, en lisant `GradDot` : il rend `ru + rv` où `ru` et `rv` sont des
|
||||
* composantes de l'offset fractionnaire, donc chacune dans `[-1, 1]` ⇒ `|GradDot| ≤ 2`. La
|
||||
* valeur finale est une interpolation trilinéaire de huit `GradDot`, et une interpolation
|
||||
* convexe ne sort jamais de l'enveloppe de ses entrées ⇒ `|Perlin3D| ≤ 2`. (La vraie borne
|
||||
* de Perlin 3D est `√3/2 ≈ 0.87` ; on ne s'appuie pas dessus, elle dépend du jeu de
|
||||
* gradients.) Se tromper ici coûte une boîte de recherche un peu plus large, jamais un
|
||||
* verdict faux : plus large ⇒ SUR-ensemble de primitives ⇒ `Identity` plus rare.
|
||||
*
|
||||
* ⚠️⚠️ **CORRIGÉ DE 2.0 À 1.5 LE 2026-07-28, ET CETTE CONSTANTE ÉTAIT LE TERME DOMINANT DE
|
||||
* TOUTE LA FONCTION PENDANT TROIS BUILDS.** À lire avant d'y retoucher.
|
||||
*
|
||||
* La dilatation vaut `CaveWarpStrength · VOXEL_NOISE_SCALE · CETTE BORNE`. Avec les défauts
|
||||
* (`CaveWarpStrength = 8`, `SCALE = 1.25`) elle valait **20 voxels** — appliquée des deux
|
||||
* côtés de chaque axe d'une tuile de **10 voxels**, soit une boîte de requête de 50 voxels,
|
||||
* **125× le volume de la tuile**. Trois passes de resserrement (le ver, les colonnes,
|
||||
* l'échantillonneur, la disjonction des tunnels) ont été faites AUTOUR de ce terme sans que
|
||||
* personne ne le mesure. Le test des tunnels, annoncé « un ordre de grandeur plus serré », ne
|
||||
* gagnait en pratique que 25 % — exactement parce que `BoxHalfDiag` était dominé par cette
|
||||
* dilatation et non par la géométrie.
|
||||
*
|
||||
* ⚠️ ET LE RESTE DU PLUGIN N'A JAMAIS ÉTÉ AUSSI PRUDENT : `BuildChunkCache` est appelée avec
|
||||
* `Expansion = CaveWarpStrength + 2` (ici comme dans `GetDensityWithParams`), ce qui suppose
|
||||
* `|Perlin3D| · SCALE ≤ CaveWarpStrength`, donc `|Perlin3D| ≤ 0.8`. Le code qui tourne en
|
||||
* production depuis toujours parie déjà là-dessus. Prendre 2.0 était 2,5× plus conservateur
|
||||
* que l'hypothèse dont dépend déjà la correction du cache.
|
||||
*
|
||||
* LA BORNE 1.5, DÉMONTRÉE (et non observée) :
|
||||
* 1. `GradDot` rend `±u ± v` où `u` et `v` sont deux composantes **distinctes** de l'offset
|
||||
* du coin — vérifié sur les quatre branches du `switch` de hash, pas supposé.
|
||||
* 2. Pour l'axe x : les coins à `i=0` portent le poids `(1−su)` et l'offset `fx`, ceux à
|
||||
* `i=1` le poids `su` et l'offset `1−fx`. Donc `Σ_c w_c·|dx_c| = (1−su)·fx + su·(1−fx)`,
|
||||
* dont le maximum sur `[0,1]` vaut **0.5** (atteint en `fx = 0.5`, où `su = 0.5` ;
|
||||
* 0.302 en 0.25 comme en 0.75).
|
||||
* 3. `|Perlin| ≤ Σ_c w_c(|a_c| + |b_c|) ≤ S_x + S_y + S_z ≤ 3 × 0.5 = 1.5.`
|
||||
* (Le vrai maximum est plus bas encore — seuls DEUX axes apparaissent par coin — mais 1.5
|
||||
* est la borne qui se démontre sans analyse de cas sur les hash. `√3/2 ≈ 0.87`, la borne
|
||||
* classique de Perlin 3D, dépend du jeu de gradients : on ne s'appuie pas dessus.)
|
||||
*
|
||||
* Was 2.0, and that constant was the dominant term of this whole function for three builds:
|
||||
* it inflated a 10-voxel tile into a 50-voxel query box (125x the volume), which is why the
|
||||
* "order of magnitude tighter" tunnel test only won 25%. The rest of the plugin has always
|
||||
* assumed |Perlin3D| <= 0.8 (BuildChunkCache's Expansion = CaveWarpStrength + 2). 1.5 is
|
||||
* PROVED above from GradDot's two-distinct-axes form and the per-axis weighted bound of 0.5.
|
||||
*/
|
||||
static constexpr float PerlinAbsBound = 1.5f;
|
||||
|
||||
/**
|
||||
* ✅ LA RÉPONSE SPATIALE. La dette annoncée ici pendant tout le portage est payée.
|
||||
*
|
||||
* Ce que ça débloque, en un mot : `FSdfConvertOp` renvoie déjà `Identity` (« la source a
|
||||
* répondu pour la paire ») et les douze modificateurs de détail héritent de ce verdict par
|
||||
* `VF_NoCaveOverBox`. Le jour où cette fonction rend `Identity` pour une boîte, **quatorze
|
||||
* opérateurs deviennent l'identité d'un coup** et la tuile est prouvable — c'est pour ça que
|
||||
* le câblage a été posé à UN endroit et pas treize.
|
||||
*
|
||||
* LE CRITÈRE — **UNE PRIMITIVE NE COMPTE PAS SI ELLE RATE SON CULL *OU* SI SON SDF RESTE
|
||||
* AU-DESSUS DU SEUIL `T`.** Une disjonction, pas une seule règle, et chaque branche gagne sur
|
||||
* une classe différente. Le détail de `T` et sa condition de validité sont dans la note
|
||||
* « LE SEUIL T » à l'intérieur de la fonction — la lire avant toute modification.
|
||||
*
|
||||
* • branche CULL — `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive
|
||||
* qui SURVIT à son cull par voxel. Aucune survivante ⇒ `Sdf` reste `FLT_MAX`. C'est la
|
||||
* même inégalité que le cull, élevée du point à la boîte. **Meilleure pour les salles** :
|
||||
* leur cull (`Rmax + 3K`) est plus serré que le seuil (`Rmax + T + K`).
|
||||
* • branche SEUIL — une primitive peut survivre à son cull et rester malgré tout trop loin
|
||||
* pour qu'un consommateur s'allume. **Meilleure pour les tunnels**, dont le cull est la
|
||||
* sphère englobante d'une capsule : rayon ~107 pour un tube de rayon 7 long de 200.
|
||||
*
|
||||
* Mélanger les deux est sûr : les primitives de la branche cull ne contribuent RIEN, les
|
||||
* autres sont toutes ≥ `T + K`, donc le pli vaut ≥ `T` (voir la saturation de `SmoothMin`),
|
||||
* et les trois consommateurs sont éteints. Une seule raison d'échouer, dans les deux cas.
|
||||
*
|
||||
* LES TROIS CHOSES QUI RENDENT LE TEST CONSERVATIF DU BON CÔTÉ :
|
||||
* 1. le warp déplace la coordonnée de REQUÊTE, donc la boîte est dilatée de sa borne
|
||||
* prouvable avant d'être confrontée aux salles et aux tunnels ;
|
||||
* 2. les pits et les cheminées sont interrogés en coordonnées RÉELLES (voir `Eval`), donc
|
||||
* ils sont confrontés à la boîte NON dilatée — la dilater serait juste plus prudent, ne
|
||||
* pas la dilater pour eux serait faux ;
|
||||
* 3. la boîte de recherche du cache est PLUS LARGE que celle de `Eval`, ce qui donne un
|
||||
* SUR-ensemble de primitives : si rien n'atteint la boîte ici, rien ne l'atteint là-bas.
|
||||
*
|
||||
* The criterion is the per-voxel cull lifted from point to box: if no cached primitive can
|
||||
* survive its own cull anywhere in the box, Sdf stays FLT_MAX across the whole box and the
|
||||
* source — with the converter and all twelve modifiers behind it — is the identity.
|
||||
*/
|
||||
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
|
||||
{
|
||||
if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return EVoxelOpEffect::Identity; }
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// 1. LA BOÎTE DOIT TENIR DANS UNE SEULE STRATE, PARAMS COMPRIS
|
||||
//-----------------------------------------------------------------
|
||||
// ⚠️ C'est la moitié « boîte » de la garde d'AUDIT §C2. `Eval` résout l'index de strate
|
||||
// et le pool d'ops PAR CHUNK ; une boîte qui traverse une frontière verrait donc deux
|
||||
// graphes de salles différents, et un cache unique n'en représenterait aucun. On ne
|
||||
// devine pas lequel : on rend `Both`. Ça arrive au plus sur les tuiles de bord.
|
||||
const int32 CZ0 = FMath::FloorToInt((float)VoxelBox.Min.Z / (float)CHUNK_SIZE);
|
||||
const int32 CZ1 = FMath::FloorToInt((float)VoxelBox.Max.Z / (float)CHUNK_SIZE);
|
||||
const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / (float)CHUNK_SIZE);
|
||||
const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / (float)CHUNK_SIZE);
|
||||
const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / (float)CHUNK_SIZE);
|
||||
const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / (float)CHUNK_SIZE);
|
||||
|
||||
// Une boîte qui couvre des dizaines de chunks n'est de toute façon jamais prouvable ;
|
||||
// la borne évite qu'un appelant futur transforme ce test en boucle coûteuse.
|
||||
if ((int64)(CX1 - CX0 + 1) * (CY1 - CY0 + 1) * (CZ1 - CZ0 + 1) > 64)
|
||||
{
|
||||
return EVoxelOpEffect::Both;
|
||||
}
|
||||
|
||||
int32 StrateIdx = 0;
|
||||
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr;
|
||||
if (Manager)
|
||||
{
|
||||
StrateIdx = Manager->GetStrateIndex(((float)CZ0 + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
|
||||
for (int32 CZ = CZ0 + 1; CZ <= CZ1; ++CZ)
|
||||
{
|
||||
if (Manager->GetStrateIndex(((float)CZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE) != StrateIdx)
|
||||
{
|
||||
return EVoxelOpEffect::Both;
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ LE POOL D'OPS FAIT PARTIE DE LA GÉOMÉTRIE, contrairement à ce qu'on croit en
|
||||
// lisant `FCachedRoom` : `BuildChunkCache` s'en sert pour cuire les PITS et les
|
||||
// CHEMINÉES (`OpParams` y lit `PitDensity`, `PitMinRadius`…). Passer `nullptr`
|
||||
// « puisque la forme des salles n'en dépend pas » sous-bornerait le cache et
|
||||
// pourrait rendre `Identity` au-dessus d'un pit réel. Un trou, exactement.
|
||||
UVoxelStrateDefinition* Def0 = Manager->GetStrateForChunk(FIntVector(CX0, CY0, CZ0));
|
||||
for (int32 CZ = CZ0; CZ <= CZ1; ++CZ)
|
||||
for (int32 CY = CY0; CY <= CY1; ++CY)
|
||||
for (int32 CX = CX0; CX <= CX1; ++CX)
|
||||
{
|
||||
if (Manager->GetStrateForChunk(FIntVector(CX, CY, CZ)) != Def0)
|
||||
{
|
||||
return EVoxelOpEffect::Both;
|
||||
}
|
||||
}
|
||||
if (Def0) { TerrainOps = &Def0->TerrainOperations; }
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// 2. LE MÉMO — clé complète (§C2 : jamais de clé sans params ni LayoutVersion)
|
||||
//-----------------------------------------------------------------
|
||||
// `Ctx.LayoutVersion` plutôt que le membre rempli par `PrepareChunk` : rien ne garantit
|
||||
// qu'un appelant de `ClassifyBox` ait ouvert un chunk, et une version périmée dans une
|
||||
// clé de cache est précisément la régression du 2026-07-27.
|
||||
const uint32 LV = Ctx.LayoutVersion;
|
||||
|
||||
FBoxState& B = BoxState();
|
||||
if (B.bValid && B.KeyBox == VoxelBox && B.KeyStrate == StrateIdx
|
||||
&& B.KeySeed == SeedU && B.KeyFingerprint == ParamsFingerprint && B.KeyLayout == LV)
|
||||
{
|
||||
return B.Verdict;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// 3. LE CACHE POUR LA BOÎTE INTERROGÉE
|
||||
//-----------------------------------------------------------------
|
||||
const float Warp = (P.CaveWarpStrength > 0.0f)
|
||||
? P.CaveWarpStrength * VOXEL_NOISE_SCALE * PerlinAbsBound
|
||||
: 0.0f;
|
||||
|
||||
// `+ 2` : la même marge de gradient que la boîte de recherche de `Eval`.
|
||||
VoxelCaveMorphology::BuildChunkCache(
|
||||
B.Cache,
|
||||
(float)VoxelBox.Min.X - Warp - 2.0f, (float)VoxelBox.Min.Y - Warp - 2.0f,
|
||||
(float)VoxelBox.Max.X + Warp + 2.0f, (float)VoxelBox.Max.Y + Warp + 2.0f,
|
||||
P, SeedU, StrateIdx, TerrainOps);
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// 4. LE CULL PAR VOXEL, ÉLEVÉ DU POINT À LA BOÎTE
|
||||
//-----------------------------------------------------------------
|
||||
// Espace de REQUÊTE des salles et des tunnels : XY dilaté du warp, Z passé par `EffZ`
|
||||
// (monotone croissante tant que `VerticalScale > 0`, donc min et max se conservent)
|
||||
// puis dilaté du warp lui aussi — `Eval` warpe bien les trois axes.
|
||||
const FVector QMin((float)VoxelBox.Min.X - Warp,
|
||||
(float)VoxelBox.Min.Y - Warp,
|
||||
EffZ((float)VoxelBox.Min.Z) - Warp);
|
||||
const FVector QMax((float)VoxelBox.Max.X + Warp,
|
||||
(float)VoxelBox.Max.Y + Warp,
|
||||
EffZ((float)VoxelBox.Max.Z) + Warp);
|
||||
|
||||
auto SphereHitsBox = [](const FVector& C, float RSq, const FVector& Mn, const FVector& Mx)
|
||||
{
|
||||
const float dx = FMath::Max3((float)(Mn.X - C.X), 0.0f, (float)(C.X - Mx.X));
|
||||
const float dy = FMath::Max3((float)(Mn.Y - C.Y), 0.0f, (float)(C.Y - Mx.Y));
|
||||
const float dz = FMath::Max3((float)(Mn.Z - C.Z), 0.0f, (float)(C.Z - Mx.Z));
|
||||
return (dx * dx + dy * dy + dz * dz) <= RSq;
|
||||
};
|
||||
|
||||
// Pits, cheminées et colonnes : coordonnées RÉELLES, donc boîte NON dilatée.
|
||||
const float RMinX = (float)VoxelBox.Min.X, RMaxX = (float)VoxelBox.Max.X;
|
||||
const float RMinY = (float)VoxelBox.Min.Y, RMaxY = (float)VoxelBox.Max.Y;
|
||||
const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z;
|
||||
|
||||
auto CircleHitsBoxXY = [&](float CX, float CY, float RSq)
|
||||
{
|
||||
const float dx = FMath::Max3(RMinX - CX, 0.0f, CX - RMaxX);
|
||||
const float dy = FMath::Max3(RMinY - CY, 0.0f, CY - RMaxY);
|
||||
return (dx * dx + dy * dy) <= RSq;
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// ⚠️ PAS D'EARLY-OUT : ON COMPTE PAR CLASSE, ET C'EST DÉLIBÉRÉ
|
||||
//-----------------------------------------------------------------
|
||||
// La version d'origine s'arrêtait à la première primitive atteinte. Elle donnait le bon
|
||||
// verdict et AUCUNE information : quand `AllSolid killed by: RoomGraphSource x40` est
|
||||
// tombé, il n'y avait aucun moyen de dire si le coupable était les salles, les tunnels
|
||||
// ou les pits — donc aucun moyen de savoir quoi resserrer. Compter les cinq classes
|
||||
// sépare les causes, et c'est la règle que ce projet a payée plusieurs fois : quand un
|
||||
// zéro a plusieurs causes possibles, chacune a son propre nombre.
|
||||
//
|
||||
// Le coût est nul à l'échelle qui compte : on vient d'appeler `BuildChunkCache`, qui
|
||||
// est de plusieurs ordres de grandeur au-dessus d'un parcours de ~100 structs, et le
|
||||
// verdict est mémoïsé donc ce parcours arrive UNE fois par boîte, pas treize.
|
||||
//
|
||||
// No early-out on purpose: stopping at the first hit gives the right verdict and no
|
||||
// information. When a zero has several possible causes, each gets its own number.
|
||||
//-----------------------------------------------------------------
|
||||
// ⚠️⚠️ LE SEUIL `T` — CE QUE `Identity` VEUT DIRE ICI, ET SA CONDITION DE VALIDITÉ
|
||||
//-----------------------------------------------------------------
|
||||
// Jusqu'ici `Identity` signifiait « `Sdf` reste `FLT_MAX` sur toute la boîte ». C'est
|
||||
// vrai, mais c'est plus fort que nécessaire, et cette force coûtait la quasi-totalité du
|
||||
// gain : aucun consommateur ne regarde `Sdf` au-delà d'un seuil.
|
||||
//
|
||||
// Les TROIS consommateurs du canal SDF de cette pile, RELUS un par un (pas supposés) :
|
||||
// • `FSdfConvertOp::Eval` → `if (InOut.Sdf >= Blend) return;` et
|
||||
// `BuildTunnelNetworkStack` l'instancie par `MakeSdfCarve(P.SDFBlendRadius, …)`
|
||||
// ⇒ seuil = `K`.
|
||||
// • les DOUZE modificateurs → `VF_NearCaveSurface` ⇒ seuil = `3·K`.
|
||||
// • `FWormFieldSource::Eval` → `if (CaveSDF >= P.WormNetworkRange) NetworkMask = 0;`
|
||||
// puis `if (NetworkMask <= 0) return;` ⇒ seuil = `WormNetworkRange`.
|
||||
// (`FCaveTerraceMod` re-sonde le SDF en Z±1, donc HORS de la boîte — mais son gate
|
||||
// `VF_NearCaveSurface` est testé AVANT la sonde, vérifié ligne par ligne. Un gate faux
|
||||
// partout ⇒ aucune sonde n'est jamais émise.)
|
||||
//
|
||||
// Donc `Sdf ≥ T` avec `T = max(K, 3K, WormNetworkRange)` suffit à éteindre les trois.
|
||||
//
|
||||
// ⚠️ **TOUT NOUVEAU CONSOMMATEUR DU CANAL `Sdf` DOIT AVOIR UN SEUIL ≤ T, OU ÊTRE AJOUTÉ
|
||||
// À CE `Max`.** C'est la seule dette de couplage de cette fonction, et elle est réelle :
|
||||
// un opérateur qui regarderait `Sdf < 100` verrait des verdicts `Identity` faux, donc
|
||||
// des tuiles sans géométrie ET SANS COLLISION. Écrit ici parce que c'est ici qu'on
|
||||
// atterrit en l'ajoutant.
|
||||
//
|
||||
// ⚠️ ET LA RAISON POUR LAQUELLE `− K` SUFFIT MALGRÉ N PRIMITIVES. `SmoothMin(A,B,K)`
|
||||
// vaut `min(A,B) − H³K/6` avec `H = max(K − |A−B|, 0)/K`. Deux conséquences lues sur la
|
||||
// formule : la pénalité est EXACTEMENT nulle dès que `|A−B| ≥ K`, et le minimum courant
|
||||
// ne peut donc jamais descendre plus de `K` sous le plus petit des termes — arrivé là,
|
||||
// `H = 0` et les plis suivants le laissent intact. D'où `Sdf ≥ min_i(SDF_i) − K` pour un
|
||||
// nombre QUELCONQUE de primitives, et non `− N·K/6`. C'est ce qui rend ce critère
|
||||
// utilisable au lieu d'être noyé sous le nombre de tunnels.
|
||||
//
|
||||
// Identity now means "Sdf >= T over the box", not "Sdf stays FLT_MAX" — no consumer
|
||||
// looks past its own threshold, and the three that exist were read one by one. ANY NEW
|
||||
// CONSUMER OF THE Sdf CHANNEL MUST HAVE A THRESHOLD <= T OR BE ADDED TO THIS MAX.
|
||||
// The -K slack covers any number of primitives because SmoothMin's penalty is exactly
|
||||
// zero once |A-B| >= K, so the running minimum saturates at K below the true minimum.
|
||||
const float K = FMath::Max(P.SDFBlendRadius, 0.0f);
|
||||
const float T = FMath::Max(3.0f * K, P.WormNetworkRange);
|
||||
|
||||
B.NumRooms = B.Cache.Rooms.Num();
|
||||
B.NumTunnels = B.Cache.Tunnels.Num();
|
||||
B.NumPits = B.Cache.Pits.Num();
|
||||
B.NumChimneys = B.Cache.Chimneys.Num();
|
||||
B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0;
|
||||
|
||||
// La MÊME boîte sans dilatation de warp — diagnostic seulement, voir plus bas.
|
||||
const FVector NWMin((float)VoxelBox.Min.X, (float)VoxelBox.Min.Y, EffZ((float)VoxelBox.Min.Z));
|
||||
const FVector NWMax((float)VoxelBox.Max.X, (float)VoxelBox.Max.Y, EffZ((float)VoxelBox.Max.Z));
|
||||
const float NoWarpHalfDiag = 0.5f * (float)(NWMax - NWMin).Size();
|
||||
B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0;
|
||||
B.WarpDilation = Warp;
|
||||
|
||||
for (const FCachedRoom& R : B.Cache.Rooms)
|
||||
{
|
||||
if (SphereHitsBox(R.Center, R.CullRadiusSq, QMin, QMax)) { ++B.HitRooms; }
|
||||
if (SphereHitsBox(R.Center, R.CullRadiusSq, NWMin, NWMax)) { ++B.HitRoomsNoWarp; }
|
||||
}
|
||||
//-----------------------------------------------------------------
|
||||
// LES TUNNELS ONT DROIT À UN SECOND TEST, ET C'EST LÀ QUE SE TROUVE LE GAIN
|
||||
//-----------------------------------------------------------------
|
||||
// ⚠️ CECI CHANGE LE SENS D'`Identity` POUR CET OPÉRATEUR — lire la note « LE SEUIL T »
|
||||
// ci-dessus avant de toucher quoi que ce soit ici.
|
||||
//
|
||||
// Le cull par voxel d'un tunnel est sa SPHÈRE ENGLOBANTE. Pour une capsule longue et
|
||||
// fine c'est une sur-estimation énorme : avec `MaxTunnelLength = 200` et
|
||||
// `TunnelMaxRadius = 7`, la sphère a un rayon jusqu'à ~107 pour un tube de rayon 7. La
|
||||
// mesure le disait sans ambiguïté — 32 tuiles bloquées sur 34 par des tunnels, contre
|
||||
// 21 par des salles.
|
||||
//
|
||||
// Donc : soit le tunnel rate son cull (il ne s'exécute pas), soit son PROPRE SDF reste
|
||||
// ≥ `T + K` sur toute la boîte (il s'exécute mais ne peut pas descendre le champ assez
|
||||
// bas pour qu'un consommateur s'allume). L'un ou l'autre suffit.
|
||||
//
|
||||
// La borne est exacte, pas prudente : `TaperedCapsule` rend
|
||||
// `Dist(P, PlusProcheSurSegment) − Lerp(Ra, Rb, t)`, donc
|
||||
// `SDF ≥ dist(P, segment) − max(Ra, Rb)` — RELU dans `VoxelCaveMorphology.h`, pas supposé.
|
||||
// Et `dist(boîte, segment) ≥ dist(centre, segment) − demi-diagonale` par inégalité
|
||||
// triangulaire : conservatif du bon côté, et trivialement vrai.
|
||||
//
|
||||
// A tunnel's per-voxel cull is its BOUNDING SPHERE — for a 200-long tube of radius 7
|
||||
// that sphere has radius ~107. So a tunnel does not matter if it fails that cull OR if
|
||||
// its own SDF stays >= T + K over the box. The bound is exact: TaperedCapsule is
|
||||
// genuinely dist-to-segment minus an interpolated radius, and box-to-segment distance is
|
||||
// bounded below by centre-to-segment minus the half-diagonal.
|
||||
const float TunnelClear = T + K;
|
||||
const FVector QCenter = (QMin + QMax) * 0.5;
|
||||
const float BoxHalfDiag = 0.5f * (float)(QMax - QMin).Size();
|
||||
|
||||
for (const FCachedTunnel& Tn : B.Cache.Tunnels)
|
||||
{
|
||||
if (!SphereHitsBox(Tn.BoundCenter, Tn.BoundRadiusSq, QMin, QMax)) { continue; }
|
||||
|
||||
float MaxR = FMath::Max(Tn.RadiusA, Tn.RadiusB);
|
||||
float DistToAxis;
|
||||
if (Tn.bHasMidpoint)
|
||||
{
|
||||
// Deux segments : le SDF du tunnel est le `Min` des deux, donc sa borne
|
||||
// inférieure est le `Min` des deux bornes.
|
||||
MaxR = FMath::Max(MaxR, Tn.RadiusMid);
|
||||
DistToAxis = FMath::Min(
|
||||
VF_DistPointSegment(QCenter, Tn.EndpointA, Tn.Midpoint),
|
||||
VF_DistPointSegment(QCenter, Tn.Midpoint, Tn.EndpointB));
|
||||
}
|
||||
else
|
||||
{
|
||||
DistToAxis = VF_DistPointSegment(QCenter, Tn.EndpointA, Tn.EndpointB);
|
||||
}
|
||||
|
||||
if (DistToAxis - BoxHalfDiag - MaxR >= TunnelClear) { continue; }
|
||||
|
||||
++B.HitTunnels;
|
||||
|
||||
// DIAGNOSTIC — le MÊME test avec une dilatation de warp NULLE. Ne participe à aucun
|
||||
// verdict ; il répond à la seule question que trois builds de resserrement n'ont
|
||||
// jamais posée : « combien de ce blocage est de la géométrie, et combien est ma
|
||||
// propre boîte dilatée ? ». `HitTunnels - HitTunnelsNoWarp` est exactement la part
|
||||
// que le warp coûte.
|
||||
if (DistToAxis - NoWarpHalfDiag - MaxR < TunnelClear) { ++B.HitTunnelsNoWarp; }
|
||||
}
|
||||
// Miroir exact des deux `continue` de `Eval` : actif si `Z < TopZ + BlendK` ET
|
||||
// `Z >= TopZ - Depth - BlendK`.
|
||||
for (const FCachedPit& Pit : B.Cache.Pits)
|
||||
{
|
||||
if (!(RMinZ < Pit.TopZ + Pit.BlendK)) { continue; }
|
||||
if (!(RMaxZ >= Pit.TopZ - Pit.Depth - Pit.BlendK)) { continue; }
|
||||
if (CircleHitsBoxXY(Pit.CenterX, Pit.CenterY, Pit.BoundXYRadiusSq)) { ++B.HitPits; }
|
||||
}
|
||||
// Miroir exact : actif si `Z > BottomZ - BlendK` ET `Z <= BottomZ + Height + BlendK`.
|
||||
for (const FCachedChimney& Ch : B.Cache.Chimneys)
|
||||
{
|
||||
if (!(RMaxZ > Ch.BottomZ - Ch.BlendK)) { continue; }
|
||||
if (!(RMinZ <= Ch.BottomZ + Ch.Height + Ch.BlendK)) { continue; }
|
||||
if (CircleHitsBoxXY(Ch.CenterX, Ch.CenterY, Ch.BoundXYRadiusSq)) { ++B.HitChimneys; }
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// ✅ LES COLONNES NE SONT PLUS TESTÉES — ET C'EST PROUVÉ, PAS RELÂCHÉ
|
||||
//-----------------------------------------------------------------
|
||||
// La première version les traitait en cylindres INFINIS en Z (le cache ne leur donne
|
||||
// aucune borne verticale), ce qui rendait `Both` pour une boîte située des centaines de
|
||||
// voxels sous la salle propriétaire. Inutile : le seul consommateur des colonnes est
|
||||
// `FRoomColumnMod`, dont l'`Eval` commence par
|
||||
// `if (!VF_NearCaveSurface(InOut.Sdf, P.SDFBlendRadius)) { return; }`
|
||||
// Si aucune salle, aucun tunnel, aucun pit et aucune cheminée n'atteint la boîte, `Sdf`
|
||||
// y reste `FLT_MAX`, le gate est faux à chaque voxel, et **aucune colonne ne peut
|
||||
// s'exécuter** — quelle que soit sa position XY. Le test était donc REDONDANT, pas
|
||||
// prudent. Le retirer resserre le verdict sans toucher à sa correction.
|
||||
//
|
||||
// Columns are not tested: their only consumer gates on Sdf being near a cave surface,
|
||||
// which cannot happen in a box no room/tunnel/pit/chimney reaches. The test was
|
||||
// redundant rather than conservative, and it was the loosest one here.
|
||||
const bool bReached = (B.HitRooms + B.HitTunnels + B.HitPits + B.HitChimneys) > 0;
|
||||
|
||||
B.Verdict = bReached ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity;
|
||||
B.KeyBox = VoxelBox;
|
||||
B.KeyStrate = StrateIdx;
|
||||
B.KeySeed = SeedU;
|
||||
B.KeyFingerprint = ParamsFingerprint;
|
||||
B.KeyLayout = LV;
|
||||
B.bValid = true;
|
||||
return B.Verdict;
|
||||
}
|
||||
|
||||
const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); }
|
||||
|
||||
private:
|
||||
FStrateGenerationParams P;
|
||||
int32 Seed;
|
||||
@@ -3257,8 +3782,12 @@ namespace
|
||||
class FWormFieldSource final : public IVoxelDensityOp
|
||||
{
|
||||
public:
|
||||
FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed)
|
||||
: P(InP), SeedU((uint32)Seed) {}
|
||||
/** @param InRooms ⚠️ UNIQUEMENT pour `EffectOverBox` / `MaxCarveOverBox`. `Eval` lit le
|
||||
* canal SDF de `InOut`, pas ce pointeur — le ver n'interroge jamais la
|
||||
* source directement, il consomme ce qu'elle a écrit. Peut être nullptr. */
|
||||
FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed,
|
||||
const FRoomGraphSource* InRooms = nullptr)
|
||||
: P(InP), SeedU((uint32)Seed), Rooms(InRooms) {}
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
@@ -3324,10 +3853,57 @@ namespace
|
||||
* 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
|
||||
/**
|
||||
* ✅ **LE VER HÉRITE DU VERDICT DE LA SOURCE DE SALLES — ET C'EST CE QUI DÉBLOQUE TOUT.**
|
||||
*
|
||||
* La note ci-dessus (« aucune borne spatiale, donc il tue `AllSolid` sur CHAQUE tuile »)
|
||||
* était vraie, et pourtant elle passait à côté de ce que son propre `Eval` fait trois
|
||||
* lignes plus haut :
|
||||
*
|
||||
* ```
|
||||
* if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX)
|
||||
* { NetworkMask = 0.0f; }
|
||||
* ...
|
||||
* if (NetworkMask <= 0.0f) { return; }
|
||||
* ```
|
||||
*
|
||||
* **Le ver EST spatialement borné** — pas par une borne à lui, mais par celle de la source
|
||||
* de salles, exactement comme les douze modificateurs de détail. Là où `FRoomGraphSource`
|
||||
* prouve `Identity`, `Sdf` reste `FLT_MAX` sur toute la boîte, donc `NetworkMask` vaut 0
|
||||
* partout, donc ce `return` est pris à chaque voxel. Le ver est l'identité, pas « un carve
|
||||
* borné » : il ne s'exécute pas.
|
||||
*
|
||||
* ⚠️ POURQUOI CE CONTRÔLE COMPTAIT AUTANT. `BaseDensity = 8` et `WormStrength = 10` sont
|
||||
* les DÉFAUTS, et le commentaire de `WormStrength` dit pourquoi (« must exceed BaseDensity
|
||||
* to create air »). Donc `SolidMargin = 8 − 10 < 0` : tant que le ver rendait `CarveOnly`
|
||||
* partout, il tuait `AllSolid` sur **toutes** les tuiles, et la réponse spatiale de la
|
||||
* source de salles ne pouvait rien prouver derrière lui. Le premier build l'a montré —
|
||||
* 0 tuile prouvée sur 40, la source ayant pourtant appris à répondre.
|
||||
*
|
||||
* ⚠️ ET POURQUOI ON N'UTILISE **PAS** `VF_NoCaveOverBox` ICI. Cet assistant rend `true`
|
||||
* quand `Rooms == nullptr` — correct pour les douze modificateurs, qui n'existent que dans
|
||||
* une pile où la source de salles est le seul écrivain du canal SDF. Le ver, lui, est un
|
||||
* opérateur dont un futur assemblage pourrait le placer derrière un AUTRE écrivain de SDF
|
||||
* (`FLatticeCorridorSource` en écrit un). Sans source de salles, on ne sait pas : on rend
|
||||
* `CarveOnly`. Ne pas savoir doit coûter du CPU, jamais un trou.
|
||||
*
|
||||
* The worm IS spatially bounded — by the room source's bound, not one of its own, exactly
|
||||
* like the twelve detail modifiers. Where the room source proves Identity, Sdf stays
|
||||
* FLT_MAX, NetworkMask is 0 everywhere and Eval returns immediately. This mattered because
|
||||
* BaseDensity=8 < WormStrength=10 BY DEFAULT, so an unconditional CarveOnly killed AllSolid
|
||||
* on every tile. Deliberately not VF_NoCaveOverBox: its null-Rooms case answers "identity",
|
||||
* which is wrong for an op that could sit behind a different SDF writer.
|
||||
*/
|
||||
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
|
||||
{
|
||||
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? EVoxelOpEffect::CarveOnly
|
||||
: EVoxelOpEffect::Identity;
|
||||
if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return EVoxelOpEffect::Identity; }
|
||||
|
||||
if (P.WormNetworkRange > 0.0f && Rooms != nullptr
|
||||
&& Rooms->EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity)
|
||||
{
|
||||
return EVoxelOpEffect::Identity;
|
||||
}
|
||||
return EVoxelOpEffect::CarveOnly;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3340,8 +3916,12 @@ namespace
|
||||
* la seule sorte qui ait le droit d'être ici : sur-estimer coûte du CPU, sous-estimer fait
|
||||
* un trou.
|
||||
*/
|
||||
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||
float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
|
||||
{
|
||||
// Cohérent avec `EffectOverBox` PAR CONSTRUCTION plutôt que par relecture : deux
|
||||
// conditions écrites deux fois finiraient par diverger. Le mémo de verdict de
|
||||
// `FRoomGraphSource` rend ce second appel gratuit.
|
||||
if (EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity) { return 0.0f; }
|
||||
return MaxCarveAmplitude();
|
||||
}
|
||||
|
||||
@@ -3351,15 +3931,19 @@ namespace
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
/** L'amplitude max de carve, en unités de densité. */
|
||||
/** L'amplitude max de carve, en unités de densité. Borne BRUTE : elle ignore la portée du
|
||||
* réseau, c'est `MaxCarveOverBox` qui l'applique. */
|
||||
float MaxCarveAmplitude() const
|
||||
{
|
||||
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f;
|
||||
}
|
||||
|
||||
const TCHAR* DebugName() const override { return TEXT("WormFieldSource"); }
|
||||
|
||||
private:
|
||||
FStrateGenerationParams P;
|
||||
uint32 SeedU;
|
||||
const FRoomGraphSource* Rooms; // NON possédant — peut être nullptr (voir EffectOverBox)
|
||||
};
|
||||
|
||||
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
|
||||
@@ -3368,6 +3952,29 @@ namespace
|
||||
// ajoutée avec elle ne ferme rien → C2059.
|
||||
// END OF THE ANONYMOUS NAMESPACE — new operators go ABOVE this line.
|
||||
|
||||
//=============================================================================
|
||||
// DIAGNOSTIC — voir la déclaration dans VoxelDensityOpStack.h
|
||||
//=============================================================================
|
||||
|
||||
VoxelDensityOps::FRoomBoxDiagnostic VoxelDensityOps::GetLastRoomBoxDiagnostic()
|
||||
{
|
||||
const FRoomGraphSource::FBoxState& B = FRoomGraphSource::BoxState();
|
||||
|
||||
FRoomBoxDiagnostic D;
|
||||
D.HitRooms = B.HitRooms;
|
||||
D.HitTunnels = B.HitTunnels;
|
||||
D.HitPits = B.HitPits;
|
||||
D.HitChimneys = B.HitChimneys;
|
||||
D.NumRooms = B.NumRooms;
|
||||
D.NumTunnels = B.NumTunnels;
|
||||
D.NumPits = B.NumPits;
|
||||
D.NumChimneys = B.NumChimneys;
|
||||
D.HitRoomsNoWarp = B.HitRoomsNoWarp;
|
||||
D.HitTunnelsNoWarp = B.HitTunnelsNoWarp;
|
||||
D.WarpDilation = B.WarpDilation;
|
||||
return D;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// FVoxelOpStack
|
||||
//=============================================================================
|
||||
@@ -3571,7 +4178,11 @@ namespace VoxelDensityOps
|
||||
OutStack.Add(MakeUnique<FDomeMod>(P, RoomPtr)); // 4g — dômes
|
||||
OutStack.Add(MakeUnique<FPinchMod>(P, RoomPtr)); // 4h — pincement
|
||||
OutStack.Add(MakeUnique<FFloorBiasMod>(P, RoomPtr)); // fin 4h — biais de sol
|
||||
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed));
|
||||
// ⚠️ `RoomPtr` N'EST PAS DÉCORATIF ICI. Le ver hérite du verdict de boîte de la source de
|
||||
// salles, faute de quoi il rend `CarveOnly` partout et tue `AllSolid` sur chaque tuile —
|
||||
// avec les défauts (`BaseDensity = 8`, `WormStrength = 10`) la marge part négative, donc
|
||||
// aucune tuile n'est prouvable, quoi que la source de salles ait réussi à prouver.
|
||||
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed, RoomPtr));
|
||||
|
||||
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
|
||||
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
|
||||
|
||||
@@ -582,6 +582,10 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||
thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork;
|
||||
thread_local FStrateGenerationParams CP_Tunnel;
|
||||
// AUDIT §C2 — empreinte de `CP_Tunnel`, rafraîchie avec lui. Elle voyage jusqu'à la clé du
|
||||
// cache SDF de `GetDensityWithParams` pour qu'un chunk ne puisse plus être évalué contre
|
||||
// les salles d'un chunk voisin aux params blendés différemment.
|
||||
thread_local uint32 CP_TunnelFP = 0xFFFFFFFFu;
|
||||
thread_local FSlabGenerationParams CP_Slab;
|
||||
thread_local FMazeGenerationParams CP_Maze;
|
||||
thread_local FSurfaceGenerationParams CP_Surface;
|
||||
@@ -639,7 +643,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
case ECaveGeneratorType::FloatingIslands:
|
||||
CP_Float = StrateManager->GetFloatingIslandParamsForChunk(ChunkCoord); break;
|
||||
default: // TunnelNetwork / Underwater
|
||||
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); break;
|
||||
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord);
|
||||
// AUDIT §C2 — l'empreinte est calculée ICI, une fois par chunk, au seul endroit où
|
||||
// les params changent. `FStrateGenerationParams` est du POD pur (aucun TArray /
|
||||
// FString / pointeur), donc une CRC mémoire ne peut pas donner de FAUX POSITIF ; au
|
||||
// pire un octet de padding donne un faux MANQUE, c'est-à-dire une reconstruction de
|
||||
// cache. On se trompe du côté du CPU, jamais du côté d'une salle fausse.
|
||||
CP_TunnelFP = FCrc::MemCrc32(&CP_Tunnel, sizeof(CP_Tunnel));
|
||||
break;
|
||||
}
|
||||
CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord);
|
||||
|
||||
@@ -754,7 +765,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
case ECaveGeneratorType::TunnelNetwork:
|
||||
default:
|
||||
// Underwater shares tunnel rock (water table is a render-side overlay).
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel); break;
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel,
|
||||
CP_TunnelFP, LayoutVersion); break;
|
||||
}
|
||||
|
||||
// Disturbance layer (the "wow" post-process) — cached params, MC convention.
|
||||
@@ -764,8 +776,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
{
|
||||
// ── FALLBACK (no strate manager) ──
|
||||
// Use default TunnelNetwork params — produces generic caves.
|
||||
FStrateGenerationParams FallbackParams;
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams);
|
||||
// `static` : ces params sont constants (construction par défaut), donc leur empreinte l'est
|
||||
// aussi. La calculer une fois évite un CRC par voxel sur un chemin qui n'en a aucun besoin.
|
||||
// `LayoutVersion = 0` : sans `StrateManager` il n'y a pas de layout, donc rien qui puisse
|
||||
// périmer — et l'empreinte constante suffit à distinguer ce cache de tous les autres.
|
||||
static const FStrateGenerationParams FallbackParams;
|
||||
static const uint32 FallbackFP = FCrc::MemCrc32(&FallbackParams, sizeof(FallbackParams));
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
@@ -813,7 +830,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
}
|
||||
|
||||
float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
|
||||
const FStrateGenerationParams& Params) const
|
||||
const FStrateGenerationParams& Params,
|
||||
uint32 ParamsFingerprint, uint32 LayoutVersion) const
|
||||
{
|
||||
//=========================================================================
|
||||
// STRATE DENSITY FUNCTION (Morphology Pipeline)
|
||||
@@ -930,6 +948,20 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f;
|
||||
thread_local int32 CachedStrate = INT32_MIN;
|
||||
thread_local uint32 CachedSeed = 0;
|
||||
// ⚠️ AUDIT §C2 (corrigé le 2026-07-28). Les deux lignes qui manquaient à cette clé.
|
||||
// La clé ci-dessus décrit la GÉOMÉTRIE de la fenêtre (boîte, strate, seed) et rien de ce qui
|
||||
// détermine les PARAMS avec lesquels les salles ont été cuites. Comme `GetGenerationParams`
|
||||
// blende à l'intérieur d'une strate (`Alpha` = f(chunk Z), et f(chunk XY) aussi en
|
||||
// `Interleaved`), deux chunks voisins produisent la MÊME clé avec des params DIFFÉRENTS, et le
|
||||
// deuxième se sert des salles du premier. Non déterministe entre pairs, parce que l'ordre des
|
||||
// workers décide lequel est « le premier » — exactement ce que §2.6.1 interdit.
|
||||
//
|
||||
// Pourquoi ça ne casse PAS l'invariant de perf de §8.10 : la clé reste une BOÎTE, donc les
|
||||
// sondes de gradient à `WorldX ± 1` ne font toujours pas tourner le cache. Ce qui le fait
|
||||
// tourner en plus, c'est un changement RÉEL de params — une fois par chunk dans une bande de
|
||||
// transition, ce qui est le nombre de reconstructions que ce cache aurait toujours dû faire.
|
||||
thread_local uint32 CachedFingerprint = 0xFFFFFFFFu;
|
||||
thread_local uint32 CachedLayout = 0xFFFFFFFFu;
|
||||
|
||||
// Index of the room with the smallest (most-inside) SDF for this voxel.
|
||||
// Written by EvaluateSDFCached, read by the terrain ops block to pick the
|
||||
@@ -968,6 +1000,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
// cached search box, or the strate/seed changed.
|
||||
const bool bNeedRebuild =
|
||||
StrateIdx != CachedStrate || (uint32)Seed != CachedSeed ||
|
||||
ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout ||
|
||||
WarpedX < CachedSMinX || WarpedX > CachedSMaxX ||
|
||||
WarpedY < CachedSMinY || WarpedY > CachedSMaxY;
|
||||
|
||||
@@ -1011,6 +1044,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
CachedSMinY = SMinY; CachedSMaxY = SMaxY;
|
||||
CachedStrate = StrateIdx;
|
||||
CachedSeed = (uint32)Seed;
|
||||
CachedFingerprint = ParamsFingerprint;
|
||||
CachedLayout = LayoutVersion;
|
||||
}
|
||||
|
||||
// Evaluate SDF using cached rooms and tunnels (WARPED coordinates).
|
||||
|
||||
@@ -415,6 +415,27 @@ public:
|
||||
* ValidateDeterminism — qui échantillonne le long d'une frontière en X — ne le verrait pas.
|
||||
*/
|
||||
virtual bool IsXYPure() const { return false; }
|
||||
|
||||
/**
|
||||
* DIAGNOSTIC UNIQUEMENT — le nom que les rapports de test impriment pour cet opérateur.
|
||||
*
|
||||
* ⚠️ POURQUOI CETTE MÉTHODE EXISTE, ET CE QU'ELLE A COÛTÉ DE NE PAS AVOIR. Le premier build de
|
||||
* l'`EffectOverBox` spatial est revenu **vert avec 0 tuile prouvée sur 40**, et la seule chose
|
||||
* que le rapport pouvait dire était « ou bien les tuiles traversent toutes une grotte, ou bien
|
||||
* la source n'atteint pas sa branche `Identity` ». Deux causes, zéro nombre pour les
|
||||
* départager — exactement le piège que ce projet a déjà payé plusieurs fois. La vraie cause
|
||||
* était un TROISIÈME opérateur (le ver, qui rendait `CarveOnly` partout). Avec un nom par
|
||||
* opérateur, `ClassifyBoxAttributed` répond « c'est celui-là » au lieu de laisser deviner.
|
||||
*
|
||||
* N'entre dans AUCUNE clé de cache, dans aucun hash, dans aucune décision de génération : le
|
||||
* changer ne peut pas changer le monde. Le défaut est volontairement laconique — un opérateur
|
||||
* sans nom se repère à son index, ce qui suffit à savoir où regarder.
|
||||
*
|
||||
* Diagnostic only: the first build of the spatial EffectOverBox came back green with 0 tiles
|
||||
* proved, and the report could not name which operator was killing the hypothesis. It was a
|
||||
* third one nobody was looking at. Never part of a cache key or any generation decision.
|
||||
*/
|
||||
virtual const TCHAR* DebugName() const { return TEXT("(unnamed op)"); }
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
|
||||
@@ -135,6 +135,50 @@ public:
|
||||
return H.Resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
|
||||
*
|
||||
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même early-out,
|
||||
* même ordre. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
|
||||
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
|
||||
* l'autre change avec lui.
|
||||
*
|
||||
* `OutSolidKiller` / `OutAirKiller` reçoivent l'INDEX du premier opérateur qui fait passer
|
||||
* l'hypothèse correspondante de vraie à fausse, ou `INDEX_NONE` si elle a survécu. Le nom
|
||||
* lisible s'obtient par `GetOpDebugName(index)`.
|
||||
*
|
||||
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
|
||||
* ClassifyBox — a diagnostic that takes a slightly different path sends you hunting in the
|
||||
* wrong place.
|
||||
*/
|
||||
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||||
int32& OutSolidKiller, int32& OutAirKiller) const
|
||||
{
|
||||
OutSolidKiller = INDEX_NONE;
|
||||
OutAirKiller = INDEX_NONE;
|
||||
|
||||
FVoxelBoxHypotheses H;
|
||||
for (int32 i = 0; i < Ops.Num(); ++i)
|
||||
{
|
||||
const bool bSolidBefore = H.bCanBeAllSolid;
|
||||
const bool bAirBefore = H.bCanBeAllAir;
|
||||
|
||||
VF_FoldOp(H, *Ops[i], VoxelBox, Ctx);
|
||||
|
||||
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
|
||||
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
|
||||
|
||||
if (H.IsDead()) { return EVoxelTileClass::Mixed; }
|
||||
}
|
||||
return H.Resolve();
|
||||
}
|
||||
|
||||
/** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */
|
||||
const TCHAR* GetOpDebugName(int32 Index) const
|
||||
{
|
||||
return Ops.IsValidIndex(Index) ? Ops[Index]->DebugName() : TEXT("(none)");
|
||||
}
|
||||
|
||||
/**
|
||||
* RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
|
||||
* spine (0,0) → seal de frontière → carve de passage.
|
||||
@@ -286,6 +330,38 @@ namespace VoxelDensityOps
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE du dernier `FRoomGraphSource::EffectOverBox`
|
||||
* évalué sur ce thread. **Tests uniquement. N'entre dans aucune décision de génération.**
|
||||
*
|
||||
* ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
|
||||
* pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une
|
||||
* DEUXIÈME définition du critère, qui dériverait de la vraie et mentirait exactement le jour où
|
||||
* on la croirait. C'est la même raison qui a fait exister `VF_BuildOpStackForChunk`. On expose
|
||||
* donc ce que l'opérateur a réellement calculé.
|
||||
*
|
||||
* `Hit*` = combien de primitives de cette classe atteignent la boîte (0 partout ⇒ `Identity`).
|
||||
* `Num*` = combien le cache en contenait, ce qui distingue « aucune n'atteint » de « il n'y en
|
||||
* avait aucune » — deux zéros de sens opposé.
|
||||
*
|
||||
* Reads back what the operator actually computed, rather than letting the test re-derive the
|
||||
* criterion: a second copy would drift and would lie on the day it was believed.
|
||||
*/
|
||||
struct FRoomBoxDiagnostic
|
||||
{
|
||||
int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0;
|
||||
int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0;
|
||||
|
||||
/** Les mêmes comptes si la dilatation de warp valait ZÉRO, et de combien de voxels la boîte
|
||||
* est effectivement dilatée. `Hit* - Hit*NoWarp` = la part du blocage due à MA boîte plutôt
|
||||
* qu'à la géométrie. Cette mesure manquait, et son absence a coûté trois builds de
|
||||
* resserrement autour du mauvais terme. */
|
||||
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
|
||||
float WarpDilation = 0.0f;
|
||||
};
|
||||
|
||||
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
|
||||
|
||||
/**
|
||||
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
|
||||
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
|
||||
|
||||
@@ -118,9 +118,35 @@ public:
|
||||
* Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise).
|
||||
* Utilisée en interne par GetDensityAt quand la strate est de ce type.
|
||||
* Exposée pour permettre des tests isolés avec des params custom.
|
||||
*
|
||||
* ⚠️ `ParamsFingerprint` ET `LayoutVersion` SONT OBLIGATOIRES, ET C'EST LE CORRECTIF
|
||||
* D'`AUDIT §C2` (2026-07-28). Le cache SDF interne est clé sur (boîte XY, strate, seed) et
|
||||
* PAS sur les params. Or `GetGenerationParams` BLENDE les params à l'intérieur d'une même
|
||||
* strate — `Alpha` dépend du chunk Z en mode `Gradient` (le DÉFAUT, avec
|
||||
* `TransitionBlendChunks = 2`) et du chunk XY en plus en mode `Interleaved`. Deux chunks de la
|
||||
* même strate, même seed, donc même clé, mais des params DIFFÉRENTS : le worker évalue le
|
||||
* deuxième chunk qu'il construit contre les salles du premier. Et comme *quel* chunk vient en
|
||||
* premier dépend de l'ordre des workers, **deux pairs divergent depuis la même seed** — ce que
|
||||
* `OPSTACK-PLAN §2.6.1` interdit explicitement.
|
||||
*
|
||||
* Pourquoi une empreinte PASSÉE plutôt qu'un `MemCrc32` calculé ici : ce serait ~300 octets de
|
||||
* CRC PAR VOXEL sur le chemin le plus chaud du plugin. L'appelant la calcule UNE fois par
|
||||
* chunk, là où le mémo de params vit déjà (`CP_*`), donc le coût par voxel est exactement deux
|
||||
* comparaisons d'entiers. Pas de valeur par défaut : un appelant qui oublie doit ne pas
|
||||
* compiler, pas hériter silencieusement du trou (la discipline de `FVoxelOpContext`).
|
||||
*
|
||||
* ⚠️ POUR LES TESTS : passez `FCrc::MemCrc32(&Params, sizeof(Params))`. Un oracle qui partage
|
||||
* le défaut qu'il teste ne prouve rien — c'est précisément ce que la note de
|
||||
* `VoxelForgeOpStackTunnelTest.cpp` (contrôle 3) décrivait comme le trou de l'original.
|
||||
*
|
||||
* The SDF cache key had neither the params nor anything that determines them, while the params
|
||||
* are blended per chunk INSIDE a strate — so a worker could evaluate one chunk against another
|
||||
* chunk's rooms, and which came first depends on worker order. Passing a once-per-chunk
|
||||
* fingerprint keeps the fix off the per-voxel path. No default: forgetting it must not compile.
|
||||
*/
|
||||
float GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
|
||||
const FStrateGenerationParams& Params) const;
|
||||
const FStrateGenerationParams& Params,
|
||||
uint32 ParamsFingerprint, uint32 LayoutVersion) const;
|
||||
|
||||
/**
|
||||
* Densité pour une strate Slab (FlatPlain / CrystalChamber).
|
||||
|
||||
Reference in New Issue
Block a user