feat(opstack): EffectOverBox correct in ISOLATION -- the blocker is closed

COMPOSER-NOTES §6.1, titled THE BLOCKER, gated everything after it. A
source writing only Sdf did not touch density, so it answered the box
query ON BEHALF OF the converter that followed it -- fine when a human
wrote and verified the pair, unsafe the moment the composer assembles a
combination nobody checked. And the failure is the silent one: a tile
wrongly proved uniform means no geometry, no collision, no error, and a
player falls through the floor.

FVoxelBoxHypotheses now carries FVoxelBoxSdfInterval. SDF writers publish
their own [Min, Max]; replacement writers replace it, accumulating writers
MinWith it; converters and detail ops consume the prior interval through a
state-aware EffectOverBox. Each op answers only for itself and the fold
composes them. Unknown, invalid, non-finite or unimplemented bounds force
Mixed -- when in doubt the answer is Mixed, because a missed skip costs
milliseconds and a false uniform costs a player.

Bounds are SUPREMA of what Eval can produce, never the param named "max":
FBM 1.5 (not 1.0), max(Min,Max) radius envelopes, warp sqrt(2), SmoothMin
slack. [FLT_MAX, FLT_MAX] is the exact no-surface identity.

⭐ THE PROOF IS THE NOVEL STACK. Built a combination no shipping builder
produces:
    ConstantVoid -> LatticeCorridorSource -> SdfRoughness -> SdfFill
brute-forced 332,750 voxels across 256 boxes: 250 proved AllAir,
0 VIOLATIONS. If isolation is real an unverified stack must still be
sound, and it is. That test -- not the refactor -- is the deliverable.

Every archetype re-proved by brute force, 0 violations everywhere:
  Maze 112/256 · CrystalChamber 38/60 · Underwater 33/40 · FlatPlain 32/60
  VerticalShafts 28/60 · FloatingIslands 15/60 · TunnelNetwork prod 9/40
  TunnelNetwork dense 0/40 (correctly -- it really is all mixed)

COST, reported not hidden: isolation gives up some skipping. TunnelNetwork
production 12->9, VerticalShafts 32->28. Answering for yourself alone is
strictly weaker than answering for a verified pair. The classifier stays
materially useful and the trade buys safe composition, which is the point.

Eval is untouched; equivalence tests bit-identical; no per-voxel box query.
SurfaceWorld keeps its separate exact height/lattice classifier.

Also: ⛔ WITHDREW the "corrective ops must be fused" claim from §3.2.
FFloorBiasMod does NOT undo FCaveRoughnessMod -- it adds an independent
NormZ^2 x FloorBias term to the ACCUMULATED density and never reads
TotalRough. Not a correction, no fuse, no "corrects" dependency. The claim
came from reading a name and a comment; one grep would have shown the ten
Density-writing ops in between before the paragraph was written.

Build clean, 22 succeeded / 0 failed, verified in VoxelM.log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-04 06:20:40 +02:00
1 parent 7b982ad5ae
commit 8e346a3054
11 files changed
+1193 -890

No files matched your search

+5
View File
@@ -383,6 +383,11 @@ driven by `EditorBrush*` props.
the stack entry. `ValidateChannelOrder` is assembly/diagnostic-only; no declaration virtual may
enter `Eval`'s per-voxel loop. If `FVoxelOpSample` gains a field, add its channel bit and update
every operator declaration and the validator together.
- **Isolated box proofs** (`FVoxelBoxHypotheses` + `FVoxelBoxSdfInterval`): the box fold carries
the SDF interval produced so far. An SDF-only source publishes only its own interval; a later
converter or detail op applies its own state-aware response. Unknown or invalid bounds force the
fold toward `Mixed`: a missed skip costs CPU, while a false uniform verdict removes geometry and
collision. Every new SDF writer must implement `PropagateSdfOverBox`; the default is unknown.
- **VerticalShafts field cache**: shaft rolls and connector decisions, including the structural
tree and structural-spine connector endpoint, are rebuilt only when the thread-local centre cell
or a geometry-affecting parameter changes. Each rebuild rolls a wider direct-indexed geometric
+11 -11
View File
@@ -106,10 +106,10 @@ reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for t
| `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. |
| `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. |
| `FVoxelOpContext` | Chunk-constant inputs. Carries `LayoutVersion` plus the copied global `WorldRadiusVoxels`/`EdgeSealThickness`, so a new op cannot forget them. |
| `IVoxelDensityOp` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `PrepareChunk` / `Eval` / `EffectOverBox` / `ClassifyBox` / `IsXYPure`. The three declarations are pure virtual and are snapshotted during stack assembly; none is called per voxel. |
| `IVoxelDensityOp` | `ChannelReads` / `ChannelWrites` / `IsAdditive` plus `PrepareChunk` / `Eval` / intrinsic and state-aware `EffectOverBox` / `MaxCarveOverBox` / `MaxFillOverBox` / `PropagateSdfOverBox` / `ClassifyBox` / `IsXYPure`. Box-query methods are never called per voxel; channel declarations are snapshotted during stack assembly. |
| `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the vertical seal and XY edge seal inside their proven bands) overwrite the input entirely, which pure direction cannot express. |
| `FVoxelOpSample` | The state threaded through the stack: **two** channels, `Density` (INTERNAL convention, **positive = SOLID**, negated to MC once by the caller) and `Sdf` (standard SDF, negative = inside). ⚠️ `min()` therefore means opposite things on the two channels. |
| `FVoxelBoxHypotheses` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`. Reproduces today's hand-written `ClassifyTile` line for line — the mapping is written out in the header. |
| `FVoxelBoxHypotheses` + `FVoxelBoxSdfInterval` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`, carrying the SDF interval between independent writers and consumers. Unknown is biased toward `Mixed`; the mapping and soundness rule are written out in the header. |
### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h`
`VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` · `VF_ApplyXYEdgeSeal` —
@@ -148,28 +148,28 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. |
| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). |
| `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. |
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). Its `EffectOverBox` answers for the source+carve **pair** (Phase 1 simplification) so it must be told the downstream `ExtraReach`. |
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). The source reports its own conservative SDF interval; a later converter supplies its own carve/fill response, so the source is independent of the chosen consumer. |
| `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. |
| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. |
| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. |
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: the proved FBM supremum `1.5` bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild uses a direct-indexed wider halo (default 9×9 tree-emission / 15×15 roll) so every emitted shaft has its complete 5×5 parent window plus capsule reach; the nearest strictly more-central shaft in ±2 cells is the deterministic parent, with a deterministic lower-origin ±3 fallback and a direct `(0,0)` spine fallback. Thus every shaft has a provable path to the spine. Existing `VoxelHash::Pair`/`Spacing*1.6` links remain additive texture/loops. Tree and spine capsules use a radius strictly above `sup|FBM|=1.5`, and tree Z is chosen in a ledge-free band; the structural post still owns the column and the ledge modifier keeps its mathematical axis boundary open. `EffectOverBox` mirrors both tree and random capsules conservatively. |
| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild uses a direct-indexed wider halo (default 9×9 tree-emission / 15×15 roll) so every emitted shaft has its complete 5×5 parent window plus capsule reach; the nearest strictly more-central shaft in ±2 cells is the deterministic parent, with a deterministic lower-origin ±3 fallback and a direct `(0,0)` spine fallback. Thus every shaft has a provable path to the spine. Existing `VoxelHash::Pair`/`Spacing*1.6` links remain additive texture/loops. Tree and spine capsules use a radius strictly above `sup|FBM|=1.5`, and tree Z is chosen in a ledge-free band; the structural post still owns the column and the ledge modifier keeps its mathematical axis boundary open. The source publishes a conservative interval covering both shafts and connector capsules; it does not answer for the ledge or converter. |
| `VoxelDensityOps::BuildSlabStack` | — | 6 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns a **six-box spatial LRU** of direct-indexed per-column cells, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed, ParamsFingerprint)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. Six 81×81 boxes preserve hot columns across interleaved regions at roughly 0.79 MiB TLS before padding (more memory, fewer whole-cache recenter/recompute misses). Fractional XY remains direct-compute. |
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 4 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 9 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`). Passes the runtime spine radius into `FShaftFieldSource`, which mirrors the generator's connector-only origin endpoint and roughness-safe spine capsule. 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**; 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, 20 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) → 4 structural posts. 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). The requested 4b→4h fuse is pending: all 10 intervening ops touch `Density`, so moving the corrective phase fails the bit-identity precondition. |
| `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. The source now publishes a conservative SDF interval through the box fold; it does not answer for the carve or detail stack. It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), and the interval uses the same cull/threshold disjunction as the per-voxel source: a primitive is harmless when it fails its cull or its own SDF stays ≥ `T+K`; `T = max(3·SDFBlendRadius, WormNetworkRange)`, with `−K` slack for the final `SmoothMin`. Rooms, tunnels, pits and chimneys are bounded in their respective coordinate frames; warp dilation uses the proved `sup|Perlin3D| ≤ 1.5` envelope. |
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by the propagated SDF interval (reads `InOut.Sdf` *after* pits/chimneys). Its state-aware box response is `Identity` only when the preceding interval proves `Sdf ≥ WormNetworkRange`; otherwise it reports its own `CarveOnly` direction and bound. With no preceding SDF source it remains conservative, so it cannot inherit a verdict from an unrelated future writer. |
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 20 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) → 4 structural posts. Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line. `FFloorBiasMod` is an independent additive term on the density accumulated before it; it is kept late because it does not undo the roughness delta. The requested 4b→4h fuse is not semantically justified. |
| `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. |
| `FLayerLineMod` / `FRibbingMod` (internal) | 3 | The same sine along Z: cubed and subtracted (grooves) vs quarter-phase-shifted, squared and added (ribs). `CarveOnly` / `FillOnly` — two of the few detail modifiers that keep a usable direction for the fold. |
| `FCaveOverhangMod` / `FCaveCliffMod` / `FScallopMod` (internal) | 3 | STEP 4c. ⚠️ The cliff's own comment promises a sampled vertical gradient; the **code** uses a Z-stretched Perlin as a proxy and samples nothing. Ported as written — fixing it would change the world. |
| `FCaveArchMod` / `FDomeMod` / `FPinchMod` / `FFloorBiasMod` (internal) | 3 | Room-relative: they read `FRoomGraphSource::GetNearestRoomIdx()` and the cached room. Their gate is `SDF < SDFBlendRadius`, **not** the shared `·3` one — they live in the cave's void, not its wall. |
| `FRoomColumnMod` (internal) | 3 | STEP 4d, and **not** `MakeGridColumnMod`: it walks `SDFCache.Columns`, pre-baked per room. ⚠️ It has **no strate parameter at all** — neither the bake nor the loop reads `FStrateGenerationParams::ColumnDensity`. Columns exist only through a `Column` terrain-op asset in the strate's pool, and the only way to prove they fired is to look at `SDFCache.Columns.Num()`. |
| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. ⚠️ `EffectOverBox` still answers from STRATE params, so a box verdict can be **too optimistic** on a strate with a terrain-op pool; the cave classifier remains conservative by bailing Mixed on any unresolved cave guard. |
| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. `EffectOverBox` → `FillOnly` when a blob reaches the box, `Identity` otherwise; its pad must cover warp·**√2** (two independent noise axes), roughness, fill blend and the `SmoothMin` dip. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. |
| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. The detail consumers use the propagated interval for their shared near/far gate; unknown remains conservative, and the per-room parameter override is never used to claim an unproved box identity. |
| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. The source publishes an interval covering the full authored radius envelope (`max(MinRadius, MaxRadius)`), the two-axis warp·**√2** envelope, top surface and SmoothMin dip; the fill converter makes its own decision. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. |
| `VoxelDensityOps::BuildFloatingIslandStack` | — | 8 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. |
| `VoxelDensityOps::BuildMazeStack` | — | The 8-op Maze stack (4 structural posts included). If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
@@ -497,7 +497,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 6-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. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only ±2/±3 windows in a wider direct-indexed halo (default 9×9 emit / 15×15 roll), plus the old random links. Tree connector Z is exact and ledge-free; tree and random capsules are spatially culled from the cached rebuild. **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 tests tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. |
| `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. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only ±2/±3 windows in a wider direct-indexed halo (default 9×9 emit / 15×15 roll), plus the old random links. Tree connector Z is exact and ledge-free; tree and random capsules are spatially culled from the cached rebuild. **Fixed 2026-07-29:** its box query 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 publishes an isolated interval for tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative; the ledge and converter decide their own effects. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. |
| `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. |
| `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). |
+44 -26
View File
@@ -118,19 +118,32 @@ succeeded, 0 failed, 0 not run**.
**void** source with a **fill** instead of a rock source with a carve — and you get islands instead of
tunnels.
**Corrective ops must be FUSED, not ordered.** `FFloorBiasMod` exists only to undo what
`FCaveRoughnessMod` did to floors. Both are density-space, so channel rules permit either order.
**An operator whose only purpose is to correct another operator is not a separate operator** — fuse them
into one op with two internal phases. That removes the invalid state instead of annotating it. Fifteen
ops that compose in any legal order beat eighteen with a footnote. (Fusing with identical order and
math leaves the field bit-identical.)
⛔ **"Corrective ops must be FUSED" — WITHDRAWN 2026-09-04. The premise was false.**
**Current implementation finding:** the fuse is not landed. Ten operators intervene between 4b and
4h — `FCaveTerraceMod`, `FLayerLineMod`, `FRibbingMod`, `FCaveOverhangMod`, `FCaveCliffMod`,
`FScallopMod`, `FCaveArchMod`, `FRoomColumnMod`, `FDomeMod`, and `FPinchMod` — and every one reads
and writes `Density`. Moving the floor phase next to roughness would therefore reorder the live
density updates; the required bit-identical gate cannot be assumed. The current 20-op stack remains
in its original order until a fusion that preserves that order and math is available.
This section used to assert: *"`FFloorBiasMod` exists only to undo what `FCaveRoughnessMod` did to
floors... fuse them into one op with two internal phases."* **It does not, and they must not be fused.**
Read the math rather than the names. `FCaveRoughnessMod` adds its own displacement:
`D += TotalRough`. Eleven ops later, `FFloorBiasMod` computes an **independent** term
`B = NormZ² × FloorBias` from `Sdf`, room geometry and `WorldZ`, and adds it: `D += B`.
**It never reads or subtracts `TotalRough`.** It is not a correction to roughness at all — it acts on
the accumulated density and belongs late in the stack, exactly where it is.
⇒ **No fuse. No `corrects` dependency either** — there is nothing being corrected. The general
principle ("an operator whose only purpose is to correct another is not a separate operator") stands
as a rule for ops that genuinely are corrective; this simply was not an instance of it.
**Two lessons, both cheap here and expensive elsewhere:**
- The claim came from reading a *name* and a *comment*. Ten Density-writing ops sit between the two
(`FCaveTerraceMod`, `FLayerLineMod`, `FRibbingMod`, `FCaveOverhangMod`, `FCaveCliffMod`,
`FScallopMod`, `FCaveArchMod`, `FRoomColumnMod`, `FDomeMod`, `FPinchMod`) — a fact one grep would
have shown before the design paragraph was written.
- The old parenthetical *"fusing with identical order and math leaves the field bit-identical"* was
also wrong: reordering across ten accumulating ops cannot be bit-identical, because IEEE-754
addition is not commutative. ⚠️ But do not let that argument decide anything on its own —
**bit-identity across builds was never required** (see §8: peer determinism is *same build, all
machines*). Had the fuse been semantically right, a slightly changed field would have been an
acceptable price. It was the *semantics* that killed it, not the bits.
**One declaration covers the rest:** an op is **additive** (small displacement, commutes freely — shuffle
at will) or **transformative** (clamps, multiplies, gates — position matters, needs explicit placement).
@@ -335,21 +348,24 @@ still a gradient, and gradients are what Jahni rejected.
All verified against the source on 2026-08-17 unless marked otherwise.
### ⚠️ 6.1 THE BLOCKER — box verdicts are only correct in the stack they ship in
### ✅ 6.1 THE BLOCKER — `EffectOverBox` correct in isolation (resolved 2026-09-04)
CODEMAP, on the lattice corridor source: *"Its `EffectOverBox` answers for the source+carve **pair**
(Phase 1 simplification) so it must be told the downstream `ExtraReach`."* The op interface says the
same in its own comment: a source writing only `Sdf` does not touch density itself, so it answers on
behalf of the converter that follows it.
The source+converter coupling is removed. `FVoxelBoxHypotheses` now carries an
`FVoxelBoxSdfInterval`; each SDF writer publishes only the interval for its own field, and each
converter/detail operator folds that interval through its own formula. `FVoxelOpStack::VF_FoldOp`
propagates the interval in stack order, so an operator cannot borrow a neighbour's reach or verdict.
Fine when a human wrote the pair. **It breaks the moment the composer assembles a combination nobody
verified**, and the failure is the silent one: a tile wrongly proved uniform means no geometry, no
collision, no error, and a player falls through the floor.
The interval is deliberately a proof, not a likelihood: invalid, non-finite, or unimplemented
bounds become unknown and therefore cost a skip rather than manufacture `AllSolid`/`AllAir`.
Bounds use the supremum of `Eval` over the authored parameter range, including `sup|FBM| = 1.5`,
the `max(Min, Max)` radius envelope, warp·√2 where two axes are independent, and the SmoothMin
slack. The existing fold carries the interval; no parallel box-query path was needed.
⇒ **Prerequisite before free composition is safe: every operator's `EffectOverBox` must be correct in
ISOLATION**, not correct-given-its-neighbours. That means propagating an **SDF interval** through the
box query instead of letting the source answer for the pair — which the interface comment already names
as the Phase 3 version of the contract. Not huge, but load-bearing, and it must land first.
The isolation deliverable is an unverified combination: `ConstantVoid → LatticeCorridorSource →
SdfRoughness → SdfFill`, which no shipping builder emits. Across 256 boxes it proved 250 `AllAir`
and 6 `Mixed`; **332,750 voxels checked, 0 violations**. The shipping archetype scans likewise
reported zero violations. This resolves §6.1 for the current operators, with one ongoing interface
rule: every future SDF writer must implement interval propagation or accept `Mixed` by default.
### ⚠️ 6.2 Box-verdict bounds are proved against HAND-SET params
@@ -509,10 +525,12 @@ they became "findings" (§13).
2026-09-04).
- ⏸ **Fuse corrective ops** (`FFloorBiasMod` into `FCaveRoughnessMod`) — blocked by the ten
intervening `Density` readers/writers under the bit-identity requirement; see §3.2.
- ⚠️ **`EffectOverBox` correct in ISOLATION** (§6.1) — the gate on everything after.
- ✅ **`EffectOverBox` correct in ISOLATION** (§6.1; built and brute-force verified 2026-09-04).
- **The XY edge seal** (§6.7).
*Standalone value:* the fuse and the isolation fix are both correctness improvements to shipping code.
*Standalone value:* the isolation fix is a correctness improvement to shipping code. The fuse remains
an open semantic decision; the Part 0 analysis above concludes that FloorBias is not a correction of
roughness and should stay late in the stack.
### Tier 4 — the composer proper
Structure roll (§3.2) · parameter roll (§3.3) · reject-and-resample driven by Tier 2 (§3.5) · the
@@ -233,7 +233,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
// 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin
//=========================================================================
{
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0;
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680);
// 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.
@@ -268,6 +268,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
@@ -275,7 +276,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
AddError(FString::Printf(
TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the blob source's Pad (does it cover the WARP amplitude ")
TEXT("order: the blob source's propagated SDF interval (does it cover the WARP amplitude ")
TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ")
TEXT("then the Z bound -- note there is NO lower bound, a thin thread ")
TEXT("of matter hangs below each island down the axis, so only the ")
@@ -295,11 +296,11 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ")
TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
TEXT("%d Mixed, %d voxels checked, %d violations. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing,
NumProvedSolid, NumProvedAir, NumMixed));
NumProvedSolid, NumProvedAir, NumMixed, NumBruteSamples, NumUnsound));
if (NumProvedAir == 0)
{
@@ -87,6 +87,7 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(
namespace
{
constexpr int32 NumMazeSamples = 20000;
constexpr int32 NumMazeBoxTests = 256;
/** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */
bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out,
@@ -265,14 +266,14 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du
// gain T1.d. Tout nombre > 0 ici est du saut de tuile que Maze n'a jamais eu.
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680);
// 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 = 40;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < 60; ++t)
for (int32 t = 0; t < NumMazeBoxTests; ++t)
{
const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable
const int32 Extent = Step * Cells;
@@ -299,6 +300,7 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
@@ -307,8 +309,8 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ")
TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ")
TEXT("the lattice source's ExtraReach (does it cover the roughness ")
TEXT("amplitude AND the carve blend?), then the seal's forcing verdict."),
TEXT("the source interval, the SDF roughness interval, the converter's ")
TEXT("threshold fold, then the seal's forcing verdict."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
@@ -323,12 +325,12 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("CellSize %.0f): %d proved uniform, %d Mixed. Today's ClassifyTile ")
TEXT("Box verdicts over %d Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("CellSize %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ClassifyTile ")
TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ")
TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."),
SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
NumProved, NumMixed));
NumMazeBoxTests, SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
NumProved, NumMixed, NumBruteSamples, NumUnsound));
if (NumProved == 0)
{
@@ -338,6 +340,86 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
}
}
//==========================================================================
// ISOLATION — a source with a converter it was never authored with
//==========================================================================
// Maze's hand-written stack uses ConstantRock -> Lattice -> SdfRoughness -> SdfCarve.
// This stack deliberately uses a VOID root and SdfFill instead. The only way it may
// claim AllAir is for the propagated lattice/roughness interval to prove that the fill
// converter is inactive over the whole box. A source that still answered for its old
// carve neighbour would not be a valid implementation of this test.
{
FMazeGenerationParams NovelParams = MazeParams;
NovelParams.BranchProbability = 0.12f;
NovelParams.Verticality = 0.08f;
NovelParams.CorridorRadius = 1.0f;
FVoxelOpStack NovelStack;
NovelStack.Add(VoxelDensityOps::MakeConstantVoidSource(NovelParams.BaseDensity));
NovelStack.Add(VoxelDensityOps::MakeLatticeCorridorSource(NovelParams, World.Settings->Seed));
NovelStack.Add(VoxelDensityOps::MakeSdfRoughnessMod(
0.35f, 0.12f, 3, NovelParams.CorridorRadius + 2.0f));
NovelStack.Add(VoxelDensityOps::MakeSdfFill(1.25f, NovelParams.BaseDensity));
NovelStack.PrepareChunk(Ctx);
FString ChannelError;
TestTrue(TEXT("the unverified source/converter stack has a valid channel order"),
NovelStack.ValidateChannelOrder(&ChannelError));
if (!ChannelError.IsEmpty()) { AddError(ChannelError); }
constexpr int32 NumNovelBoxTests = 256;
const int32 NovelSpanCells = 160;
const int32 NovelSpanVoxels = NovelSpanCells * 8;
int32 NumNovelSolid = 0, NumNovelAir = 0, NumNovelMixed = 0;
int32 NumNovelVoxels = 0, NumNovelViolations = 0;
FRandomStream NovelRng(86420);
for (int32 t = 0; t < NumNovelBoxTests; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent,
NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent,
FMath::Clamp(NovelRng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = NovelStack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed)
{
++NumNovelMixed;
continue;
}
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
if (bClaimsSolid) { ++NumNovelSolid; } else { ++NumNovelAir; }
// No sampled subset: every lattice point in every proved box is checked.
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = NovelStack.EvalMC(X, Y, Z);
++NumNovelVoxels;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { ++NumNovelViolations; }
}
}
TestEqual(TEXT("the unverified source/converter stack has zero box-verdict violations"),
NumNovelViolations, 0);
AddInfo(FString::Printf(
TEXT("Novel lattice+roughness+FILL stack: %d boxes proved (%d AllSolid, %d AllAir), " )
TEXT("%d Mixed; %d voxels checked, %d violations; XY sampled from +/- %d voxels."),
NumNovelSolid + NumNovelAir, NumNovelSolid, NumNovelAir, NumNovelMixed,
NumNovelVoxels, NumNovelViolations, NovelSpanVoxels));
}
return true;
}
@@ -252,7 +252,7 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
// 3. LE VERDICT DE BOÎTE
//=========================================================================
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 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.
@@ -292,6 +292,7 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
@@ -299,8 +300,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
AddError(FString::Printf(
TEXT("HOLE: the shaft stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the shaft source's ExtraReach (does it cover the roughness ")
TEXT("amplitude AND the carve blend?), then the connector sweep (a ")
TEXT("order: the shaft source's SDF interval (does it cover all shaft ")
TEXT("and connector geometry?), then the connector sweep (a ")
TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ")
TEXT("op's FillOnly."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
@@ -317,22 +318,22 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
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("ShaftSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %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));
NumProved, NumMixed, NumBruteSamples, 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."));
TEXT("and the interval must cover both the shaft and connector capsules before ")
TEXT("either test is relaxed."));
}
}
@@ -278,7 +278,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
//=====================================================================
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680 + SlotIndex);
// 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.
@@ -312,6 +312,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
@@ -320,7 +321,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ")
TEXT("ops is not conservative. Suspects, in order: the slab source's ")
TEXT("noise amplitude bounds (does FBM really honour [-1,1]?), the ")
TEXT("noise amplitude bounds (the proved FBM supremum is 1.5, not 1.0), the ")
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
TEXT("mod's reach (MaxRadius + blend)."),
SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
@@ -340,12 +341,12 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed. Today's ")
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ")
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
SlotName, NumSlabTiles, SpanVoxels,
(float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing,
NumProved, NumMixed));
NumProved, NumMixed, NumBruteSamples, NumUnsound));
if (NumProved == 0)
{
@@ -1086,8 +1086,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
// 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
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que la source de salles répondait
// `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.
//
@@ -1239,7 +1239,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
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("40 Mixed until the SDF source interval and state-aware fold 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 ")
@@ -1408,6 +1408,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
UWCtx.StrateBottomWorldZ = UP.StrateBottomWorldZ;
UWStack.PrepareChunk(UWCtx);
// Underwater shares the tunnel builder, but it is a separate production archetype. Keep
// its box proof in the report so a future change cannot silently make only this slot
// unsound or sterile.
RunTileScan(UWStack, UP, UWCtx, TEXT("Underwater"), /*bZeroProvedIsExpected*/ false);
TestEqual(TEXT("the Underwater stack is the tunnel stack: same 20 ops"), UWStack.Num(), 20);
// 8 → 24 chunks, comme le balayage principal. Le premier run vert a rapporté **0 de 2000
File diff suppressed because it is too large. Load diff
+142 -25
View File
@@ -39,22 +39,35 @@
// the descent structure intact no matter what an author assembles.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LA CLÉ DE VOÛTE : EffectOverBox — direction, pas intervalle / THE KEYSTONE: direction, not intervals
// LA CLÉ DE VOÛTE : EffectOverBox + intervalle SDF / THE KEYSTONE: EffectOverBox + SDF interval
// ─────────────────────────────────────────────────────────────────────────────────────────
// La version « complète » d'une borne rendrait un intervalle numérique. NE PAS COMMENCER LÀ.
// Presque tout opérateur existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir.
// Cela suffit à reproduire GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile :
// Un verdict de boîte doit être une preuve. Rendre `Both` coûte du CPU ; rendre `Identity` ou une
// direction fausse supprime de la géométrie et peut laisser un joueur tomber au travers du monde.
// En cas de doute, `Mixed`.
//
// Un opérateur qui écrit seulement le canal SDF ne peut pas honnêtement répondre pour le convertisseur
// qui viendra peut-être après lui : il doit propager un intervalle SDF, et le convertisseur doit plier
// cet intervalle avec sa propre formule. Chaque opérateur répond ainsi pour lui-même, et la composition
// reste sûre même pour une combinaison que personne n'a écrite à la main auparavant.
//
// A box verdict is a proof. `Both` costs CPU; a false `Identity` or direction removes geometry and can
// let a player fall through the world. When in doubt, return `Mixed`.
// An SDF-only writer reports an SDF interval; a later converter folds that interval through its own
// response. Each operator answers for itself, so an unverified composition remains sound.
//
// La première version de cette interface n'avait qu'une direction numérique. Presque tout opérateur
// existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir. Cela suffit à reproduire
// GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile :
//
// « passages ⇒ bCanSolid = false » EST CarveOnly
// « ponts/arêtes ⇒ bCanAir = false » EST FillOnly
// « aucun passage près de cette boîte » EST Identity
//
// Donc la Phase 1 n'a besoin d'AUCUNE borne numérique et obtient déjà toute la propriété de
// sûreté. Les intervalles sont un resserrement ultérieur pour le coût de génération, pas un
// prérequis de correction. C'est ce qui rend le premier pas petit.
// La direction reste le repli conservateur par défaut pour les opérateurs qui ne connaissent pas
// d'intervalle ; elle ne permet jamais à un écrivain SDF d'usurper la réponse d'un voisin.
//
// Phase 1 needs NO numeric bounds and already gets the whole safety property. Intervals are a
// later tightening for gen cost, not a correctness prerequisite.
// Direction remains the conservative fallback for operators without an interval implementation;
// it never lets an SDF writer impersonate a downstream neighbour.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui
@@ -283,6 +296,64 @@ struct FVoxelOpSample
float Sdf = FLT_MAX;
};
/**
* Intervalle conservateur du canal SDF sur une boîte.
*
* `Min` et `Max` sont les bornes SUPRÊMES de toutes les valeurs que l'opérateur peut produire dans
* la boîte. `[FLT_MAX, FLT_MAX]` est l'identité exacte : aucune surface connue. Un intervalle inconnu
* est volontairement large ; il force le fold à rester `Mixed` plutôt que de risquer un faux tile
* uniforme. Les setters refusent toute borne non finie ou inversée : un opérateur qui ne peut pas
* prouver son intervalle perd le skip, jamais la géométrie.
*
* Conservative SDF interval over a box. `Min`/`Max` bound every value the operator can produce.
* `[FLT_MAX, FLT_MAX]` is the exact "no surface" identity. Unknown is deliberately wide: it costs
* a skip, never a hole. Invalid bounds become unknown instead of being guessed.
*/
struct FVoxelBoxSdfInterval
{
bool bKnown = true;
float Min = FLT_MAX;
float Max = FLT_MAX;
bool IsKnown() const { return bKnown; }
void SetUnknown()
{
bKnown = false;
Min = -FLT_MAX;
Max = FLT_MAX;
}
void Set(float InMin, float InMax)
{
if (!FMath::IsFinite(InMin) || !FMath::IsFinite(InMax) || InMin > InMax)
{
SetUnknown();
return;
}
bKnown = true;
Min = InMin;
Max = InMax;
}
/** Output of `min(Input, Other)` when both intervals are known. */
void MinWith(const FVoxelBoxSdfInterval& Other)
{
if (!bKnown || !Other.bKnown)
{
SetUnknown();
return;
}
Min = FMath::Min(Min, Other.Min);
Max = FMath::Min(Max, Other.Max);
}
};
// Forward declaration: the interval-aware EffectOverBox overloads consume the hypotheses that
// have already been folded, while the hypotheses themselves are declared immediately below the
// interface.
struct FVoxelBoxHypotheses;
//=============================================================================
// L'INTERFACE / THE INTERFACE
//=============================================================================
@@ -344,8 +415,9 @@ public:
virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0;
/**
* CONSERVATIF. Phase 1 : direction seule. Phase 3 : surcharge avec intervalle numérique.
* Rendre Both est toujours sûr ; rendre le mauvais est un trou.
* CONSERVATIF. The two-argument method is the intrinsic fallback. The state-aware overload
* below is what the stack calls: it sees the SDF interval already produced by earlier ops.
* Returning Both is always safe; returning the wrong direction is a hole.
*
* Le contrat est « conservatif », pas « forme close » : un opérateur A LE DROIT
* D'ÉCHANTILLONNER pour répondre. C'est exactement ce que fait ClassifyTile aujourd'hui pour
@@ -354,20 +426,20 @@ public:
*
* The contract is "conservative", not "closed-form": an op MAY sample to answer.
*
* ⚠️ SIMPLIFICATION DE PHASE 1, à connaître : une source qui n'écrit QUE le canal SDF ne touche
* pas la densité par elle-même — c'est l'opérateur de conversion (`FSdfCarve`/`FSdfFill`) qui le
* fait. Répondre honnêtement demanderait de propager un INTERVALLE de SDF à travers la requête
* de boîte, exactement comme `Eval` propage une valeur de SDF. En attendant, **la source répond
* pour la paire** (elle rend `CarveOnly`/`FillOnly` quand une primitive atteint la boîte,
* `Identity` sinon) et la conversion rend `Identity`. Conservatif et correct ; à remplacer par
* une requête de boîte à deux canaux quand les intervalles numériques arriveront (Phase 3).
*
* PHASE 1 SIMPLIFICATION: an SDF-only source answers for itself AND its conversion op; the
* conversion returns Identity. Answering honestly needs an SDF INTERVAL threaded through the box
* query, mirroring how Eval threads an SDF value. Conservative and correct meanwhile.
* An SDF-only source MUST NOT answer for a converter that happens to follow it. It reports its
* own interval through `PropagateSdfOverBox`; the converter's state-aware overload then folds
* the interval through its own formula. This is the isolation rule that makes free composition
* safe.
*/
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
/** State-aware effect. The default preserves old custom operators' conservative direction. */
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
const FVoxelBoxHypotheses&) const
{
return EffectOverBox(VoxelBox, Ctx);
}
/**
* ⚠️ LE PLIAGE QUI PORTE DES NOMBRES — `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de
* perf du plan.
@@ -406,11 +478,38 @@ public:
return FLT_MAX;
}
/** State-aware amplitude. The default is the old, conservative amplitude. */
virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
const FVoxelBoxHypotheses&) const
{
return MaxCarveOverBox(VoxelBox, Ctx);
}
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
return FLT_MAX;
}
/** State-aware amplitude. The default is the old, conservative amplitude. */
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
const FVoxelBoxHypotheses&) const
{
return MaxFillOverBox(VoxelBox, Ctx);
}
/**
* Propagate this operator's own SDF result through a box query. Only call this for an operator
* whose cached channel declaration writes `Sdf`. The default is unknown by construction: a new
* SDF writer cannot accidentally make a false uniform verdict merely because it forgot this
* method. Implementations that assign SDF replace the interval; min/accumulating writers use
* `FVoxelBoxSdfInterval::MinWith`.
*/
virtual void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox,
const FVoxelOpContext&) const
{
InOut.SetUnknown();
}
/**
* Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien
* la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ?
@@ -537,6 +636,9 @@ struct FVoxelBoxHypotheses
float SolidMargin = 0.0f;
float AirMargin = 0.0f;
/** SDF interval after all SDF writers folded so far. It starts at the exact no-surface identity. */
FVoxelBoxSdfInterval Sdf;
bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; }
/** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */
@@ -626,14 +728,29 @@ FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect,
* pure FillOnly would have thrown away, while still letting a passage take it back.
*/
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
const FBox& VoxelBox, const FVoxelOpContext& Ctx)
const FBox& VoxelBox, const FVoxelOpContext& Ctx,
bool bWritesSdf)
{
// Soundness bias: an unknown SDF interval is allowed to kill a skip, never to manufacture a
// uniform tile. The interval is propagated only after this op's own effect has been folded, so
// the next op sees exactly the SDF version that Eval would expose at this point in the stack.
const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx);
if (Forced != EVoxelTileClass::Mixed)
{
VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx));
if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
return;
}
VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx),
Op.MaxCarveOverBox(VoxelBox, Ctx), Op.MaxFillOverBox(VoxelBox, Ctx));
VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx, H),
Op.MaxCarveOverBox(VoxelBox, Ctx, H),
Op.MaxFillOverBox(VoxelBox, Ctx, H));
if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
}
/** Backward-compatible helper for unit tests and callers with no cached declaration metadata. */
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
const FBox& VoxelBox, const FVoxelOpContext& Ctx)
{
VF_FoldOp(H, Op, VoxelBox, Ctx,
(Op.ChannelWrites() & VoxelOpChannels::Sdf) != 0);
}
+13 -12
View File
@@ -154,7 +154,8 @@ public:
FVoxelBoxHypotheses H;
for (const FOpEntry& Entry : Ops)
{
VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx);
VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx,
(Entry.Writes & VoxelOpChannels::Sdf) != 0);
// Do not early-out on a dead hypothesis: a later forcing structural post may
// deliberately overwrite it (the XY edge seal is appended after passage carving).
}
@@ -189,7 +190,8 @@ public:
const bool bSolidBefore = H.bCanBeAllSolid;
const bool bAirBefore = H.bCanBeAllAir;
VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx);
VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx,
(Ops[i].Writes & VoxelOpChannels::Sdf) != 0);
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
@@ -270,14 +272,11 @@ namespace VoxelDensityOps
* Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux
* chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
* COLLECT, zéro risque de couture (AUDIT §6.4 — le motif à préférer). */
/* `ExtraReach` = tout ce qui peut eLARGIR la portée du couloir en aval (amplitude de rugosité
* rayon de blend du carve). La source répond pour la paire source+conversion dans
* `EffectOverBox` (voir la note « SIMPLIFICATION DE PHASE 1 » dans VoxelDensityOp.h), donc elle
* doit connaître cette marge, sinon sa réponse `Identity` serait un MENSONGE — c'est-à-dire un
* trou. / The source answers for the source+conversion pair, so it must know the downstream
* margin: an Identity that is wrong is a hole. */
/** The source answers only for its own SDF field. Its box query publishes an SDF interval;
* each later converter or modifier consumes that interval through the generic fold. It does
* not know, and must not answer for, any downstream operator. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
int32 Seed, float ExtraReach);
int32 Seed);
/** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands).
* `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est
@@ -302,7 +301,8 @@ namespace VoxelDensityOps
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
* bornées par le contrat [-1,1] de FBM. Sert FlatPlain **et** CrystalChamber. */
* bornées par le supremum prouvé de FBM (1.5, not the nominal parameter label). Sert
* FlatPlain **et** CrystalChamber. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed);
/** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒
@@ -380,8 +380,9 @@ namespace VoxelDensityOps
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.**
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle
* de `FRoomGraphSource` évaluée 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