feat(world): the XY edge seal -- the fourth structural invariant

VF_ApplyBoundarySeal was purely VERTICAL: it sealed each strate's ceiling
and floor and there was no horizontal equivalent, because until the depths
were bounded the world had no sides. The GDD now says "bounded, but
gigantic", so a player who walked far enough reached unterminated
generation running off into nothing.

Built as a FORCING op, appended automatically and not author-omittable, so
an invented strate cannot forget its own edge. Because a forcing op proves
AllSolid in its band, the outer shell also becomes free to skip in tile
classification -- correctness and a perf win from one change.

  order  spine -> vertical seal -> passage carving -> XY EDGE SEAL
         -> disturbances -> diff
  A near-rim passage may pierce a vertical seal; the XY seal runs after and
  wins. A passage must never punch a hole through the world's edge.

ClassifyBox/ForcedMarginOverBox proved via the closest AABB radius with a
one-voxel margin: 8 boxes proved, 1 tile proved, 600 voxels brute-forced,
0 violations. FXYEdgeSealOp is IsXYPure. Max rim discontinuity .187 against
a 1.0 threshold -- a ramp, not a cylinder wall.

⚠️ WorldRadiusVoxels DEFAULTS TO 0, and that is deliberate. Codex flagged
that DA_Settings and BP_VoxelSettings predate the property and so do not
serialize it -- they inherit the C++ default silently. A default of 8192
would have walled in every existing world on next editor open with nothing
in the asset to explain it. 0 is a bitwise no-op (45 interior comparisons
against the unbounded path prove it). The GDD's intended ~8192 is a value
to set in the asset, deliberately, once. Documented at the declaration.

Codex found four spec errors, three of them mine: VF_ApplyBoundarySeal
lives in VoxelDensityPrimitives.h not VoxelCaveMorphology; the asset
default trap above; and my "density increases through the band" wording
contradicts the MC sign convention (solid is more NEGATIVE) -- the test
measures increasing solidness.

Build clean, 21 succeeded / 0 failed, run and verified by me.

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 01:17:12 +02:00
1 parent 44938f3f98
commit 9d2324879d
19 files changed
+1004 -124

No files matched your search

+40 -2
View File
@@ -27,7 +27,10 @@ by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispat
| Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table |
All density fns share the convention: internal **positive=solid**, apply origin spine →
boundary seal → inter-strate passages, then `return -Density` (MC: negative=solid).
vertical boundary seal → inter-strate passages → XY edge seal, then `return -Density` (MC:
negative=solid). The final MC-facing edge pass in `GetDensityAt` is repeated after disturbances so
the global rim cannot be reopened by a post-process; the diff layer remains the explicit player
override.
StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk`
(macro `VF_ARCHETYPE_PARAMS_GETTER`) — no cross-boundary blend (Hard transitions between archetypes).
On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a
@@ -68,10 +71,37 @@ edge, and every chain strictly decreases distance to the origin until it reaches
that links to the spine. It does not alter passage placement. The acceptance measurement remains
the actual arrival→departure flood-fill at the independently placed passage endpoints.
### 8.2a XY edge seal (bounded-world invariant)
`UVoxelSettings::WorldRadiusVoxels` and `EdgeSealThickness` define the bounded world in actor-space
XY around `(0,0)`. The default radius is 8192 voxels (~2.05 km at 25 cm/voxel) and the default
solid ramp is 64 voxels. A radius of **0 is a true no-op**: the hot path returns before doing any
arithmetic, so the legacy unbounded field is unchanged. From `R - Thickness` to `R`,
`VF_ApplyXYEdgeSeal` raises the internal positive-solid density with the same `SmoothStep01` ramp
idiom as the vertical seal; at and beyond `R` it forces positive `BaseDensity`. The generator
negates once for marching cubes, so the externally visible sealed density is negative (solid).
This is the fourth structural post and is appended automatically by
`FVoxelOpStack::AppendStructuralPost`, after the spine, vertical seal, and passage carve. Passage
carving deliberately remains before the edge seal: passages may pierce a vertical strate seal, but
a passage whose mouth or body reaches the XY rim is overwritten by the edge force and cannot open a
hole through the world boundary. The legacy density functions apply the same post in the same
order. `GetDensityAt` reapplies the MC-facing form after disturbances as a backstop for out-of-layout
air and any disturbance carve; player diff edits still run last by design and remain authoritative.
`FXYEdgeSealOp` is `IsXYPure() == true`, uses only a squared radial early-out in the common interior
case, and returns `AllSolid` from `ClassifyBox` only when the closest point of the whole box is at
least one voxel into the ramp (or beyond `R`). Its `ForcedMarginOverBox` reports the actual positive
lower bound, conservatively reduced after the one-voxel proof margin; it never treats the
mathematical inner edge as solid. The same proof is used by the global `ClassifyTile` shell guard,
so tiles wholly in the proven outer band can skip T1.d generation/collision work. A box touching the
band but not meeting that proof remains `Mixed`.
### 8.3 Disturbance layer (the "wow" post-process)
`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances`
(VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch:
chasms (carve air), bridges (solid spans), ridges (solid blades). Stays inside seal bands.
chasms (carve air), bridges (solid spans), ridges (solid blades). The XY edge seal is reapplied after
this layer, so disturbances cannot reopen the outer shell; the layer otherwise stays inside the
vertical seal bands.
Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
### 8.4 Cross-chunk determinism (the seam-prevention invariant)
@@ -352,6 +382,11 @@ driven by `EditorBrush*` props.
thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID
prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner
or layout key, and don't move the fetch/blend back to per-voxel.
- **XY edge seal hot path** (§8.2a): `WorldRadiusVoxels == 0` returns immediately; otherwise the
common interior case is a squared-distance test with no square root. The forcing op receives the
two global settings through `FVoxelOpContext` once per chunk, while `ClassifyBox`/`ClassifyTile`
share the radial proof. This adds no cache, state, RNG, or clipmap stream, and does not touch the
density grid's two-pass MC loop or any existing `thread_local` cache.
- **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX +
ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is
noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep
@@ -542,6 +577,9 @@ driven by `EditorBrush*` props.
**Conservative guards** (anything that can carve/fill): player mods (`HasAnyModInChunkRange`) ⇒
Mixed; passages (`AnyPassageNearBox`, bounding spheres + carve blend pad) and the (0,0) spine
(circle/box XY) kill AllSolid; disturbance chasms kill AllSolid, bridges/ridges kill AllAir.
The global XY edge proof runs first and returns AllSolid for a box entirely in the forced band;
the same force is the final generated post after disturbances, so a near-rim passage cannot
invalidate that verdict. Every other uncertain case remains Mixed.
A false `Mixed` only costs CPU; the code must NEVER emit a false AllSolid/AllAir (that's a hole).
Capture tiles (`bWantCapture`, density-volume shadow window) always generate — the volume wants the
grid even for uniform cells. A sparse ~5×5 column pre-pass exits Mixed fast on surface-crossing
+27 -24
View File
@@ -95,28 +95,29 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
### 3.2b Density operator stack contract — `Public/VoxelDensityOp.h` (plain C++, no UHT)
**Phase 1 skeleton, added 2026-07-27. Nothing is wired in yet** — `GetDensityAt`'s archetype
`switch` is untouched and no operator exists. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for the plan and
[OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md) for the per-archetype breakdown.
**Production path behind a per-strate opt-in** — the stack is built/evaluated by `GetDensityAt` for
all eight archetypes when `bUseOperatorStack` is enabled, while the legacy `switch` remains the
reference path for opt-out strates. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for the decomposition.
| Symbol | Notes |
|--------|-------|
| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→seal→passage→diff, appended automatically, never author-omittable). |
| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→vertical seal→passage→XY edge seal→diff, appended automatically, never author-omittable). |
| `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` by construction** so a new op cannot forget it (AUDIT C2). |
| `FVoxelOpContext` | Chunk-constant inputs. Carries `LayoutVersion` plus the copied global `WorldRadiusVoxels`/`EdgeSealThickness`, so a new op cannot forget them. |
| `IVoxelDensityOp` | `PrepareChunk` / `Eval` / `EffectOverBox` / `ClassifyBox` / `IsXYPure`. |
| `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the boundary seal inside its band) overwrite the input entirely, which pure direction cannot express. |
| `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. |
### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h`
`VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` — the three world
invariants every archetype appends, **moved here 2026-07-27** so the generator and the operator
stack share ONE copy. `VoxelGenerator.cpp` keeps same-named `static FORCEINLINE` forwarders so its
~20 call sites are unchanged; bodies are byte-identical. Also `VoxelDensityReach::SpineBlend` /
`PassageBlend`, the blend radii `ClassifyTile` currently hand-duplicates.
**Convention: INTERNAL (positive = solid).**
`VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` · `VF_ApplyXYEdgeSeal` —
the four world invariants every archetype appends, **moved here 2026-07-27** so the generator and
the operator stack share ONE copy. The XY helper is a radial smooth ramp in actor-space and is a
true no-op when `WorldRadiusVoxels == 0`; `VF_XYEdgeSealForcedMarginOverBox` is the shared sound
proof for `ClassifyBox` and T1.d's global shell skip. `VoxelGenerator.cpp` keeps same-named
`static FORCEINLINE` forwarders for the original three helpers; the new edge helper is called
directly. **Convention: INTERNAL (positive = solid); `VF_ApplyXYEdgeSealMC` is the output wrapper.**
### 3.2d Operator stack — `Public/VoxelDensityOpStack.h` + `Private/VoxelDensityOpStack.cpp`
⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack
@@ -141,8 +142,8 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| Symbol | Role | Notes |
|--------|------|-------|
| `FVoxelOpStack` | — | Ordered `TUniquePtr` list. `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold, with an early-out when both hypotheses die). |
| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → seal → passage **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. |
| `FVoxelOpStack` | — | Ordered `TUniquePtr` list. `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold lets a later forcing post overwrite a dead hypothesis). |
| `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`. |
@@ -152,23 +153,23 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild collects 7×7 cells so every emitted inner shaft has its complete 5×5 candidate window; the nearest strictly more-central shaft is the deterministic parent, and a local minimum links to the runtime `(0,0)` 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`; the structural post still owns the column and the ledge modifier ignores the synthetic endpoint. `EffectOverBox` mirrors both tree and random capsules conservatively. |
| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
| `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 + 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`). 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. |
| `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, 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). |
| `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). |
| `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; harmless until `ClassifyTile` consumes `ClassifyBox`, and it must be fixed before that. |
| `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. |
| `VoxelDensityOps::BuildFloatingIslandStack` | — | 7 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. |
| `VoxelDensityOps::BuildMazeStack` | — | The 7-op Maze stack. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
| `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`. |
### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp`
⚠️ **Feeds nothing yet** — built and exercised only by `VoxelForge.OpStack.SurfaceHeightEquivalence`.
@@ -200,6 +201,7 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|-------|---------------|
| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) |
| Clipmap | `ClipRadius`, `MaxClipLevel`, `FullResClipLevels`, `CoarseTileCells`, `RenderDistanceChunks` (custom horizontal reach: the outermost shell keeps generating until it covers this many chunks; 0 = off), `bFarSheetRing` + `FarSheetSpanLevels` (F18 — the render-distance ring streams per-surface SHEETS: level MaxClipLevel+span heightfield tiles instead of MC, see `GenerateSheetMesh`), skirts, `LODOctaveDrop` (T2.b octave drop on coarse tiles — 0 = off/byte-identical) (the old `LOD0/1Distance` + `ContentMaxLevel` were dead → removed) |
| World bounds | `WorldRadiusVoxels=8192` and `EdgeSealThickness=64` (actor-space XY radial shell; radius 0 = true legacy no-op) |
| Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) |
| Rendering | `VoxelMaterial` (61) |
| Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) |
@@ -277,14 +279,14 @@ This is **where terrain shape lives.**
| `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. |
| `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. |
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. |
| `InitializeSettings` | 211 | Copies seed from settings. |
| `InitializeSettings` | 211 | Copies seed, spine radius, and global XY world-bound settings from `UVoxelSettings`; radius 0 preserves the legacy unbounded field. |
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. Its `CP_*` per-chunk state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; every key component is an integer compare and a different generator/world cannot inherit the previous owner's cached params or op stack. |
| **`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. |
| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. |
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. Its diagnostic-only not-op-stack bail attribution distinguishes a tile wholly inside the disabled slot, a boundary tile, and an unresolved layout; the classifier's conditions/returns are unchanged. §8.10. |
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): first applies the shared radial XY edge proof (global outer-shell tiles → AllSolid, including gaps/out-of-layout), then proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. Its diagnostic-only not-op-stack bail attribution distinguishes a tile wholly inside the disabled slot, a boundary tile, and an unresolved layout; the classifier's conditions/returns are unchanged. §8.10. |
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
@@ -484,7 +486,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.OpStack.BoxVerdictFold` | Pure-logic walk of the fold in `VoxelDensityOp.h`, case by case — including the seal-forces-AllSolid case that justifies `ClassifyBox` existing. Also the only `.cpp` that includes the op header, so the build actually sees it. |
| `VoxelForgeHeightStackTest.cpp` | `VoxelForge.OpStack.SurfaceHeightEquivalence` | The height-space stack vs `ComputeSurfaceTerrainZ`, in **altitudes**. Runs twice: defaults, then **all F20 terrain ops ON** — the load-bearing pass, since the ops are off by default and the defaults pass exercises only the structural source. Also brute-forces `MaxDisplacement` (a false bound would be a hole). Bar is bit-identity; a height delta is a visibly different world, not rounding. |
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
| `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 7×7/5×5 windows, plus the old random links. **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. |
| `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. |
@@ -493,6 +495,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. |
| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. |
| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test explicitly checks margins 0/2/4/8 at step 2/radius 192, reports components/largest-component share, the `(0,0)` spine's full-window/interior vertical run and component, plus arrival/departure→spine and arrival→departure enums across 16 VerticalShafts seeds. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. |
| `VoxelForgeWorldEdgeSealTest.cpp` | `VoxelForge.Generation.WorldEdgeSeal` | Positive samples outside the radius across every fixture strate, bit-identical interior negative control against radius 0, monotonic smooth-ramp check, radius-0 no-op, radial `ClassifyBox`/T1.d proofs brute-forced for every reported voxel, and synthetic plus generated near-rim passage coverage. Emits one aggregate summary with proved-box and voxel counts. |
## 4. The density pipeline (most-edited hot path)
@@ -151,10 +151,9 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
TEXT("ClassifyTile verdicts over %d scanned tiles: Mixed %d, AllSolid %d, AllAir %d ")
TEXT("(brute-forcing %d of them). NOTE: cave archetypes always return Mixed today — see ")
TEXT("VoxelGenerator.cpp \"archétype cave [...] pas prouvable en v1\". A low non-Mixed count ")
TEXT("is expected and is exactly the tile-skipping prize OPSTACK-PLAN wants EffectOverBox ")
TEXT("to unlock."),
TEXT("(brute-forcing %d of them). Cave archetypes now use the opt-in operator-stack ")
TEXT("ClassifyBox fold; the global XY edge proof also skips proven outer-shell tiles. A low ")
TEXT("non-Mixed count is expected: every uncertain case remains Mixed for safety."),
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
if (ToVerify.Num() == 0)
@@ -315,10 +315,10 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// 1 source + 1 overhang + 3 structurels. L'op overhang est présent mais inerte ici
// 1 source + 1 overhang + 4 structurels. L'op overhang est présent mais inerte ici
// (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
TestEqual(TEXT("the surface density stack is source + overhang + 3 structural"),
Stack.Num(), 5);
TestEqual(TEXT("the surface density stack is source + overhang + 4 structural"),
Stack.Num(), 6);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -103,8 +103,8 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildFloatingIslandStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// void + blobs + roughness + fill + 3 structurels.
TestEqual(TEXT("the island stack is decomposed into 7 ops"), Stack.Num(), 7);
// void + blobs + roughness + fill + 4 structurels.
TestEqual(TEXT("the island stack is decomposed into 8 ops"), Stack.Num(), 8);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -140,8 +140,8 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
// La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les
// tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor (§2.5).
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 3 structural)"),
Stack.Num(), 7);
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 4 structural)"),
Stack.Num(), 8);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -91,8 +91,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildVerticalShaftStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + shafts + roughness + carve + ledges + 3 structurels.
TestEqual(TEXT("the shaft stack is decomposed into 8 ops"), Stack.Num(), 8);
// rock + shafts + roughness + carve + ledges + 4 structurels.
TestEqual(TEXT("the shaft stack is decomposed into 9 ops"), Stack.Num(), 9);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -103,9 +103,9 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 3 structurels.
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 3 structural"), SlotName),
Stack.Num(), 5);
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 4 structurels.
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 4 structural"), SlotName),
Stack.Num(), 6);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -464,11 +464,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + roomgraph + carve + **les 12 modificateurs de détail** + worms + 3 structurels = 19.
// rock + roomgraph + carve + **les 12 modificateurs de détail** + worms + 4 structurels = 20.
// L'étape C n'ajoute AUCUN opérateur — elle change ce que onze d'entre eux LISENT — donc ce
// nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une
// formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté.
TestEqual(TEXT("the stage-A+B tunnel stack is decomposed into 19 ops"), Stack.Num(), 19);
TestEqual(TEXT("the stage-A+B tunnel stack is decomposed into 20 ops"), Stack.Num(), 20);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -1408,7 +1408,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
UWCtx.StrateBottomWorldZ = UP.StrateBottomWorldZ;
UWStack.PrepareChunk(UWCtx);
TestEqual(TEXT("the Underwater stack is the tunnel stack: same 19 ops"), UWStack.Num(), 19);
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
// échantillons en grotte ouverte** là où le slot TunnelNetwork en avait 16,7 % : trop peu de
@@ -2471,6 +2471,228 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet
*ConnectivityProbeText(SeedReport.DepartureToSpine),
*ConnectivityProbeText(SeedReport.ArrivalToDeparture));
}
// PART 0: resolve the two remaining step-2 negatives without changing generation. The
// 12M cap is deliberately high enough for the 320x320x112 step-1/radius-160 fallback,
// but low enough that the requested 384x384x112 step-1/radius-192 attempt is explicitly
// reported as a MaxCells refusal rather than silently changing the measurement budget.
struct FSeedResolutionRow
{
int32 SampleStep = 0;
int32 RadiusInVoxels = 0;
bool bMaxCellsRefused = false;
FVoxelStrateMetrics Metrics;
FVoxelConnectivityDiagnostics Diagnostics;
};
static constexpr int32 ResolutionSeeds[] = {6, 14};
static constexpr FRefinementCase ResolutionCases[] = {
{2, 192},
{1, 192},
{2, 128},
{1, 128},
};
constexpr int32 ResolutionMaxCells = 12000000;
Summary += TEXT(
"PART 0 — residual VerticalShafts seed refinement (seeds 6 and 14; margin derived; "
"MaxCells=12000000):\n");
Summary += TEXT(
" seed | SampleStep | RadiusInVoxels | verdict | components | largest share | retries | "
"arrival mouth (x,y,z; component share) | departure mouth (x,y,z; component share)\n");
for (const int32 Seed : ResolutionSeeds)
{
FTestWorld ResolutionWorld;
ResolutionWorld.Build(Seed, /*InGapChunks=*/2);
if (!ResolutionWorld.IsValid())
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d could not build the VerticalShafts fixture."),
Seed));
bAllChecksPassed = false;
continue;
}
FVector ResolutionArrivalPoint = FVector::ZeroVector;
FVector ResolutionDeparturePoint = FVector::ZeroVector;
if (!FindChainMouths(
ResolutionWorld.StrateManager->GetPassages(),
VerticalShaftsIndex,
ResolutionArrivalPoint,
ResolutionDeparturePoint))
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d does not have exactly one arrival and "
"departure mouth."),
Seed));
bAllChecksPassed = false;
continue;
}
TArray<FSeedResolutionRow> ResolutionRows;
ResolutionRows.Reserve(UE_ARRAY_COUNT(ResolutionCases) + 1);
for (const FRefinementCase& TestCase : ResolutionCases)
{
FSeedResolutionRow& Row = ResolutionRows.AddDefaulted_GetRef();
Row.SampleStep = TestCase.SampleStep;
Row.RadiusInVoxels = TestCase.RadiusInVoxels;
FVoxelStrateMeasureSettings RowSettings = DiagnosticSettings;
RowSettings.SampleStep = TestCase.SampleStep;
RowSettings.RadiusInVoxels = TestCase.RadiusInVoxels;
RowSettings.MaxCells = ResolutionMaxCells;
RowSettings.InteriorMarginVoxels = -1;
Row.Metrics = VF_MeasureStrate(
*ResolutionWorld.Generator,
*ResolutionWorld.StrateManager,
VerticalShaftsIndex,
RowSettings);
const bool bMaxCellsRefusal = !Row.Metrics.bValid
&& Row.Metrics.RefusalReason.Contains(TEXT("MaxCells"));
Row.bMaxCellsRefused = bMaxCellsRefusal;
Row.Diagnostics = VF_DiagnoseConnectivity(
*ResolutionWorld.Generator,
*ResolutionWorld.StrateManager,
VerticalShaftsIndex,
ResolutionArrivalPoint,
ResolutionDeparturePoint,
RowSettings);
const bool bExpectedFallbackRefusal = TestCase.SampleStep == 1
&& TestCase.RadiusInVoxels == 192;
if (!Row.Metrics.bValid
&& !(bExpectedFallbackRefusal && bMaxCellsRefusal))
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d row step=%d radius=%d was not a "
"bounded measurement (metrics valid=%s reason='%s')."),
Seed,
Row.SampleStep,
Row.RadiusInVoxels,
Row.Metrics.bValid ? TEXT("true") : TEXT("false"),
*Row.Metrics.RefusalReason));
bAllChecksPassed = false;
}
if (Row.Metrics.bValid
&& !Row.Diagnostics.bValid
&& Row.Diagnostics.Result != EVoxelConnectivityResult::OutOfWindow)
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d row step=%d radius=%d produced an "
"invalid connectivity diagnostic with result %s."),
Seed,
Row.SampleStep,
Row.RadiusInVoxels,
ConnectivityResultName(Row.Diagnostics.Result)));
bAllChecksPassed = false;
}
}
// If the requested fine grid is over the cap, perform the specified radius-control
// substitution. This row is the one used to decide whether step refinement changes
// the verdict; the refused 192 row remains in the report as evidence of the cap.
if (ResolutionRows.IsValidIndex(1) && ResolutionRows[1].bMaxCellsRefused)
{
FSeedResolutionRow& FallbackRow = ResolutionRows.AddDefaulted_GetRef();
FallbackRow.SampleStep = 1;
FallbackRow.RadiusInVoxels = 160;
FVoxelStrateMeasureSettings FallbackSettings = DiagnosticSettings;
FallbackSettings.SampleStep = 1;
FallbackSettings.RadiusInVoxels = 160;
FallbackSettings.MaxCells = ResolutionMaxCells;
FallbackSettings.InteriorMarginVoxels = -1;
FallbackRow.Metrics = VF_MeasureStrate(
*ResolutionWorld.Generator,
*ResolutionWorld.StrateManager,
VerticalShaftsIndex,
FallbackSettings);
FallbackRow.Diagnostics = VF_DiagnoseConnectivity(
*ResolutionWorld.Generator,
*ResolutionWorld.StrateManager,
VerticalShaftsIndex,
ResolutionArrivalPoint,
ResolutionDeparturePoint,
FallbackSettings);
if (!FallbackRow.Metrics.bValid)
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d step=1 radius=160 fallback was not "
"a bounded measurement (metrics valid=%s reason='%s')."),
Seed,
FallbackRow.Metrics.bValid ? TEXT("true") : TEXT("false"),
*FallbackRow.Metrics.RefusalReason));
bAllChecksPassed = false;
}
if (FallbackRow.Metrics.bValid
&& !FallbackRow.Diagnostics.bValid
&& FallbackRow.Diagnostics.Result != EVoxelConnectivityResult::OutOfWindow)
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d step=1 radius=160 produced an "
"invalid connectivity diagnostic with result %s."),
Seed,
ConnectivityResultName(FallbackRow.Diagnostics.Result)));
bAllChecksPassed = false;
}
}
for (const FSeedResolutionRow& Row : ResolutionRows)
{
const bool bUsable = Row.Metrics.bValid && Row.Diagnostics.bValid;
const bool bOutOfWindow = Row.Diagnostics.Result == EVoxelConnectivityResult::OutOfWindow;
const FString Verdict = Row.bMaxCellsRefused
? TEXT("EXCEEDS_MAXCELLS")
: (Row.Metrics.bValid && bOutOfWindow)
? TEXT("OUT_OF_WINDOW")
: bUsable
? ConnectivityResultName(Row.Diagnostics.Result)
: TEXT("UNUSABLE");
const FString Components = Row.Metrics.bValid
? FString::Printf(TEXT("%d"), Row.Metrics.NumAirComponents)
: TEXT("N/A");
const FString LargestShare = Row.Metrics.bValid
? FString::Printf(TEXT("%.9g"), Row.Metrics.LargestComponentShare)
: TEXT("N/A");
const FString Retries = Row.Diagnostics.bValid || (Row.Metrics.bValid && bOutOfWindow)
? FString::Printf(TEXT("%d"), Row.Diagnostics.NumRouteRetries)
: TEXT("N/A");
const FString ArrivalShare = bUsable
? FString::Printf(TEXT("%.9g"), Row.Diagnostics.StartComponentShare)
: TEXT("N/A");
const FString DepartureShare = bUsable
? FString::Printf(TEXT("%.9g"), Row.Diagnostics.GoalComponentShare)
: TEXT("N/A");
Summary += FString::Printf(
TEXT(" %d | %d | %d | %s | %s | %s | %s | (%.3f, %.3f, %.3f; %s) | "
"(%.3f, %.3f, %.3f; %s)%s\n"),
Seed,
Row.SampleStep,
Row.RadiusInVoxels,
*Verdict,
*Components,
*LargestShare,
*Retries,
ResolutionArrivalPoint.X,
ResolutionArrivalPoint.Y,
ResolutionArrivalPoint.Z,
*ArrivalShare,
ResolutionDeparturePoint.X,
ResolutionDeparturePoint.Y,
ResolutionDeparturePoint.Z,
*DepartureShare,
Row.bMaxCellsRefused
? TEXT(" [requested row refused; use step=1/radius=160 below]")
: TEXT(""));
}
}
Summary += TEXT(
" Part 0 interpretation: step=1/radius=192 is attempted first; when it exceeds the "
"12M measurement cap, step=1/radius=160 is the prescribed radius control.\n");
}
else
{
@@ -79,6 +79,8 @@ namespace VoxelForgeTest
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
Settings->Seed = InSeed;
Settings->InterStrateGapChunks = InGapChunks;
// WorldRadiusVoxels/EdgeSealThickness intentionally inherit the UVoxelSettings defaults;
// WorldEdgeSeal overrides them explicitly when it needs a small test world or radius 0.
// Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize()
// mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z
@@ -0,0 +1,339 @@
// VoxelForgeWorldEdgeSealTest.cpp
// Falsifiable coverage for the bounded-world XY edge seal.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelDensityPrimitives.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeWorldEdgeSealTest,
"VoxelForge.Generation.WorldEdgeSeal",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr float TestWorldRadius = 256.0f;
constexpr float TestEdgeThickness = 64.0f;
constexpr float RimJumpThreshold = 1.0f;
void SetEdgeSettings(VoxelForgeTest::FTestWorld& World, float Radius)
{
World.Settings->WorldRadiusVoxels = Radius;
World.Settings->EdgeSealThickness = TestEdgeThickness;
World.Generator->InitializeSettings(World.Settings.Get());
}
void ConfigureNearRimPassages(VoxelForgeTest::FTestWorld& World)
{
for (TStrongObjectPtr<UVoxelStrateDefinition>& DefPtr : World.Definitions)
{
if (!DefPtr.IsValid()) continue;
FStratePassageConfig& Cfg = DefPtr->PassageConfig;
Cfg.Connections = 1;
Cfg.Style = EVoxelPassageStyle::Straight;
Cfg.MouthRadius = 6.0f;
Cfg.MidRadius = 6.0f;
Cfg.ReachMin = 40.0f;
Cfg.ReachMax = 40.0f;
Cfg.DistanceMin = 240.0f;
Cfg.DistanceMax = 240.0f;
Cfg.Wander = 0.0f;
Cfg.Segments = 4;
Cfg.VerticalWobble = 0.0f;
}
World.Reinitialize();
}
bool IsSolidMC(const UVoxelGenerator& Generator, const FVector& Point)
{
return Generator.GetDensityAt(Point.X, Point.Y, Point.Z) < 0.0f;
}
}
bool FVoxelForgeWorldEdgeSealTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
int32 NumFailures = 0;
FString FailureKinds;
auto Fail = [&](const TCHAR* Kind)
{
++NumFailures;
if (!FailureKinds.IsEmpty()) FailureKinds += TEXT(", ");
FailureKinds += Kind;
};
// The ordinary fixture deliberately uses the requested default, so existing op-stack and
// legacy tests exercise the setting without having to know about the new post.
FTestWorld Legacy;
Legacy.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
FTestWorld StackWorld;
StackWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
FTestWorld Unbounded;
Unbounded.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
if (!Legacy.IsValid() || !StackWorld.IsValid() || !Unbounded.IsValid())
{
AddError(Legacy.IsValid() && StackWorld.IsValid() && Unbounded.IsValid()
? TEXT("Unexpected edge-seal fixture state")
: (!Legacy.IsValid() ? Legacy.WhyInvalid()
: (!StackWorld.IsValid() ? StackWorld.WhyInvalid() : Unbounded.WhyInvalid())));
return false;
}
SetEdgeSettings(Legacy, TestWorldRadius);
SetEdgeSettings(StackWorld, TestWorldRadius);
SetEdgeSettings(Unbounded, 0.0f);
//==========================================================================
// POSITIVE CONTROL — far outside the radius, across several Z strata.
//==========================================================================
const FVector FarXY[] = {
FVector(384.0f, 0.0f, 0.0f),
FVector(-384.0f, 0.0f, 0.0f),
FVector(0.0f, 384.0f, 0.0f),
FVector(0.0f, -384.0f, 0.0f),
FVector(320.0f, 320.0f, 0.0f),
};
TArray<float> StrateZ;
for (int32 Slot = 0; Slot < 8; ++Slot)
{
int32 TopZ = 0, BottomZ = 0;
if (Legacy.GetSlotVoxelZRange(Slot, TopZ, BottomZ))
{
StrateZ.Add((float)((TopZ + BottomZ) / 2));
}
}
StrateZ.Add(Legacy.MidVoxelZ());
int32 NumPositiveSamples = 0;
for (const FVector& XY : FarXY)
{
for (const float Z : StrateZ)
{
const FVector P(XY.X, XY.Y, Z);
++NumPositiveSamples;
if (!IsSolidMC(*Legacy.Generator, P)) Fail(TEXT("positive-legacy"));
if (!IsSolidMC(*StackWorld.Generator, P)) Fail(TEXT("positive-opstack"));
}
}
//==========================================================================
// NEGATIVE CONTROL — the interior must be bit-identical to radius == 0.
//==========================================================================
const FVector InteriorXY[] = {
FVector(0.0f, 0.0f, 0.0f),
FVector(64.0f, -80.0f, 0.0f),
FVector(128.0f, 0.0f, 0.0f),
FVector(0.0f, -128.0f, 0.0f),
FVector(100.0f, 100.0f, 0.0f),
};
int32 NumInteriorComparisons = 0;
for (const FVector& XY : InteriorXY)
{
for (const float Z : StrateZ)
{
const FVector P(XY.X, XY.Y, Z);
const float BoundedD = Legacy.Generator->GetDensityAt(P.X, P.Y, P.Z);
const float UnboundedD = Unbounded.Generator->GetDensityAt(P.X, P.Y, P.Z);
++NumInteriorComparisons;
if (!BitEqual(BoundedD, UnboundedD)) Fail(TEXT("interior-not-no-op"));
}
}
//==========================================================================
// RIM SMOOTHNESS — measure solidness (the negative MC density magnitude), not its sign.
//==========================================================================
float MaxAdjacentSolidnessJump = 0.0f;
int32 NumRimDirections = 0;
const FVector RimDirections[] = {
FVector(1.0f, 0.0f, 0.0f),
FVector(0.0f, 1.0f, 0.0f),
FVector(-1.0f, 0.0f, 0.0f),
FVector(0.0f, -1.0f, 0.0f),
FVector(0.70710678f, 0.70710678f, 0.0f),
};
for (const FVector& Dir : RimDirections)
{
++NumRimDirections;
float PreviousSolidness = 0.0f;
bool bHasPrevious = false;
for (int32 R = (int32)(TestWorldRadius - TestEdgeThickness - 4.0f);
R <= (int32)(TestWorldRadius + 4.0f); ++R)
{
float InternalDensity = 0.0f;
VF_ApplyXYEdgeSeal(InternalDensity, Dir.X * (float)R, Dir.Y * (float)R,
TestWorldRadius, TestEdgeThickness, 8.0f);
const float Solidness = InternalDensity;
if (bHasPrevious)
{
MaxAdjacentSolidnessJump = FMath::Max(
MaxAdjacentSolidnessJump, FMath::Abs(Solidness - PreviousSolidness));
if (Solidness + KINDA_SMALL_NUMBER < PreviousSolidness)
Fail(TEXT("rim-not-monotonic"));
}
PreviousSolidness = Solidness;
bHasPrevious = true;
}
}
if (MaxAdjacentSolidnessJump > RimJumpThreshold) Fail(TEXT("rim-discontinuity"));
// Explicitly verify the true no-op contract, including the MC-facing wrapper.
float NoOpDensity = 3.25f;
const float NoOpBefore = NoOpDensity;
VF_ApplyXYEdgeSealMC(NoOpDensity, 100000.0f, -100000.0f,
0.0f, TestEdgeThickness, 8.0f);
if (!BitEqual(NoOpDensity, NoOpBefore)) Fail(TEXT("radius-zero-not-no-op"));
//==========================================================================
// CLASSIFYBOX — force boxes in the ramp and beyond the radius, then brute-force every voxel.
//==========================================================================
int32 TopShaftZ = 0, BottomShaftZ = 0;
if (!StackWorld.GetSlotVoxelZRange(FTestWorld::SlotVerticalShafts, TopShaftZ, BottomShaftZ))
{
Fail(TEXT("missing-shaft-slot"));
}
int32 NumBoxesProved = 0;
int64 NumVoxelsChecked = 0;
if (TopShaftZ > BottomShaftZ)
{
const float BoxZ0 = (float)(BottomShaftZ + 8);
const float BoxZ1 = (float)FMath::Min(BottomShaftZ + 10, TopShaftZ - 8);
const FBox ProvedBoxes[] = {
// Ramp boxes: closest radius is 240, safely past the 1-voxel proof margin.
FBox(FVector(240.0f, -2.0f, BoxZ0), FVector(244.0f, 2.0f, BoxZ1)),
FBox(FVector(-244.0f, -2.0f, BoxZ0), FVector(-240.0f, 2.0f, BoxZ1)),
FBox(FVector(-2.0f, 240.0f, BoxZ0), FVector(2.0f, 244.0f, BoxZ1)),
FBox(FVector(170.0f, 170.0f, BoxZ0), FVector(174.0f, 174.0f, BoxZ1)),
// Forced-solid boxes beyond the world radius.
FBox(FVector(320.0f, -2.0f, BoxZ0), FVector(324.0f, 2.0f, BoxZ1)),
FBox(FVector(-324.0f, -2.0f, BoxZ0), FVector(-320.0f, 2.0f, BoxZ1)),
FBox(FVector(-2.0f, 320.0f, BoxZ0), FVector(2.0f, 324.0f, BoxZ1)),
FBox(FVector(226.0f, 226.0f, BoxZ0), FVector(230.0f, 230.0f, BoxZ1)),
};
const int32 MidChunkZ = FMath::FloorToInt((BoxZ0 + BoxZ1) * 0.5f / CHUNK_SIZE);
const FVerticalShaftParams ShaftParams = StackWorld.StrateManager->GetVerticalShaftParamsForChunk(
FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildVerticalShaftStack(
Stack, ShaftParams, StackWorld.Settings->Seed,
StackWorld.Generator->OriginSpineRadius, StackWorld.StrateManager.Get());
FVoxelOpContext Ctx;
Ctx.ChunkCoord = FIntVector(0, 0, MidChunkZ);
Ctx.Seed = (uint32)StackWorld.Settings->Seed;
Ctx.LayoutVersion = StackWorld.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = ShaftParams.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = ShaftParams.StrateBottomWorldZ;
Ctx.WorldRadiusVoxels = TestWorldRadius;
Ctx.EdgeSealThickness = TestEdgeThickness;
Stack.PrepareChunk(Ctx);
for (const FBox& Box : ProvedBoxes)
{
if (Stack.ClassifyBox(Box, Ctx) != EVoxelTileClass::AllSolid)
{
Fail(TEXT("classifybox-missed-forced-band"));
continue;
}
++NumBoxesProved;
const int32 X0 = FMath::CeilToInt(Box.Min.X);
const int32 X1 = FMath::FloorToInt(Box.Max.X);
const int32 Y0 = FMath::CeilToInt(Box.Min.Y);
const int32 Y1 = FMath::FloorToInt(Box.Max.Y);
const int32 Z0 = FMath::CeilToInt(Box.Min.Z);
const int32 Z1 = FMath::FloorToInt(Box.Max.Z);
for (int32 Z = Z0; Z <= Z1; ++Z)
for (int32 Y = Y0; Y <= Y1; ++Y)
for (int32 X = X0; X <= X1; ++X)
{
++NumVoxelsChecked;
if (!IsSolidMC(*StackWorld.Generator, FVector((float)X, (float)Y, (float)Z)))
Fail(TEXT("classifybox-false-allsolid"));
}
}
}
// The T1.d path must be able to skip an edge tile without knowing which Z category it hits.
const EVoxelTileClass EdgeTile = StackWorld.Generator->ClassifyTile(
FIntVector(320, 0, BottomShaftZ + 8), 1, 8);
int32 NumTilesProved = (EdgeTile == EVoxelTileClass::AllSolid) ? 1 : 0;
if (EdgeTile != EVoxelTileClass::AllSolid) Fail(TEXT("classifytile-missed-edge"));
//==========================================================================
// PASSAGE ORDER — a near-rim passage carve is followed by the edge force.
//==========================================================================
float NearRimInternalDensity = 8.0f;
VF_ApplyPassageCarving(NearRimInternalDensity, 0.0f, 8.0f, TestEdgeThickness);
const float PassageOnlyDensity = NearRimInternalDensity;
VF_ApplyXYEdgeSeal(NearRimInternalDensity, TestWorldRadius - 16.0f, 0.0f,
TestWorldRadius, TestEdgeThickness, 8.0f);
int32 NumNearRimPassageChecks = 1;
if (!(PassageOnlyDensity < 0.0f && NearRimInternalDensity > 0.0f))
Fail(TEXT("passage-breaches-rim"));
// Try the same construction through the real deterministic passage generator. It is diagnostic
// coverage only: the synthetic helper case above is the hard assertion even if landing-site
// adjustment moves every generated mouth out of this small test rim.
FTestWorld GeneratedPassageWorld;
GeneratedPassageWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
FTestWorld GeneratedPassageStackWorld;
GeneratedPassageStackWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
auto CheckGeneratedPassages = [&](FTestWorld& World, const TCHAR* FailureKind)
{
if (!World.IsValid())
{
Fail(FailureKind);
return;
}
ConfigureNearRimPassages(World);
SetEdgeSettings(World, TestWorldRadius);
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
{
TArray<FVector> Candidates = Passage.ControlPoints;
Candidates.Add(Passage.UpperPoint);
Candidates.Add(Passage.LowerPoint);
for (const FVector& P : Candidates)
{
if (P.X * P.X + P.Y * P.Y <= FMath::Square(TestWorldRadius - TestEdgeThickness)) continue;
++NumNearRimPassageChecks;
if (!IsSolidMC(*World.Generator, P)) Fail(FailureKind);
break;
}
}
};
CheckGeneratedPassages(GeneratedPassageWorld, TEXT("generated-passage-breaches-rim"));
CheckGeneratedPassages(GeneratedPassageStackWorld, TEXT("generated-opstack-passage-breaches-rim"));
// One sorted summary block; individual samples intentionally never log.
AddInfo(FString::Printf(
TEXT("WORLD_EDGE_SEAL SUMMARY\n")
TEXT(" classify_box_proved=%d\n")
TEXT(" classify_tile_proved=%d\n")
TEXT(" interior_comparisons=%d\n")
TEXT(" near_rim_passage_checks=%d\n")
TEXT(" positive_sample_points=%d (checked in both paths)\n")
TEXT(" rim_directions=%d\n")
TEXT(" rim_max_adjacent_solidness_jump=%.6f (threshold=%.6f)\n")
TEXT(" voxels_checked=%lld\n")
TEXT(" failures=%d (%s)"),
NumBoxesProved, NumTilesProved, NumInteriorComparisons, NumNearRimPassageChecks,
NumPositiveSamples, NumRimDirections, MaxAdjacentSolidnessJump, RimJumpThreshold,
(long long)NumVoxelsChecked, NumFailures, *FailureKinds));
if (NumFailures > 0)
{
AddError(FString::Printf(TEXT("WorldEdgeSeal aggregate checks failed: %d (%s)"),
NumFailures, *FailureKinds));
}
return NumFailures == 0;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -2,7 +2,7 @@
// Les opérateurs concrets de la Phase 1 : la décomposition de Maze + le post-traitement structurel.
// The concrete Phase 1 operators: the Maze decomposition + the structural post-process.
//
// ⚠️ AUCUN de ces opérateurs n'alimente le jeu. Voir l'en-tête de VoxelDensityOpStack.h.
// Ces opérateurs alimentent le jeu derrière l'opt-in décrit dans l'en-tête de VoxelDensityOpStack.h.
//
// FIDÉLITÉ / FIDELITY
// Chaque corps ci-dessous est une transcription LITTÉRALE du bloc correspondant de
@@ -20,7 +20,7 @@
#include "VoxelDensityOpStack.h"
#include "VoxelDensityPrimitives.h" // VF_ApplyOriginSpine / Seal / PassageCarving
#include "VoxelDensityPrimitives.h" // VF_ApplyOriginSpine / seals / PassageCarving
#include "VoxelCaveMorphology.h" // VoxelSDF::Capsule, VoxelHash
#include "VoxelGenerator.h" // VoxelGenLOD::Eff
#include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks
@@ -1406,6 +1406,65 @@ namespace
float TopZ, BotZ, Thickness, Base;
};
//=========================================================================
// RÔLE 4 — STRUCTUREL : SCELLEMENT DE LA LIMITE XY (opérateur FORÇANT)
//=========================================================================
class FXYEdgeSealOp final : public IVoxelDensityOp
{
public:
explicit FXYEdgeSealOp(float InBase)
: Base(InBase) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; }
void PrepareChunk(const FVoxelOpContext& Ctx) override
{
// The settings are global, but PrepareChunk is the stack's one per-chunk hand-off.
// Keeping the prepared copy makes Eval hot and keeps the op independent of the asset.
WorldRadius = Ctx.WorldRadiusVoxels;
Thickness = Ctx.EdgeSealThickness;
}
void Eval(float X, float Y, float, FVoxelOpSample& InOut) const override
{
VF_ApplyXYEdgeSeal(InOut.Density, X, Y, WorldRadius, Thickness, Base);
}
/**
* The seal is forcing, not merely FillOnly: once every point in the box is at least one
* voxel into the ramp (or beyond the radius), the result is positive-solid regardless of
* all preceding source/carve operators.
*/
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
return VF_XYEdgeSealBoxIsForcedSolid(
VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness, Base)
? EVoxelTileClass::AllSolid : EVoxelTileClass::Mixed;
}
float ForcedMarginOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
return VF_XYEdgeSealForcedMarginOverBox(
VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness, Base);
}
// In the inner world it is Identity; a box that reaches the radial band can only gain rock.
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
return VF_XYEdgeSealBoxTouchesBand(
VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness)
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
bool IsXYPure() const override { return true; }
const TCHAR* DebugName() const override { return TEXT("XYEdgeSealOp"); }
private:
float Base = 8.0f;
mutable float WorldRadius = 8192.0f;
mutable float Thickness = 64.0f;
};
//=========================================================================
// RÔLE 4 — STRUCTUREL : CARVE DE PASSAGE
//=========================================================================
@@ -4294,12 +4353,15 @@ void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBott
float SealThickness, float BaseDensity, float SpineRadius,
const UVoxelStrateManager* StrateManager)
{
// ORDRE NON NÉGOCIABLE, et c'est l'ordre que les six fonctions de densité utilisent déjà :
// la spine creuse l'intérieur (et ne touche JAMAIS les bandes de seal), le seal re-solidifie
// ses bandes, les passages percent tout — seal compris —, le joueur gagne en dernier.
// ORDRE NON NÉGOCIABLE : la spine creuse l'intérieur (et ne touche JAMAIS les bandes de seal),
// le seal vertical re-solidifie ses bandes, les passages percent les seals, puis la limite XY
// gagne sur tout ce qui précède. Ainsi, même un passage placé dans la rampe ou au-delà du rayon
// ne peut pas ouvrir la coque extérieure. Les éditions joueur restent le dernier post de
// GetDensityAt, hors de cette pile, comme avant.
Add(MakeUnique<FOriginSpineOp>(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity, SpineRadius));
Add(MakeUnique<FBoundarySealOp>(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity));
Add(MakeUnique<FPassageCarveOp>(StrateManager, BaseDensity, SealThickness));
Add(MakeUnique<FXYEdgeSealOp>(BaseDensity));
}
//=============================================================================
+58 -12
View File
@@ -13,7 +13,7 @@
#include "VoxelDiffLayer.h"
#include "VoxelBiomeDefinition.h"
#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core
#include "VoxelDensityPrimitives.h" // spine / seal / passage — shared with the operator stack
#include "VoxelDensityPrimitives.h" // spine / seals / passage — shared with the operator stack
#include "VoxelDensityOpStack.h" // OPSTACK Phase 1: the opt-in per-strate operator stack
#include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it
#include "VoxelStats.h"
@@ -231,13 +231,13 @@ static float CellularNoise3D(const FVector& Position)
//=============================================================================
// Les CORPS de ces trois helpers ont déménagé dans Public/VoxelDensityPrimitives.h : la pile
// d'opérateurs (VoxelDensityOpStack) a besoin exactement des mêmes, et deux copies de trois
// INVARIANTS de monde (descente possible, seals qui tiennent, passages qui percent) finiraient par
// d'opérateurs (VoxelDensityOpStack) a besoin exactement des mêmes, et deux copies des INVARIANTS
// de monde (descente possible, seals qui tiennent, passages qui percent, bord fermé) finiraient par
// diverger. Ces trois lignes gardent les noms locaux pour que les ~20 sites d'appel ci-dessous ne
// bougent pas d'un caractère — le déplacement ne change AUCUN comportement.
//
// The BODIES moved to Public/VoxelDensityPrimitives.h; the operator stack needs the same three, and
// two copies of three world invariants would eventually drift. These forwarders keep the local names
// The BODIES moved to Public/VoxelDensityPrimitives.h; the operator stack needs the same primitives,
// and duplicate world invariants would eventually drift. These forwarders keep the local names
// so not one of the ~20 call sites below changes. No behavioural change.
//
// Convention INTERNE ici : positif = SOLIDE. La négation vers MC se fait sur le `return`.
@@ -558,10 +558,12 @@ UVoxelGenerator::UVoxelGenerator()
void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
{
// Seul le seed est copié ici. Tout le reste (params de cave, transitions,
// blendings) vient des strate definitions via le StrateManager.
// Les paramètres globaux sont copiés ici une seule fois : le chemin voxel ne doit pas
// déréférencer l'asset de settings.
Seed = Settings ? Settings->Seed : 0;
OriginSpineRadius = Settings ? Settings->OriginSpineRadius : 14.0f;
WorldRadiusVoxels = Settings ? Settings->WorldRadiusVoxels : 0.0f;
EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.0f;
}
float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) const
@@ -580,10 +582,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
{
// SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel
// through it, but the (0,0) descent stays solid here so the player digs the gap
// to reach the next layer. No caves, no spine, no seal — just rock + passages.
// to reach the next layer. No caves, no spine, no vertical seal — just rock + passages;
// the global XY edge seal is applied below for bounded worlds.
float Density = 8.0f; // bedrock solidity (positive = solid)
const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ);
ApplyPassageCarving(Density, ModSDF, 8.0f, 0.0f);
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f);
Result = -Density;
}
else if (StrateManager)
@@ -687,6 +691,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
OpCtx.ChunkCoord = ChunkCoord;
OpCtx.Seed = (uint32)Seed;
OpCtx.LayoutVersion = LayoutVersion;
OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
OpCtx.EdgeSealThickness = EdgeSealThickness;
// ⚠️ LE MAPPING VIT DANS `VF_BuildOpStackForChunk` (haut de ce fichier) ET NULLE
// PART AILLEURS — `ClassifyTile` appelle la MÊME fabrique. Un verdict de tuile issu
@@ -807,6 +813,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0);
}
// The edge is the final generated structural invariant. Passage carving is ordered before it
// in every archetype/stack, and this MC-facing pass also covers out-of-layout air plus any
// disturbance that might otherwise carve back into the shell. Player edits remain separate
// below and are deliberately still the user override path.
VF_ApplyXYEdgeSealMC(Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f);
//=========================================================================
// PLAYER MODIFICATIONS (diff layer)
//=========================================================================
@@ -873,7 +885,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
// 4h. Pinch/Bottleneck: ellipsoidal passage narrowing for chokepoints
// 5. Worm tunnels: additional organic connectivity
// 6. Boundary seal: solid rock at strate top/bottom
// 7. Modifiers: passages between strates (punch through seals)
// 7. Modifiers: passages between strates (punch through vertical seals)
// 8. XY edge seal: global bounded-world shell wins over passages at the rim
//
// Convention: positive density = solid, negative = air (internally).
// At the end, we negate for the MC table (negative = solid there).
@@ -1945,6 +1958,10 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
// Fourth structural post: the XY edge wins over a passage near the rim.
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
// Convention MC: négatif = solide, positif = air.
// La logique interne utilise positif = solide (plus lisible), donc on négate.
return -Density;
@@ -2146,7 +2163,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
}
//=========================================================================
// STEP 5: BOUNDARY SEAL + STEP 6: PASSAGES
// STEP 5: BOUNDARY SEAL + STEP 6: PASSAGES + STEP 7: XY EDGE SEAL
//=========================================================================
// Même logique que TunnelNetwork — factorisée dans les helpers ci-dessus.
ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
@@ -2163,6 +2180,9 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
// Convention MC: négatif = solide.
return -Density;
}
@@ -2276,6 +2296,9 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ,
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}
@@ -2536,6 +2559,9 @@ float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, floa
ApplyPassageCarving(Density, ModSDF, S.BaseDensity, S.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, S.BaseDensity);
return -Density;
}
@@ -2681,8 +2707,6 @@ bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 Chunk
EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const
{
if (!StrateManager) return EVoxelTileClass::Mixed;
// Mêmes clamps que GenerateMesh — le verdict doit couvrir le treillis réellement échantillonné.
Step = FMath::Max(1, Step);
const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE);
@@ -2708,6 +2732,20 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (DiffLayer->HasAnyModInChunkRange(MinChunk, MaxChunk)) return EVoxelTileClass::Mixed;
}
// The bounded shell is global: it also covers gaps and out-of-layout air, so prove it before
// consulting the strate layout. The final MC edge pass uses a positive base, hence 1.0 is a
// deliberately conservative proof input here; the stack op performs the same proof with the
// archetype base for its own sub-box.
const FBox TileVoxelBox(FVector((float)MinX, (float)MinY, (float)MinZ),
FVector((float)MaxX, (float)MaxY, (float)MaxZ));
if (VF_XYEdgeSealBoxIsForcedSolid(TileVoxelBox, WorldRadiusVoxels,
EdgeSealThickness, 1.0f))
{
return EVoxelTileClass::AllSolid;
}
if (!StrateManager) return EVoxelTileClass::Mixed;
bool bCanSolid = true; // "tout le treillis est solide" encore prouvable
bool bCanAir = true; // "tout le treillis est air" encore prouvable
@@ -3140,6 +3178,8 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
OpCtx.Seed = (uint32)Seed;
OpCtx.LayoutVersion = TC_LayoutVersion;
OpCtx.Step = Step;
OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
OpCtx.EdgeSealThickness = EdgeSealThickness;
FVoxelOpStack TileStack;
if (!VF_BuildOpStackForChunk(CaveType, Refs, Seed, OriginSpineRadius,
@@ -3940,6 +3980,9 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}
@@ -4097,5 +4140,8 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}
+24 -20
View File
@@ -31,7 +31,7 @@
// 2. COMBINER — comment deux champs fusionnent (min/max/smooth/mask). C'EST le rôle qui
// achète la composition ; sans lui il n'y a pas de refactor.
// 3. DETAIL MODIFIER — l'ancien système, rétrogradé à un rôle sur quatre. Inchangé.
// 4. STRUCTURAL POST — spine (0,0) → seal → passages → diff layer. Des INVARIANTS de monde,
// 4. STRUCTURAL POST — spine (0,0) → seal vertical → passages → seal XY → diff layer. Des INVARIANTS de monde,
// pas des choix créatifs : toujours ajoutés, dans cet ordre, jamais
// omissibles par l'auteur.
//
@@ -60,27 +60,23 @@
// LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui
// THE PERF PRIZE: cave strates skip ZERO tiles today
// ─────────────────────────────────────────────────────────────────────────────────────────
// ClassifyTile ne sait prouver que les gaps de bedrock et SurfaceWorld ; tout le reste tombe sur
// `return EVoxelTileClass::Mixed; // archétype cave […] pas prouvable en v1`. TunnelNetwork, Maze,
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent donc RIEN du
// gain T1.d (84 % des générations vides, −44 % de CPU worker). Écrire un prouveur sur mesure par
// archétype a toujours été trop cher — EffectOverBox EST le mécanisme générique qui le rend gratuit :
// une source à graphe de salles qui rend Identity quand aucune borne de salle ni de tunnel n'atteint
// la boîte rend le bedrock profond sautable pour la première fois.
// ClassifyTile conserve ses preuves exactes pour les gaps de bedrock et SurfaceWorld ; les
// archétypes de cave opt-in passent maintenant par le même pliage `ClassifyBox`. Le post XY fournit
// en plus la première preuve globale de coque : une tuile entièrement dans la bande forcée peut être
// sautée sans connaître l'archétype ou le Z.
//
// **Traiter cela comme un livrable explicite de chaque portage, pas comme un effet de bord.**
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// ÉTAT / STATUS
// ─────────────────────────────────────────────────────────────────────────────────────────
// Phase 1. Le premier archétype (Maze) EST porté, dans VoxelDensityOpStack.{h,cpp} — mais
// **GetDensityAt et ClassifyTile ne sont PAS touchés** : le `switch` reste le seul chemin qui
// alimente le jeu. La pile est validée par un test qui la compare à GetMazeDensity point par point.
// Le branchement dans GetDensityAt attend un build vert.
// Les huit archétypes ont maintenant une pile derrière l'opt-in `bUseOperatorStack`, et ClassifyTile
// utilise cette pile pour les slots de cave opt-in. Le chemin legacy reste disponible pour les
// strates non opt-in; les tests d'équivalence servent de garde de portage.
//
// Phase 1. Maze IS ported (VoxelDensityOpStack.{h,cpp}) but **GetDensityAt and ClassifyTile are NOT
// touched** — the switch is still the only path feeding the game. The stack is validated by a test
// that compares it to GetMazeDensity point by point. Wiring it in waits for a green build.
// All eight archetypes now have an operator-stack path behind `bUseOperatorStack`, and ClassifyTile
// uses that stack for opt-in cave slots. The legacy switch remains available for opt-out strates;
// equivalence tests are port-correctness guards.
//
// NOTE sur les UENUM : ces types sont volontairement du C++ nu (pas d'UHT, pas de .generated.h).
// Ils deviendront UENUM/USTRUCT en Phase 3, quand les opérateurs deviendront des data assets et
@@ -99,7 +95,7 @@ struct FBiomeContext;
//=============================================================================
// Le rôle n'est pas décoratif : le compilateur de pile s'en sert pour ORDONNER. Les
// StructuralPost sont toujours ajoutés en dernier, dans l'ordre fixe spine → seal → passage →
// diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit
// seal XY → diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit
// rester possible, les seals doivent tenir, les passages doivent percer, les éditions du joueur
// gagnent toujours.
enum class EVoxelOpRole : uint8
@@ -210,6 +206,12 @@ struct FVoxelOpContext
// DOIT faire partie de toute clé de cache. Voir AUDIT C2.
uint32 LayoutVersion = 0;
// Global XY world-edge invariant. These are copied from UVoxelSettings by the generator and
// travel with the chunk context so the automatically appended edge op cannot be omitted by a
// builder. Radius 0 is the legacy unbounded/no-op mode.
float WorldRadiusVoxels = 8192.0f;
float EdgeSealThickness = 64.0f;
// Bornes Z de la strate en coords VOXEL (pas cm).
float StrateTopWorldZ = 0.0f;
float StrateBottomWorldZ = 0.0f;
@@ -386,8 +388,8 @@ public:
*
* Deux familles répondent autre chose que Mixed :
* • les SOURCES (rôle 1) — elles posent le champ, donc elles le savent par construction ;
* • les op STRUCTURELS forçants — typiquement ApplyBoundarySeal, qui à l'intérieur de sa
* bande fait `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est
* • les op STRUCTURELS forçants — ApplyBoundarySeal et ApplyXYEdgeSeal — qui, dans leur bande
* prouvée, font `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est
* solide garanti quoi qu'il y ait eu avant. C'est exactement ce que ClassifyTile encode
* aujourd'hui avec « bande de seal ⇒ bCanAir = false » — et un simple FillOnly ne suffirait
* PAS à le reproduire (voir VF_FoldOp plus bas).
@@ -397,8 +399,8 @@ public:
* échantillonnant ses colonnes sur le treillis exact, exactement comme aujourd'hui.
*
* FORCING OPS. Some ops do not *move* the input density, they *overwrite* it. Default Mixed =
* "I don't know", always safe. The boundary seal is the non-source example, and it is the
* reason this method exists at all rather than being folded into EffectOverBox.
* "I don't know", always safe. The vertical and XY edge seals are the non-source examples, and
* they are the reason this method exists at all rather than being folded into EffectOverBox.
*/
virtual EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
@@ -451,6 +453,8 @@ public:
// ApplyOriginSpine → CarveOnly (tue AllSolid) ≡ le test cercle/boîte XY
// ApplyBoundarySeal → ClassifyBox = AllSolid DANS sa bande (opérateur forçant),
// FillOnly ailleurs ≡ « bande de seal ⇒ bCanAir=false »
// ApplyXYEdgeSeal → ClassifyBox = AllSolid DANS sa bande radiale (forçant) ; il gagne
// après les passages, donc la coque extérieure reste fermée.
// disturbances chasms → CarveOnly ≡ « ChasmDensity > 0 ⇒ bCanSolid=false »
// disturbances ponts/arêtes → FillOnly ≡ « Bridge/RidgeDensity > 0 ⇒ bCanAir=false »
// diff layer → Both si des mods touchent la boîte, sinon Identity
+24 -21
View File
@@ -6,15 +6,16 @@
// `UVoxelGenerator::GetDensityAt` construit la pile par chunk et l'évalue à la place du `switch`
// UNIQUEMENT quand `UVoxelStrateManager::UsesOperatorStackForChunk` rend true — c.-à-d. quand la
// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés :
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands (6 sur 8)**.
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork,
// Underwater (8 sur 8)**.
// Toute autre strate passe encore par le `switch`, inchangé.
// `ClassifyTile` n'est PAS branché : il utilise toujours ses gardes écrites à la main, pas
// `ClassifyBox`. C'est la Phase 2.
// `ClassifyTile` est branché pour les archétypes de cave opt-in : il construit la même pile et
// plie `ClassifyBox`; les gaps et SurfaceWorld gardent leurs preuves exactes dédiées.
//
// THIS FEEDS THE GAME, BUT ONLY BEHIND AN OPT-IN. GetDensityAt builds the stack per chunk and
// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate ticked
// bUseOperatorStack AND its archetype ported — 6 of 8). ClassifyTile is NOT wired: it still uses its
// hand-written guards rather than ClassifyBox. That is Phase 2.
// bUseOperatorStack AND its archetype is ported — all 8). ClassifyTile uses the same stack for cave
// archetypes; gaps and SurfaceWorld retain their exact hand-written proofs.
//
// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde.
// ⚠️ EN REVANCHE, LES COMPARER EST DEVENU LÉGITIME — cette ligne disait l'inverse et elle est
@@ -43,7 +44,7 @@ class UVoxelStrateManager;
* FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte.
*
* PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et
* l'ordre spine → seal → passage est garanti par cette fonction, pas par l'auteur. Un auteur ne
* l'ordre spine → seal → passage → seal XY est garanti par cette fonction, pas par l'auteur. Un auteur ne
* peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester
* possible, les seals doivent tenir, les passages doivent percer).
*
@@ -122,7 +123,7 @@ public:
/**
* Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile.
* Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un
* opérateur FORÇANT (le seal dans sa bande) écrase ce que la pile avait conclu avant lui.
* opérateur FORÇANT (les seals dans leurs bandes) écrase ce que la pile avait conclu avant lui.
*/
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
@@ -130,7 +131,8 @@ public:
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops)
{
VF_FoldOp(H, *Op, VoxelBox, Ctx);
if (H.IsDead()) { return EVoxelTileClass::Mixed; } // early-out : plus rien à prouver
// 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).
}
return H.Resolve();
}
@@ -138,8 +140,8 @@ public:
/**
* 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
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même ordre,
* y compris le fait de laisser un post forçant ultérieur ressusciter une hypothèse morte. 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.
*
@@ -148,8 +150,8 @@ public:
* 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.
* ClassifyBox, including later forcing posts that may overwrite a dead hypothesis — 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
@@ -168,7 +170,6 @@ public:
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();
}
@@ -181,7 +182,7 @@ public:
/**
* 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.
* spine (0,0) → seal de frontière → carve de passage → seal de limite XY.
*
* ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la
* négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront
@@ -192,6 +193,8 @@ public:
* It joins the stack when disturbances are ported. Harmless while the stack feeds nothing.
*
* @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel).
* Le seal XY reste toujours présent ; son rayon/thickness viennent de Ctx
* au moment de PrepareChunk (0 = monde non borné).
*/
VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
float SealThickness, float BaseDensity, float SpineRadius,
@@ -286,7 +289,7 @@ namespace VoxelDensityOps
/**
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
* SlabVoidSource → GridColumnMod → [structural post ×3]
* SlabVoidSource → GridColumnMod → [structural post ×4]
*
* C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes
* disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans
@@ -297,8 +300,8 @@ namespace VoxelDensityOps
const UVoxelStrateManager* StrateManager);
/**
* VerticalShafts — 8 ops, et **TROIS viennent de Maze sans une ligne de changement** :
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge → [structural post ×3]
* VerticalShafts — 9 ops, et **TROIS viennent de Maze sans une ligne de changement** :
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge → [structural post ×4]
*
* C'est la démonstration que `§2.5` promettait : dans le `switch`, Maze et VerticalShafts sont
* deux fonctions de ~100 lignes sans rien de commun à l'œil ; en opérateurs, ce sont les mêmes
@@ -310,10 +313,10 @@ namespace VoxelDensityOps
const UVoxelStrateManager* StrateManager);
/**
* TunnelNetwork — **COMPLET, 19 ops** :
* TunnelNetwork — **COMPLET, 20 ops** :
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×3]
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×4]
*
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
* `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et
@@ -363,8 +366,8 @@ namespace VoxelDensityOps
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
/**
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
* FloatingIslands — 8 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×4]
*
* Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et
* REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et
+151 -18
View File
@@ -1,27 +1,26 @@
// VoxelDensityPrimitives.h
// Les trois post-traitements STRUCTURELS partagés par chaque archétype.
// The three STRUCTURAL post-processes every archetype shares.
// Les quatre post-traitements STRUCTURELS partagés par chaque archétype.
// The four STRUCTURAL post-processes every archetype shares.
//
// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS
// Ces trois fonctions étaient `static` dans VoxelGenerator.cpp et appelées à l'identique par les six
// fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici : UNE copie,
// partagée par le générateur et par les opérateurs. Dupliquer serait garantir qu'elles divergent —
// et ce sont des INVARIANTS de monde (la descente doit rester possible, les seals doivent tenir, les
// passages doivent percer), pas des choix créatifs.
// Les trois primitives verticales étaient `static` dans VoxelGenerator.cpp et appelées à l'identique
// par les six fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici :
// UNE copie partagée. Le scellement XY est ajouté dans ce même fichier pour que les deux chemins
// partagent aussi la nouvelle invariance — les invariants de monde ne sont pas des choix créatifs.
//
// They were `static` in VoxelGenerator.cpp and called identically by all six density functions. The
// operator stack needs the same ones, so they move here: ONE copy, shared. Duplicating would
// guarantee divergence, and these are world INVARIANTS, not creative choices.
// The three vertical primitives were `static` in VoxelGenerator.cpp and called identically by all six
// density functions. The operator stack needs the same ones, so they move here: ONE shared copy. The
// XY seal is added beside them so both paths share the new invariant too.
//
// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin.
// Ces trois fonctions travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
// Ces primitives travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la
// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`.
// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC
// convention happens ONCE, at the caller's return.
//
// Aucun changement de comportement en les déplaçant : corps identiques, FORCEINLINE au lieu de
// static, mêmes appelants. / No behavioural change: identical bodies, FORCEINLINE instead of static.
// Les trois corps déplacés restent identiques; le quatrième est la nouvelle frontière globale.
// The three moved bodies remain identical; the fourth is the new global boundary.
#pragma once
@@ -117,14 +116,148 @@ FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY,
}
//=============================================================================
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
// XY EDGE SEAL / SCELLEMENT DE LA LIMITE XY
//=============================================================================
// Les constantes de blend ci-dessus (3.0 pour la spine, 4.0 pour les passages) sont dupliquées à la
// main dans ClassifyTile aujourd'hui. Les nommer ici pour qu'un futur test de bornes ne puisse pas
// les désynchroniser. / The blend constants above are hand-duplicated inside ClassifyTile today.
// Naming them here so a future bounds test cannot let the two drift apart.
// The bounded-world rim is a radial solid ramp in actor-space XY. This helper uses the same
// internal convention as VF_ApplyBoundarySeal (positive = solid); the MC-facing wrapper below
// negates it so the output convention remains negative = solid.
//
// Radius == 0 is deliberately checked before any arithmetic: it is a true legacy no-op, not a
// nearly-invisible setting-dependent perturbation. The common interior case also returns from a
// squared-distance test and pays no sqrt.
FORCEINLINE void VF_ApplyXYEdgeSeal(float& Density, float WorldX, float WorldY,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
if (!(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f) || !(BaseDensity > 0.0f)) return;
if (!FMath::IsFinite(WorldX) || !FMath::IsFinite(WorldY)
|| !FMath::IsFinite(WorldRadiusVoxels) || !FMath::IsFinite(Thickness)
|| !FMath::IsFinite(BaseDensity)) return;
const float InnerRadius = WorldRadiusVoxels - Thickness;
const float InnerRadiusSq = InnerRadius * InnerRadius;
const float DistSq = WorldX * WorldX + WorldY * WorldY;
const float RadiusSq = WorldRadiusVoxels * WorldRadiusVoxels;
// The overwhelmingly common case: well inside the bounded world.
if (InnerRadius > 0.0f && DistSq <= InnerRadiusSq) return;
// Keep the outer-boundary comparison in squared space too. This makes the exact force branch
// and the ClassifyBox proof use the same predicate: at or beyond R the result is BaseDensity,
// with no sqrt-rounding ambiguity in the forced-solid margin.
if (DistSq >= RadiusSq)
{
Density = FMath::Max(Density, BaseDensity);
return;
}
const float DistXY = FMath::Sqrt(DistSq);
// 0 at the inner edge, 1 at the world radius; smoothstep removes a cylindrical cliff.
const float SealFactor = SmoothStep01(FMath::Clamp(
(DistXY - InnerRadius) / Thickness, 0.0f, 1.0f));
// At/beyond WorldRadiusVoxels the branch above already forced exactly BaseDensity.
Density = FMath::Max(Density, SealFactor * BaseDensity);
}
/** MC-facing form used after post-processes that already operate in the output convention. */
FORCEINLINE void VF_ApplyXYEdgeSealMC(float& Density, float WorldX, float WorldY,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
if (!(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f)) return;
float InternalDensity = -Density;
VF_ApplyXYEdgeSeal(InternalDensity, WorldX, WorldY,
WorldRadiusVoxels, Thickness, BaseDensity);
Density = -InternalDensity;
}
//=============================================================================
// XY EDGE PROOF / PREUVE DE BOITE
//=============================================================================
// The closest point of an AABB to the XY origin gives the minimum radius over the whole box.
// The helpers below are shared by ClassifyBox, ClassifyTile's global shell early-out, and the
// soundness test. They intentionally require one voxel of radial margin in the ramp: at the
// mathematical inner edge the helper is a true no-op, so claiming AllSolid there would be false.
namespace VoxelDensityReach
{
constexpr float SpineBlend = 3.0f;
constexpr float PassageBlend = 4.0f;
constexpr float EdgeProofMargin = 1.0f;
}
FORCEINLINE bool VF_IsValidXYEdgeSealProofInput(const FBox& VoxelBox,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
return VoxelBox.IsValid
&& FMath::IsFinite(VoxelBox.Min.X) && FMath::IsFinite(VoxelBox.Min.Y)
&& FMath::IsFinite(VoxelBox.Max.X) && FMath::IsFinite(VoxelBox.Max.Y)
&& FMath::IsFinite(WorldRadiusVoxels) && FMath::IsFinite(Thickness)
&& FMath::IsFinite(BaseDensity)
&& WorldRadiusVoxels > 0.0f && Thickness > 0.0f && BaseDensity > 0.0f;
}
FORCEINLINE float VF_XYEdgeClosestRadiusSq(const FBox& VoxelBox)
{
const float ClosestX = (VoxelBox.Min.X > 0.0f) ? VoxelBox.Min.X
: (VoxelBox.Max.X < 0.0f) ? VoxelBox.Max.X : 0.0f;
const float ClosestY = (VoxelBox.Min.Y > 0.0f) ? VoxelBox.Min.Y
: (VoxelBox.Max.Y < 0.0f) ? VoxelBox.Max.Y : 0.0f;
return ClosestX * ClosestX + ClosestY * ClosestY;
}
FORCEINLINE float VF_XYEdgeFarthestRadiusSq(const FBox& VoxelBox)
{
const float FarthestX = FMath::Max(FMath::Abs(VoxelBox.Min.X), FMath::Abs(VoxelBox.Max.X));
const float FarthestY = FMath::Max(FMath::Abs(VoxelBox.Min.Y), FMath::Abs(VoxelBox.Max.Y));
return FarthestX * FarthestX + FarthestY * FarthestY;
}
/**
* Return the actual positive lower-bound margin imposed by the edge seal over a box, or 0 when
* the box is not provably forced. The ramp bound is intentionally reduced by 2× after the
* one-voxel proof margin, so float evaluation error can only make this estimate too small.
*/
FORCEINLINE float VF_XYEdgeSealForcedMarginOverBox(const FBox& VoxelBox,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
if (!VF_IsValidXYEdgeSealProofInput(VoxelBox, WorldRadiusVoxels, Thickness, BaseDensity)) return 0.0f;
const float ClosestRadiusSq = VF_XYEdgeClosestRadiusSq(VoxelBox);
const float RadiusSq = WorldRadiusVoxels * WorldRadiusVoxels;
if (ClosestRadiusSq >= RadiusSq)
{
// The per-voxel helper takes the >= WorldRadiusVoxels branch and forces BaseDensity.
return BaseDensity;
}
const float InnerRadius = WorldRadiusVoxels - Thickness;
const float ProofRadius = InnerRadius + VoxelDensityReach::EdgeProofMargin;
if (!(Thickness > VoxelDensityReach::EdgeProofMargin)
|| !(ClosestRadiusSq >= ProofRadius * ProofRadius)) return 0.0f;
const float GuaranteedFactor = SmoothStep01(
FMath::Clamp(VoxelDensityReach::EdgeProofMargin / Thickness, 0.0f, 1.0f));
return BaseDensity * GuaranteedFactor * 0.5f;
}
FORCEINLINE bool VF_XYEdgeSealBoxIsForcedSolid(const FBox& VoxelBox,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
return VF_XYEdgeSealForcedMarginOverBox(
VoxelBox, WorldRadiusVoxels, Thickness, BaseDensity) > 0.0f;
}
FORCEINLINE bool VF_XYEdgeSealBoxTouchesBand(const FBox& VoxelBox,
float WorldRadiusVoxels, float Thickness)
{
if (!(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f)
|| !FMath::IsFinite(WorldRadiusVoxels) || !FMath::IsFinite(Thickness)) return false;
const float InnerRadius = WorldRadiusVoxels - Thickness;
return VF_XYEdgeFarthestRadiusSq(VoxelBox) > InnerRadius * InnerRadius;
}
//=============================================================================
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
//=============================================================================
// The constants are named above so a future bounds test cannot let them drift from the hot path.
+10 -3
View File
@@ -58,7 +58,7 @@ namespace VoxelGenLOD
* UVoxelGenerator
*
* Objet UObject léger — ne stocke pas de données lourdes.
* Tient juste un pointeur sur les settings (pour le seed) et les services
* Tient les paramètres globaux copiés depuis les settings (seed, spine, bord XY) et les services
* dont il a besoin (StrateManager pour les params par chunk, DiffLayer pour
* les carves du joueur).
*/
@@ -73,8 +73,8 @@ public:
//=========================================================================
// SEED (source unique: Settings->Seed)
//=========================================================================
// On copie juste le seed au démarrage pour éviter un déréférencement
// par voxel. Tout le reste vient des params de strate.
// Les paramètres globaux sont copiés au démarrage pour éviter un déréférencement par voxel.
// Les formes créatives viennent ensuite des params de strate.
int32 Seed = 0;
// Radius (voxels) of the guaranteed open vertical landing column carved at world
@@ -82,6 +82,13 @@ public:
// 0 disables the spine carve.
float OriginSpineRadius = 14.0f;
// Radius (voxels) of the bounded world in actor-space XY, measured from (0,0).
// 0 keeps the legacy unbounded behavior and is a true no-op for the edge seal.
float WorldRadiusVoxels = 8192.0f;
// Width (voxels) of the smooth solid ramp at the XY world edge.
float EdgeSealThickness = 64.0f;
//=========================================================================
// SERVICES (injectés par AVoxelWorld::BeginPlay)
//=========================================================================
+22
View File
@@ -179,6 +179,28 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "3"))
int32 LODOctaveDrop = 0;
//==========================================================================
// WORLD BOUNDS (bounded gigantic world in actor-space XY)
//==========================================================================
// Radius (voxels) of the bounded world in XY, measured from actor-space (0,0).
// 0 = unbounded (legacy behaviour, no edge seal).
//
// ⚠️ DEFAULTS TO 0 ON PURPOSE, and it is not the value the GDD wants.
// Existing DA_Settings / BP_VoxelSettings assets predate this property, so they do NOT serialize
// it — they silently inherit whatever C++ default is written here. A non-zero default would
// therefore wall in every existing world the next time the editor opens, with nothing in the
// asset to show why. The GDD calls for "bounded, but gigantic": ~8192 voxels (2.05 km) is the
// intended production value. SET IT IN THE ASSET, deliberately, once.
//
// FR : 0 par défaut EXPRÈS — les assets existants n'ont pas cette propriété et héritent
// silencieusement de la valeur C++. La valeur de production visée (~8192) se règle dans l'asset.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|World Bounds", meta = (ClampMin = "0.0"))
float WorldRadiusVoxels = 0.0f;
// Solid ramp thickness at the radial world edge, in voxels.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|World Bounds", meta = (ClampMin = "0.0"))
float EdgeSealThickness = 64.0f;
//=========================================================================
// CONTENT — distance-based decoration grid (no LOD pop)
//=========================================================================