Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 451ebcb776 | |||
| 91585ea493 | |||
| 4d33321bfa | |||
| 2303442d8f | |||
| 871ca190af | |||
| a2c5e02713 | |||
| 05986bd875 | |||
| 4ae9e6e72f | |||
| b06c39c077 | |||
| 9740d117e0 | |||
| 45c61dd00e | |||
| 6e7ea7038a | |||
| f90c4e56c3 | |||
| 1e02c6314f | |||
| 8295f6e76b | |||
| 588f9e0294 | |||
| 4ba53f2ede | |||
| 3ad2720d0a | |||
| 49a9959aed | |||
| bc0bf83c64 | |||
| cab8e8fe55 | |||
| 8ef4d7dc09 | |||
| 21f62c4a70 | |||
| f737488d88 | |||
| c993e6e877 | |||
| eaa44bf0c0 | |||
| 108982d135 | |||
| 7dbdf51b44 | |||
| b5294b2d5b | |||
| b426cfcb0d | |||
| eb317d9933 | |||
| 7909c4f2ca | |||
| 7313201e91 | |||
| 57002b35cf | |||
| 6bd5589d53 | |||
| 251a1288e0 | |||
| e4d1fdd7ea | |||
| 41ba3c34a9 | |||
| e002bd4e2e | |||
| 861dc8e109 | |||
| b2938d38f1 | |||
| 9733179723 | |||
| 87a0b996ec | |||
| f2fefade4c | |||
| 8043a613c3 | |||
| f537648867 | |||
| 03ddcde334 |
+3
-1
@@ -306,7 +306,9 @@ driven by `EditorBrush*` props.
|
||||
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
|
||||
sampling must not thrash the (expensive) rebuild.
|
||||
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
|
||||
thread-locally per chunk; don't move the fetch/blend back to per-voxel.
|
||||
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.
|
||||
- **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
|
||||
|
||||
+51
-6
@@ -221,12 +221,57 @@ evaluates the second chunk against **the room list baked from the first chunk's
|
||||
(so "which archetype owns this chunk" stays unambiguous), which switches the blend off entirely.
|
||||
The one configuration the tests never build is the default one.
|
||||
|
||||
**Fix (unimplemented, deliberately — this is the `switch` path, not the port):** fold the params into
|
||||
the SDF cache key exactly as `FRoomGraphSource` already does — `FCrc::MemCrc32` over the params
|
||||
struct, plus `LayoutVersion`. `FStrateGenerationParams` is pure POD, so a memory CRC cannot produce a
|
||||
false *match*; at worst padding causes a needless rebuild. Err on CPU, never on a wrong room.
|
||||
Alternatively add chunk Z to the key, which is coarser (it rebuilds on every Z step even outside a
|
||||
blend band) but needs no CRC.
|
||||
#### ✅✅ SECTION FULLY CLOSED 2026-08-16 — the live-edit half was fixed too. Do not re-open.
|
||||
|
||||
The text above still reads as though `OC_Chunk`, `BM_Chunk` and `FChunkBiomeCache` were open. **They
|
||||
are not.** Checked site by site on 2026-08-16, while about to spec a fix for them — the premise
|
||||
reversed on reading, for the seventh time in this project. Every per-chunk cache in the plugin now
|
||||
carries the layout version:
|
||||
|
||||
| cache | guard | site |
|
||||
|---|---|---|
|
||||
| `CP_Chunk` (density params + op stack) | `CP_Version` vs `GetLayoutVersion()` | `VoxelGenerator.cpp` ~613 |
|
||||
| `OC_Chunk` (`GetSurfaceHeightAt` oracle) | `OC_Version` | ~2626 |
|
||||
| `BM_Chunk` (`GetBiomeMaterialAt`) | `BM_Version` | ~3470 |
|
||||
| `TC_BiomeCache` (the `ClassifyTile` grid) | `TC_SeenVersion` | ~2724 |
|
||||
|
||||
`FChunkBiomeCache` gained an explicit `Invalidate()` (`VoxelBiomeTypes.h` ~253) precisely because its
|
||||
validity box says nothing about the `FBiomeContext` its cells were classified against; **all four**
|
||||
`thread_local` instances call it on a version change. The only other two instances in the tree —
|
||||
`VoxelContentManager.cpp` ~445 and the height-stack test — are **function-local**, constructed fresh
|
||||
per task, so no staleness is possible by construction.
|
||||
|
||||
⇒ Both the determinism half and the live-edit half of C2 are closed. The remaining audit item on
|
||||
this theme is **C9's library half** (`sinf`/`cosf` are not IEEE-754 specified), which is unrelated
|
||||
and still open with 0 measured exposure.
|
||||
|
||||
#### ✅ FIXED 2026-07-28 (the SDF-cache half) — pending build
|
||||
|
||||
The params now travel into the key, and the shape of the fix is worth recording because the obvious
|
||||
version of it was the wrong one.
|
||||
|
||||
`GetDensityWithParams` takes **two new required arguments**, `ParamsFingerprint` and `LayoutVersion`,
|
||||
and both go into the `bNeedRebuild` test next to the existing `(box, strate, seed)`.
|
||||
|
||||
- **Required, not defaulted.** A caller that forgets must fail to compile rather than silently
|
||||
inherit the hole — the same discipline that put `LayoutVersion` inside `FVoxelOpContext`.
|
||||
- **The CRC is computed once per chunk, not per voxel.** `FCrc::MemCrc32` over ~300 bytes on the
|
||||
hottest path in the plugin would have been a real regression; instead it is taken where the params
|
||||
memo already lives (`CP_TunnelFP`, refreshed in the same block that refetches `CP_Tunnel`), so the
|
||||
per-voxel cost is two integer compares.
|
||||
- **Why not "add chunk Z to the key" (the alternative this section used to offer):** it is not
|
||||
actually cheaper *and* it is not sufficient. `Interleaved` makes `Alpha` depend on chunk **XY**
|
||||
too, and chunk XY is *not* pinned by the existing key — the box deliberately outlives the chunk so
|
||||
that `WorldX ± 1` gradient probes don't thrash it (`ARCHITECTURE §8.10`). Pinning chunk XY to fix
|
||||
the params would have destroyed that invariant. The fingerprint fixes the cause and leaves the box
|
||||
reuse intact: what forces a rebuild now is a *real* params change, once per chunk in a transition
|
||||
band, which is the number of rebuilds this cache should always have done.
|
||||
|
||||
The three test call sites pass `VF_FP(P)` so the **oracle no longer shares the defect under test** —
|
||||
see the rewritten note at check 3 of `VoxelForgeOpStackTunnelTest.cpp`.
|
||||
|
||||
**Still open in this section:** the `OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache` sites listed above.
|
||||
Those are the live-edit staleness half, not the determinism half, and they are untouched.
|
||||
|
||||
The operator-stack port does **not** inherit this: `FRoomGraphSource` folds a `FCrc::MemCrc32`
|
||||
fingerprint of the params (plus `LayoutVersion`) into its key, so differing params force a rebuild.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
> ## ⚠️ REVIEWER'S NOTE — Claude, 2026-08-16. Read before acting on anything below.
|
||||
>
|
||||
> This report was produced by a **read-only Codex pass (`gpt-5.6-sol`, high effort)**. It is a
|
||||
> *lead list*, not a verified defect list. Every item is labelled "Verified by reading" **by its own
|
||||
> author**; that label is the author's claim, not an independent check.
|
||||
>
|
||||
> **What I checked myself, and what came of it:**
|
||||
>
|
||||
> | finding | my verdict |
|
||||
> |---|---|
|
||||
> | **VF-05** (radius envelope in `BuildChunkCache`) | ✅ **CONFIRMED and FIXED** — `CODEX-TASK-006`. Real, and the worst of three instances of this class: it is `TunnelNetwork`, it is in code **both** density paths share, and it breaks window invariance (`ARCHITECTURE §8.4`). Genuinely good find. |
|
||||
> | **VF-02** (3-second shutdown timeout) | ✅ **premise confirmed** — `VoxelWorld.cpp:327` literally reads *"Timeout after 3 seconds to avoid hanging the editor."* Note `CLAUDE.md` states the invariant more strongly than the code implements it ("EndPlay blocks on `ActiveTaskCount → 0`" — it blocks *with a deadline*). Worth deciding deliberately. |
|
||||
> | **VF-03** (TLS caches omit the owning world) | ⛔ **MY EARLIER VERDICT HERE WAS WRONG — corrected 2026-08-16.** I wrote that its fixture citation was fabricated. **It is not.** `VoxelForgeTestFixture.h` lines ~134/146 explicitly document `CP_UseOpStack` contamination between worlds; I had read only the file's 30-line header comment and asserted a negative from a partial read. VF-03's core claim is **CONFIRMED**: `GetDensityAt` keys its `thread_local CP_*` state by `(ChunkCoord, LayoutVersion)` with **no generator/world identity**, and every manager's version starts at the same value — so a second world on the same worker can inherit the first's params, `CP_UseOpStack` and stack. The *breadth* of VF-03 (the `OC_*`/`BM_*`/passage/biome/diff caches) is still unproven and should be audited as one owner-identity task. Original note kept below for the record: ~~substance plausible, evidence overstated~~ |
|
||||
> | ~~VF-03 (superseded)~~ | ~~**substance plausible, EVIDENCE OVERSTATED.**~~ It claims *"the test fixture explicitly documents observed cross-world contamination."* It does not. `VoxelForgeTestFixture.h` documents that the `thread_local` caches exist and flags an unrelated `TSoftObjectPtr` risk. The underlying point (caches keyed on chunk/seed/layout but not on which generator owns them) may still hold — but it needs checking on its own merits, not on this citation. |
|
||||
> | **VF-01** (live rebuild races streaming workers) | ✅ **CONFIRMED — the most serious finding here.** `UVoxelStrateManager::Initialize` does `StrateLayout.Empty()` (:~36) **and** `Passages.Empty()` (:171) then `Passages.Add()`, i.e. it frees and reallocates both arrays. There is **no lock, no barrier, no drain** anywhere in that file. Worker-side readers of the same arrays: `AnyPassageNearBox` (:460, range-for over `Passages`), `EvaluateModifierSDF` (indexes `Passages[...]`), `FindSlotIndexForChunkZ` (iterates `StrateLayout`) — all reached from `GetDensityAt`/`ClassifyTile` on mesher workers. And `RegenerateAllChunks()` (which bumps the epoch) runs **after** `Initialize`, so previous-epoch workers are still live during the mutation. **This is the same class already fixed once in this codebase** — `DiffLayer.ChunkMods` got `ModsLock` after a carve-vs-stream access violation. Four call sites, incl. `OnObjectModifiedInEditor` (:309), which fires automatically when a strate asset is edited while the world streams. **NOT fixed — see the note below.** |
|
||||
> | **VF-10** (~74-field per-voxel params copy) | ✅ **confirmed real, but Sol missed the conclusion that matters.** The 74 fields are real and the copy is per near-surface sample. **However it is INHERITED from the original path — `GetDensityWithParams` does the same copy — so both paths pay it equally and it does NOT explain the op-stack perf regression.** The op stack actually *improved* it (memoised so eleven detail ops don't each repeat it), and the site says so in its own comment. Genuine future optimisation for both paths; **not** the answer to "why is the op path slower". |
|
||||
> | VF-04, VF-06, VF-07, VF-08, VF-09 | **NOT independently verified.** Read them as leads. |
|
||||
>
|
||||
> **Do not treat an unverified row as actionable.** The lesson this project keeps paying for is that a
|
||||
> confident chain resting on an unchecked premise reverses about half the time — and VF-03 is an
|
||||
> instance of exactly that, inside an audit written to find them.
|
||||
|
||||
# VoxelForge code quality and efficiency audit — August 2026
|
||||
|
||||
| Finding | File | Severity | Tier | Evidence status |
|
||||
|---|---|---:|---|---|
|
||||
| VF-01 — Live rebuilds mutate generation state while workers read it | `VoxelWorld.cpp`, `VoxelStrateManager.cpp` | Critical | Async lifecycle / per-tile workers | Verified by reading |
|
||||
| VF-02 — Shutdown timeouts allow tasks to outlive their owners | `VoxelWorld.cpp`, `VoxelContentManager.cpp` | Critical | Async lifecycle | Verified by reading |
|
||||
| VF-03 — Function-static TLS caches omit the owning world/generator | `VoxelGenerator.cpp`, `VoxelStrateManager.cpp`, `VoxelDensityOpStack.cpp` | Critical | Per-voxel caches; per-chunk refill | Verified by reading |
|
||||
| VF-04 — Box/capsule edits bypass the intended budget and use the wrong live-deco removal volume | `VoxelDiffLayer.cpp`, `VoxelDiffLayer.h`, `VoxelWorld.cpp` | High | Per modification | Verified by reading |
|
||||
| VF-05 — Cave collection bounds can be smaller than generated geometry when min/max fields are reversed | `VoxelCaveMorphology.cpp`, `VoxelStrateTypes.h` | High | Per-chunk cache construction / skip bound | Verified by reading |
|
||||
| VF-06 — A fixed-only strate configuration silently disables the strate system | `VoxelWorld.cpp`, `VoxelStrateManager.cpp` | High | Initialization | Verified by reading |
|
||||
| VF-07 — World origin is used as the “no player” sentinel | `VoxelWorld.cpp`, `VoxelWorld.h` | Medium | Per frame / streaming gate | Verified by reading |
|
||||
| VF-08 — Decoration palettes are rebuilt every tick and deep-copied into every cell task | `VoxelContentManager.cpp`, `VoxelContentManager.h`, `VoxelStrateTypes.h` | Medium | Per frame and per decoration cell | Verified by reading |
|
||||
| VF-09 — Clearing decoration builds forgets still-running tasks and defeats the concurrency cap | `VoxelContentManager.cpp` | Medium | Per rebuild / async scheduling | Verified by reading |
|
||||
| VF-10 — Per-room terrain params are reconstructed for every near-surface sample | `VoxelGenerator.cpp`, `VoxelDensityOpStack.cpp`, `VoxelCaveMorphology.cpp` | Medium | Per near-surface voxel | Verified by reading |
|
||||
|
||||
## Scope and evidence
|
||||
|
||||
This was a static, read-only review. I read `CODEMAP.md`, `ARCHITECTURE.md` including §8.10, `REVIEW_FINDINGS.md`, the relevant public contracts, implementations, and tests. I did not build, compile, or run the plugin. Every item below is therefore marked **Verified by reading**: the cited control flow or cache-key omission is present in the source. Runtime frequency and timing impact are reasoned from that source, not measured in this review. No “suspicious only” item is included.
|
||||
|
||||
The deliberate old/new density-path duplication and every settled decision listed in the review request are excluded.
|
||||
|
||||
## Findings
|
||||
|
||||
### VF-01 — Live rebuilds mutate generation state while workers read it
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::RebuildStrates` (lines 140–153), `AVoxelWorld::OnObjectModifiedInEditor` (238–316), `AVoxelWorld::ChangeSeed` (2064–2114), `AVoxelWorld::LoadTile` (1345–1478), and `AVoxelWorld::GenerateTileResult` (1481 onward); `Source/VoxelForge/Private/VoxelStrateManager.cpp` — `UVoxelStrateManager::Initialize` (28–163).
|
||||
|
||||
**What is wrong:** chunk tasks capture `this` and call `GenerateTileResult`, which reads `Generator`, `Mesher`, and through them `StrateManager`. Meanwhile, each live-rebuild path mutates the same objects on the game thread. `Initialize` empties and repopulates `StrateLayout`, empties and repopulates `Passages`, and changes cached seed/settings fields. `ChangeSeed` also writes the generator's plain `Seed`/`OriginSpineRadius`. There is no lock, immutable snapshot, or worker quiescence around those writes.
|
||||
|
||||
The order makes the race especially direct: `RebuildStrates` and `OnObjectModifiedInEditor` call `StrateManager->Initialize(...)` before `RegenerateAllChunks()` increments `GenerationEpoch`. `ChangeSeed` also changes generator and manager state before regeneration. The epoch only rejects a finished result; it does not make concurrent reads of reallocating `TArray`s safe and cannot repair undefined behavior that happened while producing the result. Decoration and density-volume workers also read the generator and need to be included in the same transition.
|
||||
|
||||
**Why it matters:** an edit or seed change during active streaming can race a worker iterating or indexing storage that `Initialize` has freed/reallocated. Outcomes range from a tile built from mixed old/new settings to an access violation. This is a correctness and lifetime issue, not merely stale-result work.
|
||||
|
||||
**Concrete change:** introduce an immutable generation snapshot containing the seed, layout, passages, resolved definitions/op data, and a unique generation ID. Atomically publish the new snapshot and have every task capture a strong reference to one snapshot. The smaller alternative is a rebuild barrier: stop new chunk/deco/density work, wait without timeout for all generator readers, mutate the state, bump the epoch, then resume. Incrementing the epoch before mutation is useful but is not sufficient without snapshotting or quiescence.
|
||||
|
||||
### VF-02 — Shutdown timeouts allow tasks to outlive their owners
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::EndPlay` (320–375) and the `[this, ...]` task in `AVoxelWorld::LoadTile` (1456–1478); `Source/VoxelForge/Private/VoxelContentManager.cpp` — `UVoxelContentManager::BeginDestroy` (58–63), `NotifyShutdown` (65–80), and the `[this, ...]` task in `LaunchDecoTasks` (384–404).
|
||||
|
||||
**What is wrong:** both shutdown drains stop waiting after three seconds and continue teardown while work may still be running. Chunk tasks retain a raw `this` and even their RAII guard holds a reference to `AVoxelWorld::ActiveTaskCount`. Decoration tasks retain a raw content-manager `this`, read its `bShuttingDown`, and may access its queue. `BeginDestroy` sets a flag but does not establish task completion before calling `Super::BeginDestroy`.
|
||||
|
||||
The decoration counter is also file-global (`GActiveDecoTasks`), so it is neither an ownership handle nor a per-manager proof that this manager's tasks are finished.
|
||||
|
||||
**Why it matters:** if the timeout is reached, later task reads, queue writes, or the guard decrement can target an object whose EndPlay/destruction has advanced. The timeout converts a slow task into a possible use-after-free. The shutdown flag reduces ordinary latency but does not cancel a task already inside generation or marching.
|
||||
|
||||
**Concrete change:** retain `UE::Tasks::FTask` handles per owner and make UObject destruction contingent on their completion. Stop submissions first, request cancellation, and either wait unconditionally in a safe shutdown phase or defer final destruction through `IsReadyForFinishDestroy` until the owner's task group is empty. Replace the global decoration count with per-instance task ownership. A watchdog may log a long wait, but it must not release the objects that unfinished tasks can still touch.
|
||||
|
||||
### VF-03 — Function-static TLS caches omit the owning world/generator
|
||||
|
||||
**Evidence status:** Verified by reading. The test fixture explicitly documents observed cross-world contamination.
|
||||
|
||||
**Location:**
|
||||
|
||||
- `Source/VoxelForge/Private/VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityAt`: `CP_*` cache (583–622), `GSurfColCache` access (738), and `DiffSlots` (808–826); `ClassifyTile`: `TC_BiomeCache`/`TC_SeenVersion` (2720–2731); `GetBiomeMaterialAt`: `BM_*` cache (3470–3485).
|
||||
- `Source/VoxelForge/Private/VoxelStrateManager.cpp` — `GeneratePassages` (169–173, 352–353) and `EvaluateModifierSDF`: `SL_*` shortlist plus unchecked `Passages[PIdx]` (372–420).
|
||||
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp` — `FRoomGraphSource::Eval`: `SI_*` strate-index memo (2172–2188).
|
||||
- `Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h` — `FTestWorld` construction (126–152).
|
||||
|
||||
**What is wrong:** these are function/file-static `thread_local` caches, so one worker-thread cache is shared by every VoxelForge instance evaluated on that thread. Their keys use coordinates and per-instance counters such as `LayoutVersion` or `ModsVersion`, but omit the owning generator/manager/diff layer. Two freshly initialized worlds normally both report layout version 1; two diff layers also start with the same modification version. Equal coordinates and versions therefore make the second world reuse the first world's params, operator stack (including its manager pointer), biome context, surface columns, or modification snapshot.
|
||||
|
||||
This is not hypothetical test hygiene. `VoxelForgeTestFixture.h` states that two test worlds both reporting version 1 caused the second world to receive the first world's params and `CP_UseOpStack`; it works around the problem by repeatedly initializing each test manager until its version is process-unique. Production has no such workaround.
|
||||
|
||||
The passage cache has a more severe failure mode. `SL_Nearby` stores indices from manager A, then manager B with the same `(chunk, PassagesVersion)` can execute `Passages[PIdx]` without `IsValidIndex`. Also, `GeneratePassages` empties `Passages` and returns for an empty layout before incrementing `PassagesVersion`, so the same manager can retain stale indices after an empty rebuild.
|
||||
|
||||
**Why it matters:** multiple VoxelWorld actors, PIE worlds, tests, previews, or address-reused objects can produce density/materials/modifications from the wrong world. The passage case can read out of bounds. This affects the per-voxel tier—up to roughly 35³ = 42,875 base samples per full-resolution tile—although the bad selection occurs at cache-refill granularity.
|
||||
|
||||
**Concrete change:** give each immutable generation context a process-unique, monotonic cache ID and include it in every shared TLS key. Give each diff layer its own unique ID as well. Prefer a per-worker cache object scoped to that context over scattered function statics. Move the passage-version increment so every clear/rebuild, including the empty-layout exit, invalidates the cache; retain `Passages.IsValidIndex(PIdx)` as defense in depth. Remove the test fixture's serial-bump workaround once production keys express owner identity.
|
||||
|
||||
### VF-04 — Box/capsule edits bypass the intended budget and use the wrong live-deco removal volume
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelDiffLayer.cpp` — `UVoxelDiffLayer::CanModify` (20–45) and `ApplyModification` (63–133); `Source/VoxelForge/Public/VoxelDiffLayer.h` — `FVoxelModification::GetWorldBounds` (97–121); `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::ApplyModification` (1839–1871), `CarveBox`/`FillBox` (1874–1893), and `CarveCapsule`/`FillCapsule` (1896–1915).
|
||||
|
||||
**What is wrong:** budget validation knows only a scalar radius and always charges `4/3*pi*r^3`. `ApplyModification` clamps `Mod.Radius`, but leaves `BoxExtent`, `CapsuleEnd`, and `Falloff` unchanged. The shape-aware AABB then uses those unchanged values. Consequently:
|
||||
|
||||
- a box with huge extents is stored across its full huge AABB even though only its proxy radius was clamped and sphere volume was charged;
|
||||
- a capsule of arbitrary length is charged only as a sphere of its tube radius;
|
||||
- an untrusted or accidental large shape can enumerate and allocate entries for an enormous number of chunks despite `MaxBrushRadius`/`MaxTotalVolume` being presented as safety limits.
|
||||
|
||||
The live decoration cleanup is inconsistent in the other direction. It always removes a sphere centered at `Modification.Center` with the original `Modification.Radius`. For a capsule this is only endpoint A, leaving decorations floating along most of the segment. For a box, `max(half extent)` does not cover the corners and ignores falloff. It also does not use the clamped modification that was actually stored.
|
||||
|
||||
**Why it matters:** the budget can be bypassed precisely by the shapes most able to create a large remesh/storage burst. Separately, box/capsule edits leave visibly invalid live content until a later decoration rebuild.
|
||||
|
||||
**Concrete change:** make validation and accounting accept the complete `FVoxelModification`. Validate finite, non-negative geometry; enforce extent/tube-radius and capsule-length limits; and charge a documented shape volume (or a deliberately conservative support-AABB volume including falloff). Return the normalized/applied modification or its actual bounds from `ApplyModification`. Use those applied bounds for decoration invalidation—prefer a shape-aware removal query, or at least a conservative sphere centered on the bounds center with the bounds half-diagonal. Keep `CanModify` and `ApplyModification` on the same normalization/accounting function so UI/server decisions cannot drift.
|
||||
|
||||
### VF-05 — Cave collection bounds can be smaller than generated geometry when min/max fields are reversed
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelCaveMorphology.cpp` — `VoxelCaveMorphology::BuildChunkCache`: `MaxInfluence` (127–130), `CollectMargin` (152), `RoomZBuffer` (171), room radius generation (260–265), and tunnel radius generation (464–468); `EvaluateSDF` margin (871–874). Authoring fields are in `Source/VoxelForge/Public/VoxelStrateTypes.h` — `MinRoomRadius`/`MaxRoomRadius` (490/498) and `TunnelMinRadius`/`TunnelMaxRadius` (595/602).
|
||||
|
||||
**What is wrong:** `FMath::Lerp(Min, Max, t)` produces values up to `max(Min, Max)` even when an asset has the endpoints reversed. The collection/influence math assumes the field named `Max*` is numerically largest: it uses only `MaxRoomRadius` and `TunnelMaxRadius`. The properties have no cross-field validation enforcing `Min <= Max`.
|
||||
|
||||
If `MinRoomRadius > MaxRoomRadius`, actual generated rooms can be larger than `MaxInfluence`, `CollectMargin`, and `RoomZBuffer` assume. If `TunnelMinRadius > TunnelMaxRadius`, the same applies to tunnel reach. `RoomReachesSearchBox` uses the actual radius, but it cannot test a room whose anchor cell was never collected because the collect region was too small. The convenience wrapper repeats the underestimated margin.
|
||||
|
||||
**Why it matters:** this is an under-bound, not a conservative overestimate. A room/tunnel able to affect a chunk may not be created in that chunk's cache, producing window-dependent density, seams, missing mesh, or missing collision. Reversed ranges are authorable and can also arise transiently while live-editing the two fields.
|
||||
|
||||
**Concrete change:** derive bound-only envelopes as `Max(MinRoomRadius, MaxRoomRadius)` and `Max(TunnelMinRadius, TunnelMaxRadius)` and use them in `MaxInfluence`, collection margins, vertical room buffer, and the wrapper margin. Do not reorder the endpoints passed to `Lerp`, because that would change deterministic room/tunnel assignment; only make the bounds cover every value the existing interpolation can produce. Add asset validation that warns on reversed or non-positive ranges.
|
||||
|
||||
### VF-06 — A fixed-only strate configuration silently disables the strate system
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::BeginPlay` (413–421); `Source/VoxelForge/Private/VoxelStrateManager.cpp` — `UVoxelStrateManager::Initialize` (43–55, 76–105).
|
||||
|
||||
**What is wrong:** `BeginPlay` creates the strate manager only when `Settings->StratePool.Num() > 0`. The manager itself explicitly supports fixed entries independently: it loads `FixedStrates` and selects a fixed definition before consulting the shuffled pool. A valid setup in which every requested slot is fixed and `StratePool` is empty therefore never constructs the manager.
|
||||
|
||||
**Why it matters:** the generator silently falls back to generic TunnelNetwork terrain, while content and atmosphere receive a null manager. Authored fixed strata are ignored without an initialization error.
|
||||
|
||||
**Concrete change:** initialize the manager when either `StratePool` or `FixedStrates` is non-empty. Validate that every index in `[0, TotalStrates)` can resolve either a fixed definition or a pool fallback, and emit a clear error for uncovered slots rather than silently changing generation mode.
|
||||
|
||||
### VF-07 — World origin is used as the “no player” sentinel
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp` — `AVoxelWorld::Tick` (456–484) and `GetPlayerPosition` (529–537); declaration/comment in `Source/VoxelForge/Public/VoxelWorld.h` (634–635).
|
||||
|
||||
**What is wrong:** `GetPlayerPosition` returns `FVector::ZeroVector` when there is no pawn, but a pawn at the real world origin returns the same value. `Tick` tests `PlayerLastPos != FVector::ZeroVector` before all terrain streaming, atmosphere, decorations, landmarks, water, and density-volume updates.
|
||||
|
||||
**Why it matters:** origin is a common initial spawn. While the pawn is exactly there, no initial terrain/content streaming is submitted; behavior begins only after it moves away.
|
||||
|
||||
**Concrete change:** return success separately from the coordinate (`bool TryGetPlayerPosition(FVector& Out)` or an optional), or obtain the controller/pawn in `Tick` and gate on pointer validity. Treat every finite coordinate, including zero, as a valid position.
|
||||
|
||||
### VF-08 — Decoration palettes are rebuilt every tick and deep-copied into every cell task
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelContentManager.cpp` — `UVoxelContentManager::UpdateDecorations` (147–256) and `LaunchDecoTasks` (366–398); `Source/VoxelForge/Public/VoxelContentManager.h` — `FDecoCellResult::Entries` (151–158); nested decoration arrays in `Source/VoxelForge/Public/VoxelStrateTypes.h` — `FDecoCompanion::SubCompanions` (2073–2076) and `FStrateDecoration::Companions` (2120–2123).
|
||||
|
||||
**What is wrong:** `UpdateDecorations` is called every tick. Before it checks whether the player changed cell or stratum, it resets both flattened palettes, walks every biome and decoration, and copies every `FStrateDecoration`. Those structs contain nested `TArray`s, so this is not a trivial POD copy. `LaunchDecoTasks` then deep-copies the same grid palette and biome-tag array once for every cell task and moves that copy through the result solely so `EntryIdx` can be decoded on the game thread.
|
||||
|
||||
At the default 4x4 region size, one new region is 16 cell tasks carrying 16 copies of the same immutable palette. The per-frame rebuild also contradicts the nearby “cheap no-op unless the player crosses a decoration cell boundary or changes strate” expectation.
|
||||
|
||||
**Why it matters:** this creates allocator traffic and memory bandwidth on both the steady game-thread path and every decoration-streaming burst. Large biome palettes with companion/sub-companion trees amplify the cost.
|
||||
|
||||
**Concrete change:** build an immutable resolved palette snapshot only when its inputs change (stratum/layout/asset revision, tier assignment, or relevant settings). Capture a thread-safe shared reference in cell tasks and carry that same reference in results, or resolve spawn commands to a compact immutable profile table once. Continue draining tasks/results each tick, but do not destroy and reconstruct unchanged nested arrays.
|
||||
|
||||
### VF-09 — Clearing decoration builds forgets still-running tasks and defeats the concurrency cap
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelContentManager.cpp` — `ResetGridBuildState` (88–94), `LaunchDecoTasks` (344–404), `ProcessDecoResults` (763–774), and `ClearAllDecorations` (983–994).
|
||||
|
||||
**What is wrong:** `ClearAllDecorations` abandons builds and calls `ResetGridBuildState`, which clears `InFlightCells` even though the corresponding tasks are not cancelled or awaited. A new build can immediately launch another task for the same cell. When the old result arrives, `ProcessDecoResults` removes `R.Cell` from `InFlightCells` before checking its `BuildId`; this can remove the new task's marker. Payload merging is protected by `BuildId`, but scheduling ownership is not.
|
||||
|
||||
The throttle uses `NearGrid.InFlightCells.Num() + FarGrid.InFlightCells.Num()`, not `GActiveDecoTasks`, so forgotten/incorrectly removed markers allow actual worker count to exceed `MaxConcurrentDecorationTasks`. Repeated clear/rebuild cycles can compound the excess precisely when live editing or regeneration is already generating other work.
|
||||
|
||||
**Why it matters:** the configured cap is documented as preventing decoration marching from crowding mesh-generation workers. This bookkeeping path invalidates that guarantee and can create avoidable CPU/memory bursts. It can also cause redundant same-cell work, though `BuildId` prevents duplicate applied decorations.
|
||||
|
||||
**Concrete change:** track an in-flight token that includes grid, cell, and build ID (for example, `TMap<FIntPoint, uint32>`), and remove it only when the completing result owns that exact token. Do not erase live tokens when abandoning build payloads; retain them until completion/cancellation. Better, keep per-instance task handles/counts and throttle on the actual running count, with build identity used only for result relevance.
|
||||
|
||||
### VF-10 — Per-room terrain params are reconstructed for every near-surface sample
|
||||
|
||||
**Evidence status:** Verified by reading.
|
||||
|
||||
**Location:** `Source/VoxelForge/Private/VoxelGenerator.cpp` — `UVoxelGenerator::GetDensityWithParams`, per-room block (1294–1328); `Source/VoxelForge/Private/VoxelDensityOpStack.cpp` — `FRoomGraphSource::FState`/`LocalParams` (1993–2012, 2041–2073); `Source/VoxelForge/Private/VoxelCaveMorphology.cpp` — room-op selection and existing per-room feature pre-bake (653–678).
|
||||
|
||||
**What is wrong:** for every `bNearCaveSurface` sample, the original path copies the roughly 74-field `FStrateGenerationParams` and makes a virtual `RoomOp->ApplyTo` call for the nearest room. The operator stack preserves one such copy per sample through `LocalParams()`—correctly memoized so eleven detail operators do not each repeat it—but the work is still invariant for all samples whose nearest cached room is the same.
|
||||
|
||||
`BuildChunkCache` already selects `RoomOp`/weight per cached room and calls `ApplyTo` once per room to pre-bake pits, chimneys, and columns. The remaining detail-op parameters can be resolved at that same per-room tier.
|
||||
|
||||
**Why it matters:** this is inside the density hot path. A full-resolution tile has about 42,875 base grid samples, plus density calls used for surface normals; only near-surface samples pay this block, but those are exactly the samples concentrated around generated geometry. Copying a large struct and dispatching virtually per sample adds bandwidth and instruction cost that could be per-room/per-chunk.
|
||||
|
||||
**Concrete change:** add a compact `FResolvedRoomDetailParams` to `FCachedRoom`, containing only fields consumed by the eleven per-room detail stages, and populate it once during `BuildChunkCache` from the chunk's base params plus the selected op. Both the oracle path and operator-stack path should reference that shared resolved payload. Keep the existing operation order and verify bit-for-bit equivalence; this is a hoist of loop-invariant data, not a split or transcription of `BuildChunkCache`.
|
||||
|
||||
## Priority order
|
||||
|
||||
Fix VF-01 through VF-03 first: they are memory-model/lifetime/cache-identity problems and can produce crashes or cross-world corruption. VF-04 through VF-06 are deterministic correctness failures with bounded, local fixes. VF-07 is a small but user-visible initialization defect. VF-08 through VF-10 are worthwhile efficiency changes after the correctness hazards are closed; VF-08 and VF-09 should be addressed together because an immutable palette snapshot and explicit task ownership naturally simplify both paths.
|
||||
+13
-10
@@ -79,6 +79,7 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
|
||||
| `../../VoxelForge.uplugin` | Plugin manifest. One Runtime module `VoxelForge`. Beta. |
|
||||
| `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. |
|
||||
| `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). |
|
||||
| `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. The former ambiguous `Cave Bail Not Op Stack` is split into `Sole Slot`, `Boundary Tile`, `No Layout`, and late `Recheck` counters, so each increment names one guard/context. |
|
||||
|
||||
### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it)
|
||||
| Symbol | Line | Notes |
|
||||
@@ -151,11 +152,11 @@ 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. |
|
||||
| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
|
||||
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. |
|
||||
| `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`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
|
||||
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion** that the original lacks — see the suspected staleness note in AUDIT §C2. `EffectOverBox` → `Both` for now (a real answer means building the cache for the queried box; only pays once `ClassifyTile` consumes `ClassifyBox`). |
|
||||
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). `EffectOverBox` → **`CarveOnly` everywhere** — no spatial bound, so it kills `AllSolid` on every tile of every strate with worms on. `MaxCarveAmplitude()` holds the bound from DECOMPOSITION §0.2 that would recover it, waiting for a fold that carries numbers. |
|
||||
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)` — **any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `−K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|A−B\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. |
|
||||
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. |
|
||||
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). |
|
||||
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
|
||||
| `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. |
|
||||
@@ -250,24 +251,26 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
||||
> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_ClassifyTile` (T1.d) / `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)".
|
||||
|
||||
### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp`
|
||||
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services
|
||||
`StrateManager` + `DiffLayer` (both nullable). This is **where terrain shape lives.**
|
||||
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, a process-unique
|
||||
`DensityCacheOwnerId`, and injected services `StrateManager` + `DiffLayer` (both nullable).
|
||||
This is **where terrain shape lives.**
|
||||
|
||||
| Symbol | .cpp line | Role |
|
||||
|--------|-----------|------|
|
||||
| `UVoxelGenerator` / `DensityCacheOwnerId` | — | Constructor allocates a process-unique integer identity (relaxed atomic, once per object). `GetDensityAt` includes it in the `CP_*` thread-local key, preventing a worker from serving another generator/world's params, biome context, `CP_UseOpStack`, or stack when `(ChunkCoord, LayoutVersion)` happens to match. Hot-path cost: one `uint64` compare per voxel. Scope is deliberately only the proved `CP_*` path. |
|
||||
| `FractalNoise3D` (static) | 25 | fBM (layered Perlin). |
|
||||
| `RidgedNoise3D` (static) | 55 | Ridged multifractal — craggy. |
|
||||
| `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. |
|
||||
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. |
|
||||
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. |
|
||||
| **`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. §8.10. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -337,7 +340,7 @@ Maps depth→strate at runtime; owns passages.
|
||||
- `FStrateSlot` (h:84): definition + chunk-Z range + index.
|
||||
| Method | .cpp line | Role |
|
||||
|--------|-----------|------|
|
||||
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), then `GeneratePassages`. |
|
||||
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. |
|
||||
| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-type control points). |
|
||||
| `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. |
|
||||
| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. |
|
||||
@@ -433,7 +436,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
|
||||
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
|
||||
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
|
||||
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
|
||||
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. Known-pessimistic: proves **0 of 60** tiles (its `EffectOverBox` rejects on a `Spacing*1.6` halo instead of real connector capsules — lost CPU, never a hole). |
|
||||
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. |
|
||||
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
|
||||
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. |
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Codex task 001 — make tile-skipping observable in the running game
|
||||
|
||||
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||
**Status:** specified, not started
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
Jahni built the world, looked at it, and said: *"I don't know if it dropped any meshing? but it looks
|
||||
alright by the eye."*
|
||||
|
||||
He's right to be unsure — **there is no way to answer that question from inside the game.** The
|
||||
plugin has **zero** stat counters (`grep INC_DWORD_STAT` → nothing). Tile-skipping is the largest
|
||||
perf item in the whole plan and it is currently unobservable in production; it has only ever been
|
||||
measured in an automation harness, on 40 sampled tiles.
|
||||
|
||||
And a visual check cannot answer it: *skipped correctly* and *skipped nothing* render identically.
|
||||
This codebase has paid repeatedly for exactly that confusion — see the "coverage is a number, not a
|
||||
boolean" lessons in `OPSTACK-HANDOFF.md`.
|
||||
|
||||
**The real prize:** with no strate opted in, cave-archetype skips must read **0**. After ticking
|
||||
`bUseOperatorStack` on one `TunnelNetwork` strate and flying underground, they must become non-zero.
|
||||
That is the **production-side proof of T1.d**, which does not exist today.
|
||||
|
||||
## The sites — there are TWO, and the second one is the one that answers the question
|
||||
|
||||
### Site A — the skip itself
|
||||
|
||||
`Source/VoxelForge/Private/VoxelWorld.cpp`, in **`AVoxelWorld::GenerateTileResult`** (~line 1501).
|
||||
Trust the symbol, not the line number.
|
||||
|
||||
```cpp
|
||||
bool bTrivialEmpty = false;
|
||||
if (!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel == 0.0f)
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile);
|
||||
bTrivialEmpty = (Generator->ClassifyTile(OriginVoxels, Step, Cells) != EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
FVoxelMeshData MeshData;
|
||||
if (!bTrivialEmpty)
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
||||
MeshData = bSheetTile ? Mesher->GenerateSheetMesh(...) : Mesher->GenerateMesh(...);
|
||||
}
|
||||
```
|
||||
|
||||
### Site B — where the OPERATOR STACK's verdict is produced
|
||||
|
||||
`Source/VoxelForge/Private/VoxelGenerator.cpp`, in **`UVoxelGenerator::ClassifyTile`**, at the **exit
|
||||
of the `if (bAnyCave)` block** (~line 3009) — the last two lines of that block:
|
||||
|
||||
```cpp
|
||||
if (bCanSolid == bCanAir) { return EVoxelTileClass::Mixed; }
|
||||
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
|
||||
```
|
||||
|
||||
**Why site A alone cannot answer the question — this is the correction that makes the task
|
||||
meaningful.** `ClassifyTile` has *two* independent ways to reach a non-`Mixed` verdict:
|
||||
|
||||
- the **hand-written** path, which predates all of this work: a chunk in a **bedrock gap** sets
|
||||
`bCanAir = false` (VoxelGenerator.cpp ~2835) and, absent a passage or the origin spine, the tile
|
||||
resolves **`AllSolid`**. Likewise the SurfaceWorld column scan. This fires with **no strate opted
|
||||
in at all**;
|
||||
- the **operator-stack** path, the `if (bAnyCave)` block, which is the only thing T1.d added.
|
||||
|
||||
So `TilesSkippedAllSolid` at site A **will already be non-zero underground before any strate is
|
||||
ticked** — the bedrock between strates guarantees it. A single lumped counter would make the
|
||||
before/after unreadable, and that is exactly the "when a zero has several possible causes, give each
|
||||
one its own number" lesson this project already paid for.
|
||||
|
||||
Site B's counters have the opposite property, and it is a strong one: `ClassifyTile` returns `Mixed`
|
||||
outright at the cave branch when `UsesOperatorStackForChunk(CC)` is false (~line 2809, and again per
|
||||
chunk of the box at ~2914). **With no strate opted in, the site-B counters are zero by
|
||||
construction, not merely by observation** — so a non-zero reading after ticking the box cannot come
|
||||
from anywhere else.
|
||||
|
||||
## What to build
|
||||
|
||||
1. **A stat group.** New header `Source/VoxelForge/Public/VoxelStats.h`:
|
||||
`DECLARE_STATS_GROUP(TEXT("VoxelForge"), STATGROUP_VoxelForge, STATCAT_Advanced);` plus
|
||||
`DECLARE_DWORD_COUNTER_STAT_EXTERN` for each counter below. `DEFINE_STAT` for each goes in **one**
|
||||
`.cpp` — put them in a new `Source/VoxelForge/Private/VoxelStats.cpp`.
|
||||
|
||||
2. **Six per-frame counters** (`DWORD_COUNTER`, so `stat VoxelForge` shows a rate, not a total):
|
||||
|
||||
| counter | site | incremented when |
|
||||
|---|---|---|
|
||||
| `TilesClassified` | A | the classifier gate was entered (the `if` above ran `ClassifyTile`) |
|
||||
| `TilesSkippedAllSolid` | A | verdict was `AllSolid` |
|
||||
| `TilesSkippedAllAir` | A | verdict was `AllAir` |
|
||||
| `TilesMeshed` | A | `GenerateMesh` / `GenerateSheetMesh` actually ran |
|
||||
| `TilesOpStackSolid` | B | the `bAnyCave` block returned `AllSolid` |
|
||||
| `TilesOpStackAir` | B | the `bAnyCave` block returned `AllAir` |
|
||||
|
||||
Splitting solid from air is the point, not decoration: **cave archetypes prove `AllSolid`**.
|
||||
Splitting site B from site A is the whole deliverable — see "Why site A alone cannot answer the
|
||||
question" above. `TilesOpStackSolid ≤ TilesSkippedAllSolid` always, and the difference is the
|
||||
pre-existing bedrock/surface skipping.
|
||||
|
||||
At site B, increment on the `return` line only — **not** before the
|
||||
`if (bCanSolid == bCanAir) return Mixed;` guard, which is where the block bails out with no
|
||||
verdict.
|
||||
|
||||
3. To get the verdict you need it as a value, not a bool. Changing
|
||||
`bTrivialEmpty = (Classify(...) != Mixed)` into a stored `EVoxelTileClass Verdict = Classify(...)`
|
||||
followed by `bTrivialEmpty = (Verdict != Mixed)` is **fine and expected**.
|
||||
|
||||
## ⚠️ Invariants — a violation here is not a bug, it is a hole
|
||||
|
||||
1. **DO NOT change `bTrivialEmpty`'s value or the control flow.** That bool decides whether a tile
|
||||
gets geometry **and collision**. A wrong value is invisible until a player falls through the
|
||||
floor. Refactor the expression, never the condition.
|
||||
2. **DO NOT touch the gate `!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel
|
||||
== 0.0f`.** Every clause is load-bearing and documented in the comment above it — sheet tiles have
|
||||
no marching cubes, capture tiles need the grid even when uniform, and the verdicts assume the MC
|
||||
iso is exactly zero.
|
||||
3. **Thread safety: this runs on WORKERS.** `GenerateTileResult` is called from the async ChunkGen
|
||||
task *and* the synchronous carve path. Use the `INC_DWORD_STAT` family, which is per-thread-packet
|
||||
safe. **A plain `static int32` counter, even `++` on an `int32`, is a data race — do not.**
|
||||
4. **Zero cost when stats are compiled out.** The `INC_DWORD_STAT` macros already vanish when
|
||||
`STATS == 0`. Do not wrap them in an `if` that survives, and do not compute anything solely to
|
||||
feed a counter outside the macro.
|
||||
5. **No new includes in a public header beyond `Stats/Stats.h`**; the plugin follows IWYU and the
|
||||
include debt was cleared deliberately (`AUDIT §C9` work).
|
||||
6. **At site B, do not touch `ClassifyTile`'s control flow either — and do not add an early
|
||||
`return`.** That function is a chain of conservative guards that all **fail to `Mixed`**; every
|
||||
`return` in it is load-bearing. Add the counter to the existing `return` expression's statement,
|
||||
nothing else. `ClassifyTile` is `const` and runs on the same workers as site A, so the same
|
||||
`INC_DWORD_STAT`-not-`static int32` rule applies.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Editor, `stat VoxelForge` on screen, fly around: the numbers move.
|
||||
- **`TilesClassified == TilesSkippedAllSolid + TilesSkippedAllAir + TilesMeshed`** for tiles that
|
||||
entered the gate. (Tiles that fail the gate are meshed without being classified, so `TilesMeshed`
|
||||
is legitimately larger than the classified total — say so in a comment rather than "fixing" it.)
|
||||
- **`TilesOpStackSolid ≤ TilesSkippedAllSolid`** and **`TilesOpStackAir ≤ TilesSkippedAllAir`**,
|
||||
always. A violation means site B is counting a verdict that site A did not act on.
|
||||
- With **no strate opted in**, flying underground through a `TunnelNetwork` strate:
|
||||
- `TilesSkippedAllSolid` is expected to be **non-zero** — that is the pre-existing bedrock/surface
|
||||
skipping, not a bug, and it is why the lumped counter cannot be the deliverable;
|
||||
- `TilesOpStackSolid` and `TilesOpStackAir` are **0**. This is the baseline, and it must be
|
||||
*observed* before the next step even though it is guaranteed by the flag gate.
|
||||
- Tick `bUseOperatorStack` on **one** `TunnelNetwork` strate, fly the same route:
|
||||
**`TilesOpStackSolid` becomes non-zero.** ← this is the deliverable, and it is the first
|
||||
production-side evidence T1.d has ever had.
|
||||
|
||||
## Notes for the reviewer (Claude)
|
||||
|
||||
- Check the verdict refactor byte-for-byte against the original condition. `!= Mixed` is the whole
|
||||
contract.
|
||||
- Check the counters are `DWORD_COUNTER` (per-frame) and not `DWORD_ACCUMULATOR`.
|
||||
- Confirm no counter is incremented outside the gate in a way that double-counts the carve path,
|
||||
which calls `GenerateTileResult` synchronously from the game thread.
|
||||
- Site B: confirm the increment sits **after** the `bCanSolid == bCanAir` bail-out, and that the two
|
||||
counters follow `bCanSolid` the same way the returned enum does — a swapped pair reads as a
|
||||
plausible result and proves the wrong thing.
|
||||
- The automation tests call `ClassifyTile` directly; they will move the site-B counters. Harmless,
|
||||
but do not let a test-only path become the only thing that moves them.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Codex task 002 — is the op-stack column memo thrashing? Count, don't guess.
|
||||
|
||||
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||
**Status:** specified, not started
|
||||
**Depends on:** `CODEX-TASK-001` — this adds counters to the **same** `stat VoxelForge` group.
|
||||
Do 001 first; this task assumes `VoxelStats.h` already exists.
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
The operator-stack density path is measurably slower than the `switch` it replaces, and the cause
|
||||
has never been attributed. There are three standing suspects. **This task measures the first one,
|
||||
and does not fix anything.** That order is deliberate: `AUDIT §C10` cost six builds and five refuted
|
||||
hypotheses by reasoning first, and the last session re-learned it.
|
||||
|
||||
### The hypothesis, derived from the code
|
||||
|
||||
`FSurfaceColumnSource::GetColumn` (`VoxelDensityOpStack.cpp` ~615) memoises a computed column in a
|
||||
**direct-mapped, 4096-entry `thread_local` table**, indexed by a hash of the two XY floats:
|
||||
|
||||
```cpp
|
||||
struct FSlot { uint64 Key; float X, Y; FColumn C; }; // 40 bytes
|
||||
thread_local FSlot Slots[4096] = {}; // 160 KB per worker
|
||||
const uint32 Idx = ((HX * 0x9E3779B9u) ^ (HY * 0x85EBCA6Bu)) >> 20; // [0,4095]
|
||||
```
|
||||
|
||||
The original path, `GSurfColCache` in `GetDensityAt`, is instead a **direct-indexed box**:
|
||||
`CI = (IY - Box.BaseY) * Dim + (IX - Box.BaseX)`, with a `Computed[CI]` flag. **No hash, therefore
|
||||
no collisions, therefore every column is computed exactly once.**
|
||||
|
||||
Now the sampling order, which is the premise that makes this bite. `FVoxelMarchingCubesMesher`
|
||||
pre-samples with **Z as the OUTERMOST loop** (`VoxelMarchingCubesMesher.cpp` ~226):
|
||||
|
||||
```cpp
|
||||
for (int32 gz = GzLo; gz <= GzHi; gz++)
|
||||
for (int32 gy = -1; gy <= GridDim; gy++)
|
||||
for (int32 gx = -1; gx <= GridDim; gx++)
|
||||
Generator->GetDensityAt(...);
|
||||
```
|
||||
|
||||
So the mesher sweeps a **whole XY plane at every Z level**. Every column in the tile is revisited
|
||||
once per Z plane — roughly 34 times.
|
||||
|
||||
The table's own comment sized it for this: *"A chunk is CHUNK_SIZE² columns (1024), so the first
|
||||
draft's 256 entries could not even hold one chunk and thrashed inside a single tile. 4096 covers
|
||||
four chunks."* **That reasoning has a gap.** A direct-mapped table does not need to be full to
|
||||
evict — it needs two live keys to collide. At ~1156 columns per plane in 4096 slots (load factor
|
||||
0.28), the expected number of columns sharing a slot with another is **~285, about 25 %**. Those
|
||||
columns evict each other, miss again on the next Z plane, and recompute the **entire height stack** —
|
||||
structural source, cliff (four structural resamples), terrace, layer-line, beach, and the ceiling
|
||||
stack — every single plane.
|
||||
|
||||
Order of magnitude if that is right: ~1156 column computations on the original path versus
|
||||
~1156 + 285 × 34 ≈ **10 000** on the op path. Roughly **9×** the column work, on the plugin's most
|
||||
expensive archetype.
|
||||
|
||||
**That is a derivation, not a measurement, and it is exactly the kind of confident chain this
|
||||
project has watched reverse six times.** Hence: count first.
|
||||
|
||||
## What to build — two counters, no behaviour change
|
||||
|
||||
Add to the existing `stat VoxelForge` group from task 001:
|
||||
|
||||
| counter | incremented when |
|
||||
|---|---|
|
||||
| `ColumnMemoHit` | `GetColumn` found a live entry (the `if` body did **not** run) |
|
||||
| `ColumnMemoMiss` | `GetColumn` recomputed (the `if` body ran) |
|
||||
|
||||
That is the entire change. Two `INC_DWORD_STAT` calls inside `FSurfaceColumnSource::GetColumn`,
|
||||
around the existing `if (S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY)`.
|
||||
|
||||
## ⚠️ Invariants
|
||||
|
||||
1. **Change nothing else in `GetColumn`.** Not the table size, not the hash, not the key comparison.
|
||||
The point of this task is to produce a number that decides whether the fix is worth writing; a
|
||||
change bundled in makes the number unattributable. **Do not "obviously improve" the table while
|
||||
you are in there** — if the fix ships in the same build as the instrument, we learn nothing, and
|
||||
this project has a written lesson about exactly that.
|
||||
2. **`GetColumn` is `const` and runs on mesher workers.** `INC_DWORD_STAT`, never a `static int32++`
|
||||
— same rule as task 001, same reason.
|
||||
3. **The key comparison is load-bearing and must stay complete.** `S.Key != ColumnKey || S.X != X ||
|
||||
S.Y != Y` — the full key is compared on every touch precisely so a hash collision can only cost a
|
||||
recompute and never return **someone else's column**. Do not shorten it to feed a counter.
|
||||
4. **Zero cost when `STATS == 0`.** No value computed outside the macros.
|
||||
5. `FCaveCliffMod` and the overhang read this same memo through
|
||||
`Column->GetColumn(...)` (~line 864). They are legitimate traffic and must be counted, not
|
||||
excluded — they are part of why a miss is expensive.
|
||||
|
||||
## Acceptance — the prediction is now numeric (tightened 2026-08-16 from the real grid dimensions)
|
||||
|
||||
The hand-wavy "20–30 %" band this section used to carry has been replaced by an arithmetic
|
||||
prediction, because the inputs are all statically knowable and were read out of the source:
|
||||
|
||||
- `CHUNK_SIZE = 32`, `CellsPerAxis = 32`, `GridDim = 33`, and the pre-sample loops run
|
||||
`g ∈ [-1, GridDim]` per axis ⇒ **35 × 35 = 1225 distinct columns per tile**, over **35 Z planes**
|
||||
(the mesher's own buffer comment, "35³ floats", confirms the dimension).
|
||||
- 1225 keys in 4096 slots is a load factor of **0.299**. Expected slots holding exactly one key
|
||||
`= 4096 · np(1-p)^(n-1) ≈ 908`, so **~317 columns (25.9 %) share a slot with another** and evict
|
||||
each other on every plane.
|
||||
- ⇒ op path ≈ `1225 + 34 × 317` ≈ **12 000** column computations per tile.
|
||||
Original path (`GSurfColCache`, direct-indexed, `Computed[CI]` persists) = **1225**.
|
||||
**≈ 9.8×.**
|
||||
|
||||
### How to read the result
|
||||
|
||||
⚠️ **Compare the RATIO OF THE TWO HYPOTHESES, not an absolute percentage.** The overhang and cliff
|
||||
modifiers call `GetColumn` again at the same XY (~line 864); every extra consumer adds **hits** and
|
||||
no misses, so it inflates the denominator and drags the miss *rate* down without changing the
|
||||
verdict. What does not move is the ~10× gap between the two outcomes.
|
||||
|
||||
| observation | verdict |
|
||||
|---|---|
|
||||
| misses ≈ **8–10×** the hit-path baseline (single-consumer: ~28 % of lookups) | **CONFIRMED** — the table evicts on collision every Z plane. The fix gets its own task, with this run as its "before". |
|
||||
| misses ≈ **1 per distinct column** (single-consumer: ~3 %, hit rate ≥ 97 %) | **hypothesis WRONG.** The table behaves like the box, the ~9.8× does not exist, and the perf cost is suspect 2 (19 virtual calls per voxel). |
|
||||
|
||||
*A negative result here is a real result.* It retires the most-suspected cause and is worth the build
|
||||
either way; it must be written into `OPSTACK-PROGRESS.md`, not quietly dropped.
|
||||
|
||||
Report **both raw numbers**, never the ratio alone — a ratio cannot distinguish "few lookups" from
|
||||
"many", and the absolute miss count is what the fix would be reducing.
|
||||
|
||||
## Notes for the reviewer (Claude)
|
||||
|
||||
- Confirm the miss counter sits inside the `if`, and the hit counter in an `else` — not computed
|
||||
from a subtraction, which would silently agree with itself.
|
||||
- Confirm `GetColumn`'s early-out path (if any is added later) cannot skip both counters.
|
||||
- Confirm nothing else in `VoxelDensityOpStack.cpp` changed. `git diff --stat` should show one file
|
||||
and a handful of lines.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Codex task 003 — three `ExtraReach` formulas use an FBM bound this file already proved wrong
|
||||
|
||||
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||
**Status:** specified, not started
|
||||
**Kind:** ⚠️ **correctness of a box verdict** — the class of bug that deletes collision. Not a perf task.
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
`VoxelDensityOpStack.cpp` contains a rigorous, written derivation that `|Perlin3D| ≤ 1.5`, exposes it
|
||||
as `PerlinAbsBound` (line ~2336), and uses it correctly for the tunnel warp dilation (~2452). The
|
||||
comment there is explicit that the loose "~[-1,1]" figure from the noise header is **not** to be
|
||||
relied on, and `OPSTACK-HANDOFF.md` records the standard: *a bound in a box verdict must be PROVED,
|
||||
not observed — over-estimating costs CPU, under-estimating deletes collision.*
|
||||
|
||||
**Three `ExtraReach` formulas in the same file silently assume `sup|FBM| ≤ 1.0`.** Each carries the
|
||||
comment "FBM ∈ [-1,1]", which is exactly the claim the file disproves 1800 lines earlier.
|
||||
|
||||
And `VoxelNoise::FBM` **is normalised** — it returns `Total / MaxValue` where `MaxValue = Σ Amp`
|
||||
(`VoxelNoise.h` ~272). So `sup|FBM| = sup|Perlin3D|` exactly: **1.5, not 1.0.** The octave sum
|
||||
neither amplifies nor attenuates the bound.
|
||||
|
||||
### What that costs, per archetype, at the shipped defaults
|
||||
|
||||
`Identity` from these sources means "no primitive within `ExtraReach` of the box", i.e. `Sdf ≥
|
||||
ExtraReach` throughout. Roughness then does `Sdf += FBM · VOXEL_NOISE_SCALE · Strength`
|
||||
(`FSdfRoughnessMod::Eval`), so worst case `Sdf' ≥ ExtraReach − B·1.25·|Roughness|`. Soundness
|
||||
requires `Sdf'` to stay at or above the downstream carve/fill threshold.
|
||||
|
||||
| archetype | `ExtraReach` at defaults | downstream threshold | needs `B ≤` | verdict at `B = 1.5` |
|
||||
|---|---|---|---|---|
|
||||
| **VerticalShafts** (`Rough 3.0`) | `1.25·3 + 2 + 1` = **6.75** | carve blend **2.0** | **1.27** | ⛔ **UNSOUND** (margin −0.875) |
|
||||
| **Maze** (`Rough 2.0`) | `1.25·2 + 2 + 1` = **5.5** | carve blend **2.0** | **1.40** | ⛔ **UNSOUND** (margin −0.25) |
|
||||
| **FloatingIslands** (`Rough 4.0`, `K 5.0`) | `1.25·4 + 2·5 + 1` = **16.0** | fill `K` **5.0** (+ `K/6` SmoothMin dip) | **2.03** | ✅ sound — but only because `K` is large. Sound by parameter luck, not by construction. |
|
||||
|
||||
Break-even roughness for the two carve archetypes is `|Rough| ≤ 1.6`; they ship at 3.0 and 2.0.
|
||||
|
||||
**How alarmed to be, stated honestly.** No strate has `bUseOperatorStack` ticked, so nothing in the
|
||||
running game is affected today. The brute-force tile scans report 0 violations — but they *sample*,
|
||||
and they were sampling against a shaft source that proved **zero** tiles until `e002bd4`, so the
|
||||
shaft path has never been exercised at all. The empirical sup of this Perlin is estimated at
|
||||
~1.0–1.1, which is *below* the 1.27 the shafts need — which is why nothing has been seen yet, and
|
||||
also why the margin is uncomfortably thin. The bug is that the verdict rests on an unproved bound,
|
||||
which is the thing this codebase has already decided it does not do.
|
||||
|
||||
## The fix
|
||||
|
||||
1. **Hoist `PerlinAbsBound` to file scope and rename it `VF_PerlinAbsBound`**, so there is **one**
|
||||
definition rather than a class-static plus three implicit `1.0`s. Keep the existing derivation
|
||||
comment with it — it is the justification, not decoration.
|
||||
|
||||
**Naming, resolved:** the file-scope helpers in this file are all `VF_`-prefixed
|
||||
(`VF_NearCaveSurface`, `VF_DistPointSegment`, `VF_NoCaveOverBox`), so a file-scope constant takes
|
||||
the same prefix. That means this is a **rename**, not just a move:
|
||||
- delete the `static constexpr float PerlinAbsBound = 1.5f;` class-static inside `FRoomGraphSource`
|
||||
(~2336), moving its whole derivation comment with it;
|
||||
- **update `FRoomGraphSource`'s own use at ~2452** (`P.CaveWarpStrength * VOXEL_NOISE_SCALE *
|
||||
PerlinAbsBound`) to the new name. This is the one place where the warp dilation is computed and
|
||||
it must keep computing the identical value — the rename must not change its arithmetic.
|
||||
- after the edit, `grep -n "PerlinAbsBound" ` must show **only** `VF_PerlinAbsBound` occurrences.
|
||||
2. **Multiply the roughness term by it in all three `ExtraReach` formulas** (~4106 VerticalShafts,
|
||||
~4215 FloatingIslands, ~4247 Maze):
|
||||
|
||||
```cpp
|
||||
// before
|
||||
FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE
|
||||
// after
|
||||
FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound
|
||||
```
|
||||
3. **Fix the three comments.** Each says "FBM ∈ [-1,1]". Replace with the real statement: `FBM` is
|
||||
normalised (`Total / MaxValue`), so `sup|FBM| = sup|Perlin3D| =` the proved `PerlinAbsBound`.
|
||||
A comment that states a refuted bound is how this happened in the first place.
|
||||
|
||||
## ⚠️ Invariants
|
||||
|
||||
1. **This must not change density by one bit.** `ExtraReach` is read **only** inside
|
||||
`EffectOverBox` (verified: every other occurrence is a comment or the `float ExtraReach;` member
|
||||
declaration — no `Eval`, no `GetCells`). The eight equivalence tests compare `Eval` bit for bit
|
||||
and must stay green. **If you find yourself editing an `Eval`, stop — you have the wrong site.**
|
||||
2. **The change direction is strictly conservative**: larger `ExtraReach` ⇒ more `CarveOnly`, fewer
|
||||
`Identity` ⇒ *fewer* tiles proved uniform. It can only cost CPU, never open a hole. Do not
|
||||
"balance" it by tightening something else in the same edit.
|
||||
3. **Anonymous-namespace placement.** Put the hoisted constant **above the labelled end of the
|
||||
anonymous namespace**, not anchored on the FACTORIES banner — anchoring there puts it outside and
|
||||
the brace added with it closes nothing. This mistake has been made twice in this file and the
|
||||
file says so.
|
||||
4. **`FRoomGraphSource`'s warp dilation changes NAME ONLY.** It already uses the bound correctly and
|
||||
is the reference implementation for this fix; the value it computes must be bit-identical after
|
||||
the rename. Do not alter its formula, its `√2` factor, or anything else in that function.
|
||||
5. Comments are French + English; match the surrounding file.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `git diff --stat` shows **one** file: `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`.
|
||||
- All three `ExtraReach` definitions include the bound; no fourth site exists (`BuildTunnelNetworkStack`
|
||||
has no `ExtraReach` — it uses `PerlinAbsBound` directly for the warp).
|
||||
- No `Eval` body changed.
|
||||
- After the build: the eight equivalence tests stay green (density unchanged), and the box-verdict
|
||||
lines for **Maze** and **VerticalShafts** may report *fewer* proved tiles than before. **A drop
|
||||
there is the expected, correct outcome, not a regression** — it is the cost of a sound bound.
|
||||
Record the before/after in `OPSTACK-PROGRESS.md`.
|
||||
|
||||
## Notes for the reviewer (Claude)
|
||||
|
||||
- Confirm the constant is genuinely at file scope inside the anonymous namespace and that the
|
||||
class-static is gone, not shadowed — two definitions that can drift is the failure this fixes.
|
||||
- Confirm all three call sites got it. Two out of three is worse than none, because it looks done.
|
||||
- Confirm no `Eval`, `GetCells`, or `GetCellsAt` body appears in the diff.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Codex task 004 — two box verdicts assume `MinRadius ≤ MaxRadius`; a third one already doesn't
|
||||
|
||||
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||
**Status:** specified, not started
|
||||
**Kind:** ⚠️ **correctness of a box verdict.** Same class as task 003. Small fix, closes a class.
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
Three operators roll a primitive radius from a hash between two designer-set params:
|
||||
|
||||
```cpp
|
||||
Out.R = FMath::Lerp(MinRadius, MaxRadius, hash01); // FGridColumnMod ~1129
|
||||
Out.R = FMath::Lerp(P.ShaftMinRadius, P.ShaftMaxRadius, hash01); // FShaftFieldSource ~1560
|
||||
Out.Rxy = FMath::Lerp(P.IslandMinRadius, P.IslandMaxRadius, hash01); // FIslandBlobSource ~1850
|
||||
```
|
||||
|
||||
`FMath::Lerp(A, B, t)` with `t ∈ [0,1]` lands anywhere in `[min(A,B), max(A,B)]` — it does **not**
|
||||
require `A ≤ B`.
|
||||
|
||||
Each op's `EffectOverBox` then sweeps a **range of lattice cells** around the query box, padded by
|
||||
the largest radius a cell could hold, and tests each rolled primitive exactly. The pad decides which
|
||||
cells are *looked at at all*, so a pad smaller than the true maximum radius means **cells are never
|
||||
examined**, their primitives are never tested, and the op reports `Identity` for a box that its own
|
||||
`Eval` will carve or fill.
|
||||
|
||||
| op | pad used for the cell sweep | correct? |
|
||||
|---|---|---|
|
||||
| `FGridColumnMod` ~1086 | `FMath::Max(MaxRadius, 0.0f) + ColBlend` | ⛔ **exposed** |
|
||||
| `FShaftFieldSource` ~1436 | `FMath::Max(P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach` | ⛔ **exposed** |
|
||||
| `FIslandBlobSource` ~1803 | `const float MaxR = FMath::Max(P.IslandMinRadius, P.IslandMaxRadius);` | ✅ **already correct** |
|
||||
|
||||
**The third one is the point.** Someone hit this exact concern while writing the island source and
|
||||
guarded it. The other two shipped without the guard. This task makes the three consistent.
|
||||
|
||||
## How exploitable, stated honestly
|
||||
|
||||
The shipped defaults are correctly ordered (`2/5`, `2/7`, `5/11`), so **nothing is broken out of the
|
||||
box.** It needs a mis-ordered asset value — `ColumnMinRadius = 8, ColumnMaxRadius = 4`.
|
||||
|
||||
Nothing prevents that. The `UPROPERTY` metas carry `ClampMin = "1.0"`, which is a per-property
|
||||
floor; Unreal has no declarative way to say "must be ≤ that other property". And `ColumnMinRadius`
|
||||
is *also* settable per-room through `UVoxelTerrainOpDefinition`, so it is not only the strate asset.
|
||||
|
||||
What makes it worth the five lines: when it does happen, the failure is **invisible and maddening**.
|
||||
`Eval` still draws the fat column perfectly, so every tile that gets meshed looks correct; only the
|
||||
tiles the classifier *skipped* are missing — no geometry, no collision, in a world that otherwise
|
||||
looks right.
|
||||
|
||||
## The fix — five lines, and one thing you must NOT do
|
||||
|
||||
Make each pad use the true envelope:
|
||||
|
||||
```cpp
|
||||
// FGridColumnMod ~1086
|
||||
const float Reach = FMath::Max3(MinRadius, MaxRadius, 0.0f) + ColBlend;
|
||||
|
||||
// FShaftFieldSource ~1436
|
||||
const float Pad = FMath::Max3(P.ShaftMinRadius, P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach;
|
||||
```
|
||||
|
||||
(Use whatever spelling is idiomatic here — check that `FMath::Max3` is already used in this codebase
|
||||
before reaching for it; nested `FMath::Max` is fine and matches `FIslandBlobSource`'s existing line.)
|
||||
|
||||
### ⛔ DO NOT "fix it properly" by normalising the params
|
||||
|
||||
The tempting larger fix — swap `Min`/`Max` at resolution time so `Min ≤ Max` always — is **wrong and
|
||||
will break the build's tests.** `Eval` computes `Lerp(Min, Max, t)`; swapping the endpoints maps the
|
||||
same hash `t` to a *different* radius for the same cell. That changes generated geometry and breaks
|
||||
the eight bit-for-bit equivalence tests against the `switch` path.
|
||||
|
||||
**Only the BOUND may become conservative. `Eval` stays byte-identical.** This is the same rule as
|
||||
task 003 and the same reason.
|
||||
|
||||
## ⚠️ Invariants
|
||||
|
||||
1. **No `Eval`, `RollColumn`, `RollShaft`, `GetCells`, or `GetCellsAt` body may change.** If your
|
||||
diff touches one, you have the wrong site — stop and say so.
|
||||
2. **Do not touch `FIslandBlobSource`.** It is already correct and is the reference for this fix.
|
||||
3. The change direction is strictly conservative: a wider sweep examines *more* cells, so a verdict
|
||||
can only move from `Identity` toward `CarveOnly`/`FillOnly`, never the reverse. Do not add any
|
||||
compensating tightening.
|
||||
4. Comments are French + English; match the surrounding file. Say **why** the envelope is
|
||||
`max(Min, Max)` and not `Max` — the next reader must not "simplify" it back.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `git diff --stat` shows exactly one file: `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`,
|
||||
and a handful of lines.
|
||||
- The three ops now agree on the pattern.
|
||||
- After the build: **every box-verdict line and every equivalence test is unchanged**, because the
|
||||
shipped defaults are correctly ordered and the envelope only differs when they are not. **A change
|
||||
in any of those numbers means the diff did something it should not have.** That is this task's
|
||||
whole acceptance signal — a *no-op at defaults* is the expected, correct result.
|
||||
|
||||
## Notes for the reviewer (Claude)
|
||||
|
||||
- Confirm both pads changed and `FIslandBlobSource` did not.
|
||||
- Confirm no `Lerp` argument order was touched anywhere — that is the failure mode that would look
|
||||
like a tidy-up and silently change the world.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Codex task 005 — three box-verdict tile scans sample less than one lattice period
|
||||
|
||||
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||
**Status:** specified, not started
|
||||
**Kind:** test coverage. **Zero risk to the game — no non-test file may change.**
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
This project has already found and fixed this exact defect **twice**:
|
||||
|
||||
> *"A sampler must cover at least one period of what it samples. The tunnel test drew tile XY from
|
||||
> ±32 voxels with `RoomSpacing 80` — it measured the spine hub and called it the world. The shaft
|
||||
> test had the identical bug (±48 against `ShaftSpacing 55`)."* — `OPSTACK-HANDOFF.md`
|
||||
|
||||
Both were fixed to `SpanCells = 55` ⇒ **±440 voxels**, and both now print their own extent in units
|
||||
of the pattern's period so it cannot silently regress.
|
||||
|
||||
**Three box-verdict tile scans were never fixed**, because the fix was applied where the bug was
|
||||
noticed rather than to the class. All three still use the original `Rng.RandRange(-6, 6) * Extent`
|
||||
with `Step = 1, Cells = 8` ⇒ `Extent = 8` ⇒ **half-extent ±48 voxels**:
|
||||
|
||||
| test | half-extent | lattice period (default, **unchanged by the fixture**) | coverage |
|
||||
|---|---|---|---|
|
||||
| `VoxelForgeOpStackIslandTest.cpp` ~244 | ±48 | `IslandSpacing` **95** | **0.51 periods** ⛔ worse than either bug already fixed |
|
||||
| `VoxelForgeOpStackSlabTest.cpp` ~289 | ±48 | `ColumnSpacing` **60** | **0.80 periods** ⛔ |
|
||||
| `VoxelForgeOpStackMazeTest.cpp` ~276 | ±48 | `CellSize` **40** | 1.20 periods ⚠️ marginal |
|
||||
|
||||
Verified: none of `EnableIslandFeatures` / the slab tuning / the maze setup overrides the spacing, so
|
||||
the header defaults are what these tests actually run against.
|
||||
|
||||
**Why it matters right now, specifically.** Two commits just changed the box verdicts these very
|
||||
tests are supposed to guard — `7dbdf51` (the `ExtraReach` bound, which touches **islands**, maze and
|
||||
shafts) and `eaa44bf` (the radius envelope, which touches **slab columns** and shafts). The tests
|
||||
that would catch a mistake in those changes currently sample about half a lattice cell.
|
||||
|
||||
## The rule the two fixed tests already encode
|
||||
|
||||
`Extent = Step * Cells = 8` voxels, so `SpanVoxels = SpanCells * 8`. To get **8 periods** of
|
||||
half-extent you set:
|
||||
|
||||
> **`SpanCells` = the lattice spacing** (`55` for `ShaftSpacing 55` — that is where the shaft test's
|
||||
> `55` comes from, and it is not a coincidence).
|
||||
|
||||
Apply the same:
|
||||
|
||||
| test | `SpanCells` | resulting half-extent | periods |
|
||||
|---|---|---|---|
|
||||
| Island | `95` | ±760 | 8.0 |
|
||||
| Slab | `60` | ±480 | 8.0 |
|
||||
| Maze | `40` | ±320 | 8.0 |
|
||||
|
||||
## What to build
|
||||
|
||||
For each of the three tests, mirror **exactly** what `VoxelForgeOpStackShaftTest.cpp` (~212) does:
|
||||
|
||||
1. Hoist `const int32 SpanCells` and `const int32 SpanVoxels = SpanCells * 8;` **outside the tile
|
||||
loop** — the report needs them and `Extent` is loop-local. (That scoping slip has already happened
|
||||
once in this file family; the shaft test's comment records it.)
|
||||
2. Draw `Rng.RandRange(-SpanCells, SpanCells) * Extent` for X and Y. **Leave the Z draw exactly as
|
||||
it is** — it is clamped to the strate slot and is not part of this defect.
|
||||
3. Extend the existing report line to print `SpanVoxels`, the live ratio
|
||||
`(float)SpanVoxels / FMath::Max(<the spacing param>, 1.0f)`, and the spacing itself — so the
|
||||
extent is stated in units of the pattern's own period and a future narrowing is visible.
|
||||
|
||||
## ⚠️ Invariants
|
||||
|
||||
1. **This is NOT "widen until it passes."** The comment already in the tunnel test says it best and
|
||||
the same reasoning applies here: *every proved tile is still brute-forced voxel by voxel below, so
|
||||
a wider sampler that produced a FALSE verdict fails exactly as before. We are changing what the
|
||||
measurement **looks at**, not what it **demands**.* Do not touch the brute-force loop, its
|
||||
tolerance, or any `AddError`.
|
||||
2. **Do not adjust an assertion to accommodate a moved number.** Widening will change the proved /
|
||||
Mixed counts — that is the point. If an existing assertion would now fail, **report it and stop**;
|
||||
do not retune it. ("Don't assert a number you want to improve" is a written lesson here.)
|
||||
3. **No file outside `Source/VoxelForge/Private/Tests/` may change.** `git diff --stat` must list
|
||||
only those three test files.
|
||||
4. Do not change `Step`, `Cells`, the tile count (`60`), or the RNG seeds — a changed seed makes the
|
||||
before/after incomparable, and comparability is the whole point of touching this now.
|
||||
5. Comments are French + English; match the surrounding file.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Three test files changed, nothing else.
|
||||
- Each of the three now prints its extent **and** that extent in periods of its own spacing param.
|
||||
- After the build, each ratio line reads **≥ 8 periods**.
|
||||
- The proved counts will move. **That is expected.** What must NOT move: `violations` / `NumUnsound`
|
||||
stays **0** in all three. If it becomes non-zero, the wider sampler has found a genuine hole that
|
||||
the narrow one was hiding — which would be this task paying for itself immediately, and must be
|
||||
reported loudly rather than tuned away.
|
||||
|
||||
## Notes for the reviewer (Claude)
|
||||
|
||||
- Confirm `SpanCells`/`SpanVoxels` are outside the tile loop in all three.
|
||||
- Confirm the Z draw is untouched.
|
||||
- Confirm the ratio is computed **live** from the params struct, not hardcoded — a hardcoded "8.0
|
||||
periods" in a format string would be a success message that asserts coverage while measuring
|
||||
nothing, which is a named failure mode in this project.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Codex task 006 — the same `Min > Max` under-bound, in `BuildChunkCache` (both density paths)
|
||||
|
||||
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||
**Status:** specified, not started
|
||||
**Kind:** ⚠️ **correctness of a collection bound.** Third instance of this class; the worst of the three.
|
||||
**Credit:** found by the Sol-High read-only audit (`AUDIT-2026-08-CODEX.md`, VF-05) and **verified
|
||||
against the code** before being specified.
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
`CODEX-TASK-004` fixed two cell-sweep pads that assumed the field named `Max*` was numerically the
|
||||
larger one. **The same defect exists in `VoxelCaveMorphology.cpp`, and it matters more**, for three
|
||||
reasons:
|
||||
|
||||
1. It is **`TunnelNetwork`** — the largest and most-used archetype.
|
||||
2. `BuildChunkCache` is called by **both** density paths: the original `switch` *and*
|
||||
`FRoomGraphSource`, which deliberately calls it rather than transcribing it. **This is not an
|
||||
operator-stack bug — it is in the shipped original code and always has been.**
|
||||
3. Its failure mode is a **window-invariance break** (`ARCHITECTURE §8.4`), not just a missing room:
|
||||
whether a room exists depends on which chunk you queried from. In a multiplayer game that means
|
||||
two peers generate different geometry from the same seed.
|
||||
|
||||
### The mechanism
|
||||
|
||||
Radii are interpolated:
|
||||
|
||||
```cpp
|
||||
Room.RadiusXY = FMath::Lerp(Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor); // ~262
|
||||
const float RadA = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorA); // ~467
|
||||
const float RadB = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorB); // ~468
|
||||
```
|
||||
|
||||
`FMath::Lerp(A, B, t)` with `t ∈ [0,1]` yields anywhere in `[min(A,B), max(A,B)]` — it does **not**
|
||||
require `A ≤ B`. But every bound derived from those radii reads only the `Max*` field:
|
||||
|
||||
| site | line | expression |
|
||||
|---|---|---|
|
||||
| `MaxInfluence` | ~127–130 | `FMath::Max(Params.MaxRoomRadius, Params.TunnelWarpStrength + Params.TunnelMaxRadius) + Params.SDFBlendRadius` |
|
||||
| `CollectMargin` | ~152 | `2.0f * MaxTunnelLen + MaxInfluence` (inherits it — **no separate edit needed**) |
|
||||
| `RoomZBuffer` | ~171 | `Params.MaxRoomRadius * Params.RoomHeightRatio` |
|
||||
| `EvaluateSDF`'s `Margin` | ~871–874 | the same expression as `MaxInfluence`, duplicated |
|
||||
|
||||
With `MinRoomRadius > MaxRoomRadius`, rooms larger than `MaxInfluence` are generated, so a room that
|
||||
can reach a chunk may sit in a cell the collect region never visited. `RoomReachesSearchBox` uses the
|
||||
*actual* radius and is therefore correct — but it can only test rooms that were collected at all.
|
||||
|
||||
## The fix
|
||||
|
||||
Derive **bound-only envelopes** and use them at all four sites:
|
||||
|
||||
```cpp
|
||||
const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius);
|
||||
const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius);
|
||||
```
|
||||
|
||||
- `MaxInfluence` → `FMath::Max(RoomRadiusEnvelope, Params.TunnelWarpStrength + TunnelRadiusEnvelope) + Params.SDFBlendRadius`
|
||||
- `RoomZBuffer` → `RoomRadiusEnvelope * Params.RoomHeightRatio`
|
||||
- `EvaluateSDF`'s `Margin` → the same corrected expression.
|
||||
|
||||
⚠️ `MaxInfluence` and `EvaluateSDF`'s `Margin` are the **same formula written twice**. They must stay
|
||||
identical. If a shared helper is natural here, use one — two copies of a rule that must agree is a
|
||||
bug factory, and this file already has the duplication. If you introduce a helper, keep it local to
|
||||
this translation unit and do not change either call site's semantics.
|
||||
|
||||
## ⛔ DO NOT reorder the `Lerp` endpoints
|
||||
|
||||
Swapping to `Lerp(min, max, t)` maps the same hash `t` to a **different radius** for the same room,
|
||||
which changes generated geometry and breaks the eight bit-for-bit equivalence tests. **Only the
|
||||
bounds may become conservative. The three `Lerp` calls must not be touched at all.**
|
||||
This is the same rule as tasks 003 and 004, and it is the third time it applies.
|
||||
|
||||
## ⚠️ Invariants
|
||||
|
||||
1. **No `Lerp` line changes. No room/tunnel placement, hashing, or `bStore` logic changes.**
|
||||
If your diff touches `Room.RadiusXY`, `RadA`, `RadB`, or `RoomReachesSearchBox`, stop and say so.
|
||||
2. The change is strictly conservative: a larger envelope collects **more** cells, never fewer.
|
||||
3. Exactly one file: `Source/VoxelForge/Private/VoxelCaveMorphology.cpp`.
|
||||
4. Comments are French + English; match the file. Say **why** the envelope is `max(Min, Max)` — the
|
||||
next reader must not "simplify" it back to `MaxRoomRadius`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- One file changed, a handful of lines.
|
||||
- **With correctly ordered params, `max(Min, Max) == Max`, so every number is bit-identical and this
|
||||
is a NO-OP. That is the acceptance signal.** The eight equivalence tests, every box-verdict line
|
||||
and every `violations` count must be **unchanged** after the build. A moved number means the diff
|
||||
did something it should not have.
|
||||
- It only changes behaviour for an asset whose range is inverted — which is precisely the case that
|
||||
was silently producing window-dependent geometry.
|
||||
|
||||
## Notes for the reviewer (Claude)
|
||||
|
||||
- Confirm all four bound sites use the envelopes, and that `CollectMargin` inherits rather than being
|
||||
edited separately.
|
||||
- Confirm `MaxInfluence` and `EvaluateSDF`'s `Margin` still compute the identical expression.
|
||||
- Confirm no `Lerp` argument order changed anywhere in the file.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Codex task 007 — VF-01: never mutate layout/passages while workers read them
|
||||
|
||||
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||
**Status:** specified, not started
|
||||
**Kind:** ⚠️ **crash class (use-after-free).** Highest-severity item found on 2026-08-16.
|
||||
**Origin:** Sol-High audit VF-01, **independently confirmed by reading** before this spec was written.
|
||||
|
||||
---
|
||||
|
||||
## The defect
|
||||
|
||||
`UVoxelStrateManager::Initialize` does `StrateLayout.Empty()` and `Passages.Empty()` + `Passages.Add()`
|
||||
— it **frees and reallocates** both arrays. There is **no lock, no barrier, no drain** in that file.
|
||||
|
||||
Meanwhile those same arrays are read **on mesher worker threads**:
|
||||
|
||||
| reader | access |
|
||||
|---|---|
|
||||
| `AnyPassageNearBox` (`VoxelStrateManager.cpp:460`) | range-`for` over `Passages` |
|
||||
| `EvaluateModifierSDF` | indexes `Passages[...]` |
|
||||
| `FindSlotIndexForChunkZ` | iterates `StrateLayout` |
|
||||
|
||||
all reached from `GetDensityAt` / `ClassifyTile` inside chunk tasks.
|
||||
|
||||
`RegenerateAllChunks()` bumps the epoch **after** `Initialize`, so previous-epoch workers are live
|
||||
*during* the mutation. **The epoch rejects a finished result; it cannot make a read of a freed
|
||||
allocation safe.**
|
||||
|
||||
**Precedent in this very codebase:** `DiffLayer.ChunkMods` is read on mesher workers and written on
|
||||
the game thread, and all access now holds `ModsLock` — added after a real carve-vs-stream access
|
||||
violation. `StrateLayout` / `Passages` are the same shape with no guard.
|
||||
|
||||
Four `Initialize` call sites:
|
||||
|
||||
| line | function | dangerous? |
|
||||
|---|---|---|
|
||||
| 145 | `RebuildStrates` | **yes** |
|
||||
| 309 | `OnObjectModifiedInEditor` | **yes — fires automatically on a strate asset edit while streaming** |
|
||||
| 417 | `BeginPlay` | **no** — no tasks exist yet. **Leave it alone.** |
|
||||
| 2091 | `ChangeSeed` | **yes** (also writes `Generator`'s `Seed` / `OriginSpineRadius`) |
|
||||
|
||||
## Why THIS fix and not the other two
|
||||
|
||||
Rejected deliberately — do not "improve" the design into either of these:
|
||||
|
||||
- **An `FRWLock` around the two arrays** (the `ModsLock` shape) would put a **read lock on the
|
||||
per-voxel hot path** — `EvaluateModifierSDF` and `FindSlotIndexForChunkZ` run ~43k times per tile.
|
||||
There is an open, unmeasured perf regression under active investigation (`CODEX-TASK-001/002`);
|
||||
adding hot-path lock traffic now would **contaminate the very measurement those tasks exist to
|
||||
take.** Correct, but the worst possible timing.
|
||||
- **An immutable generation snapshot** (Sol's suggestion) is the right long-term architecture and a
|
||||
real refactor of `UVoxelStrateManager`'s whole API surface. Too large to improvise, and it belongs
|
||||
in a design conversation.
|
||||
|
||||
**The drain has zero hot-path cost**, reuses machinery already proven in `EndPlay`, and its only
|
||||
cost — a brief stall — lands exclusively on **human-initiated editor actions** (asset edit, rebuild,
|
||||
seed change). It never occurs during play.
|
||||
|
||||
## What to build
|
||||
|
||||
### 1. A pause flag distinct from shutdown
|
||||
|
||||
Add to `AVoxelWorld`: `std::atomic<bool> bGenerationPaused{false};`
|
||||
|
||||
⚠️ **Do NOT reuse `bShuttingDown` for this.** It would work mechanically, but it means "we are tearing
|
||||
down" and a future reader would be misled about lifetime. Introduce a small helper used at the
|
||||
existing gate points:
|
||||
|
||||
```cpp
|
||||
FORCEINLINE bool ShouldAbortWork() const
|
||||
{
|
||||
return bShuttingDown.load(std::memory_order_relaxed)
|
||||
|| bGenerationPaused.load(std::memory_order_relaxed);
|
||||
}
|
||||
```
|
||||
|
||||
Route the **existing** checks through it — the submission gate (`VoxelWorld.cpp:638`) and the
|
||||
in-task checks (`:1467`, `:1474`). **Do not add new check points**; do not change what those sites do
|
||||
when the check is true.
|
||||
|
||||
### 2. An RAII scoped pause, modelled on `EndPlay`'s drain
|
||||
|
||||
`EndPlay` (`:323–334`) already implements this exact pattern: raise the gate, then spin until
|
||||
`ActiveTaskCount` reaches 0. Mirror it.
|
||||
|
||||
```
|
||||
FScopedGenerationPause guard(this);
|
||||
if (!guard.Acquired()) { /* log error, DO NOT mutate, return */ }
|
||||
```
|
||||
|
||||
- **Ctor:** set `bGenerationPaused = true`, then wait for **both** `AVoxelWorld::ActiveTaskCount == 0`
|
||||
**and** the decoration tasks to finish. Decoration tasks are counted by the file-static
|
||||
`GActiveDecoTasks` in `VoxelContentManager.cpp` and already drained by `NotifyShutdown` (`:65–80`) —
|
||||
add a small public drain/wait accessor on `UVoxelContentManager` rather than exposing the counter.
|
||||
- **Dtor:** always clear `bGenerationPaused`, including on the failure path.
|
||||
|
||||
### 3. ⚠️ FAIL SAFE — this is the most important line in the spec
|
||||
|
||||
If the deadline expires with tasks still running: **DO NOT MUTATE.** Log an error naming the
|
||||
function, clear the flag, and return, leaving the world in its previous consistent state. The user
|
||||
can retry the edit.
|
||||
|
||||
**Mutating anyway is what the bug already does.** A timeout that proceeds is not a fix. The three
|
||||
dangerous call sites must each be structured so the `Initialize` call is *unreachable* unless the
|
||||
pause was acquired.
|
||||
|
||||
Use a generous deadline (≥ 5 s) and log at `Error` when it expires — a silent skip would look like
|
||||
the edit simply didn't apply.
|
||||
|
||||
### 4. Wrap the three call sites
|
||||
|
||||
`RebuildStrates`, `OnObjectModifiedInEditor`, `ChangeSeed`. The pause must cover **all** the mutation,
|
||||
including `ChangeSeed`'s writes to the generator's `Seed` / `OriginSpineRadius`, and it must be
|
||||
released **before** `RegenerateAllChunks()` so regeneration can submit work. **`BeginPlay` is not
|
||||
wrapped.**
|
||||
|
||||
## ⚠️ Invariants
|
||||
|
||||
1. **No density, mesher, or geometry code may change.** This must not move one bit of generated
|
||||
terrain. If your diff touches `VoxelGenerator.cpp`, `VoxelDensityOpStack.cpp`,
|
||||
`VoxelCaveMorphology.cpp` or `VoxelMarchingCubesMesher.cpp`, stop — wrong site.
|
||||
2. **No deadlock.** The pause is taken on the **game thread**. Verify by reading that chunk tasks
|
||||
never block on the game thread (they read the generator and `Enqueue` to an MPSC queue, which is
|
||||
non-blocking) — so a drain is bounded. **State in your report that you checked this**, and if you
|
||||
find any worker path that waits on the game thread, STOP and report it instead of proceeding.
|
||||
3. **`ProcessQueue` stays `EQueueMode::Mpsc`.** Do not touch it.
|
||||
4. **Do not change `EndPlay`.** Its 3-second timeout is a separate, deliberate decision
|
||||
(audit VF-02) and is Jahni's call, not part of this task.
|
||||
5. **Carry the `Epoch`** through anything you touch; do not reorder the existing epoch bump relative
|
||||
to `RegenerateAllChunks`.
|
||||
6. Comments are French + English; match the surrounding file.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `stat`/gameplay unchanged; **generated terrain bit-identical** (the equivalence tests and every
|
||||
box-verdict number must be untouched — this change cannot reach them).
|
||||
- Editing a strate asset while the world streams: brief stall, then the edit applies. **No crash.**
|
||||
- The failure path is reachable and honest: if the drain times out, an `Error` log names the function
|
||||
and the world keeps its previous state.
|
||||
- `git diff --stat` should list `VoxelWorld.cpp`, `VoxelWorld.h`, and `VoxelContentManager.{h,cpp}`
|
||||
for the drain accessor. Nothing else.
|
||||
|
||||
## Notes for the reviewer (Claude)
|
||||
|
||||
- Confirm the three dangerous sites cannot reach `Initialize` when the pause was not acquired, and
|
||||
that `BeginPlay` is untouched.
|
||||
- Confirm the dtor clears the flag on **every** path including early return.
|
||||
- Confirm `ShouldAbortWork` replaced the existing checks rather than adding new ones, and that
|
||||
`bShuttingDown`'s own semantics are unchanged.
|
||||
- Confirm the deco drain is included — chunk tasks alone are not the whole reader set.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Codex task 008 — (A) fix the measured column-memo thrash, (B) diagnose why T1.d never fires in game
|
||||
|
||||
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||
**Status:** specified, not started
|
||||
|
||||
Two independent changes in different subsystems, deliberately bundled into one build because their
|
||||
signals cannot contaminate each other: (A) is SurfaceWorld column caching, (B) is a counter in the
|
||||
cave branch of `ClassifyTile`.
|
||||
|
||||
---
|
||||
|
||||
# PART A — replace the hashed column memo with a direct-indexed box
|
||||
|
||||
## This is now MEASURED, not suspected
|
||||
|
||||
`stat VoxelForge` in the running game, SurfaceWorld-dominated flight:
|
||||
|
||||
```
|
||||
Column Memo Hits avg 102,300.84
|
||||
Column Memo Misses avg 17,683.60 → miss rate 14.7%
|
||||
```
|
||||
|
||||
Predicted **14.0%** if the table thrashes, **1.4%** if it does not. It thrashes. Second confirmation
|
||||
from a different statistic: 17,683 misses ÷ 2.13 tiles meshed = **~8,300 column recomputes per
|
||||
tile**, where a healthy cache does ~1,225 — **6.8×**.
|
||||
|
||||
**The cause.** `FSurfaceColumnSource::GetColumn` (`VoxelDensityOpStack.cpp` ~615) uses a
|
||||
**direct-mapped, 4096-entry hashed** table. A direct-mapped table evicts on *collision*, not on
|
||||
fullness: 1225 columns per tile in 4096 slots is a load factor of 0.30, at which ~317 columns (26%)
|
||||
share a slot and evict each other — **on every one of the ~35 Z planes**, because the mesher
|
||||
pre-samples Z-outermost (`VoxelMarchingCubesMesher.cpp` ~226). Each miss recomputes the entire height
|
||||
stack: structural source, cliff (four structural resamples), terrace, layer-line, beach, ceiling.
|
||||
|
||||
## The fix — copy the scheme that already works, one file away
|
||||
|
||||
`GSurfColCache` / `FSurfaceColumnBox` in `VoxelGenerator.cpp` (~152, and its use at ~737) is the
|
||||
original path's solution to the identical problem: a **direct-indexed box** —
|
||||
`CI = (IY - Box.BaseY) * Dim + (IX - Box.BaseX)` with a `Computed[CI]` flag — centred on the first
|
||||
sample and rebuilt when a query leaves it. **No hash ⇒ no collisions ⇒ every column computed exactly
|
||||
once.** Read it before writing; you are porting a proven scheme, not inventing one.
|
||||
|
||||
Apply the same structure inside `FSurfaceColumnSource`, keeping it `thread_local`.
|
||||
|
||||
## ⚠️ The invariant that must not be lost in the port
|
||||
|
||||
The current memo's validity check is `S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY`, and
|
||||
`ColumnKey` is built in `PrepareChunk` from **strate + layout version + seed + `ParamsFingerprint`**.
|
||||
|
||||
**`ParamsFingerprint` is load-bearing and its absence was a real shipped bug** — without it, two
|
||||
stacks of the same strate with different params shared columns, the overhang silently vanished, and
|
||||
only 69 of 20000 samples showed it. The comment at the site records this. **The new box's validity
|
||||
key must still contain all four**, or you reopen a fixed bug. (Note `GSurfColCache` itself keys on
|
||||
`(box XY, StrateKey, Seed, LayoutVersion)` **without** the fingerprint — do **not** copy that part;
|
||||
it is the weakness the op-stack memo deliberately closed.)
|
||||
|
||||
Also keep the full XY comparison semantics: a lookup must never return a column computed for a
|
||||
different XY. With a direct-indexed box that is structural (the index *is* the XY), but the box
|
||||
bounds check must be exact.
|
||||
|
||||
## Keep the counters
|
||||
|
||||
`ColumnMemoHit` / `ColumnMemoMiss` must keep working, incremented on the same meaning (miss = a
|
||||
column was recomputed). They are the before/after instrument.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **Density must not move by one bit.** This changes *caching*, never a computed value. The eight
|
||||
equivalence tests — especially `SurfaceHeightEquivalence` and its overhang section — must stay
|
||||
bit-identical. If your diff changes what `TerrainStack.EvalHeight` / `CeilingStack.EvalHeight`
|
||||
compute, or the overhang gate maths, you have the wrong site.
|
||||
2. Both consumers keep working: the source (`~858`) and the overhang (`~923`, via
|
||||
`Column->GetColumn`). The overhang **must** see the same column the source did — that is
|
||||
"by construction rather than by convention", and the current code says so.
|
||||
3. Sizing: state in a comment how many columns a tile needs (a 35×35 grid = 1225) and size the box so
|
||||
one tile fits without eviction, as `FSurfaceColumnBox` does.
|
||||
4. `thread_local` stays; no shared mutable state across workers.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `ColumnMemoMiss` drops roughly **10×**; miss rate goes from **14.7% → ~1.5%**.
|
||||
- The eight equivalence tests stay green and bit-identical.
|
||||
- If the miss rate does **not** fall, say so plainly — a fix that does not move its own instrument is
|
||||
a failed fix, not a partial one.
|
||||
|
||||
---
|
||||
|
||||
# PART B — name which guard stops T1.d in the running game
|
||||
|
||||
## The problem
|
||||
|
||||
`Tiles Operator Stack Solid` / `Air` **never appeared** in `stat VoxelForge`, while the harness proves
|
||||
11 of 40 tiles at production defaults. Rows only render in frames where a counter fires, so site B is
|
||||
never reached in game. The cave branch of `UVoxelGenerator::ClassifyTile` has **13 `return
|
||||
EVoxelTileClass::Mixed` paths** and we cannot tell which one fires.
|
||||
|
||||
## What to build
|
||||
|
||||
Add DWORD counters to the existing `stat VoxelForge` group (`VoxelStats.h` / `.cpp`) that attribute
|
||||
the bail, grouped by *reason* rather than one per line:
|
||||
|
||||
| counter | fires when |
|
||||
|---|---|
|
||||
| `CaveBailNotOpStack` | `UsesOperatorStackForChunk` is false (either the initial check or the per-chunk sweep) |
|
||||
| `CaveBailMixedContent` | `bAnyNonCave` — the tile also touches a gap or SurfaceWorld chunk — or a second cave slot, or out-of-layout |
|
||||
| `CaveBailParams` | the params `Memcmp` disagreed across the box, the archetype differed, or `NumChunkCoords > 27` |
|
||||
| `CaveBailStackVerdict` | the stack built fine but `ClassifyBox` returned `Mixed` |
|
||||
| `CaveBailDisturbance` | the final `bCanSolid == bCanAir` after disturbances |
|
||||
| `CaveBailNoStack` | `VF_BuildOpStackForChunk` returned false |
|
||||
|
||||
Increment **exactly one** per bail, immediately before the `return`. Together with the existing
|
||||
`TilesOpStackSolid` / `TilesOpStackAir`, one underground flight then names the cause outright.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. **DO NOT change any control flow, condition, or return value in `ClassifyTile`.** Every `return`
|
||||
there is a conservative guard that fails to `Mixed`; a wrong verdict leaves a tile with no
|
||||
geometry and no collision. Add counters beside the existing returns and nothing else.
|
||||
2. `ClassifyTile` is `const` and runs on **worker threads** — `INC_DWORD_STAT` only, never a
|
||||
`static int32++`. It routes through `FThreadStats::AddMessage` (per-thread packets), which is why
|
||||
it is safe.
|
||||
3. Zero cost when `STATS == 0`: compute nothing outside the macros.
|
||||
4. Do not touch the non-cave parts of `ClassifyTile` (gap / SurfaceWorld / column scan).
|
||||
|
||||
## Acceptance
|
||||
|
||||
Fly underground in a TunnelNetwork strate: exactly one bail counter should dominate, or
|
||||
`TilesOpStackSolid` should finally appear. Either outcome is a result.
|
||||
|
||||
---
|
||||
|
||||
# Shared rules
|
||||
|
||||
- **NEVER build, compile or run the editor or the tests.** Stop when the code is written.
|
||||
- **Do not `git commit`, `git push`, `git checkout`, `git stash`, `git restore`.** Uncommitted work
|
||||
in the tree must survive.
|
||||
- Comments are French + English; match the surrounding file.
|
||||
- Macro spelling: `KINDA_SMALL_NUMBER`, not the `UE_`-prefixed form.
|
||||
- When inserting anything into `VoxelDensityOpStack.cpp`, put it **above the labelled end of the
|
||||
anonymous namespace** — anchoring on the FACTORIES banner puts it outside and the brace closes
|
||||
nothing. This mistake has been made twice in that file.
|
||||
|
||||
## Report
|
||||
|
||||
1. The diff for Part A and Part B separately.
|
||||
2. `git diff --stat`.
|
||||
3. Explicit confirmation that: no height-stack or overhang maths changed; the new column key still
|
||||
contains strate + layout + seed + `ParamsFingerprint`; `ClassifyTile`'s control flow and return
|
||||
values are untouched; exactly one bail counter fires per bail path.
|
||||
4. Likely compile-error spots, specifically.
|
||||
5. Anything in this spec that contradicts the code — **stop and say so rather than guessing.**
|
||||
@@ -0,0 +1,165 @@
|
||||
# VoxelForge investigation — T1.d and SurfaceWorld column memo
|
||||
|
||||
Date: 2026-08-16
|
||||
Base: `experimental` at `4ba53f2`
|
||||
Method: static source review only. **No build, compile, editor launch, or automation test was run.**
|
||||
|
||||
## Executive conclusion
|
||||
|
||||
The current explanation “one unticked neighbouring strate makes the exhaustive per-chunk sweep
|
||||
reject otherwise-safe boundary tiles” is not what the code does.
|
||||
|
||||
`UVoxelStrateManager::UsesOperatorStackForChunk` depends only on `ChunkCoord.Z`. Before the full XYZ
|
||||
sweep, `UVoxelGenerator::ClassifyTile` has already visited every sampled lattice Z, required the
|
||||
operator-stack predicate for every cave Z it sampled, and required all cave samples to belong to one
|
||||
layout slot. Within that one slot the flag cannot vary with X or Y. The later XYZ flag check is
|
||||
therefore redundant with the current layout implementation.
|
||||
|
||||
That does **not** make cross-strate boundary tiles safe to classify with one stack. They still fail
|
||||
the independent one-slot, generator-type, and bit-identical-params guards. Removing only the later
|
||||
flag check would not unlock those tiles. Requiring the density samples represented by one verdict to
|
||||
use one implementation is necessary in principle; the current exhaustive re-check is over-strict/
|
||||
redundant, but it is not the measured T1.d blocker.
|
||||
|
||||
The diagnostic is less precise than the log claims: the initial `UsesOperatorStackForChunk` check
|
||||
runs before the different-slot check, so a coarse/boundary tile that reaches an unticked adjacent
|
||||
cave slot can increment `Cave Bail Not Op Stack` even though it would subsequently have failed as
|
||||
mixed content. The measured 80% therefore does not distinguish “the flown slot itself is unticked”
|
||||
from “a boundary tile encountered an unticked slot first.” The asset state still has to be read in
|
||||
the editor.
|
||||
|
||||
For the column memo, the port is genuinely incomplete. `GSurfColCache` is a six-box spatial LRU;
|
||||
`FSurfaceColumnSource::GetColumn` currently owns one 81x81 direct-indexed box. A recenter or
|
||||
`ColumnKey` change clears all 6,561 computed flags in that one box. This is a verified structural
|
||||
difference, but the observed 10–30% location-dependent miss band does not prove its performance
|
||||
impact. Only the same seed and same route can do that.
|
||||
|
||||
## Findings
|
||||
|
||||
| Status | Finding | Evidence and consequence |
|
||||
|---|---|---|
|
||||
| **Verified by reading** | The late “every chunk opted in” sweep is redundant today. | `VoxelGenerator.cpp`, `UVoxelGenerator::ClassifyTile`; `VoxelStrateManager.cpp`, `UVoxelStrateManager::UsesOperatorStackForChunk`. The predicate ignores X/Y and is constant for a layout slot. The earlier Z loop and `CaveBotChunkZ` guard already establish one opted-in cave slot. |
|
||||
| **Verified by reading** | Boundary tiles remain unsafe for a one-stack verdict for reasons independent of the flag. | `ClassifyTile` rejects a second cave slot, a different generator type, and any non-bit-identical parameter struct. Gradient/Interleaved transitions can vary params per chunk. Removing the redundant flag re-check alone cannot change the safe result from `Mixed`. |
|
||||
| **Verified by reading** | `Cave Bail Not Op Stack` is an ambiguous attribution counter. | The first predicate check occurs before `GetStrateChunkZBounds`/different-slot attribution. A boundary tile can be counted as NotOpStack even though mixed content would also reject it. Terrain correctness is unaffected; diagnosis is affected. |
|
||||
| **Suspicious, needs checking** | Most of the measured 1.42/1.77 NotOpStack bails come from the primary cave asset itself being unticked. | This is the simplest explanation for interior tiles, but `.uasset` state is not readable from this source tree and the counter does not separate primary-slot from boundary-slot failures. Confirm in the editor or use Approach A's initialization log. |
|
||||
| **Verified by reading** | The op-stack column memo loses an entire 6,561-cell working set on any recenter/key change. | `VoxelDensityOpStack.cpp`, `FSurfaceColumnSource::GetColumn`, has one direct-indexed box. `VoxelGenerator.cpp`, `FSurfaceColumnCache`, has `NumBoxes = 6` and evicts only one LRU box. |
|
||||
| **Suspicious, needs checking** | The one-box design materially causes the observed 10–30% in-game miss rate. | Plausible and location-sensitive, but unmeasured. The retracted different-route comparison cannot support a before/after claim. |
|
||||
| **Verified by reading** | VF-03's core CP-cache contamination defect is present, and the current source now contradicts the audit reviewer's header. | `VoxelGenerator.cpp`, `UVoxelGenerator::GetDensityAt`, keys the function-static `thread_local CP_*` state by `(ChunkCoord, LayoutVersion)` with no generator/world identity. Each manager's version begins at the same value. `VoxelForgeTestFixture.h`, `FTestWorld::Build`, now explicitly documents the contamination and repeatedly calls `Initialize` to give test worlds process-unique versions. Production has no equivalent owner key. A second world on the same worker can reuse the first world's params, `CP_UseOpStack`, and stack. |
|
||||
| **Suspicious, needs checking** | Every other TLS cache named in audit VF-03 has the same cross-world exposure. | Several keys visibly omit an owner, but this pass proved the `CP_*` path only. The broader `OC_*`, `BM_*`, passage shortlist, biome, diff, and op-local cache set should be audited as one owner-identity task rather than assumed from the old audit row. Both approaches below leave this open. |
|
||||
|
||||
The audit header says VF-03's fixture citation was fabricated. That statement is stale relative to
|
||||
the checked-out source: the cited explanatory block exists now in `VoxelForgeTestFixture.h` and is
|
||||
specific about `CP_UseOpStack` contamination. This does not automatically validate every cache
|
||||
listed in VF-03; it does validate the CP-cache defect above.
|
||||
|
||||
## Two implemented approaches
|
||||
|
||||
| | Approach A — explicit opt-in + six-box spatial LRU | Approach B — code-enforced cutover + four-way associative memo |
|
||||
|---|---|---|
|
||||
| Strategy | Preserve the per-asset A/B contract. Add initialization-time warnings naming every disabled layout slot; the human fixes the `.uasset`. Port the reference six-box LRU to the op source. | Ignore the serialized flag at runtime and route all eight ported archetypes through the stack. Keep the UPROPERTY for asset compatibility. Replace the box with 4,096 exact coordinate entries arranged as 1,024 sets x 4 ways. |
|
||||
| T1.d effect | No silent behavior change. T1.d fires only after the relevant assets are enabled. Boundary tiles still conservatively fail for slot/params reasons. | `Cave Bail Not Op Stack` should disappear for every valid ported slot without asset edits. `GetDensityAt` and `ClassifyTile` still use the same manager predicate and same stack factory. |
|
||||
| Cache behavior | Six independent 81x81 boxes. An acquisition miss recenters/clears one LRU victim; five boxes remain warm. Closest match to the proven reference. | Hash routes by exact X/Y plus both halves of `ColumnKey`; exact X/Y/key comparison decides hits. A miss evicts one entry in one set. No bulk clear. Four-way conflicts and capacity eviction remain possible. |
|
||||
| Runtime/memory cost | Approximately 0.79 MiB TLS per worker for this memo, versus roughly 0.13 MiB for the current single box. Six linear box checks per integer query. | Approximately 160 KiB TLS per worker (compiler padding can change this). Four exact probes per integer query plus hashing/LRU-rank updates. |
|
||||
| Main risk | High per-worker memory multiplication. The diagnostic currently enumerates SurfaceWorld slots too, although SurfaceWorld's exact-lattice T1.d path does not depend on this flag; narrow that warning before adopting it. | Intentional behavior change for every false-flag asset; the switch can no longer be selected for production A/B. Existing false/true ClassifyTile fixtures now exercise the same runtime path, reducing the distinction between those two end-to-end tests. Associative conflicts may underperform the spatial LRU. |
|
||||
| What it does not solve | VF-03 owner identity; boundary params/slot conservatism; proof of memo benefit. | VF-03 owner identity; boundary params/slot conservatism; proof of memo benefit. |
|
||||
|
||||
### Recommendation
|
||||
|
||||
Start from **Approach A**, after narrowing its warning to cave archetypes or rewording the
|
||||
SurfaceWorld entry. The code does not justify weakening `ClassifyTile`, and the current flag is still
|
||||
valuable for a same-route A/B. Approach A preserves that measurement lever and ports the known
|
||||
reference cache design. Its memory cost is the reason not to merge it blindly: measure it against
|
||||
Approach B on the identical route.
|
||||
|
||||
Approach B is the cleaner long-term endpoint only if the project has deliberately decided to retire
|
||||
the switch as a runtime fallback. It removes configuration drift and is much smaller in TLS, but it
|
||||
spends the A/B lever and moves every false-flag asset at once. The eight equivalence suites make that
|
||||
a defensible experiment, not a zero-risk migration.
|
||||
|
||||
## Worktrees and files touched
|
||||
|
||||
### Approach A
|
||||
|
||||
Worktree: `E:\Projet Unreal\VoxelM\Plugins\VF-approach-A`
|
||||
|
||||
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`
|
||||
- `Source/VoxelForge/Private/VoxelStrateManager.cpp`
|
||||
- `CODEMAP.md`
|
||||
|
||||
Likely compile-error/watch spots:
|
||||
|
||||
- The function-local `thread_local FColumnCache` containing six large aggregate boxes on MSVC/UE's
|
||||
TLS implementation.
|
||||
- Nested local cache types and `FMemory::Memzero` of each victim's `Computed` array.
|
||||
- The new `UE_LOG` format strings/arguments in `UVoxelStrateManager::Initialize`.
|
||||
|
||||
### Approach B
|
||||
|
||||
Worktree: `E:\Projet Unreal\VoxelM\Plugins\VF-approach-B`
|
||||
|
||||
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`
|
||||
- `Source/VoxelForge/Private/VoxelGenerator.cpp` (comments only)
|
||||
- `Source/VoxelForge/Private/VoxelStrateManager.cpp`
|
||||
- `Source/VoxelForge/Public/VoxelDensityOpStack.h` (comments only)
|
||||
- `Source/VoxelForge/Public/VoxelStrateDefinition.h` (UPROPERTY retained; comments only)
|
||||
- `Source/VoxelForge/Public/VoxelStrateManager.h` (comments only)
|
||||
- `Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp` (comments/messages only)
|
||||
- `Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h` (comments only)
|
||||
- `CODEMAP.md`
|
||||
|
||||
Likely compile-error/watch spots:
|
||||
|
||||
- The function-local `thread_local FColumnMemo` 4,096-entry aggregate.
|
||||
- `VoxelHash::Mix` calls and casts for X/Y plus low/high halves of the 64-bit key.
|
||||
- The `uint8` four-way LRU ranks and local-entry default initialization.
|
||||
- UHT should see no serialized layout change: `bUseOperatorStack` was not removed or renamed.
|
||||
|
||||
Both worktrees are detached from `experimental`, contain uncommitted changes, and passed
|
||||
`git diff --check`. Neither was built or tested.
|
||||
|
||||
## Human measurement protocol
|
||||
|
||||
Do not compare screenshots or stat averages from different flights. For every baseline/candidate:
|
||||
|
||||
1. Use the **same world seed, same asset values, same start point, same route, same speed, same LOD/
|
||||
streaming settings, same capture duration, and the same warm-up policy**.
|
||||
2. Record at least one repeat of the route; scheduler variation can move worker-local cache reuse even
|
||||
when the geographic route is identical.
|
||||
3. Capture `stat VoxelForge` and an Insights trace together. Normalize column misses and worker time by
|
||||
`Tiles Meshed`; totals alone conflate cheaper tiles with fewer tiles.
|
||||
4. Change only one worktree/approach at a time. Do not compare Approach A after an asset edit with
|
||||
Approach B before that edit and call it a cache result.
|
||||
|
||||
For T1.d, record:
|
||||
|
||||
- `Cave Bail Not Op Stack`, `Cave Bail Mixed Content`, `Cave Bail Params`,
|
||||
`Cave Bail Stack Verdict`, `Cave Bail Disturbance`, `Cave Bail No Stack`;
|
||||
- `Tiles Classified`, `Tiles Meshed`, `Tiles Operator Stack Solid/Air`, and total skipped Solid/Air;
|
||||
- the route segment's active strate and whether each neighboring asset's legacy flag is enabled.
|
||||
|
||||
Expected interpretation:
|
||||
|
||||
- Approach A: the initialization log must identify any false flag. After the human enables the
|
||||
relevant cave definitions, NotOpStack should approach zero in single-strate interiors and
|
||||
`Tiles Operator Stack Solid/Air` should become non-zero. Boundary tiles may move to MixedContent,
|
||||
Params, or StackVerdict; that is conservative and expected.
|
||||
- Approach B: NotOpStack should be zero for valid ported cave slots without asset edits. A non-zero
|
||||
value then points to out-of-layout/invalid-slot logic or a stale build, not the legacy flag.
|
||||
- In both: `Tiles Meshed < Tiles Classified` is the production prize. A zero `violations` result and
|
||||
all eight bit-equivalence suites green remain mandatory before trusting it.
|
||||
|
||||
For the memo, record:
|
||||
|
||||
- `Column Memo Hits` and `Column Memo Misses` as a miss percentage;
|
||||
- misses per `Tiles Meshed`;
|
||||
- Insights `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` count and time per meshed tile;
|
||||
- visible LOD-ring update time/throughput on the identical route;
|
||||
- process memory if comparing the six-box LRU against the associative table at the same worker count.
|
||||
|
||||
A lower miss percentage on a different route is not evidence. A valid claim is: same seed, same
|
||||
route, same settings, same denominator, with the candidate as the only change.
|
||||
|
||||
## Ready-to-build status
|
||||
|
||||
Both alternatives are ready for the human's review/build step. No result in this document is a
|
||||
compile or runtime claim; all implementation conclusions are from source and diff inspection.
|
||||
+82
-187
@@ -1,208 +1,103 @@
|
||||
# Handoff — VoxelForge operator stack, 2026-07-28 (Phase 2 complete and green)
|
||||
# VoxelForge handoff — the operator stack is DONE. Read this, not the old jargon.
|
||||
|
||||
> Paste the block below into a fresh session. Everything it refers to is on disk and in git.
|
||||
>
|
||||
> **State:** Phase 2 is **DONE — 8 of 8 archetypes ported, built, and green** (14 tests, two
|
||||
> consecutive green builds). `ClassifyTile` consumes `ClassifyBox`. **Everything in git is compiled
|
||||
> and tested.** One question to confirm in the first run, then one clear next task.
|
||||
> Paste this into a fresh session. Written 2026-08-16, deliberately in plain language: the previous
|
||||
> version of this file had become a private dialect that Jahni could not read, which is a failure of
|
||||
> the document, not of the reader.
|
||||
|
||||
---
|
||||
|
||||
You're picking up the VoxelForge UE5 voxel plugin on branch `experimental` (already checked out —
|
||||
do not create another). I'm Jahni. The design and the history are written down so you don't
|
||||
re-derive them.
|
||||
## 1. What the operator stack was, in one paragraph
|
||||
|
||||
## Read first, in this order
|
||||
Cave generation used to be one big `switch`: each cave type (tunnels, maze, shafts, floating
|
||||
islands, surface…) was its own hardcoded ~200–1000 line C++ function. The refactor replaced that with
|
||||
small composable pieces ("operators") that stack up to produce the same terrain. **The promise was
|
||||
that you could eventually invent new world types by combining pieces in the editor instead of asking
|
||||
for another thousand-line function.**
|
||||
|
||||
1. **`CLAUDE.md`** — project rules. **Rule #1 is absolute: never build, compile, or run the editor.**
|
||||
I build everything myself. When code is done, stop, say "ready to build", list the likely
|
||||
compile-error spots, and wait.
|
||||
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point. The last
|
||||
entry is the green build with every measured number in it.
|
||||
3. **`OPSTACK-PLAN.md`** — the plan. **§2.6.1 is the acceptance bar** and supersedes §2.6.
|
||||
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is the
|
||||
live one; §2 TunnelNetwork and §8 Underwater are now history, not instructions.
|
||||
5. **`AUDIT-2026-07.md`** — **§C2 has a CONFIRMED sub-item as of 2026-07-28, read it**; §C10 is
|
||||
SOLVED, don't reopen; §C9's library half is the top open theoretical risk with 0 measured
|
||||
exposure.
|
||||
6. **`CODEMAP.md`** — navigation. Trust symbol names over line numbers.
|
||||
**That promise — "Phase 3", ops as data assets — was never built, and is NOT being built now.**
|
||||
|
||||
## Where things stand — the transition is COMPLETE and VERIFIED
|
||||
## 2. Status: done. Stop refactoring.
|
||||
|
||||
All 8 archetypes have an operator-stack twin, per-strate opt-in, each equivalence-tested **bit for
|
||||
bit** against its original density function. The `switch` and the stack are now two complete,
|
||||
interchangeable implementations.
|
||||
- **8 of 8 archetypes ported**, running in production (`bUseOperatorStack` is ticked on the game's
|
||||
strate assets), and **bit-for-bit identical** to the old path. Verified by a 115 000-sample field
|
||||
digest plus eight per-archetype equivalence tests.
|
||||
- **14/14 automation tests green, 0 violations anywhere.**
|
||||
- **39 % of tiles are skipped in the running game** (`Tiles Meshed` 1.15 vs `Tiles Classified` 1.88).
|
||||
It was 0 % on the morning of 2026-08-16. Each skipped tile avoids ~43 000 density evaluations plus
|
||||
marching cubes.
|
||||
|
||||
| Archetype | State |
|
||||
**Decision taken 2026-08-16, with Jahni:** the op stack is finished. **Do not start Phase 3. Do not
|
||||
start another refactor.** The old `switch` stays in place as the correctness oracle — deleting it
|
||||
buys nothing today. Next work should be things Jahni can *see*: see `fable-idea.md` (F7 set-pieces,
|
||||
F9 audio were queued before this started).
|
||||
|
||||
### The honest ledger, so nobody re-litigates it
|
||||
|
||||
Three weeks, 120 commits, from 2026-07-27. Every `feat:` commit in that window is a *port* of
|
||||
something that already worked. **The world did not change by a single voxel — that was the
|
||||
acceptance criterion.** What Jahni actually got: the 39 % perf win, two genuine pre-existing bugs
|
||||
found (a use-after-free on every strate-asset edit while streaming, and an under-bounded room
|
||||
collection that could make two multiplayer peers generate different geometry), and a number of fixes
|
||||
to bugs the refactor itself introduced. That is a thin return for three weeks, and it is why the
|
||||
direction changed.
|
||||
|
||||
## 3. The jargon, translated
|
||||
|
||||
Almost all of it means one thing: **can we prove a chunk of world is entirely rock or entirely air
|
||||
without checking every point in it, so we can skip the expensive work?**
|
||||
|
||||
| term | plain meaning |
|
||||
|---|---|
|
||||
| `Maze` | ✅ ported, bit-identical, wired |
|
||||
| `FlatPlain` + `CrystalChamber` | ✅ **one op for both**, bit-identical, wired |
|
||||
| `SurfaceWorld` | ✅ ported incl. biome blending, bit-identical, wired |
|
||||
| `VerticalShafts` | ✅ ported, bit-identical, wired |
|
||||
| `FloatingIslands` | ✅ ported, bit-identical, wired — the stack that runs **backwards** |
|
||||
| `TunnelNetwork` | ✅ **19 ops**, bit-identical incl. all 12 detail modifiers + per-room override |
|
||||
| `Underwater` | ✅ same builder, second `case` — confirm its coverage number once, see below |
|
||||
| **T1.d / tile skipping** | that idea. The single biggest perf item in the plan. |
|
||||
| **box verdict / `ClassifyBox`** | "is this whole box uniform?" → `AllSolid`, `AllAir`, or `Mixed` (don't know) |
|
||||
| **`Mixed`** | "can't prove it" — always safe, just means we do the work |
|
||||
| **`ClassifyTile`** | the function that decides, per tile, whether to skip meshing |
|
||||
| **operator / op stack** | one generation step (rock, carve, roughness…) and the list of them |
|
||||
| **equivalence test** | proof the new path produces byte-identical terrain to the old one |
|
||||
| **`violations`** | ⚠️ **the only number that means danger.** A tile wrongly proved uniform has *no geometry and no collision* — a player falls through the floor. Must always be 0. |
|
||||
|
||||
Everything sits behind `UVoxelStrateDefinition::bUseOperatorStack`; the ported list lives **only** in
|
||||
`UVoxelStrateManager::UsesOperatorStackForChunk` (now all 8). **No strate asset has the box ticked**
|
||||
— that is my call and I haven't made it. But the flag is no longer a no-op anywhere: ticking it now
|
||||
really switches that strate onto the stack, for density *and* for tile classification.
|
||||
## 4. What is verified, and what is not
|
||||
|
||||
`ClassifyTile` **consumes `ClassifyBox`** for cave archetypes (SurfaceWorld and bedrock gaps keep
|
||||
their hand-written exact-lattice proofs). `GetDensityAt` and `ClassifyTile` build the stack through
|
||||
the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole, not a bug.
|
||||
**Verified:** everything through commit `871ca19` — tests green, digests unchanged, 39 % measured
|
||||
in game.
|
||||
|
||||
## The one number to confirm, and it takes one run
|
||||
⚠️ **Built but NOT re-verified:** `4d33321` (Sol's boundary fold — lets a tile that straddles cave and
|
||||
open air still resolve) and `91585ea` (a test-only warning demotion). Jahni built these and says the
|
||||
game *looks* fine, but **the test suite has not been re-run and the counters have not been re-read
|
||||
since.** Before trusting them:
|
||||
|
||||
The first green build reported this, and it is the one result worth understanding before trusting
|
||||
anything about `Underwater`:
|
||||
1. run the `VoxelForge` automation filter — **`violations` must be 0 and all eight equivalences
|
||||
bit-identical**;
|
||||
2. `stat VoxelForge` in game — the accounting must close:
|
||||
`Tiles Meshed + Skipped All Air + Skipped All Solid = Tiles Classified`.
|
||||
|
||||
```
|
||||
Underwater (stage C2): bit-identical across 2000 samples — 0 of them in open cave (0.0%)
|
||||
```
|
||||
If either fails, `git revert 4d33321` — the 39 % win does not depend on it.
|
||||
|
||||
**A green bit-identity over 2000 samples of solid rock is not evidence** — it is exactly what two
|
||||
agreeing voids look like. Same failure as stage A's 1.1 % run, in a different slot, caught by a
|
||||
counter written for it.
|
||||
## 5. Rules that still prevent real bugs
|
||||
|
||||
A real bug surfaced while diagnosing it: the sampled chunk-Z range used `Z / CHUNK_SIZE`, and C++
|
||||
integer division **truncates toward zero**. TunnelNetwork is at the top of the layout in positive Z
|
||||
where truncation == floor, so it could not show there; Underwater is at the **bottom, in negative
|
||||
Z**, where it shifts the upper chunk bound a notch high and the `Clamp` piles samples into the top
|
||||
seal band. Fixed (`FloorDivChunk`), sampling widened 8 → 24 clusters, **and not trusted**: check 5b
|
||||
gives each of the three possible causes (the bake / the sampled Z range / the XY spread) its own
|
||||
number and prints how to read them.
|
||||
- **Never build.** Jahni builds; he has the editor open and it costs him real time. Say "ready to
|
||||
build" and list likely compile-error spots.
|
||||
- **`violations` 0 and the eight equivalences bit-identical** — the only non-negotiable results.
|
||||
- **A bound used to skip work must be PROVED, not observed.** Use `VF_PerlinAbsBound` (= 1.5);
|
||||
`FMath::Lerp(A,B,t)` spans `[min(A,B), max(A,B)]`, so a radius envelope is `max(Min,Max)`.
|
||||
- **Never change `ClassifyTile`'s conditions or return values casually.** Every `return` there fails
|
||||
safe to `Mixed`.
|
||||
- **Never state a `.uasset` value from memory** (like `bUseOperatorStack`). Ask, or read it in the
|
||||
editor. This sent a full day sideways.
|
||||
- `FindSlotIndexForChunkZ` is **protected**; `GetStrateChunkZBounds` is the public equivalent.
|
||||
- Push `experimental` freely. **Never push `main`.**
|
||||
|
||||
**That fix is built — the second build was green too. What I did not see is the number.** So:
|
||||
## 6. Two lessons that generalise beyond this plugin
|
||||
|
||||
> **First action: run the `VoxelForge` filter and read the `Underwater diagnosis` line, plus the
|
||||
> cave-coverage percentage on the line above it.**
|
||||
>
|
||||
> - **Non-zero cave coverage** ⇒ the truncation *was* the cause, `Underwater` is genuinely covered,
|
||||
> and this whole section is closed. Say so in `OPSTACK-PROGRESS.md` and move on to the next
|
||||
> section — do not go looking for a bug that no longer exists.
|
||||
> - **Still 0.0 %** ⇒ the diagnosis line names which of the three causes it is, and the fix follows
|
||||
> from that rather than from a guess.
|
||||
>
|
||||
> Either way it is one run, and the answer is printed. Do not infer it from the fact that the suite
|
||||
> is green: a bit-identity over solid rock is green for the wrong reason, which is the entire point
|
||||
> of that counter existing.
|
||||
- **A signal that always says the same thing measures nothing.** A counter that can fire for two
|
||||
reasons is not a measurement — splitting one such counter is what finally cracked T1.d after a day
|
||||
of wrong inference. A warning that fires every run and always means "this is fine" is noise that
|
||||
trains the reader to ignore warnings; one of those quietly worried Jahni for several sessions.
|
||||
- **Verify the premise, and verify it completely.** Multiple confident chains reversed on checking
|
||||
this month. Twice the failure was a *partial* read — grepping a symbol and reporting it as checked
|
||||
for something else. A grep that finds a declaration has not checked its access specifier.
|
||||
|
||||
## Then the one task everything is waiting on
|
||||
## 7. The open question, which matters more than any of the above
|
||||
|
||||
**Make `FRoomGraphSource::EffectOverBox` answer spatially.**
|
||||
|
||||
TunnelNetwork proves **0 of 40** tiles today, and the test asserts that. The chain dies at the room
|
||||
source, which returns `Both` with unknown amplitude before anything downstream is reached. Its room
|
||||
and tunnel bounds (`FCachedRoom::CullRadiusSq`, `FCachedTunnel::BoundRadiusSq`) are **already in the
|
||||
SDF cache**; what it costs is building that cache for the *queried box*, on the querying thread.
|
||||
|
||||
That cost is now clearly worth paying, and every other piece is already built to receive it:
|
||||
|
||||
- `ClassifyTile` consumes `ClassifyBox` in production, so a proved tile skips `GenerateMesh` —
|
||||
30 000+ density evaluations saved against one `BuildChunkCache`;
|
||||
- the fold carries **numbers** (`MaxCarveOverBox` / `MaxFillOverBox` / `ForcedMarginOverBox`), so a
|
||||
bounded worm no longer kills `AllSolid` on rock that is solid by more than it can carve;
|
||||
- the twelve detail modifiers already **inherit** the room source's verdict via `VF_NoCaveOverBox` —
|
||||
the day the source says `Identity` for a box, all twelve follow, in one place rather than thirteen.
|
||||
|
||||
**Keep the brute-force check.** `VoxelForge.OpStack.ClassifyTileSoundness` verifies verdicts against
|
||||
`GetDensityAt` on a world where every strate opted in. A false verdict is an invisible hole: no
|
||||
geometry, **no collision**, until a player falls through it.
|
||||
|
||||
## ⚠️ Debts that must be paid BEFORE that lands, not after
|
||||
|
||||
Both were introduced knowingly and are written at the exact site a reader would land on.
|
||||
|
||||
1. **Box bounds read STRATE params, but a per-room op can raise them.** `EffectOverBox` and the new
|
||||
amplitude bounds are computed from strate params, because a box spans many rooms. But `ApplyTo`
|
||||
writes the op's value **even where the strate's was 0**, so a room op can enable a modifier the
|
||||
strate had switched off, or give it a bigger amplitude. A box verdict on a strate with a
|
||||
terrain-op pool can therefore be **too optimistic** — the dangerous direction. Harmless while the
|
||||
room source answers `Both` (nothing is provable anyway); **not harmless the moment it doesn't.**
|
||||
Noted at `FLayerLineMod::EffectOverBox` and `FRoomGraphSource::LocalParams()`.
|
||||
2. **`AUDIT §C2` is confirmed and unfixed on the `switch` path.** `GetGenerationParams` blends params
|
||||
*within* a strate (`Alpha` depends on chunk Z for `Gradient`, and on chunk XY too for
|
||||
`Interleaved`), and `Gradient` + `TransitionBlendChunks = 2` are the **defaults**. The original's
|
||||
SDF cache key has neither params nor chunk Z, so a worker evaluates the second chunk it builds
|
||||
against the first chunk's rooms — and *which* chunk came first depends on worker order, so two
|
||||
peers can diverge from the same seed. The op stack does **not** inherit it (params CRC in the
|
||||
key), and `ClassifyTile`'s new path guards against it explicitly (params must be bit-identical
|
||||
across every chunk coord the box touches). The fix on the `switch` path is a params CRC in its
|
||||
key — a live-generation change that wants a build in front of it.
|
||||
|
||||
## After that, in order
|
||||
|
||||
1. **PERF — unparked.** The op path is measurably slower. One cause found and fixed (the column memo
|
||||
discarded itself every chunk). Remaining suspects in order: the hashed column lookup vs
|
||||
`GSurfColCache`'s direct-indexed box, then per-voxel virtual dispatch. Also measured and stated:
|
||||
the gate is now tested twelve times per voxel instead of once (stage B5's deliberate trade).
|
||||
**Measure before optimising** — that is the §C10 lesson.
|
||||
2. **`VerticalShafts` proves 0 of 60 tiles.** Pessimistic, not wrong: `EffectOverBox` returns
|
||||
`CarveOnly` whenever any shaft is within a `Spacing*1.6` halo instead of testing real connector
|
||||
capsules. Lost CPU, never a hole.
|
||||
3. **`AUDIT §C9` library half** — `sinf`/`cosf` are not IEEE-754 specified, so MSVC's CRT and glibc's
|
||||
libm can differ. Currently **0 samples within 1e-6 of the isosurface**, i.e. no measured risk. Run
|
||||
`CrossPlatformDigest` on Linux, compare the SHAPE digest, pin it. The real fix if ever needed is a
|
||||
deterministic in-house sin/cos.
|
||||
4. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
|
||||
unprompted. What makes it possible is already in place: ops depend on capabilities
|
||||
(`IVoxelBiomeField`), never on `UVoxelGenerator`.
|
||||
|
||||
## Hard rules that prevent real bugs
|
||||
|
||||
- **Density sign:** negative = solid at the mesher. Inside the op stack the convention is INTERNAL
|
||||
(**positive = solid**), negated once by the caller. The SDF channel uses standard SDF convention.
|
||||
- **Never run both density paths in one world.** **Comparing them is legitimate** — §C10 is closed
|
||||
since `FPSemantics = Precise`, and all eight equivalence tests compare bit for bit. They are
|
||||
**port-correctness oracles**, not fidelity checks: §2.6.1 requires *same seed ⇒ same world on every
|
||||
peer*, not resemblance to the pre-refactor world.
|
||||
- **Every cache key includes `LayoutVersion` AND the params.** See §C2 and the overhang regression of
|
||||
2026-07-27, where omitting the params silently deleted the overhang and only 1 sample in 20 000
|
||||
crossed the isosurface.
|
||||
- `ProcessQueue` stays `EQueueMode::Mpsc`; `Epoch` carries through every async path; don't "optimize"
|
||||
the `ARCHITECTURE §8.10` invariants.
|
||||
- Commit per coherent unit with a real message. **Never push.** `main` is the known-good fallback.
|
||||
- Update `CODEMAP §3`, `ARCHITECTURE §8`, tick `OPSTACK-PLAN`, append to `OPSTACK-PROGRESS.md`.
|
||||
- **When inserting a class into `VoxelDensityOpStack.cpp` / `VoxelHeightOpStack.cpp`, put it ABOVE
|
||||
the labelled end of the anonymous namespace.** Anchoring on the FACTORIES banner puts it outside,
|
||||
and the brace added with it closes nothing. Made that mistake twice; both files say so at the
|
||||
exact line.
|
||||
|
||||
## Method lessons this refactor actually paid for
|
||||
|
||||
Ordered by how much they cost.
|
||||
|
||||
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses, then was
|
||||
solved for free by a build setting changed for an unrelated reason. Park a question whose
|
||||
consequences are measured and benign.
|
||||
- **Verify the premise before reasoning from it.** Five times now a confident chain rested on an
|
||||
unchecked assumption and the check reversed it: C1's *documented* fix was wrong; "C9's risk is gone
|
||||
after FPSemantics" was wrong; "C1 is closed, 0 sites left behind" was wrong (the sweep matched a
|
||||
*spelling*); "PitDensity enables pits" was wrong; "there are 13 detail modifiers" was wrong (twelve,
|
||||
and only eleven read the per-room copy). **A grep over a spelling is evidence about the spelling.**
|
||||
- **Read the code, not the comment.** The cliff modifier's comment promises a sampled Z±1 gradient;
|
||||
the code samples nothing and uses a Z-stretched Perlin it *calls* `VertGrad`. Ported as written —
|
||||
and written down, so nobody "fixes" it from the comment.
|
||||
- **A perf change can be a correctness change.** The column-memo optimisation silently deleted the
|
||||
overhang; the tests caught it the same day. Invisible to inspection, and it produced plausible
|
||||
terrain.
|
||||
- **Coverage is a number, not a boolean.** Four related traps, each of which produced a green run
|
||||
that proved almost nothing:
|
||||
- *A test that prints nothing on success is indistinguishable from one that never ran.*
|
||||
- *A guard that only trips at zero notices absence, it does not measure coverage.* Use fractions.
|
||||
- *A success message that **asserts** coverage instead of reporting it reads as evidence while
|
||||
measuring nothing.*
|
||||
- *A check can be vacuous as well as a counter.* "Nothing leaked" is worthless unless something
|
||||
happened — so the gate check also reports how many samples move when the modifiers are zeroed.
|
||||
- **Enabling a feature is not evidence it fired — ask the structure, not the output.** Setting
|
||||
`PitDensity` did nothing (wrong struct). Diffing two stacks with/without the op pool would have
|
||||
*lied* (the pool is not in the SDF cache key, so both share the `thread_local` cache). What worked:
|
||||
call `BuildChunkCache` and look at `Pits.Num()`. **Prefer the check that can fail for exactly one
|
||||
reason** — and when a zero has three possible causes, give each one its own number.
|
||||
- **An oracle that shares the defect under test proves nothing.** The stale-cache check compares each
|
||||
stack against *itself evaluated alone*, never against the original — which keys its SDF cache
|
||||
without the params and would fail it.
|
||||
- **One definition, not two kept in sync.** `VF_BuildOpStackForChunk` exists because a tile skipped on
|
||||
the verdict of a stack that is not the one producing its density is a hole. A "keep these in sync"
|
||||
comment would not have been enough.
|
||||
**What do you want the world to *do* that it doesn't?** Three weeks went into a pipeline instead of
|
||||
that question. Start there.
|
||||
|
||||
+2131
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -146,7 +146,9 @@ mid-edit, and nobody will be watching.** Everything below follows from that.
|
||||
CRASH-SAFE DISCIPLINE (non-negotiable when unattended)
|
||||
1. `git commit` after every coherent unit — a file, a test, a header. Small and often. You are on
|
||||
branch `experimental`; `main` is the known-good fallback, so committing costs nothing and a
|
||||
half-finished commit is infinitely better than an uncommitted half-edit. Never push.
|
||||
half-finished commit is infinitely better than an uncommitted half-edit. Pushing `experimental`
|
||||
is fine and expected (it is tracked as `origin/experimental` since 2026-07-29); **never push
|
||||
`main`.**
|
||||
2. Maintain `OPSTACK-PROGRESS.md` at the plugin root. APPEND (never rewrite) a dated entry per
|
||||
milestone: what you did, what you believe is true, what is UNVERIFIED (i.e. everything not yet
|
||||
built), and the single next action. Write the entry BEFORE starting the work it describes, so an
|
||||
|
||||
+32
-4
@@ -92,6 +92,15 @@ Legend: ✅ verified against code · ◻️ checklist box.
|
||||
`ResetGridBuildState(FDecoGrid&)`. *(2026-07-04)*
|
||||
- [ ] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — two overlapping passage-shape enums, both in
|
||||
active use (11 refs). Consider consolidating to one. *(judgment call, not dead)*
|
||||
**JUDGED 2026-08-16 — LEAVE THEM. Recommendation: close this item rather than act on it.** They
|
||||
read as duplicates from the index and are not: `EVoxelPassageType` (`VoxelStrateTypes.h` ~42) is
|
||||
the **global inter-strate bore shape** the layout generator picks (`SlopedTunnel` / `VerticalShaft`
|
||||
/ helix…); `EVoxelPassageStyle` (~1741) is **per-strate descent styling** on
|
||||
`FStratePassageConfig` (`Straight` / `Worm` / `Spiral` / `Cascading`). Different owners, different
|
||||
value sets, different lifetimes. And both are `UMETA`-tagged, i.e. **serialised into Jahni's
|
||||
authored strate assets** — merging them silently rewrites saved content. That is a content-risk
|
||||
change bought for cosmetic tidiness, which is the wrong trade at any time and especially before
|
||||
the content lock.
|
||||
|
||||
## Dead code
|
||||
- [x] **`UVoxelMarchingCubesMesher::GetDensity()`** — removed, along with `InterpolateEdge`,
|
||||
@@ -106,10 +115,29 @@ Legend: ✅ verified against code · ◻️ checklist box.
|
||||
strate-aware vertical clamp; struck from the dead list.
|
||||
|
||||
## Over-complexity (behavior-preserving splits, optional)
|
||||
- [ ] `GetDensityWithParams` (~600-1000 L) → `ApplyCaveMorphology`/`ApplySurfaceRoughness`/`ApplyTerrainOps`/`ApplyPostProcess`
|
||||
- [ ] `BuildChunkCache` (~450 L) → `CollectRooms`/`BuildNeighborGraph`/`ResolveTunnels`/`BakeRoomFeatures`
|
||||
- [ ] `GenerateMesh` (~250 L) → `PrecalcDensityGrid`/`MarchCells`/`GenerateSkirts`
|
||||
- [ ] `GetGenerationParams` (~180 L) → extract `ApplyBoundaryTransition(...)`
|
||||
|
||||
> ⛔ **REASSESSED 2026-08-16 — do not pick these up as filler work.** They were written before the
|
||||
> operator-stack refactor existed, and two of them are now actively counter-productive rather than
|
||||
> merely optional. Read the reason before ticking anything here.
|
||||
|
||||
- [ ] ~~`GetDensityWithParams` (~600-1000 L)~~ → **DON'T.** ⛔ Two independent reasons. (1) The
|
||||
operator stack is *replacing* this function archetype by archetype — splitting it produces code
|
||||
that gets deleted, and churns the eight equivalence tests that compare the stack against it **bit
|
||||
for bit**. (2) It is the hottest path in the plugin and carries the `ARCHITECTURE §8.10`
|
||||
invariants (`thread_local` box-valid caches, two-pass MC loop, SSE noise). The one time a
|
||||
"behaviour-preserving" change was made here it silently deleted the overhang and only 1 sample in
|
||||
20 000 crossed the isosurface — *a perf change can be a correctness change*. Revisit only once the
|
||||
`switch` path is retired for good.
|
||||
- [ ] ~~`BuildChunkCache` (~450 L)~~ → **DON'T, same reason.** `FRoomGraphSource` deliberately
|
||||
**calls** `BuildChunkCache`/`EvaluateSDFCached` instead of transcribing them, precisely so there is
|
||||
one definition. Restructuring it now forks the thing that was kept unforked on purpose.
|
||||
- [ ] `GenerateMesh` (~250 L) → `PrecalcDensityGrid`/`MarchCells`/`GenerateSkirts`.
|
||||
⚠️ Still genuinely optional, but the two-pass loop is an `§8.10` invariant — a split must not
|
||||
merge the passes, and the Z-outermost pre-sample order is load-bearing for every column cache
|
||||
downstream (see the column-memo work of 2026-08-16). Low value, non-zero risk.
|
||||
- [ ] `GetGenerationParams` (~180 L) → extract `ApplyBoundaryTransition(...)`. **The safest of the
|
||||
six** — pure params math, no caches, and `AUDIT §C2` already forced a close reading of both
|
||||
Gradient arms. If any of these is ever worth doing, it is this one.
|
||||
- [ ] `GeneratePassages` (~150 L) → `ComputePlacement`/`BuildControlChain`/`ComputeBounds`
|
||||
- [ ] `BuildCellSpawns` (~150 L) → `FindSurfaceCrossings`/`PlaceDecorationsAtCrossings`
|
||||
|
||||
|
||||
@@ -235,14 +235,18 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(24680);
|
||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||
const int32 SpanCells = 95;
|
||||
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||
|
||||
for (int32 t = 0; t < 60; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1;
|
||||
@@ -289,10 +293,12 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
|
||||
TestEqual(TEXT("every box verdict the island stack emits survives brute force"), NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 60 FloatingIslands tiles: %d proved AllSolid, %d proved AllAir, ")
|
||||
TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ")
|
||||
TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ")
|
||||
TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
|
||||
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
|
||||
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
|
||||
SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing,
|
||||
NumProvedSolid, NumProvedAir, NumMixed));
|
||||
|
||||
if (NumProvedAir == 0)
|
||||
|
||||
@@ -267,14 +267,18 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(24680);
|
||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||
const int32 SpanCells = 40;
|
||||
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||
|
||||
for (int32 t = 0; t < 60; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable
|
||||
const int32 Extent = Step * Cells;
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
|
||||
@@ -319,9 +323,11 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
||||
NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 60 Maze tiles: %d proved uniform, %d Mixed. Today's ClassifyTile ")
|
||||
TEXT("Box verdicts over 60 Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
|
||||
TEXT("CellSize %.0f): %d proved uniform, %d Mixed. Today's ClassifyTile ")
|
||||
TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ")
|
||||
TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."),
|
||||
SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
|
||||
NumProved, NumMixed));
|
||||
|
||||
if (NumProved == 0)
|
||||
|
||||
@@ -207,14 +207,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(13579);
|
||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||
const int32 SpanCells = 55;
|
||||
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||
|
||||
for (int32 t = 0; t < 60; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
// ⚠️ L'ÉTENDUE XY ÉTAIT ±48 VOXELS, POUR UN `ShaftSpacing` DE 55 : moins d'UNE cellule
|
||||
// de puits. C'est le même piège que celui qui a coûté trois runs au test TunnelNetwork —
|
||||
// un échantillonneur qui ne couvre pas une période du motif ne mesure pas le monde, il
|
||||
// mesure un point du motif. ±440 = 8 périodes.
|
||||
// The XY extent was ±48 voxels for a ShaftSpacing of 55 — less than one shaft cell, the
|
||||
// same trap that cost the TunnelNetwork test three runs. ±440 covers 8 periods.
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1;
|
||||
@@ -259,10 +269,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
||||
TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 60 VerticalShafts tiles: %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("ClassifyTile proves ZERO of these -- every cave archetype falls through to \"pas ")
|
||||
TEXT("prouvable en v1\"."),
|
||||
NumProved, NumMixed));
|
||||
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
|
||||
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ")
|
||||
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
|
||||
TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ")
|
||||
TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ")
|
||||
TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ")
|
||||
TEXT("because a false verdict here leaves no geometry and no collision."),
|
||||
SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing,
|
||||
NumProved, NumMixed, NumUnsound));
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ")
|
||||
TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ")
|
||||
TEXT("CAPSULE test are the only two things that can return CarveOnly here, ")
|
||||
TEXT("and ExtraReach inflates both -- check its value against ShaftMaxRadius ")
|
||||
TEXT("before touching either test."));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -280,14 +280,18 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(24680 + SlotIndex);
|
||||
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
|
||||
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
|
||||
const int32 SpanCells = 60;
|
||||
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||
|
||||
for (int32 t = 0; t < NumSlabTiles; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
|
||||
@@ -335,10 +339,13 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
||||
NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s box verdicts over %d tiles: %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ")
|
||||
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
|
||||
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
|
||||
SlotName, NumSlabTiles, NumProved, NumMixed));
|
||||
SlotName, NumSlabTiles, SpanVoxels,
|
||||
(float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing,
|
||||
NumProved, NumMixed));
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
|
||||
@@ -70,6 +70,23 @@ namespace
|
||||
constexpr int32 PointsPerChunk = 250;
|
||||
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
|
||||
|
||||
/**
|
||||
* L'empreinte de params que `GetDensityWithParams` exige depuis le correctif d'`AUDIT §C2`.
|
||||
*
|
||||
* ⚠️ CE N'EST PAS DU REMPLISSAGE D'ARGUMENT. Avant ce correctif, l'original clé son cache SDF
|
||||
* sans les params, et le contrôle 3 plus bas explique en détail pourquoi il fallait alors
|
||||
* comparer chaque pile à ELLE-MÊME plutôt qu'à l'original : l'oracle partageait le défaut
|
||||
* testé. En passant la même empreinte que la production, l'oracle ne le partage plus.
|
||||
*
|
||||
* `LayoutVersion = 0` partout dans ce test : le monde de test ne rebâtit jamais son layout en
|
||||
* cours de route, donc la version est constante — ce qui compte ici, c'est que l'empreinte
|
||||
* DIFFÈRE entre deux jeux de params, et c'est exactement ce que la CRC donne.
|
||||
*/
|
||||
FORCEINLINE uint32 VF_FP(const FStrateGenerationParams& InP)
|
||||
{
|
||||
return FCrc::MemCrc32(&InP, sizeof(InP));
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// ⚠️ `DisableStageBModifiers` A DISPARU, ET SA DISPARITION EST LE RÉSULTAT DE L'ÉTAPE B
|
||||
//=========================================================================
|
||||
@@ -503,7 +520,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, P);
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, P, VF_FP(P), 0);
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
FullVals[i] = New;
|
||||
|
||||
@@ -650,7 +667,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
for (int32 i = 0; i < RoughSweepPoints; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV), VStack.EvalMC(X, Y, Z)))
|
||||
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV, VF_FP(PV), 0),
|
||||
VStack.EvalMC(X, Y, Z)))
|
||||
{
|
||||
++VDiff;
|
||||
}
|
||||
@@ -819,22 +837,28 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
// empreinte CRC des params. Ce bloc le vérifie en ALTERNANT A, B, A, B au même point, le motif
|
||||
// qui fait mentir une clé incomplète.
|
||||
//
|
||||
// ⚠️⚠️ ON NE COMPARE **PAS** À L'ORIGINAL ICI, ET C'EST LE POINT LE PLUS IMPORTANT DE CE TEST.
|
||||
// `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed) — **sans les params**. En
|
||||
// alternance il rendrait donc, pour B, les salles de A : l'original ÉCHOUERAIT ce contrôle. Le
|
||||
// comparer à lui ici ne mesurerait pas mon opérateur, ça mesurerait son bug. On compare donc
|
||||
// chaque pile à ELLE-MÊME évaluée seule — un oracle qui ne partage pas le défaut testé.
|
||||
// ⚠️⚠️ HISTORIQUE, ET LE DÉNOUEMENT EST DANS LE PARAGRAPHE SUIVANT — À LIRE EN ENTIER.
|
||||
// Ce bloc a été écrit quand `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed),
|
||||
// **sans les params** : en alternance il rendait, pour B, les salles de A, donc l'original
|
||||
// ÉCHOUAIT ce contrôle. Le comparer à lui ici n'aurait pas mesuré l'opérateur, ça aurait mesuré
|
||||
// son bug — d'où le choix de comparer chaque pile à ELLE-MÊME évaluée seule.
|
||||
//
|
||||
// ⚠️ ET CE N'EST PEUT-ÊTRE PAS QU'UN ARTEFACT DE TEST — à vérifier, pas à croire. En production
|
||||
// `GetGenerationParams` MÉLANGE les params entre strates voisines (transitions Gradient), donc
|
||||
// deux chunks de Z différents dans la même strate peuvent avoir des params différents, avec la
|
||||
// même boîte XY, le même index de strate et le même seed ⇒ aucune reconstruction. Si c'est
|
||||
// exact, un worker qui descend une bande de transition sert les salles du chunk précédent.
|
||||
// Noté dans `AUDIT §C2` comme SUSPECTÉ, avec le test qui le confirmerait — pas comme prouvé.
|
||||
// ✅ **CE N'ÉTAIT PAS QU'UN ARTEFACT DE TEST, ET C'EST MAINTENANT CORRIGÉ** (2026-07-28). Le
|
||||
// soupçon écrit ici s'est confirmé : `GetGenerationParams` blende les params À L'INTÉRIEUR
|
||||
// d'une strate (`Alpha` = f(chunk Z) en `Gradient`, le défaut), donc deux chunks de Z différents
|
||||
// partageaient boîte XY, index de strate et seed ⇒ aucune reconstruction ⇒ le deuxième chunk
|
||||
// évalué contre les salles du premier. Et comme l'ordre des workers décide lequel est « le
|
||||
// premier », **deux pairs divergeaient depuis la même seed**, ce que §2.6.1 interdit.
|
||||
// `GetDensityWithParams` prend désormais une empreinte de params et une `LayoutVersion`
|
||||
// OBLIGATOIRES (calculées une fois par chunk côté production, `VF_FP` ici).
|
||||
//
|
||||
// We compare each stack to ITSELF evaluated alone, not to the original: the original keys its
|
||||
// SDF cache without the params and would fail this check, so comparing against it would measure
|
||||
// its bug rather than this operator.
|
||||
// ⚠️ ON GARDE POURTANT L'ORACLE « CHAQUE PILE CONTRE ELLE-MÊME », et ce n'est pas de la
|
||||
// paresse : il teste la clé de la PILE, qui est une clé distincte de celle de l'original. Les
|
||||
// faire dépendre l'une de l'autre remettrait exactement le couplage qu'on vient de défaire.
|
||||
//
|
||||
// The suspicion recorded here was CONFIRMED and is now fixed: the params fingerprint and layout
|
||||
// version are required arguments. The self-comparison oracle stays, because it tests the STACK's
|
||||
// key, which is a different key from the original's.
|
||||
{
|
||||
FStrateGenerationParams P2 = P;
|
||||
P2.RoomSpacing = P.RoomSpacing * 0.6f; // une autre disposition de salles
|
||||
@@ -1059,39 +1083,290 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 4. LE VERDICT DE BOÎTE — attendu NUL, et c'est le point
|
||||
// 4. LE VERDICT DE BOÎTE — plus attendu nul, et CHAQUE VERDICT EST BRUTE-FORCÉ
|
||||
//=========================================================================
|
||||
// ⚠️ CE BLOC A CHANGÉ DE NATURE LE 2026-07-28, ET IL FAUT SAVOIR POURQUOI.
|
||||
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que `FRoomGraphSource::EffectOverBox`
|
||||
// rendait `Both` inconditionnellement : « zéro » était alors une description honnête de l'état
|
||||
// du portage. Depuis que la source répond SPATIALEMENT, asserter zéro reviendrait à interdire
|
||||
// le gain qu'on vient de construire — et pire, ça transformerait le test en gardien du bug.
|
||||
//
|
||||
// Ce qui le remplace n'est PAS « on enlève l'assertion » : c'est l'assertion qui compte
|
||||
// vraiment, la SOUNDNESS. Un verdict faux ne se voit pas — pas de géométrie, **pas de
|
||||
// collision** — jusqu'à ce qu'un joueur traverse le sol. Donc chaque tuile déclarée prouvée est
|
||||
// ré-évaluée voxel par voxel, et le test échoue si UN seul échantillon contredit le verdict.
|
||||
// Le nombre de tuiles prouvées, lui, est REPORTÉ, pas asserté : c'est une mesure, pas un
|
||||
// contrat (la leçon « coverage is a number, not a boolean »).
|
||||
//
|
||||
// Was: assert zero proved. That was honest while the source answered Both unconditionally; it
|
||||
// would now forbid the very gain this change makes. What replaces it is the assertion that
|
||||
// actually matters — every proved tile is brute-forced voxel by voxel, because a false verdict
|
||||
// means no geometry and NO COLLISION until a player falls through it.
|
||||
// ⚠️ UNE SEULE DÉFINITION DU BALAYAGE, DEUX MONDES. Voir le commentaire d'appel plus bas :
|
||||
// la densité de la fixture rend ce verdict STRUCTURELLEMENT impossible, donc mesurer sur elle
|
||||
// seule ne dit rien de la production. Copier-coller le balayage aurait donné deux critères qui
|
||||
// divergent ; c'est un paramètre, pas un doublon.
|
||||
auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
|
||||
const FVoxelOpContext& TCtx, const TCHAR* Label,
|
||||
bool bZeroProvedIsExpected)
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0;
|
||||
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
|
||||
int32 NumBruteSamples = 0, NumViolations = 0;
|
||||
float WorstViolation = 0.0f;
|
||||
TMap<FString, int32> SolidKillerCounts;
|
||||
int32 NumRoomKilled = 0, NumTilesAwayFromSpine = 0;
|
||||
// ±320 voxels = 4 x RoomSpacing. Hors de la boucle : la ligne de rapport en a besoin, et
|
||||
// une étendue d'échantillonnage qu'on ne peut pas citer est une étendue qu'on ne surveille pas.
|
||||
const int32 SpanCells = 40;
|
||||
const int32 SpanVoxelsReported = SpanCells * 8; // Extent = Step * Cells = 1 * 8
|
||||
int32 TilesHitByRooms = 0, TilesHitByTunnels = 0, TilesHitByPits = 0, TilesHitByChimneys = 0;
|
||||
int32 SumHitRooms = 0, SumNumRooms = 0, SumHitTunnels = 0, SumNumTunnels = 0;
|
||||
int32 SumHitRoomsNoWarp = 0, SumHitTunnelsNoWarp = 0;
|
||||
float LastWarpDilation = 0.0f;
|
||||
|
||||
FRandomStream Rng(97531);
|
||||
for (int32 t = 0; t < 40; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
|
||||
// ⚠️ L'ÉTENDUE XY ÉTAIT ±32 VOXELS, ET C'EST CE QUI RENDAIT CE BLOC INEXPLOITABLE.
|
||||
// `RandRange(-4, 4) * 8` échantillonnait 40 tuiles dans un cube de ±32 voxels autour de
|
||||
// (0,0) — c'est-à-dire l'endroit le PLUS creusé du monde entier, et de loin :
|
||||
// • `RoomSpacing = 80`, donc ±32 ne couvre même pas la moitié d'UNE cellule de salle ;
|
||||
// • `OriginRoomRadius = 20` garantit une grosse salle exactement à (0,0), de rayon de
|
||||
// cull `max(20·1.5, 8) + 3·4 = 42` — qui avale la quasi-totalité de la fenêtre ;
|
||||
// • la spine (0,0) descend précisément là.
|
||||
// La mesure « 4.9 salles sur 7.2 atteignent la boîte » ne décrivait donc pas la densité
|
||||
// de grottes du monde, elle décrivait le hub de la spine. Aucune conclusion sur la
|
||||
// prouvabilité du roc profond ne pouvait sortir de cet échantillon.
|
||||
//
|
||||
// ⚠️ CE N'EST PAS « ÉLARGIR JUSQU'À CE QUE ÇA PASSE ». Le verdict de chaque tuile reste
|
||||
// brute-forcé voxel par voxel juste en dessous : un échantillonneur plus large qui
|
||||
// produirait un verdict FAUX échoue exactement comme avant. On corrige ce que la mesure
|
||||
// REGARDE, pas ce qu'elle exige.
|
||||
//
|
||||
// The XY extent was ±32 voxels around (0,0) -- with RoomSpacing = 80 and a guaranteed
|
||||
// OriginRoomRadius = 20 room at the origin, that samples the single most cave-dense spot
|
||||
// in the world and says nothing about deep rock. Widening changes what the measurement
|
||||
// LOOKS AT, not what it demands: every verdict is still brute-forced below.
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-4, 4) * Extent,
|
||||
Rng.RandRange(-4, 4) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
Rng.RandRange(-SpanCells, SpanCells) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
// Combien de tuiles échappent vraiment au hub de la spine : sans ce compte, un futur
|
||||
// resserrement de l'étendue redeviendrait invisible.
|
||||
if (FMath::Square((float)Origin.X) + FMath::Square((float)Origin.Y)
|
||||
> FMath::Square(3.0f * TP.OriginRoomRadius))
|
||||
{
|
||||
++NumTilesAwayFromSpine;
|
||||
}
|
||||
const int32 GridDim = Cells + 1;
|
||||
const FBox Box(
|
||||
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||
|
||||
if (Stack.ClassifyBox(Box, Ctx) == EVoxelTileClass::Mixed) { ++NumMixed; }
|
||||
else { ++NumProved; }
|
||||
// ATTRIBUTION — le même pliage, mais il dit QUI tue chaque hypothèse. Le premier build
|
||||
// de l'`EffectOverBox` spatial est revenu vert avec 0 tuile prouvée, et le rapport ne
|
||||
// savait nommer aucun coupable : les deux causes que la mise en garde proposait étaient
|
||||
// toutes les deux fausses, la vraie étant un troisième opérateur. On ne redevine pas.
|
||||
int32 SolidKiller = INDEX_NONE, AirKiller = INDEX_NONE;
|
||||
const EVoxelTileClass Verdict = S.ClassifyBoxAttributed(Box, TCtx, SolidKiller, AirKiller);
|
||||
|
||||
if (SolidKiller != INDEX_NONE)
|
||||
{
|
||||
const FString KillerName = S.GetOpDebugName(SolidKiller);
|
||||
SolidKillerCounts.FindOrAdd(KillerName)++;
|
||||
|
||||
// VENTILATION PAR CLASSE DE PRIMITIVE. Quand c'est la source de salles qui tue,
|
||||
// « les tuiles traversent une grotte » n'est pas une réponse : les salles, les
|
||||
// tunnels, les pits et les cheminées ont chacun leur borne, de finesse très
|
||||
// différente (une sphère englobante de capsule est un très mauvais tunnel). On lit
|
||||
// ce que l'opérateur a RÉELLEMENT calculé plutôt que de rejouer le critère ici.
|
||||
if (KillerName == TEXT("RoomGraphSource"))
|
||||
{
|
||||
const VoxelDensityOps::FRoomBoxDiagnostic D =
|
||||
VoxelDensityOps::GetLastRoomBoxDiagnostic();
|
||||
++NumRoomKilled;
|
||||
if (D.HitRooms > 0) { ++TilesHitByRooms; }
|
||||
if (D.HitTunnels > 0) { ++TilesHitByTunnels; }
|
||||
if (D.HitPits > 0) { ++TilesHitByPits; }
|
||||
if (D.HitChimneys > 0) { ++TilesHitByChimneys; }
|
||||
SumHitRooms += D.HitRooms; SumNumRooms += D.NumRooms;
|
||||
SumHitTunnels += D.HitTunnels; SumNumTunnels += D.NumTunnels;
|
||||
SumHitRoomsNoWarp += D.HitRoomsNoWarp;
|
||||
SumHitTunnelsNoWarp += D.HitTunnelsNoWarp;
|
||||
LastWarpDilation = D.WarpDilation;
|
||||
}
|
||||
}
|
||||
|
||||
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||
|
||||
++NumProved;
|
||||
const bool bClaimSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
if (bClaimSolid) { ++NumSolid; } else { ++NumAir; }
|
||||
|
||||
// BRUTE FORCE — la boîte entière, pas un échantillonnage. `EvalMC` rend la convention
|
||||
// du mesher (négatif = solide), donc « tout solide » veut dire qu'aucun échantillon
|
||||
// n'est du côté air. On teste le SIGNE, c'est-à-dire l'existence d'une traversée
|
||||
// d'isosurface : c'est exactement la propriété sur laquelle le mesher est sauté.
|
||||
for (float Z = (float)Box.Min.Z; Z <= (float)Box.Max.Z; Z += 1.0f)
|
||||
for (float Y = (float)Box.Min.Y; Y <= (float)Box.Max.Y; Y += 1.0f)
|
||||
for (float X = (float)Box.Min.X; X <= (float)Box.Max.X; X += 1.0f)
|
||||
{
|
||||
const float D = S.EvalMC(X, Y, Z);
|
||||
++NumBruteSamples;
|
||||
const bool bViolates = bClaimSolid ? (D > 0.0f) : (D < 0.0f);
|
||||
if (bViolates)
|
||||
{
|
||||
++NumViolations;
|
||||
WorstViolation = FMath::Max(WorstViolation, FMath::Abs(D));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 40 TunnelNetwork tiles: %d proved, %d Mixed. %d proved is the ")
|
||||
TEXT("EXPECTED result at stage A and not a defect: the room source answers Both (its ")
|
||||
TEXT("bounds live in the SDF cache, which it would have to build for the queried box), ")
|
||||
TEXT("and the worm source answers CarveOnly EVERYWHERE because a fielded noise carve ")
|
||||
TEXT("has no spatial bound at all. Recovering these needs the numeric amplitude cap in ")
|
||||
TEXT("OPSTACK-DECOMPOSITION 0.2 -- the largest single perf item in the whole plan, and ")
|
||||
TEXT("the reason this archetype currently skips zero tiles."),
|
||||
NumProved, NumMixed, NumProved));
|
||||
TEXT("[%s] Tile sampler: 40 tiles of 10 voxels, XY drawn from +/-%d voxels (= %.1f x ")
|
||||
TEXT("RoomSpacing %.0f), Z across the strate; %d of 40 landed further than 3 x ")
|
||||
TEXT("OriginRoomRadius from the (0,0) spine. THIS LINE EXISTS BECAUSE THE SAMPLER WAS ")
|
||||
TEXT("THE BUG ONCE: it drew XY from +/-32 voxels, i.e. entirely inside the origin room's ")
|
||||
TEXT("cull sphere, so every number below described the spine hub rather than the world. ")
|
||||
TEXT("If the last count is low, nothing below says anything about deep rock."),
|
||||
Label, SpanVoxelsReported, (float)SpanVoxelsReported / FMath::Max(TP.RoomSpacing, 1.0f),
|
||||
TP.RoomSpacing, NumTilesAwayFromSpine));
|
||||
|
||||
TestEqual(TEXT("stage A emits no unsound verdict (it emits none at all)"), NumProved, 0);
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("[%s] Box verdicts over 40 TunnelNetwork tiles: %d proved (%d AllSolid, %d AllAir), ")
|
||||
TEXT("%d Mixed -- brute-forced over %d voxels, %d violations. This number was 0 proved / ")
|
||||
TEXT("40 Mixed until FRoomGraphSource::EffectOverBox learned to answer spatially, and it ")
|
||||
TEXT("is the single largest perf item of the whole plan (OPSTACK-DECOMPOSITION 0.2): a ")
|
||||
TEXT("proved tile skips GenerateMesh entirely, so it trades one BuildChunkCache against ")
|
||||
TEXT("30000+ density evaluations. Read the PROVED count as a measurement, never as a ")
|
||||
TEXT("contract -- what is asserted below is that none of them is WRONG, because a false ")
|
||||
TEXT("verdict leaves no geometry and no collision behind it."),
|
||||
Label, NumProved, NumSolid, NumAir, NumMixed, NumBruteSamples, NumViolations));
|
||||
|
||||
// QUI TUE `AllSolid`, ET COMBIEN DE FOIS. Toujours imprimé, pas seulement en cas d'échec :
|
||||
// c'est aussi la ligne qui dit, quand des tuiles SONT prouvées, ce qui bloque les autres.
|
||||
{
|
||||
SolidKillerCounts.ValueSort([](int32 A, int32 B) { return A > B; });
|
||||
FString Breakdown;
|
||||
for (const TPair<FString, int32>& Kv : SolidKillerCounts)
|
||||
{
|
||||
if (!Breakdown.IsEmpty()) { Breakdown += TEXT(", "); }
|
||||
Breakdown += FString::Printf(TEXT("%s x%d"), *Kv.Key, Kv.Value);
|
||||
}
|
||||
if (Breakdown.IsEmpty()) { Breakdown = TEXT("nothing -- AllSolid survived every tile"); }
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("[%s] AllSolid killed by: %s. Names the first operator to kill the ")
|
||||
TEXT("hypothesis, counted per tile. (Why this line exists, and the wrong guesses ")
|
||||
TEXT("that preceded it, live in OPSTACK-PROGRESS and are deliberately NOT ")
|
||||
TEXT("repeated here: a diagnostic that carries narrative gets its live numbers ")
|
||||
TEXT("read as history and its history read as live numbers.)"),
|
||||
Label, *Breakdown));
|
||||
|
||||
if (NumRoomKilled > 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("[%s] ...and when RoomGraphSource is the killer (%d tiles), WHICH primitive class ")
|
||||
TEXT("reaches the box: rooms %d, tunnels %d, pits %d, chimneys %d (tiles, not ")
|
||||
TEXT("primitives -- a tile can be hit by several). Averages per killed tile: ")
|
||||
TEXT("%.1f of %.1f rooms reach, %.1f of %.1f tunnels reach. ")
|
||||
TEXT("WARP SHARE: the query box is dilated by +/-%.1f voxels per axis for the ")
|
||||
TEXT("warped room/tunnel query; with that dilation set to ZERO the same tests ")
|
||||
TEXT("would keep only %.1f rooms and %.1f tunnels. The gap between those ")
|
||||
TEXT("pairs is blocking caused by MY BOX rather than by geometry -- the term ")
|
||||
TEXT("that went unmeasured while three rounds of tightening happened around ")
|
||||
TEXT("it. If the gap dominates, tighten the warp bound, not the primitives."),
|
||||
Label, NumRoomKilled, TilesHitByRooms, TilesHitByTunnels, TilesHitByPits, TilesHitByChimneys,
|
||||
(float)SumHitRooms / (float)NumRoomKilled, (float)SumNumRooms / (float)NumRoomKilled,
|
||||
(float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled,
|
||||
LastWarpDilation,
|
||||
(float)SumHitRoomsNoWarp / (float)NumRoomKilled,
|
||||
(float)SumHitTunnelsNoWarp / (float)NumRoomKilled));
|
||||
}
|
||||
}
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
// ⚠️ UN AVERTISSEMENT QUI SE DÉCLENCHE À CHAQUE RUN ET VEUT DIRE « tout va bien »
|
||||
// N'EST PAS UN AVERTISSEMENT — c'est du bruit qui apprend à ignorer les vrais.
|
||||
// Sur la fixture dense, 0 prouvé est la SEULE réponse arithmétiquement possible
|
||||
// (les sphères de cull couvrent ce monde 3,6x) : c'est une info. En production
|
||||
// défauts, 0 prouvé serait une VRAIE régression (11 aujourd'hui) : ça reste un
|
||||
// avertissement.
|
||||
// A warning that fires every run and always means "this is fine" is noise that
|
||||
// trains the reader to ignore warnings. Zero proved is the only possible answer on
|
||||
// the dense fixture (info); on production defaults it would be a real regression
|
||||
// from 11 (warning).
|
||||
const FString ZeroMsg = FString::Printf(
|
||||
TEXT("[%s] No tile was proved, so the brute force verified nothing -- it has no ")
|
||||
TEXT("verdict to contradict. Do NOT re-derive the cause: read the two lines above, ")
|
||||
TEXT("which name the operator and then the primitive class. ⚠️ On the DENSE ")
|
||||
TEXT("FIXTURE this is the EXPECTED and correct result, not a defect: room cull ")
|
||||
TEXT("radius (1.5R + 3*SDFBlendRadius, mean ~42) equals RoomSpacing 42 at 85%% ")
|
||||
TEXT("occupancy, so cull spheres cover that world ~3.6x over and no box can be ")
|
||||
TEXT("outside all of them. It is the 'production defaults' run that answers ")
|
||||
TEXT("whether real worlds have skippable rock."),
|
||||
Label);
|
||||
|
||||
if (bZeroProvedIsExpected) { AddInfo(ZeroMsg); }
|
||||
else { AddWarning(ZeroMsg); }
|
||||
}
|
||||
|
||||
TestEqual(FString::Printf(
|
||||
TEXT("[%s] every proved TunnelNetwork tile survives brute force (worst ")
|
||||
TEXT("|density| on the wrong side: %.9g)"), Label, WorstViolation),
|
||||
NumViolations, 0);
|
||||
};
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// LES DEUX MONDES, ET POURQUOI IL EN FAUT DEUX
|
||||
//-------------------------------------------------------------------------
|
||||
// ⚠️ LA FIXTURE REND CE VERDICT STRUCTURELLEMENT IMPOSSIBLE, ET CE N'EST PAS UN DÉFAUT DE LA
|
||||
// FIXTURE. `EnableTunnelFeatures` densifie délibérément (`RoomSpacing` 80 → 42,
|
||||
// `RoomDensity` 0.35 → 0.85) parce qu'aux défauts le premier run n'avait que 1,1 % des
|
||||
// échantillons en grotte — l'équivalence comparait du roc plein à du roc plein. Cette
|
||||
// densification est ce qui rend le contrôle 1 SIGNIFIANT.
|
||||
//
|
||||
// Mais elle est exactement ANTAGONISTE de la prouvabilité, et l'arithmétique le dit sans
|
||||
// ambiguïté : le rayon de cull d'une salle vaut `max(R·1.5, R·HeightRatio) + 3·SDFBlendRadius`,
|
||||
// soit `1.5R + 12` ⇒ entre 27 et 57 pour `R ∈ [10, 30]`, moyenne ≈ 42 — c'est-à-dire
|
||||
// **exactement le pas du réseau**, à 85 % d'occupation. Des sphères de cull de rayon égal au pas
|
||||
// du réseau recouvrent l'espace ~3,6 fois. **Aucune boîte de ce monde ne peut être hors de
|
||||
// toutes les sphères de cull.** Le « 6.3 salles sur 8.3 atteignent la boîte » mesuré est
|
||||
// exactement ça, et élargir l'échantillonneur n'y a rien changé (39 tuiles sur 40 étaient déjà
|
||||
// loin de la spine, et le compte est resté 40 sur 40).
|
||||
//
|
||||
// Donc on mesure les DEUX : la fixture dense, où `0 prouvé` est le résultat CORRECT et
|
||||
// informatif (le gain disparaît quand les grottes saturent), et un jeu de params aux DÉFAUTS
|
||||
// DE PRODUCTION, qui est le monde dont la question « combien de tuiles peut-on sauter » parle
|
||||
// réellement. Ce n'est pas « élargir jusqu'à ce que ça passe » : les deux sont rapportés, les
|
||||
// deux sont brute-forcés, et le dense DOIT continuer à rendre ~0.
|
||||
//
|
||||
// Two worlds on purpose: the fixture is deliberately densified so the equivalence check means
|
||||
// something, and that same densification makes tile-proving structurally impossible (room cull
|
||||
// radius ~= the lattice spacing, at 85% occupancy). Both are measured and both are brute-forced;
|
||||
// the dense one reporting ~0 is the correct answer, not a failure.
|
||||
RunTileScan(Stack, P, Ctx, TEXT("dense fixture"), /*bZeroProvedIsExpected*/ true);
|
||||
|
||||
{
|
||||
FStrateGenerationParams SparseP = P;
|
||||
SparseP.RoomSpacing = 80.0f; // le défaut d'`UVoxelStrateDefinition`
|
||||
SparseP.RoomDensity = 0.35f; // idem — voir `EnableTunnelFeatures`
|
||||
|
||||
FVoxelOpStack SparseStack;
|
||||
VoxelDensityOps::BuildTunnelNetworkStack(SparseStack, SparseP, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
SparseStack.PrepareChunk(Ctx);
|
||||
|
||||
// ⚠️ Les deux piles partagent les caches `thread_local` de `FRoomGraphSource`. C'est VOULU,
|
||||
// et c'est exactement ce que le contrôle 3 vérifie : l'empreinte de params est dans la clé,
|
||||
// donc l'une ne peut pas se servir les salles de l'autre. Le jour où ce contrôle tombe,
|
||||
// cette ligne-ci devient fausse en même temps — elles se surveillent mutuellement.
|
||||
RunTileScan(SparseStack, SparseP, Ctx, TEXT("production defaults"), /*bZeroProvedIsExpected*/ false);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
@@ -1172,7 +1447,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
for (int32 i = 0; i < UWSamples; ++i)
|
||||
{
|
||||
const float X = (float)UWPoints[i].X, Y = (float)UWPoints[i].Y, Z = (float)UWPoints[i].Z;
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP);
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP, VF_FP(UP), 0);
|
||||
const float New = UWStack.EvalMC(X, Y, Z);
|
||||
if (Z > UWInnerBot && Z < UWInnerTop && Old >= 0.0f) { ++UWInCave; }
|
||||
if (!BitEqual(Old, New))
|
||||
|
||||
@@ -129,22 +129,20 @@ namespace VoxelForgeTest
|
||||
// elle n'était jusqu'ici masquée que par un accident.
|
||||
//
|
||||
// `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs
|
||||
// rendaient tous les deux **1**. Or les caches par chunk de `GetDensityAt` sont clés sur
|
||||
// `(ChunkCoord, LayoutVersion)` : deux mondes différents, même version, même chunk ⇒ le
|
||||
// second se voit servir les params — ET le drapeau `CP_UseOpStack` — du premier.
|
||||
// Personne ne l'a vu parce que `bUseOperatorStack` valait false partout : les deux
|
||||
// mondes étaient d'accord par défaut. Le premier monde qui coche la case fait tomber
|
||||
// cette coïncidence, dans les DEUX sens (il contamine, et il est contaminé).
|
||||
// rendaient tous les deux **1**. Historiquement, les caches `CP_*` de `GetDensityAt`
|
||||
// n'avaient que `(ChunkCoord, LayoutVersion)` et le second monde pouvait hériter les
|
||||
// params — ET `CP_UseOpStack` — du premier. `DensityCacheOwnerId` ferme maintenant CE
|
||||
// chemin prouvé. Les bumps restent ici comme isolation conservatrice des autres caches
|
||||
// TLS que cette correction n'a volontairement pas audités ni modifiés.
|
||||
//
|
||||
// Un compteur de processus donne à chaque monde une version distincte, donc tout cache
|
||||
// survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe
|
||||
// (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler
|
||||
// ne change pas le layout — seulement le compteur.
|
||||
//
|
||||
// Each test world gets a process-unique LayoutVersion. Two worlds both reporting 1 made
|
||||
// GetDensityAt's per-chunk caches serve the previous world's params — and its
|
||||
// CP_UseOpStack flag — for the same chunk coord. Invisible while every world agreed that
|
||||
// the flag was false.
|
||||
// Each test world still gets a process-unique LayoutVersion. DensityCacheOwnerId now
|
||||
// prevents the proved CP_* cross-world reuse directly; the version bumps remain as
|
||||
// conservative isolation for other TLS caches not audited or changed by that fix.
|
||||
static int32 GWorldSerial = 0;
|
||||
const int32 Bumps = ++GWorldSerial;
|
||||
for (int32 b = 0; b < Bumps; ++b)
|
||||
|
||||
@@ -124,9 +124,13 @@ void VoxelCaveMorphology::BuildChunkCache(
|
||||
// MaxInfluence = how far a room body / tunnel TUBE reaches PERPENDICULAR to its
|
||||
// anchor — NOT its length. A room or tunnel whose anchor lies within MaxInfluence
|
||||
// of a box can touch a voxel inside that box.
|
||||
// Envelope conservatif / conservative bound: Lerp accepts inverted endpoints,
|
||||
// so max(Min, Max) covers either radius without changing the authored roll.
|
||||
const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius);
|
||||
const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius);
|
||||
const float MaxInfluence = FMath::Max(
|
||||
Params.MaxRoomRadius,
|
||||
Params.TunnelWarpStrength + Params.TunnelMaxRadius
|
||||
RoomRadiusEnvelope,
|
||||
Params.TunnelWarpStrength + TunnelRadiusEnvelope
|
||||
) + Params.SDFBlendRadius;
|
||||
|
||||
const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f);
|
||||
@@ -165,10 +169,10 @@ void VoxelCaveMorphology::BuildChunkCache(
|
||||
// Vertical range for room CENTER placement.
|
||||
//=========================================================================
|
||||
// Buffer = seal thickness + max room half-height.
|
||||
// This guarantees the tallest possible room (MaxRoomRadius * RoomHeightRatio)
|
||||
// This guarantees the tallest possible room (RoomRadiusEnvelope * RoomHeightRatio)
|
||||
// fits entirely within the seal boundary — no room gets its ceiling or floor
|
||||
// cut flat by the seal. Smaller rooms have proportionally more margin.
|
||||
const float RoomZBuffer = Params.MaxRoomRadius * Params.RoomHeightRatio;
|
||||
const float RoomZBuffer = RoomRadiusEnvelope * Params.RoomHeightRatio;
|
||||
const float StrateMinZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness + RoomZBuffer;
|
||||
const float StrateMaxZ = Params.StrateTopWorldZ - Params.BoundarySealThickness - RoomZBuffer;
|
||||
const float StrateRangeZ = StrateMaxZ - StrateMinZ;
|
||||
@@ -868,9 +872,11 @@ float VoxelCaveMorphology::EvaluateSDF(
|
||||
const FStrateGenerationParams& Params,
|
||||
uint32 Seed, int32 StrateIndex)
|
||||
{
|
||||
const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius);
|
||||
const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius);
|
||||
const float Margin = FMath::Max(
|
||||
Params.MaxRoomRadius,
|
||||
Params.TunnelWarpStrength + Params.TunnelMaxRadius
|
||||
RoomRadiusEnvelope,
|
||||
Params.TunnelWarpStrength + TunnelRadiusEnvelope
|
||||
) + Params.SDFBlendRadius;
|
||||
|
||||
FChunkSDFCache TempCache;
|
||||
|
||||
@@ -68,17 +68,23 @@ void UVoxelContentManager::NotifyShutdown()
|
||||
|
||||
// Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs.
|
||||
const double Deadline = FPlatformTime::Seconds() + 3.0;
|
||||
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
|
||||
{
|
||||
if (FPlatformTime::Seconds() > Deadline) break;
|
||||
FPlatformProcess::Yield();
|
||||
}
|
||||
WaitForDecorationTasks(Deadline);
|
||||
|
||||
DrainDecoResults();
|
||||
ResetGridBuildState(NearGrid);
|
||||
ResetGridBuildState(FarGrid);
|
||||
}
|
||||
|
||||
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
|
||||
{
|
||||
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
|
||||
{
|
||||
if (FPlatformTime::Seconds() > Deadline) return false;
|
||||
FPlatformProcess::Yield();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void UVoxelContentManager::DrainDecoResults()
|
||||
{
|
||||
FDecoCellResult Discard;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,9 @@
|
||||
#include "VoxelDensityPrimitives.h" // spine / seal / 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"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
//=============================================================================
|
||||
// L'ADAPTATEUR DE CHAMP DE BIOMES / THE BIOME FIELD ADAPTER
|
||||
@@ -444,6 +447,13 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z,
|
||||
// never fetched here — both callers already have them.
|
||||
namespace
|
||||
{
|
||||
// Une identité monotone évite qu'un worker réutilise les CP_* d'un monde détruit même si
|
||||
// l'allocateur UObject recycle plus tard la même adresse. Relaxed suffit : on ne publie aucune
|
||||
// donnée, on alloue seulement une valeur distincte par instance.
|
||||
// A monotonic identity prevents stale CP_* reuse even if UObject allocation later recycles an
|
||||
// address. Relaxed ordering is sufficient: this allocates uniqueness, it publishes no data.
|
||||
std::atomic<uint64> GNextDensityCacheOwnerId { 0 };
|
||||
|
||||
struct FVoxelStackParamRefs
|
||||
{
|
||||
const FSlabGenerationParams* Slab = nullptr;
|
||||
@@ -541,6 +551,11 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
UVoxelGenerator::UVoxelGenerator()
|
||||
: DensityCacheOwnerId(GNextDensityCacheOwnerId.fetch_add(1, std::memory_order_relaxed) + 1)
|
||||
{
|
||||
}
|
||||
|
||||
void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
|
||||
{
|
||||
// Seul le seed est copié ici. Tout le reste (params de cave, transitions,
|
||||
@@ -578,10 +593,16 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
// The generator type, the (boundary-blended) param struct, and the disturbance
|
||||
// params are identical for the whole chunk, yet resolving them re-runs a strate
|
||||
// lookup + copies large structs (and a ~60-field Lerp for blended cave chunks).
|
||||
// Cache them thread-locally, keyed by chunk coord — refetch only on chunk change.
|
||||
// Cache them thread-locally, keyed by owner + chunk coord + layout version — refetch only
|
||||
// when one of those integer identities changes.
|
||||
thread_local uint64 CP_OwnerId = 0;
|
||||
thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||
thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork;
|
||||
thread_local FStrateGenerationParams CP_Tunnel;
|
||||
// AUDIT §C2 — empreinte de `CP_Tunnel`, rafraîchie avec lui. Elle voyage jusqu'à la clé du
|
||||
// cache SDF de `GetDensityWithParams` pour qu'un chunk ne puisse plus être évalué contre
|
||||
// les salles d'un chunk voisin aux params blendés différemment.
|
||||
thread_local uint32 CP_TunnelFP = 0xFFFFFFFFu;
|
||||
thread_local FSlabGenerationParams CP_Slab;
|
||||
thread_local FMazeGenerationParams CP_Maze;
|
||||
thread_local FSurfaceGenerationParams CP_Surface;
|
||||
@@ -608,18 +629,23 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
// "I tweaked the strate asset, regenerated, and one patch kept the old shape."
|
||||
thread_local uint32 CP_Version = 0xFFFFFFFFu;
|
||||
// OPSTACK Phase 1 — la pile d'opérateurs, construite dans le MÊME bloc de refetch que les
|
||||
// params (donc même clé chunk+version, aucune logique d'invalidation en plus). Vide tant que
|
||||
// params (donc même clé owner+chunk+version, aucune logique d'invalidation en plus). Vide tant que
|
||||
// la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté.
|
||||
thread_local FVoxelOpStack CP_OpStack;
|
||||
thread_local bool CP_UseOpStack = false;
|
||||
|
||||
const uint32 LayoutVersion = StrateManager->GetLayoutVersion();
|
||||
if (ChunkCoord != CP_Chunk || LayoutVersion != CP_Version)
|
||||
const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId;
|
||||
if (bOwnerChanged || ChunkCoord != CP_Chunk || LayoutVersion != CP_Version)
|
||||
{
|
||||
// La grille de biome est validée par une BOÎTE XY, qui ne dit rien du FBiomeContext
|
||||
// ayant servi à classer ses cellules : sur un changement de version elle est périmée
|
||||
// même si la boîte couvre encore la requête.
|
||||
if (LayoutVersion != CP_Version) { CP_BiomeCache.Invalidate(); }
|
||||
// même si la boîte couvre encore la requête. Même invalidation quand le propriétaire
|
||||
// change : deux mondes peuvent partager version et coordonnées, jamais leur contexte.
|
||||
// The biome grid's XY box says nothing about its context. Owner changes invalidate it
|
||||
// too: two worlds may share version and coordinates, never cached params/context.
|
||||
if (bOwnerChanged || LayoutVersion != CP_Version) { CP_BiomeCache.Invalidate(); }
|
||||
CP_OwnerId = DensityCacheOwnerId;
|
||||
CP_Version = LayoutVersion;
|
||||
CP_Chunk = ChunkCoord;
|
||||
CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
|
||||
@@ -639,7 +665,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
case ECaveGeneratorType::FloatingIslands:
|
||||
CP_Float = StrateManager->GetFloatingIslandParamsForChunk(ChunkCoord); break;
|
||||
default: // TunnelNetwork / Underwater
|
||||
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); break;
|
||||
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord);
|
||||
// AUDIT §C2 — l'empreinte est calculée ICI, une fois par chunk, au seul endroit où
|
||||
// les params changent. `FStrateGenerationParams` est du POD pur (aucun TArray /
|
||||
// FString / pointeur), donc une CRC mémoire ne peut pas donner de FAUX POSITIF ; au
|
||||
// pire un octet de padding donne un faux MANQUE, c'est-à-dire une reconstruction de
|
||||
// cache. On se trompe du côté du CPU, jamais du côté d'une salle fausse.
|
||||
CP_TunnelFP = FCrc::MemCrc32(&CP_Tunnel, sizeof(CP_Tunnel));
|
||||
break;
|
||||
}
|
||||
CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord);
|
||||
|
||||
@@ -754,7 +787,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
case ECaveGeneratorType::TunnelNetwork:
|
||||
default:
|
||||
// Underwater shares tunnel rock (water table is a render-side overlay).
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel); break;
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel,
|
||||
CP_TunnelFP, LayoutVersion); break;
|
||||
}
|
||||
|
||||
// Disturbance layer (the "wow" post-process) — cached params, MC convention.
|
||||
@@ -764,8 +798,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
{
|
||||
// ── FALLBACK (no strate manager) ──
|
||||
// Use default TunnelNetwork params — produces generic caves.
|
||||
FStrateGenerationParams FallbackParams;
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams);
|
||||
// `static` : ces params sont constants (construction par défaut), donc leur empreinte l'est
|
||||
// aussi. La calculer une fois évite un CRC par voxel sur un chemin qui n'en a aucun besoin.
|
||||
// `LayoutVersion = 0` : sans `StrateManager` il n'y a pas de layout, donc rien qui puisse
|
||||
// périmer — et l'empreinte constante suffit à distinguer ce cache de tous les autres.
|
||||
static const FStrateGenerationParams FallbackParams;
|
||||
static const uint32 FallbackFP = FCrc::MemCrc32(&FallbackParams, sizeof(FallbackParams));
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
@@ -813,7 +852,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
}
|
||||
|
||||
float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
|
||||
const FStrateGenerationParams& Params) const
|
||||
const FStrateGenerationParams& Params,
|
||||
uint32 ParamsFingerprint, uint32 LayoutVersion) const
|
||||
{
|
||||
//=========================================================================
|
||||
// STRATE DENSITY FUNCTION (Morphology Pipeline)
|
||||
@@ -930,6 +970,20 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f;
|
||||
thread_local int32 CachedStrate = INT32_MIN;
|
||||
thread_local uint32 CachedSeed = 0;
|
||||
// ⚠️ AUDIT §C2 (corrigé le 2026-07-28). Les deux lignes qui manquaient à cette clé.
|
||||
// La clé ci-dessus décrit la GÉOMÉTRIE de la fenêtre (boîte, strate, seed) et rien de ce qui
|
||||
// détermine les PARAMS avec lesquels les salles ont été cuites. Comme `GetGenerationParams`
|
||||
// blende à l'intérieur d'une strate (`Alpha` = f(chunk Z), et f(chunk XY) aussi en
|
||||
// `Interleaved`), deux chunks voisins produisent la MÊME clé avec des params DIFFÉRENTS, et le
|
||||
// deuxième se sert des salles du premier. Non déterministe entre pairs, parce que l'ordre des
|
||||
// workers décide lequel est « le premier » — exactement ce que §2.6.1 interdit.
|
||||
//
|
||||
// Pourquoi ça ne casse PAS l'invariant de perf de §8.10 : la clé reste une BOÎTE, donc les
|
||||
// sondes de gradient à `WorldX ± 1` ne font toujours pas tourner le cache. Ce qui le fait
|
||||
// tourner en plus, c'est un changement RÉEL de params — une fois par chunk dans une bande de
|
||||
// transition, ce qui est le nombre de reconstructions que ce cache aurait toujours dû faire.
|
||||
thread_local uint32 CachedFingerprint = 0xFFFFFFFFu;
|
||||
thread_local uint32 CachedLayout = 0xFFFFFFFFu;
|
||||
|
||||
// Index of the room with the smallest (most-inside) SDF for this voxel.
|
||||
// Written by EvaluateSDFCached, read by the terrain ops block to pick the
|
||||
@@ -968,6 +1022,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
// cached search box, or the strate/seed changed.
|
||||
const bool bNeedRebuild =
|
||||
StrateIdx != CachedStrate || (uint32)Seed != CachedSeed ||
|
||||
ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout ||
|
||||
WarpedX < CachedSMinX || WarpedX > CachedSMaxX ||
|
||||
WarpedY < CachedSMinY || WarpedY > CachedSMaxY;
|
||||
|
||||
@@ -1011,6 +1066,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
CachedSMinY = SMinY; CachedSMaxY = SMaxY;
|
||||
CachedStrate = StrateIdx;
|
||||
CachedSeed = (uint32)Seed;
|
||||
CachedFingerprint = ParamsFingerprint;
|
||||
CachedLayout = LayoutVersion;
|
||||
}
|
||||
|
||||
// Evaluate SDF using cached rooms and tunnels (WARPED coordinates).
|
||||
@@ -2668,8 +2725,8 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
if (CX * CX + CY * CY <= Reach * Reach) bCanSolid = false;
|
||||
}
|
||||
|
||||
// ── Catégorisation par Z de treillis. v1 : gap bedrock = solide ; SurfaceWorld = test
|
||||
// colonne ; tout le reste (intérieurs de caves, hors layout) = Mixed immédiat. ──
|
||||
// ── Catégorisation par Z du treillis : gap bedrock = solide ; hors layout = air constant ;
|
||||
// SurfaceWorld = test colonne ; un slot cave opt-in = verdict de pile sur sa sous-boîte. ──
|
||||
struct FSurfSlot
|
||||
{
|
||||
int32 BotChunkZ = INT32_MAX; // identité du slot (borne basse de la strate, en chunks)
|
||||
@@ -2696,17 +2753,31 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
int32 NumSlots = 0;
|
||||
|
||||
// ── T1.d GÉNÉRIQUE : la pile d'opérateurs classe les archétypes de CAVE ──
|
||||
// Ces trois-là suivent le slot de cave que la tuile touche. Le verdict de la pile porte sur la
|
||||
// BOÎTE ENTIÈRE, pas sur un z, donc il ne peut être calculé qu'après la boucle — et il n'est
|
||||
// valable que si la tuile ne touche QUE ce slot-là (voir la garde `bAnyNonCave`).
|
||||
// Ces valeurs suivent l'UNIQUE slot de cave que la tuile touche. La pile classera seulement la
|
||||
// sous-boîte Z de ses échantillons ; les catégories gap/surface/hors-layout plient séparément
|
||||
// leurs hypothèses dans `bCanSolid` / `bCanAir`.
|
||||
// These values track the ONE cave slot touched by the tile. The stack classifies only its
|
||||
// sampled Z sub-box; gap/surface/out-of-layout fold their hypotheses separately.
|
||||
int32 CaveBotChunkZ = INT32_MAX; // identité du slot de cave (borne basse, en chunks)
|
||||
int32 CaveRepChunkZ = 0;
|
||||
int32 CaveMinZ = MAX_int32;
|
||||
int32 CaveMaxZ = MIN_int32;
|
||||
bool bAnyCave = false;
|
||||
bool bAnyNonCave = false; // gap ou SurfaceWorld dans la même tuile ⇒ on abandonne
|
||||
bool bAnyGap = false;
|
||||
bool bAnySurface = false;
|
||||
bool bAnyOutOfLayout = false;
|
||||
|
||||
int32 MemoChunkZ = INT32_MAX;
|
||||
int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile d'opérateurs)
|
||||
int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile), 3 = hors layout
|
||||
int32 MemoSlotIdx = -1;
|
||||
|
||||
// « Ce chunk Z appartient-il à une strate ? » sous forme publique : `FindSlotIndexForChunkZ`
|
||||
// est `protected`, `GetStrateChunkZBounds` rend false pour exactement le même cas.
|
||||
auto VF_ChunkZHasSlot = [&](int32 Z) -> bool
|
||||
{
|
||||
int32 UnusedTopCZ = 0, UnusedBotCZ = 0;
|
||||
return StrateManager->GetStrateChunkZBounds(Z, UnusedTopCZ, UnusedBotCZ);
|
||||
};
|
||||
for (int32 g = -1; g <= GridDim; ++g)
|
||||
{
|
||||
const int32 Zi = OriginVoxels.Z + g * Step;
|
||||
@@ -2718,7 +2789,43 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
if (StrateManager->IsGapChunk(CC))
|
||||
{
|
||||
MemoCat = 0;
|
||||
bAnyNonCave = true;
|
||||
bAnyGap = true;
|
||||
}
|
||||
//=================================================================
|
||||
// ⛔ HORS LAYOUT = AIR CONSTANT. C'ÉTAIT LE BLOCAGE DE T1.d.
|
||||
//=================================================================
|
||||
// `GetGeneratorTypeForChunk` rend `TunnelNetwork` pour tout chunk hors de la pile de
|
||||
// strates (« le chemin de repli produit de la roche de toute façon » — CE COMMENTAIRE
|
||||
// EST FAUX) et `IsGapChunk` rend false au-dessus du sommet (« open air, NOT a gap »).
|
||||
// Résultat : chaque tuile touchant l'air libre au-dessus du monde entrait dans la
|
||||
// BRANCHE DE CAVE, n'y trouvait aucun slot, et abandonnait — mesuré en jeu à 83 % des
|
||||
// tuiles classées (`Cave Bail Not Op Stack No Layout` = 1.58 / 1.90).
|
||||
//
|
||||
// La vérité est dans `GetGenerationParams` : hors layout il rend `BaseDensity = -1`,
|
||||
// `RoomDensity = 0`, `WormStrength = 0` — un champ CONSTANT, donc de l'air, sans salle
|
||||
// ni ver pour le percer. Une telle tuile est prouvable sans échantillonner.
|
||||
//
|
||||
// Out-of-layout is a CONSTANT AIR field, not a cave archetype. Every tile touching the
|
||||
// open air above the world was being routed into the cave branch and bailing there.
|
||||
// `GetStrateChunkZBounds` (PUBLIC) rend false exactement quand `FindSlotIndexForChunkZ`
|
||||
// rend -1 — ce dernier est `protected`, et cette fonction l'utilise déjà deux fois pour
|
||||
// la même question. Pas de nouvelle surface d'API pour un prédicat qui existe.
|
||||
// GetStrateChunkZBounds is the public form of "has a layout slot"; the index accessor
|
||||
// is protected and this function already uses the bounds call twice for the same test.
|
||||
else if (!VF_ChunkZHasSlot(ChunkZ))
|
||||
{
|
||||
MemoCat = 3;
|
||||
bAnyOutOfLayout = true;
|
||||
|
||||
// Les disturbances sont appliquées APRÈS la densité d'archétype et peuvent AJOUTER
|
||||
// de la roche (ponts, arêtes). Même prudence que les branches gap et cave : si
|
||||
// l'une peut agir ici, on ne prouve rien. Les chasms ne font que creuser ⇒ ils ne
|
||||
// menacent pas un verdict d'air.
|
||||
const FStrateDisturbanceParams DOut = StrateManager->GetDisturbanceParamsForChunk(CC);
|
||||
if (DOut.BridgeDensity > 0.0f || DOut.RidgeDensity > 0.0f)
|
||||
{
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
}
|
||||
else if (StrateManager->GetGeneratorTypeForChunk(CC) == ECaveGeneratorType::SurfaceWorld)
|
||||
{
|
||||
@@ -2760,7 +2867,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false;
|
||||
}
|
||||
MemoCat = 1;
|
||||
bAnyNonCave = true;
|
||||
bAnySurface = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2771,17 +2878,49 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
// Condition 1 : la strate doit RÉELLEMENT être générée par la pile. Sinon on
|
||||
// classerait un champ que le mesher ne produira pas. C'est le même drapeau, lu au
|
||||
// même endroit, que `GetDensityAt`.
|
||||
if (!StrateManager->UsesOperatorStackForChunk(CC)) { return EVoxelTileClass::Mixed; }
|
||||
if (!StrateManager->UsesOperatorStackForChunk(CC))
|
||||
{
|
||||
// Attribution DIAGNOSTIQUE uniquement : l'ancien compteur mélangeait une
|
||||
// strate cave entièrement désactivée avec une tuile de frontière qui avait
|
||||
// rencontré un slot désactivé avant la garde « slot différent » ci-dessous.
|
||||
// On résout les bornes APRÈS l'échec du même prédicat ; elles ne changent ni
|
||||
// la condition, ni le point de retour, ni le verdict.
|
||||
// Diagnostic attribution only: the old counter mixed a wholly disabled cave
|
||||
// slot with a boundary tile that met a disabled slot before the different-slot
|
||||
// guard below. Resolve bounds only after the same predicate fails; classification
|
||||
// control flow and return value stay unchanged.
|
||||
int32 FailedTopCZ = 0, FailedBotCZ = 0;
|
||||
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, FailedTopCZ, FailedBotCZ))
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
|
||||
}
|
||||
else
|
||||
{
|
||||
const int32 TileMinCZ = FloorDivC(MinZ, CHUNK_SIZE);
|
||||
const int32 TileMaxCZ = FloorDivC(MaxZ, CHUNK_SIZE);
|
||||
if (TileMinCZ >= FailedBotCZ && TileMaxCZ <= FailedTopCZ)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackSoleSlot);
|
||||
}
|
||||
else
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
|
||||
}
|
||||
}
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
|
||||
// Condition 2 : un seul slot de cave par tuile. Deux slots = deux jeux de params =
|
||||
// deux piles, et une pile ne sait répondre que pour SA strate.
|
||||
int32 CaveTopCZ = 0, CaveBotCZ = 0;
|
||||
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, CaveTopCZ, CaveBotCZ))
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
|
||||
return EVoxelTileClass::Mixed; // hors layout
|
||||
}
|
||||
if (CaveBotChunkZ != INT32_MAX && CaveBotChunkZ != CaveBotCZ)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
CaveBotChunkZ = CaveBotCZ;
|
||||
@@ -2793,12 +2932,23 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
|
||||
if (MemoCat == 2)
|
||||
{
|
||||
// Rien par z : la pile répond pour la boîte entière, après la boucle.
|
||||
// La pile répond après la boucle, sur la sous-boîte Z contenant exactement les
|
||||
// échantillons cave (XY reste la boîte complète du treillis).
|
||||
CaveMinZ = FMath::Min(CaveMinZ, Zi);
|
||||
CaveMaxZ = FMath::Max(CaveMaxZ, Zi);
|
||||
}
|
||||
else if (MemoCat == 0)
|
||||
{
|
||||
bCanAir = false; // bedrock du gap = solide (le carve des passages est déjà gardé)
|
||||
}
|
||||
else if (MemoCat == 3)
|
||||
{
|
||||
// Hors layout = air constant (BaseDensity = -1, aucune salle, aucun ver). L'hypothèse
|
||||
// « tout solide » meurt ; « tout air » survit. Les passages et la spine ne font que
|
||||
// creuser — ils sont déjà gardés plus haut et ne peuvent pas rendre ce z solide.
|
||||
// Out of layout = constant air: AllSolid dies, AllAir survives.
|
||||
bCanSolid = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
FSurfSlot& S = Slots[MemoSlotIdx];
|
||||
@@ -2829,19 +2979,27 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
// DENSITÉ de cette tuile — même fabrique, mêmes params, même drapeau.
|
||||
if (bAnyCave)
|
||||
{
|
||||
// Une tuile mi-cave mi-surface (ou mi-gap) n'est pas classable ainsi : la pile de cave ne
|
||||
// répond que pour SA strate, et sa boîte couvrirait des z appartenant à une autre.
|
||||
if (bAnyNonCave) { return EVoxelTileClass::Mixed; }
|
||||
// Diagnostic de PRÉSENCE avant les gardes : le signal reste visible même si le pliage rend
|
||||
// finalement AllSolid/AllAir. Ces compteurs ne sont pas exclusifs entre eux sur une tuile
|
||||
// très haute ; chacun répond exactement à « cette catégorie était-elle aussi présente ? ».
|
||||
// Presence diagnostics run before the guards, so a successful fold cannot hide the mix.
|
||||
// They are not mutually exclusive for a very tall tile; each answers one exact question.
|
||||
if (bAnyOutOfLayout || bAnyGap || bAnySurface)
|
||||
{
|
||||
if (bAnyOutOfLayout) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout); }
|
||||
if (bAnyGap) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixGap); }
|
||||
if (bAnySurface) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixSurfaceWorld); }
|
||||
}
|
||||
|
||||
const FIntVector RepCC(0, 0, CaveRepChunkZ);
|
||||
const ECaveGeneratorType CaveType = StrateManager->GetGeneratorTypeForChunk(RepCC);
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA TUILE
|
||||
// ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA SOUS-BOÎTE CAVE
|
||||
//---------------------------------------------------------------------
|
||||
// `GetGenerationParams` et ses homologues BLENDENT les params dans les bandes de transition :
|
||||
// `Alpha` dépend du chunk Z pour `Gradient`, et du chunk XY EN PLUS pour `Interleaved`. Deux
|
||||
// chunks d'une même tuile peuvent donc porter des params différents — c'est le constat de
|
||||
// chunks d'une même sous-boîte peuvent donc porter des params différents — c'est le constat de
|
||||
// `AUDIT §C2`, confirmé par lecture le 2026-07-28 — et UNE pile ne peut pas représenter DEUX
|
||||
// champs. On construit donc les params pour CHAQUE coordonnée de chunk que la boîte touche et
|
||||
// on exige qu'ils soient identiques bit à bit.
|
||||
@@ -2850,12 +3008,21 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
// `Mixed` de trop. On se trompe du côté du CPU, jamais du côté du trou.
|
||||
const int32 CX0 = FloorDivC(MinX, CHUNK_SIZE), CX1 = FloorDivC(MaxX, CHUNK_SIZE);
|
||||
const int32 CY0 = FloorDivC(MinY, CHUNK_SIZE), CY1 = FloorDivC(MaxY, CHUNK_SIZE);
|
||||
const int32 CZ0 = FloorDivC(MinZ, CHUNK_SIZE), CZ1 = FloorDivC(MaxZ, CHUNK_SIZE);
|
||||
// IMPORTANT : les gardes restent complètes, mais seulement sur les chunks où le mesher
|
||||
// échantillonne réellement CETTE strate cave. Inclure gap/surface/hors-layout ici ferait
|
||||
// échouer la garde d'archétype avant de pouvoir plier leurs hypothèses indépendantes.
|
||||
// The guards stay exhaustive over the cave samples. Non-cave chunks are intentionally not
|
||||
// represented by this stack; their hypotheses were folded separately in the Z pass.
|
||||
const int32 CZ0 = FloorDivC(CaveMinZ, CHUNK_SIZE), CZ1 = FloorDivC(CaveMaxZ, CHUNK_SIZE);
|
||||
|
||||
// Une tuile très étalée (Step élevé) toucherait trop de chunks pour que cette vérification
|
||||
// reste bon marché. Au-delà, `Mixed` — on renonce au gain, jamais à la sûreté.
|
||||
const int64 NumChunkCoords = (int64)(CX1 - CX0 + 1) * (int64)(CY1 - CY0 + 1) * (int64)(CZ1 - CZ0 + 1);
|
||||
if (NumChunkCoords > 27) { return EVoxelTileClass::Mixed; }
|
||||
if (NumChunkCoords > 27)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
|
||||
FSlabGenerationParams TileSlab;
|
||||
FMazeGenerationParams TileMaze;
|
||||
@@ -2871,12 +3038,22 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
const FIntVector CC(cx, cy, cz);
|
||||
if (StrateManager->GetGeneratorTypeForChunk(CC) != CaveType)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
return EVoxelTileClass::Mixed; // la boîte déborde sur un autre archétype
|
||||
}
|
||||
|
||||
// Le drapeau doit tenir sur TOUS les chunks de la boîte, pas seulement sur celui qui a
|
||||
// déclenché la tentative : un seul chunk hors pile invaliderait le verdict.
|
||||
if (!StrateManager->UsesOperatorStackForChunk(CC)) { return EVoxelTileClass::Mixed; }
|
||||
if (!StrateManager->UsesOperatorStackForChunk(CC))
|
||||
{
|
||||
// Le passage Z précédent a déjà accepté l'unique slot cave. Avec le layout actuel
|
||||
// (prédicat indépendant de X/Y), ce recheck est redondant ; un hit nomme donc
|
||||
// précisément cette garde tardive au lieu d'être agrégé aux opt-ins désactivés.
|
||||
// The prior Z pass already accepted the sole cave slot. With the current X/Y-
|
||||
// independent predicate this recheck is redundant, so attribute it separately.
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
|
||||
switch (CaveType)
|
||||
{
|
||||
@@ -2885,28 +3062,44 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
{
|
||||
const FSlabGenerationParams Q = StrateManager->GetSlabParamsForChunk(CC);
|
||||
if (bFirst) { TileSlab = Q; }
|
||||
else if (FMemory::Memcmp(&Q, &TileSlab, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||
else if (FMemory::Memcmp(&Q, &TileSlab, sizeof(Q)) != 0)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ECaveGeneratorType::Maze:
|
||||
{
|
||||
const FMazeGenerationParams Q = StrateManager->GetMazeParamsForChunk(CC);
|
||||
if (bFirst) { TileMaze = Q; }
|
||||
else if (FMemory::Memcmp(&Q, &TileMaze, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||
else if (FMemory::Memcmp(&Q, &TileMaze, sizeof(Q)) != 0)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ECaveGeneratorType::VerticalShafts:
|
||||
{
|
||||
const FVerticalShaftParams Q = StrateManager->GetVerticalShaftParamsForChunk(CC);
|
||||
if (bFirst) { TileVert = Q; }
|
||||
else if (FMemory::Memcmp(&Q, &TileVert, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||
else if (FMemory::Memcmp(&Q, &TileVert, sizeof(Q)) != 0)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ECaveGeneratorType::FloatingIslands:
|
||||
{
|
||||
const FFloatingIslandParams Q = StrateManager->GetFloatingIslandParamsForChunk(CC);
|
||||
if (bFirst) { TileFloat = Q; }
|
||||
else if (FMemory::Memcmp(&Q, &TileFloat, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||
else if (FMemory::Memcmp(&Q, &TileFloat, sizeof(Q)) != 0)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ECaveGeneratorType::Underwater:
|
||||
@@ -2914,11 +3107,16 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
{
|
||||
const FStrateGenerationParams Q = StrateManager->GetGenerationParams(CC);
|
||||
if (bFirst) { TileTunnel = Q; }
|
||||
else if (FMemory::Memcmp(&Q, &TileTunnel, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||
else if (FMemory::Memcmp(&Q, &TileTunnel, sizeof(Q)) != 0)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas arriver ici (bAnyNonCave)
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas être le type du slot cave
|
||||
}
|
||||
|
||||
bFirst = false;
|
||||
@@ -2949,18 +3147,34 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
{
|
||||
// Strate dégénérée ou archétype non porté : `GetDensityAt` retomberait sur le `switch`,
|
||||
// donc la pile ne décrit pas ce que le mesher verra. Aucun verdict.
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNoStack);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
TileStack.PrepareChunk(OpCtx);
|
||||
|
||||
const FBox TileBox(FVector((float)MinX, (float)MinY, (float)MinZ),
|
||||
FVector((float)MaxX, (float)MaxY, (float)MaxZ));
|
||||
const EVoxelTileClass StackVerdict = TileStack.ClassifyBox(TileBox, OpCtx);
|
||||
if (StackVerdict == EVoxelTileClass::Mixed) { return EVoxelTileClass::Mixed; }
|
||||
const FBox CaveBox(FVector((float)MinX, (float)MinY, (float)CaveMinZ),
|
||||
FVector((float)MaxX, (float)MaxY, (float)CaveMaxZ));
|
||||
const EVoxelTileClass StackVerdict = TileStack.ClassifyBox(CaveBox, OpCtx);
|
||||
if (StackVerdict == EVoxelTileClass::Mixed)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailStackVerdict);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
|
||||
if (StackVerdict == EVoxelTileClass::AllSolid) { bCanAir = false; }
|
||||
else { bCanSolid = false; }
|
||||
|
||||
// Le verdict cave se plie avec gap=solide, hors-layout=air, seals surface=solide. Si les
|
||||
// deux hypothèses sont mortes ici, les catégories se contredisent : ce n'est PAS un échec
|
||||
// de borne de la pile ni une disturbance.
|
||||
// Fold the cave verdict with gap=solid, out-of-layout=air, and solid surface seals. If both
|
||||
// hypotheses die here, the categories conflict; this is not a stack-bound/disturbance bail.
|
||||
if (!bCanSolid && !bCanAir)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailFoldConflict);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// ⚠️ LES DISTURBANCES NE SONT PAS DANS LA PILE (`OPSTACK-DECOMPOSITION §10.2`) :
|
||||
// `GetDensityAt` les applique APRÈS, sur la densité déjà négatée. Un verdict qui les
|
||||
@@ -2971,8 +3185,11 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
if (D.ChasmDensity > 0.0f) { bCanSolid = false; }
|
||||
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) { bCanAir = false; }
|
||||
|
||||
if (bCanSolid == bCanAir) { return EVoxelTileClass::Mixed; }
|
||||
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
|
||||
if (!bCanSolid && !bCanAir)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeCaveBailDisturbance);
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne
|
||||
@@ -3031,6 +3248,15 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
||||
// Ici exactement UNE hypothèse doit survivre (chaque point testé en tue une ; les tuiles
|
||||
// sans point intérieur ont tué AllAir via gap/seal). Égalité = prudence → Mixed.
|
||||
if (bCanSolid == bCanAir) return EVoxelTileClass::Mixed;
|
||||
if (bAnyCave)
|
||||
{
|
||||
// Compte seulement les verdicts FINAUX qui sautent réellement une tuile. Une pile peut avoir
|
||||
// prouvé sa sous-boîte cave puis perdre l'hypothèse sur une colonne SurfaceWorld adjacente.
|
||||
// Count only final verdicts that actually skip a tile; a later surface column may still
|
||||
// invalidate the hypothesis proved for the cave sub-box.
|
||||
if (bCanSolid) { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid); }
|
||||
else { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir); }
|
||||
}
|
||||
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// VoxelStats.cpp
|
||||
// Definitions for the VoxelForge runtime statistics.
|
||||
// Définitions des statistiques runtime de VoxelForge.
|
||||
|
||||
#include "VoxelStats.h"
|
||||
|
||||
DEFINE_STAT(STAT_VoxelForgeTilesClassified);
|
||||
DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllSolid);
|
||||
DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllAir);
|
||||
DEFINE_STAT(STAT_VoxelForgeTilesMeshed);
|
||||
DEFINE_STAT(STAT_VoxelForgeTilesOpStackSolid);
|
||||
DEFINE_STAT(STAT_VoxelForgeTilesOpStackAir);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackSoleSlot);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveMixGap);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveMixSurfaceWorld);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailParams);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailFoldConflict);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance);
|
||||
DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack);
|
||||
DEFINE_STAT(STAT_VoxelForgeColumnMemoHit);
|
||||
DEFINE_STAT(STAT_VoxelForgeColumnMemoMiss);
|
||||
@@ -2,6 +2,7 @@
|
||||
// Runtime strate layout generation and queries.
|
||||
|
||||
#include "VoxelStrateManager.h"
|
||||
#include "CoreGlobals.h" // GIsAutomationTesting — the opt-in diagnostic stays quiet under tests
|
||||
#include "VoxelSettings.h"
|
||||
#include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord
|
||||
#include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash
|
||||
@@ -129,6 +130,71 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
|
||||
Slot.HeightInChunks);
|
||||
}
|
||||
|
||||
// Diagnostic de configuration, une seule fois par construction de layout. SurfaceWorld est
|
||||
// volontairement exclu : son chemin T1.d exact-lattice ne dépend pas de ce drapeau.
|
||||
// Configuration diagnostic once per layout build. SurfaceWorld is deliberately excluded:
|
||||
// its exact-lattice T1.d path does not depend on this flag.
|
||||
int32 NumCaveSlots = 0;
|
||||
int32 NumOperatorStackDisabledCaves = 0;
|
||||
for (const FStrateSlot& Slot : StrateLayout)
|
||||
{
|
||||
if (!Slot.Definition || Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
++NumCaveSlots;
|
||||
if (!Slot.Definition->bUseOperatorStack)
|
||||
{
|
||||
++NumOperatorStackDisabledCaves;
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ WARNING EN ÉDITEUR/JEU, JAMAIS EN TEST. Les tests `Determinism.*` construisent
|
||||
// DÉLIBÉRÉMENT un monde non opt-in — c'est leur oracle de comparaison — et le framework
|
||||
// d'automatisation compte un Warning comme un échec. Un diagnostic ne doit pas casser la suite
|
||||
// qu'il est censé éclairer. Le message reste écrit UNE fois : seule la verbosité change.
|
||||
// Warning in editor/game where it is actionable, never in tests: the Determinism.* tests build
|
||||
// a non-opted-in world ON PURPOSE as their comparison oracle, and the automation framework
|
||||
// treats a Warning as a failure. One message, two verbosities.
|
||||
const bool bQuietDiagnostic = GIsAutomationTesting;
|
||||
|
||||
if (NumOperatorStackDisabledCaves > 0)
|
||||
{
|
||||
const FString Summary = FString::Printf(
|
||||
TEXT("[StrateManager] Operator-stack opt-in: %d/%d cave layout slots have Use Operator Stack disabled. These slots cannot use operator-stack ClassifyBox/T1.d; enable the asset setting on the listed definitions if that is intended."),
|
||||
NumOperatorStackDisabledCaves, NumCaveSlots);
|
||||
|
||||
if (bQuietDiagnostic) { UE_LOG(LogTemp, Verbose, TEXT("%s"), *Summary); }
|
||||
else { UE_LOG(LogTemp, Warning, TEXT("%s"), *Summary); }
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogTemp, Log,
|
||||
TEXT("[StrateManager] Operator-stack opt-in: all %d cave layout slots have Use Operator Stack enabled."),
|
||||
NumCaveSlots);
|
||||
}
|
||||
|
||||
for (const FStrateSlot& Slot : StrateLayout)
|
||||
{
|
||||
if (!Slot.Definition
|
||||
|| Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld
|
||||
|| Slot.Definition->bUseOperatorStack)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const FString Line = FString::Printf(
|
||||
TEXT("[StrateManager] cave slot=%d name='%s' Z chunks=[%d to %d] bUseOperatorStack=false"),
|
||||
Slot.StrateIndex,
|
||||
*Slot.Definition->StrateName.ToString(),
|
||||
Slot.TopChunkZ,
|
||||
Slot.BottomChunkZ);
|
||||
|
||||
if (bQuietDiagnostic) { UE_LOG(LogTemp, Verbose, TEXT("%s"), *Line); }
|
||||
else { UE_LOG(LogTemp, Warning, TEXT("%s"), *Line); }
|
||||
}
|
||||
|
||||
CachedSeed = WorldSeed;
|
||||
bOpenSurfaceEntry = Settings->bOpenSurfaceEntry;
|
||||
OriginSpineRadius = Settings->OriginSpineRadius;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "VoxelTerrainOpDefinition.h"
|
||||
#include "VoxelContentManager.h"
|
||||
#include "VoxelDensityVolume.h"
|
||||
#include "VoxelStats.h"
|
||||
// IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc
|
||||
// APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController était
|
||||
// complet par transitivité seulement : on l'inclut explicitement, c'est exactement la fragilité
|
||||
@@ -87,6 +88,45 @@ static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, co
|
||||
}
|
||||
}
|
||||
|
||||
class FScopedGenerationPause
|
||||
{
|
||||
public:
|
||||
explicit FScopedGenerationPause(AVoxelWorld* InWorld)
|
||||
: World(InWorld)
|
||||
{
|
||||
if (!World) return;
|
||||
|
||||
World->bGenerationPaused.store(true, std::memory_order_release);
|
||||
|
||||
// The game thread owns this gate; workers only read Generator/Mesher and enqueue results.
|
||||
// La barrière est prise sur le thread de jeu ; les workers ne font qu'énumérer et Enqueue.
|
||||
const double Deadline = FPlatformTime::Seconds() + 5.0;
|
||||
while (World->ActiveTaskCount.load(std::memory_order_relaxed) > 0)
|
||||
{
|
||||
if (FPlatformTime::Seconds() > Deadline) return;
|
||||
FPlatformProcess::Yield();
|
||||
}
|
||||
|
||||
if (World->ContentManager && !World->ContentManager->WaitForDecorationTasks(Deadline)) return;
|
||||
|
||||
bAcquired = true;
|
||||
}
|
||||
|
||||
~FScopedGenerationPause()
|
||||
{
|
||||
if (World)
|
||||
{
|
||||
World->bGenerationPaused.store(false, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
bool Acquired() const { return bAcquired; }
|
||||
|
||||
private:
|
||||
AVoxelWorld* World = nullptr;
|
||||
bool bAcquired = false;
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// LIVE EDIT — regenerate all chunks when params change in the Details panel
|
||||
//=============================================================================
|
||||
@@ -138,6 +178,14 @@ void AVoxelWorld::RegenerateAllChunks()
|
||||
|
||||
void AVoxelWorld::RebuildStrates()
|
||||
{
|
||||
{
|
||||
FScopedGenerationPause Guard(this);
|
||||
if (!Guard.Acquired())
|
||||
{
|
||||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] RebuildStrates: generation pause timed out; no mutation applied."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (StrateManager && Settings)
|
||||
{
|
||||
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
|
||||
@@ -145,6 +193,7 @@ void AVoxelWorld::RebuildStrates()
|
||||
}
|
||||
if (AtmosphereManager) AtmosphereManager->Reset();
|
||||
if (ContentManager) ContentManager->ClearAll();
|
||||
}
|
||||
|
||||
// Reload all chunks against the rebuilt strate data.
|
||||
RegenerateAllChunks();
|
||||
@@ -303,6 +352,14 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
|
||||
|
||||
// Re-initialize the strate manager so it picks up the changed definition values,
|
||||
// then regenerate all chunks with the updated params.
|
||||
{
|
||||
FScopedGenerationPause Guard(this);
|
||||
if (!Guard.Acquired())
|
||||
{
|
||||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] OnObjectModifiedInEditor: generation pause timed out; no mutation applied."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (StrateManager)
|
||||
{
|
||||
StrateManager->Initialize(Settings, Settings->Seed);
|
||||
@@ -311,6 +368,7 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
|
||||
{
|
||||
Generator->InitializeSettings(Settings);
|
||||
}
|
||||
}
|
||||
|
||||
RegenerateAllChunks();
|
||||
}
|
||||
@@ -634,7 +692,7 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
|
||||
// is refilled from the diff via MarkDirtyVoxelBox in RemeshDirtyChunks).
|
||||
void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile)
|
||||
{
|
||||
if (!Generator || !Mesher || bShuttingDown.load(std::memory_order_relaxed)) return;
|
||||
if (!Generator || !Mesher || ShouldAbortWork()) return;
|
||||
|
||||
const FIntVector OriginVoxels = Tile.OriginVoxels();
|
||||
const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels)
|
||||
@@ -1463,14 +1521,14 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
|
||||
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
|
||||
} Guard{ActiveTaskCount};
|
||||
|
||||
if (bShuttingDown.load(std::memory_order_relaxed)) return;
|
||||
if (ShouldAbortWork()) return;
|
||||
|
||||
FChunkResult Result;
|
||||
GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
|
||||
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
||||
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result);
|
||||
|
||||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||||
if (!ShouldAbortWork())
|
||||
{
|
||||
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
|
||||
}
|
||||
@@ -1502,11 +1560,24 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
|
||||
if (!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel == 0.0f)
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile);
|
||||
bTrivialEmpty = (Generator->ClassifyTile(OriginVoxels, Step, Cells) != EVoxelTileClass::Mixed);
|
||||
INC_DWORD_STAT(STAT_VoxelForgeTilesClassified);
|
||||
const EVoxelTileClass Verdict = Generator->ClassifyTile(OriginVoxels, Step, Cells);
|
||||
if (Verdict == EVoxelTileClass::AllSolid)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllSolid);
|
||||
}
|
||||
else if (Verdict == EVoxelTileClass::AllAir)
|
||||
{
|
||||
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllAir);
|
||||
}
|
||||
bTrivialEmpty = (Verdict != EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// F18 — feuille : deux heightfields sol/cap échantillonnés par colonne (pas de marching
|
||||
// cubes, pas de classifieur — la classe de surface est vraie par construction).
|
||||
// `TilesMeshed` peut dépasser `TilesClassified` : les tuiles qui ratent cette porte sont
|
||||
// maillées sans classification. / `TilesMeshed` may exceed `TilesClassified`: tiles that
|
||||
// fail this gate are meshed without classification.
|
||||
FVoxelMeshData MeshData;
|
||||
if (!bTrivialEmpty)
|
||||
{
|
||||
@@ -1517,6 +1588,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
|
||||
: Mesher->GenerateMesh(OriginVoxels, Step, Cells,
|
||||
bWantCapture ? &Result.CaptureGrid : nullptr,
|
||||
BandVoxLo, BandVoxHi);
|
||||
INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed);
|
||||
}
|
||||
|
||||
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
|
||||
@@ -2057,6 +2129,14 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
|
||||
const int32 OldSeed = Settings->Seed;
|
||||
const int32 OldSeason = Settings->CurrentSeason;
|
||||
|
||||
{
|
||||
FScopedGenerationPause Guard(this);
|
||||
if (!Guard.Acquired())
|
||||
{
|
||||
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed: generation pause timed out; no mutation applied."));
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Update seed in Settings (the authoritative source)
|
||||
Settings->Seed = NewSeed;
|
||||
|
||||
@@ -2094,6 +2174,7 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
|
||||
{
|
||||
AtmosphereManager->Reset();
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Unload all existing chunks and let Tick reload them with new generation
|
||||
RegenerateAllChunks();
|
||||
|
||||
@@ -138,6 +138,10 @@ public:
|
||||
* UObject teardown (worker tasks read the Generator). */
|
||||
void NotifyShutdown();
|
||||
|
||||
/** Wait until in-flight decoration march tasks drain before a generation mutation. Deadline is absolute.
|
||||
* Attend la fin des tâches de décoration avant une mutation de génération ; échéance absolue. */
|
||||
bool WaitForDecorationTasks(double Deadline);
|
||||
|
||||
//--- async-task plumbing (public so the worker lambda can reach them) -----
|
||||
/** One placement decided off-thread; spawned on the game thread from FDecoCellResult::Entries. */
|
||||
struct FDecoSpawn
|
||||
|
||||
@@ -415,6 +415,27 @@ public:
|
||||
* ValidateDeterminism — qui échantillonne le long d'une frontière en X — ne le verrait pas.
|
||||
*/
|
||||
virtual bool IsXYPure() const { return false; }
|
||||
|
||||
/**
|
||||
* DIAGNOSTIC UNIQUEMENT — le nom que les rapports de test impriment pour cet opérateur.
|
||||
*
|
||||
* ⚠️ POURQUOI CETTE MÉTHODE EXISTE, ET CE QU'ELLE A COÛTÉ DE NE PAS AVOIR. Le premier build de
|
||||
* l'`EffectOverBox` spatial est revenu **vert avec 0 tuile prouvée sur 40**, et la seule chose
|
||||
* que le rapport pouvait dire était « ou bien les tuiles traversent toutes une grotte, ou bien
|
||||
* la source n'atteint pas sa branche `Identity` ». Deux causes, zéro nombre pour les
|
||||
* départager — exactement le piège que ce projet a déjà payé plusieurs fois. La vraie cause
|
||||
* était un TROISIÈME opérateur (le ver, qui rendait `CarveOnly` partout). Avec un nom par
|
||||
* opérateur, `ClassifyBoxAttributed` répond « c'est celui-là » au lieu de laisser deviner.
|
||||
*
|
||||
* N'entre dans AUCUNE clé de cache, dans aucun hash, dans aucune décision de génération : le
|
||||
* changer ne peut pas changer le monde. Le défaut est volontairement laconique — un opérateur
|
||||
* sans nom se repère à son index, ce qui suffit à savoir où regarder.
|
||||
*
|
||||
* Diagnostic only: the first build of the spatial EffectOverBox came back green with 0 tiles
|
||||
* proved, and the report could not name which operator was killing the hypothesis. It was a
|
||||
* third one nobody was looking at. Never part of a cache key or any generation decision.
|
||||
*/
|
||||
virtual const TCHAR* DebugName() const { return TEXT("(unnamed op)"); }
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
|
||||
@@ -135,6 +135,50 @@ public:
|
||||
return H.Resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
|
||||
*
|
||||
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même early-out,
|
||||
* même ordre. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
|
||||
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
|
||||
* l'autre change avec lui.
|
||||
*
|
||||
* `OutSolidKiller` / `OutAirKiller` reçoivent l'INDEX du premier opérateur qui fait passer
|
||||
* l'hypothèse correspondante de vraie à fausse, ou `INDEX_NONE` si elle a survécu. Le nom
|
||||
* lisible s'obtient par `GetOpDebugName(index)`.
|
||||
*
|
||||
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
|
||||
* ClassifyBox — a diagnostic that takes a slightly different path sends you hunting in the
|
||||
* wrong place.
|
||||
*/
|
||||
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||||
int32& OutSolidKiller, int32& OutAirKiller) const
|
||||
{
|
||||
OutSolidKiller = INDEX_NONE;
|
||||
OutAirKiller = INDEX_NONE;
|
||||
|
||||
FVoxelBoxHypotheses H;
|
||||
for (int32 i = 0; i < Ops.Num(); ++i)
|
||||
{
|
||||
const bool bSolidBefore = H.bCanBeAllSolid;
|
||||
const bool bAirBefore = H.bCanBeAllAir;
|
||||
|
||||
VF_FoldOp(H, *Ops[i], VoxelBox, Ctx);
|
||||
|
||||
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
|
||||
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
|
||||
|
||||
if (H.IsDead()) { return EVoxelTileClass::Mixed; }
|
||||
}
|
||||
return H.Resolve();
|
||||
}
|
||||
|
||||
/** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */
|
||||
const TCHAR* GetOpDebugName(int32 Index) const
|
||||
{
|
||||
return Ops.IsValidIndex(Index) ? Ops[Index]->DebugName() : TEXT("(none)");
|
||||
}
|
||||
|
||||
/**
|
||||
* RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
|
||||
* spine (0,0) → seal de frontière → carve de passage.
|
||||
@@ -286,6 +330,38 @@ namespace VoxelDensityOps
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE du dernier `FRoomGraphSource::EffectOverBox`
|
||||
* évalué sur ce thread. **Tests uniquement. N'entre dans aucune décision de génération.**
|
||||
*
|
||||
* ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
|
||||
* pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une
|
||||
* DEUXIÈME définition du critère, qui dériverait de la vraie et mentirait exactement le jour où
|
||||
* on la croirait. C'est la même raison qui a fait exister `VF_BuildOpStackForChunk`. On expose
|
||||
* donc ce que l'opérateur a réellement calculé.
|
||||
*
|
||||
* `Hit*` = combien de primitives de cette classe atteignent la boîte (0 partout ⇒ `Identity`).
|
||||
* `Num*` = combien le cache en contenait, ce qui distingue « aucune n'atteint » de « il n'y en
|
||||
* avait aucune » — deux zéros de sens opposé.
|
||||
*
|
||||
* Reads back what the operator actually computed, rather than letting the test re-derive the
|
||||
* criterion: a second copy would drift and would lie on the day it was believed.
|
||||
*/
|
||||
struct FRoomBoxDiagnostic
|
||||
{
|
||||
int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0;
|
||||
int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0;
|
||||
|
||||
/** Les mêmes comptes si la dilatation de warp valait ZÉRO, et de combien de voxels la boîte
|
||||
* est effectivement dilatée. `Hit* - Hit*NoWarp` = la part du blocage due à MA boîte plutôt
|
||||
* qu'à la géométrie. Cette mesure manquait, et son absence a coûté trois builds de
|
||||
* resserrement autour du mauvais terme. */
|
||||
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
|
||||
float WarpDilation = 0.0f;
|
||||
};
|
||||
|
||||
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
|
||||
|
||||
/**
|
||||
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
|
||||
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
|
||||
|
||||
@@ -68,6 +68,8 @@ class VOXELFORGE_API UVoxelGenerator : public UObject
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UVoxelGenerator();
|
||||
|
||||
//=========================================================================
|
||||
// SEED (source unique: Settings->Seed)
|
||||
//=========================================================================
|
||||
@@ -118,9 +120,35 @@ public:
|
||||
* Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise).
|
||||
* Utilisée en interne par GetDensityAt quand la strate est de ce type.
|
||||
* Exposée pour permettre des tests isolés avec des params custom.
|
||||
*
|
||||
* ⚠️ `ParamsFingerprint` ET `LayoutVersion` SONT OBLIGATOIRES, ET C'EST LE CORRECTIF
|
||||
* D'`AUDIT §C2` (2026-07-28). Le cache SDF interne est clé sur (boîte XY, strate, seed) et
|
||||
* PAS sur les params. Or `GetGenerationParams` BLENDE les params à l'intérieur d'une même
|
||||
* strate — `Alpha` dépend du chunk Z en mode `Gradient` (le DÉFAUT, avec
|
||||
* `TransitionBlendChunks = 2`) et du chunk XY en plus en mode `Interleaved`. Deux chunks de la
|
||||
* même strate, même seed, donc même clé, mais des params DIFFÉRENTS : le worker évalue le
|
||||
* deuxième chunk qu'il construit contre les salles du premier. Et comme *quel* chunk vient en
|
||||
* premier dépend de l'ordre des workers, **deux pairs divergent depuis la même seed** — ce que
|
||||
* `OPSTACK-PLAN §2.6.1` interdit explicitement.
|
||||
*
|
||||
* Pourquoi une empreinte PASSÉE plutôt qu'un `MemCrc32` calculé ici : ce serait ~300 octets de
|
||||
* CRC PAR VOXEL sur le chemin le plus chaud du plugin. L'appelant la calcule UNE fois par
|
||||
* chunk, là où le mémo de params vit déjà (`CP_*`), donc le coût par voxel est exactement deux
|
||||
* comparaisons d'entiers. Pas de valeur par défaut : un appelant qui oublie doit ne pas
|
||||
* compiler, pas hériter silencieusement du trou (la discipline de `FVoxelOpContext`).
|
||||
*
|
||||
* ⚠️ POUR LES TESTS : passez `FCrc::MemCrc32(&Params, sizeof(Params))`. Un oracle qui partage
|
||||
* le défaut qu'il teste ne prouve rien — c'est précisément ce que la note de
|
||||
* `VoxelForgeOpStackTunnelTest.cpp` (contrôle 3) décrivait comme le trou de l'original.
|
||||
*
|
||||
* The SDF cache key had neither the params nor anything that determines them, while the params
|
||||
* are blended per chunk INSIDE a strate — so a worker could evaluate one chunk against another
|
||||
* chunk's rooms, and which came first depends on worker order. Passing a once-per-chunk
|
||||
* fingerprint keeps the fix off the per-voxel path. No default: forgetting it must not compile.
|
||||
*/
|
||||
float GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
|
||||
const FStrateGenerationParams& Params) const;
|
||||
const FStrateGenerationParams& Params,
|
||||
uint32 ParamsFingerprint, uint32 LayoutVersion) const;
|
||||
|
||||
/**
|
||||
* Densité pour une strate Slab (FlatPlain / CrystalChamber).
|
||||
@@ -285,16 +313,20 @@ public:
|
||||
* (OriginVoxels, Step, CellsPerAxis) = les MÊMES arguments que GenerateMesh ; le verdict
|
||||
* porte sur le treillis exact que le mesher échantillonnerait (marge ±1 incluse).
|
||||
*
|
||||
* v1 : ne prouve que les chunks GAP (bedrock) et les strates SurfaceWorld — colonnes
|
||||
* terrain/plafond évaluées par le MÊME ComputeSurfaceColumn que le chemin densité (donc
|
||||
* bit-identiques), bandes de seal solides, gardes spine/passages/disturbances/diff.
|
||||
* Tout autre archétype (intérieur de caves) ⇒ Mixed. Worker-safe (lecture seule +
|
||||
* caches thread_local partagés avec GetDensityAt — un verdict Mixed laisse les colonnes
|
||||
* chaudes pour la génération qui suit).
|
||||
* GAP (bedrock), hors-layout (air constant) et SurfaceWorld plient leurs hypothèses par Z ;
|
||||
* SurfaceWorld évalue ses colonnes terrain/plafond avec le MÊME ComputeSurfaceColumn que le
|
||||
* chemin densité. Un unique slot cave opt-in peut ajouter le verdict conservatif de sa pile,
|
||||
* sous gardes d'archétype et de params bit-identiques. Spine/passages/disturbances/diff restent
|
||||
* des gardes conservatrices. Worker-safe (lecture seule + caches thread_local partagés avec
|
||||
* GetDensityAt — un verdict Mixed laisse les colonnes chaudes pour la génération qui suit).
|
||||
*/
|
||||
EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const;
|
||||
|
||||
private:
|
||||
/** Identité process-unique du propriétaire des caches `CP_*` thread_local.
|
||||
* Process-unique owner identity for the `CP_*` thread-local cache key. */
|
||||
uint64 DensityCacheOwnerId = 0;
|
||||
|
||||
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
|
||||
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// VoxelStats.h
|
||||
// Per-frame runtime counters for tile classification and meshing.
|
||||
// Compteurs runtime par frame pour la classification et le meshing des tuiles.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Stats/Stats.h"
|
||||
|
||||
DECLARE_STATS_GROUP(TEXT("VoxelForge"), STATGROUP_VoxelForge, STATCAT_Advanced);
|
||||
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Classified"), STAT_VoxelForgeTilesClassified, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Skipped All Solid"), STAT_VoxelForgeTilesSkippedAllSolid, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Skipped All Air"), STAT_VoxelForgeTilesSkippedAllAir, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Meshed"), STAT_VoxelForgeTilesMeshed, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Solid"), STAT_VoxelForgeTilesOpStackSolid, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Air"), STAT_VoxelForgeTilesOpStackAir, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Sole Slot"), STAT_VoxelForgeCaveBailNotOpStackSoleSlot, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Boundary Tile"), STAT_VoxelForgeCaveBailNotOpStackBoundaryTile, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack No Layout"), STAT_VoxelForgeCaveBailNotOpStackNoLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Recheck"), STAT_VoxelForgeCaveBailNotOpStackRecheck, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Out Of Layout"), STAT_VoxelForgeCaveMixOutOfLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Gap"), STAT_VoxelForgeCaveMixGap, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Surface World"), STAT_VoxelForgeCaveMixSurfaceWorld, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Two Cave Slots"), STAT_VoxelForgeCaveBailTwoCaveSlots, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Params"), STAT_VoxelForgeCaveBailParams, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Stack Verdict"), STAT_VoxelForgeCaveBailStackVerdict, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Fold Conflict"), STAT_VoxelForgeCaveBailFoldConflict, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Disturbance"), STAT_VoxelForgeCaveBailDisturbance, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail No Stack"), STAT_VoxelForgeCaveBailNoStack, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Hits"), STAT_VoxelForgeColumnMemoHit, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Misses"), STAT_VoxelForgeColumnMemoMiss, STATGROUP_VoxelForge, VOXELFORGE_API);
|
||||
@@ -25,6 +25,7 @@ class UMaterialParameterCollection;
|
||||
class UVolumeTexture;
|
||||
class UMaterialInterface;
|
||||
class UMaterialInstanceDynamic;
|
||||
class FScopedGenerationPause;
|
||||
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
|
||||
|
||||
/**
|
||||
@@ -373,6 +374,8 @@ public:
|
||||
UVolumeTexture* GetDensityVolumeTexture(int32 Level = 0) const;
|
||||
|
||||
private:
|
||||
friend class FScopedGenerationPause;
|
||||
|
||||
/** Get/create the shared MID wrapping a base terrain material (binds volume textures + shadow params).
|
||||
* Returns Base unchanged-wrapped, or nullptr if Base is null. */
|
||||
UMaterialInstanceDynamic* GetOrCreateTerrainMID(UMaterialInterface* Base);
|
||||
@@ -677,9 +680,19 @@ public:
|
||||
// Set to true during EndPlay — async tasks check this before accessing UObjects
|
||||
std::atomic<bool> bShuttingDown{false};
|
||||
|
||||
// Set during editor-driven generation mutations; distinct from teardown/shutdown semantics.
|
||||
// Active pendant les mutations de génération lancées par l'éditeur, sans signifier la destruction.
|
||||
std::atomic<bool> bGenerationPaused{false};
|
||||
|
||||
// Number of async tasks currently running — EndPlay waits for this to reach 0
|
||||
std::atomic<int32> ActiveTaskCount{0};
|
||||
|
||||
FORCEINLINE bool ShouldAbortWork() const
|
||||
{
|
||||
return bShuttingDown.load(std::memory_order_relaxed)
|
||||
|| bGenerationPaused.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
|
||||
FIntVector CurrentCenterChunk = FIntVector::ZeroValue;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user