Files
VoxelForge/AUDIT-2026-08-CODEX.md
T
Fr0zka 588f9e0294 docs: Sol investigation -- refutes my boundary-tile hypothesis, corrects my false accusation
Sol High investigated with Luna xHigh sub-agents; two approaches built in
isolated worktrees, main tree untouched.

Two corrections to my own assertions:

1. "One un-ticked neighbouring strate kills boundary tiles" is WRONG.
   UsesOperatorStackForChunk depends only on ChunkCoord.Z, and ClassifyTile's
   earlier Z loop already required the predicate for every sampled cave Z within
   one slot -- so the later XYZ sweep is redundant, not over-strict. Removing it
   unlocks nothing; boundary tiles still fail the one-slot/type/params guards,
   correctly.

2. I falsely wrote that VF-03's fixture citation was fabricated. It exists
   (VoxelForgeTestFixture.h ~134/146, explicit about CP_UseOpStack
   contamination). I had read only the 30-line header and asserted a negative
   from a partial read. Header corrected. VF-03's core claim is now CONFIRMED:
   GetDensityAt keys thread_local CP_* by (ChunkCoord, LayoutVersion) with no
   world identity, and every manager's version starts equal.

Also: the Cave Bail Not Op Stack counter I specced is ambiguous -- it fires
before the different-slot attribution, so it cannot distinguish "flown slot
un-ticked" from "boundary tile met an un-ticked slot first". My 80% reading was
over-confident. Same defect I fixed for TilesSkippedAllSolid, reintroduced one
layer down.

Approach A recommended (explicit opt-in + six-box LRU) over B (code-enforced
cutover): the code does not justify weakening ClassifyTile, and B would delete
the flag, which is the only same-route A/B lever available to prove the
refactor. Narrow A's warning to cave archetypes before adopting.

Nothing built. Worktrees left in place for review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:22:06 +02:00

28 KiB
Raw Blame History

⚠️ 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 FIXEDCODEX-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 confirmedVoxelWorld.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 codebaseDiffLayer.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.cppAVoxelWorld::RebuildStrates (lines 140153), AVoxelWorld::OnObjectModifiedInEditor (238316), AVoxelWorld::ChangeSeed (20642114), AVoxelWorld::LoadTile (13451478), and AVoxelWorld::GenerateTileResult (1481 onward); Source/VoxelForge/Private/VoxelStrateManager.cppUVoxelStrateManager::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 TArrays 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.cppAVoxelWorld::EndPlay (320375) and the [this, ...] task in AVoxelWorld::LoadTile (14561478); Source/VoxelForge/Private/VoxelContentManager.cppUVoxelContentManager::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.cppUVoxelGenerator::GetDensityAt: CP_* cache (583622), GSurfColCache access (738), and DiffSlots (808826); ClassifyTile: TC_BiomeCache/TC_SeenVersion (27202731); GetBiomeMaterialAt: BM_* cache (34703485).
  • Source/VoxelForge/Private/VoxelStrateManager.cppGeneratePassages (169173, 352353) and EvaluateModifierSDF: SL_* shortlist plus unchecked Passages[PIdx] (372420).
  • Source/VoxelForge/Private/VoxelDensityOpStack.cppFRoomGraphSource::Eval: SI_* strate-index memo (21722188).
  • Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.hFTestWorld 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.cppUVoxelDiffLayer::CanModify (2045) and ApplyModification (63133); Source/VoxelForge/Public/VoxelDiffLayer.hFVoxelModification::GetWorldBounds (97121); Source/VoxelForge/Private/VoxelWorld.cppAVoxelWorld::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.cppVoxelCaveMorphology::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.hMinRoomRadius/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.cppAVoxelWorld::BeginPlay (413421); Source/VoxelForge/Private/VoxelStrateManager.cppUVoxelStrateManager::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.cppAVoxelWorld::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.cppUVoxelContentManager::UpdateDecorations (147256) and LaunchDecoTasks (366398); Source/VoxelForge/Public/VoxelContentManager.hFDecoCellResult::Entries (151158); nested decoration arrays in Source/VoxelForge/Public/VoxelStrateTypes.hFDecoCompanion::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 TArrays, 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.cppResetGridBuildState (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.cppUVoxelGenerator::GetDensityWithParams, per-room block (12941328); Source/VoxelForge/Private/VoxelDensityOpStack.cppFRoomGraphSource::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.