diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aec094a..1d26726 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CODEMAP.md b/CODEMAP.md index 87aa7ac..d32a65e 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp index 353c839..1b8a1c0 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp @@ -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) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp index 4031706..5ab7ef2 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp @@ -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; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp index b920eb9..c123752 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackIslandTest.cpp @@ -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; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp index cbdea9a..1a5d08d 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp @@ -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; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp index 73ed705..bdca0d0 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp @@ -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; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp index 74c0607..487a3f8 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp @@ -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; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index 4c9a771..46e3d61 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -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 diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp index d70b9a9..d8ff411 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -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 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 { diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h index 19f65f2..129997b 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h +++ b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h @@ -79,6 +79,8 @@ namespace VoxelForgeTest NewObject(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 diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeWorldEdgeSealTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeWorldEdgeSealTest.cpp new file mode 100644 index 0000000..d5e8d22 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeWorldEdgeSealTest.cpp @@ -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& 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 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 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 diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 7897d65..47b6a4f 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -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(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity, SpineRadius)); Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity)); Add(MakeUnique(StrateManager, BaseDensity, SealThickness)); + Add(MakeUnique(BaseDensity)); } //============================================================================= diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index e66fd9b..5fe4c7e 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -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; } diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index df95fd9..fc3f083 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -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 diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index f4fc50c..ba53966 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -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& 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 diff --git a/Source/VoxelForge/Public/VoxelDensityPrimitives.h b/Source/VoxelForge/Public/VoxelDensityPrimitives.h index e281185..5286b31 100644 --- a/Source/VoxelForge/Public/VoxelDensityPrimitives.h +++ b/Source/VoxelForge/Public/VoxelDensityPrimitives.h @@ -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 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. diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 93f25fb..2c85e07 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -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) //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 231ab16..4a20f5b 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -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) //=========================================================================