fix(morphology): BuildChunkCache under-bounds room/tunnel collection when Min > Max

Third instance of the same class, and the worst one -- found by the Sol-High
read-only audit (VF-05) and verified against the code before acting.

MaxInfluence, RoomZBuffer and EvaluateSDF's Margin all derive from
MaxRoomRadius / TunnelMaxRadius, while the radii are Lerp(Min, Max, hash), which
yields up to max(Min, Max). A room able to reach a chunk can therefore sit in a
cell the collect region never visited.

Worse than the two fixed earlier today because:
  - it is TunnelNetwork, the largest archetype;
  - BuildChunkCache is called by BOTH density paths (FRoomGraphSource calls it
    rather than transcribing it), so this was never an op-stack bug -- it is in
    the shipped original code and always has been;
  - the failure mode is a window-invariance break (ARCHITECTURE 8.4): whether a
    room exists depends on which chunk you queried from, which in multiplayer
    means two peers generate different geometry from the same seed.

Four bound sites now use RoomRadiusEnvelope / TunnelRadiusEnvelope; the two
duplicated copies of the formula still compute the identical expression. No
Lerp, placement, hash or bStore line changed -- with correctly ordered params
max(Min,Max) == Max, so this is bit-identical. A no-op at correct values is the
acceptance signal.

Also adds a reviewer's header to AUDIT-2026-08-CODEX.md marking which findings I
verified (VF-05 confirmed, VF-02 premise confirmed, VF-03 evidence overstated --
its cited fixture corroboration does not exist) and which are unverified leads.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 17:32:09 +02:00
parent 8ef4d7dc09
commit cab8e8fe55
4 changed files with 358 additions and 6 deletions
+192
View File
@@ -0,0 +1,192 @@
> ## ⚠️ 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) | ⚠️ **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, VF-04, VF-06 … VF-10 | **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 140153), `AVoxelWorld::OnObjectModifiedInEditor` (238316), `AVoxelWorld::ChangeSeed` (20642114), `AVoxelWorld::LoadTile` (13451478), and `AVoxelWorld::GenerateTileResult` (1481 onward); `Source/VoxelForge/Private/VoxelStrateManager.cpp``UVoxelStrateManager::Initialize` (28163).
**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` (320375) and the `[this, ...]` task in `AVoxelWorld::LoadTile` (14561478); `Source/VoxelForge/Private/VoxelContentManager.cpp``UVoxelContentManager::BeginDestroy` (5863), `NotifyShutdown` (6580), and the `[this, ...]` task in `LaunchDecoTasks` (384404).
**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 (583622), `GSurfColCache` access (738), and `DiffSlots` (808826); `ClassifyTile`: `TC_BiomeCache`/`TC_SeenVersion` (27202731); `GetBiomeMaterialAt`: `BM_*` cache (34703485).
- `Source/VoxelForge/Private/VoxelStrateManager.cpp``GeneratePassages` (169173, 352353) and `EvaluateModifierSDF`: `SL_*` shortlist plus unchecked `Passages[PIdx]` (372420).
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp``FRoomGraphSource::Eval`: `SI_*` strate-index memo (21722188).
- `Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h``FTestWorld` construction (126152).
**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` (2045) and `ApplyModification` (63133); `Source/VoxelForge/Public/VoxelDiffLayer.h``FVoxelModification::GetWorldBounds` (97121); `Source/VoxelForge/Private/VoxelWorld.cpp``AVoxelWorld::ApplyModification` (18391871), `CarveBox`/`FillBox` (18741893), and `CarveCapsule`/`FillCapsule` (18961915).
**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` (127130), `CollectMargin` (152), `RoomZBuffer` (171), room radius generation (260265), and tunnel radius generation (464468); `EvaluateSDF` margin (871874). 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` (413421); `Source/VoxelForge/Private/VoxelStrateManager.cpp``UVoxelStrateManager::Initialize` (4355, 76105).
**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` (456484) and `GetPlayerPosition` (529537); declaration/comment in `Source/VoxelForge/Public/VoxelWorld.h` (634635).
**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` (147256) and `LaunchDecoTasks` (366398); `Source/VoxelForge/Public/VoxelContentManager.h``FDecoCellResult::Entries` (151158); nested decoration arrays in `Source/VoxelForge/Public/VoxelStrateTypes.h``FDecoCompanion::SubCompanions` (20732076) and `FStrateDecoration::Companions` (21202123).
**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` (8894), `LaunchDecoTasks` (344404), `ProcessDecoResults` (763774), and `ClearAllDecorations` (983994).
**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 (12941328); `Source/VoxelForge/Private/VoxelDensityOpStack.cpp``FRoomGraphSource::FState`/`LocalParams` (19932012, 20412073); `Source/VoxelForge/Private/VoxelCaveMorphology.cpp` — room-op selection and existing per-room feature pre-bake (653678).
**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.
@@ -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` | ~127130 | `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` | ~871874 | 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.
+56
View File
@@ -3779,3 +3779,59 @@ shape of `AUDIT §C2`'s staleness class, but in prose instead of a cache key.
**Rule added to the handoff: never state the flag's state from memory. Ask, or read it in the **Rule added to the handoff: never state the flag's state from memory. Ask, or read it in the
editor.** More generally: *a fact that lives outside the repo cannot be maintained inside the repo* editor.** More generally: *a fact that lives outside the repo cannot be maintained inside the repo*
docs may record what it was **and when it was checked**, never assert it as current. docs may record what it was **and when it was checked**, never assert it as current.
## 2026-08-16 (i) — Sol-High audit: one real find (fixed), one overstated citation, eight leads
Jahni asked for a second opinion: *"maybe put Codex on Sol High to do an evaluation of our code
cleanliness and efficiency?"* Run read-only as `gpt-5.6-sol` / high, output at
`AUDIT-2026-08-CODEX.md`. It touched nothing else, as instructed.
**Prompting note that mattered:** the real failure mode for a fresh model here is confidently
re-proposing decisions this project already made and documented. The prompt handed it the rejected
list up front (splitting `GetDensityWithParams`, "simplifying" the `FVector` round-trip, collapsing
the twelve-times gate, merging the passage enums, tightening "over-conservative" box bounds, the
§8.10 invariants, the deliberate old/new duplication). **It re-proposed none of them.**
### ✅ VF-05 is real, and it is the worst instance of the class I found twice today
`VoxelCaveMorphology::BuildChunkCache` derives `MaxInfluence`, `RoomZBuffer` and `EvaluateSDF`'s
`Margin` from `MaxRoomRadius` / `TunnelMaxRadius`, while the radii themselves are
`Lerp(Min, Max, hash)` — which yields up to `max(Min, Max)`. Same defect as `CODEX-TASK-004`, and
worse on three counts:
1. it is **TunnelNetwork**, the largest archetype;
2. `BuildChunkCache` is called by **both** density paths (`FRoomGraphSource` calls it rather than
transcribing it), so **this was never 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`): whether a room exists
depends on which chunk you queried from. In multiplayer, two peers generate different geometry
from the same seed.
Fixed in `CODEX-TASK-006`, verified against the code before specifying and after: four bound sites
now use `RoomRadiusEnvelope` / `TunnelRadiusEnvelope`, the two duplicated copies of the formula still
match, and **no `Lerp`, placement, hash or `bStore` line changed** — with correctly ordered params
`max(Min,Max) == Max`, so it is bit-identical. **A no-op at correct values is the acceptance signal.**
Notably, Sol independently derived the same *"do not reorder the `Lerp` endpoints, it changes
deterministic assignment"* caveat I had written into task 004. Convergent reasoning on the danger,
from a model that had not seen that task.
### ⚠️ VF-03's corroboration does not exist — and that is the finding about the audit
VF-03 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 concern (caches keyed on chunk/seed/layout but not on
*which generator owns them*) may still be valid — but it must be judged on its own merits.
An audit whose job is to catch unchecked premises contained one. Every row is self-labelled "Verified
by reading"; that label is the author's claim, not an independent check. A reviewer's header saying
so now sits at the top of the file, listing exactly which rows I verified (VF-05 ✅, VF-02 ✅ premise,
VF-03 ⚠️) and which are unverified leads (VF-01, VF-04, VF-06…VF-10).
### VF-02 is worth a deliberate decision from Jahni, not a silent fix
`VoxelWorld.cpp:327` reads *"Timeout after 3 seconds to avoid hanging the editor"*, and `EndPlay`
proceeds when it expires while tasks still hold a raw `this`. **`CLAUDE.md` states the invariant more
strongly than the code implements it** ("EndPlay blocks on `ActiveTaskCount → 0`" — it blocks *with a
deadline*). That is a design trade someone made on purpose (never hang the editor) and it should be
re-affirmed or changed deliberately, not patched by an agent while he is away.
@@ -124,9 +124,13 @@ void VoxelCaveMorphology::BuildChunkCache(
// MaxInfluence = how far a room body / tunnel TUBE reaches PERPENDICULAR to its // 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 // anchor — NOT its length. A room or tunnel whose anchor lies within MaxInfluence
// of a box can touch a voxel inside that box. // 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( const float MaxInfluence = FMath::Max(
Params.MaxRoomRadius, RoomRadiusEnvelope,
Params.TunnelWarpStrength + Params.TunnelMaxRadius Params.TunnelWarpStrength + TunnelRadiusEnvelope
) + Params.SDFBlendRadius; ) + Params.SDFBlendRadius;
const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f); const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f);
@@ -165,10 +169,10 @@ void VoxelCaveMorphology::BuildChunkCache(
// Vertical range for room CENTER placement. // Vertical range for room CENTER placement.
//========================================================================= //=========================================================================
// Buffer = seal thickness + max room half-height. // 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 // fits entirely within the seal boundary — no room gets its ceiling or floor
// cut flat by the seal. Smaller rooms have proportionally more margin. // 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 StrateMinZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness + RoomZBuffer;
const float StrateMaxZ = Params.StrateTopWorldZ - Params.BoundarySealThickness - RoomZBuffer; const float StrateMaxZ = Params.StrateTopWorldZ - Params.BoundarySealThickness - RoomZBuffer;
const float StrateRangeZ = StrateMaxZ - StrateMinZ; const float StrateRangeZ = StrateMaxZ - StrateMinZ;
@@ -868,9 +872,11 @@ float VoxelCaveMorphology::EvaluateSDF(
const FStrateGenerationParams& Params, const FStrateGenerationParams& Params,
uint32 Seed, int32 StrateIndex) 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( const float Margin = FMath::Max(
Params.MaxRoomRadius, RoomRadiusEnvelope,
Params.TunnelWarpStrength + Params.TunnelMaxRadius Params.TunnelWarpStrength + TunnelRadiusEnvelope
) + Params.SDFBlendRadius; ) + Params.SDFBlendRadius;
FChunkSDFCache TempCache; FChunkSDFCache TempCache;