# VoxelForge — Independent Audit, 2026-07-26 *Scope: every file under `Source/VoxelForge/` (~16,400 lines, 30 files) + all five design docs, read in full. Axes requested: **correctness & threading**, **perf & algorithm choice**, **architecture & maintainability**. Bar: **fitness for purpose for VoxelM** — not marketplace-grade generality.* > ⚠️ This file is currently **untracked** — the strict-whitelist `.gitignore` only keeps `Source/`, > `CODEMAP.md` and the `.uplugin`. See P1. --- ## 0. Verdict **This is good work.** Well above the median for a solo UE plugin: the perf reasoning is real and measured (Insights traces cited with numbers), the invariants are named and defended, the caches are box-validated rather than key-validated for the right reasons, and almost every expensive path is budgeted. The clipmap + trivial-tile-reject + worker-built streams architecture is the correct shape for this game. Nothing in here reads as cargo-cult. **And it is not in a state where you can answer "does it work?"** Three separate things have quietly gone wrong at the process layer, and they compound: 1. **~3 weeks of work sits in one commit called `tmp`** (3,903 insertions / 1,029 deletions across 20 files). No bisect, no per-feature revert, no "when did seed X stop matching". 2. **The design record isn't in version control.** `ARCHITECTURE.md`, `fable-idea.md`, `REVIEW_FINDINGS.md`, `CLAUDE.md` are all excluded by `.gitignore`. Only `CODEMAP.md` survives. 3. ~~A stack of "CODE-COMPLETE, PENDING BUILD"...~~ **CORRECTED 2026-07-26 (Jahni):** everything has been built and verified working. The *documents* are stale, not the code — `fable-idea.md` and `ARCHITECTURE.md` still carry "PENDING BUILD" markers dated 07-04/-06/-08 that were resolved and never ticked. That's a doc-hygiene item, not a project-state risk. The uncommitted tree is deliberate: the current 3D work is experimental (see §7). For a codebase whose central promise is **determinism**, having no per-change history is the sharpest internal contradiction in the project. That's the headline. The code findings below are secondary to that, but there are real ones — including one latent bug that will silently flatten your terrain the day you pass a large seed. --- ## 1. Correctness & threading ### C1 — Large seeds destroy the noise field ⚠️ **highest-severity latent bug** `VoxelGenerator.cpp` uses `const float SeedF = (float)Seed;` and then adds `SeedF * k` (k up to **97.7**) directly into Perlin coordinates — ~40 call sites, e.g.: ```cpp // VoxelGenerator.cpp:1390 (Scallop) CellularNoise3D(FVector(WorldX * SF + SeedF * 83.1f, ..., EffectiveZ * SF + SeedF * 97.7f)); ``` Float has a 24-bit mantissa. At magnitude `V` the ULP is `V · 2⁻²³`: | `Seed` | `SeedF · 97.7` | ULP there | Effect on a coordinate stepping by ~0.02/voxel | |---|---|---|---| | 1,000 | 9.8e4 | 0.012 | fine | | 100,000 | 9.8e6 | 1.2 | noise lattice-snaps; detail gone | | 10,000,000 | 9.8e8 | **117** | the coordinate term is *completely* absorbed → that noise field becomes **constant** | `ChangeSeed(int32)` is `BlueprintCallable`. The moment game code calls it with anything like `FMath::Rand()` (up to 2³¹), whole noise terms collapse to a constant and terrain goes flat or degenerate. It works today only because your seeds are small. `fable-idea.md` **flagged this exact issue** in T2.a ("seed offsets like `SeedF * 7.3f` can reach 1e8+ where float precision is ~64 units — hash the seed into a bounded offset range (e.g. [0, 16k])") and it was never done. **Fix (one line, one world re-tune):** ```cpp const float SeedF = (float)(VoxelHash::Mix((uint32)Seed) & 0x3FFF); // bounded [0, 16383] ``` Applied consistently at all `SeedF` definitions (`:682`, `:1756`, `:2066`, `:2208`, `:2275`, `:2687`). **Verify:** set `Seed = 1234567` in the data asset and look at the terrain. If it's flat, this is live. --- ### C2 — Per-chunk parameter caches have no layout key (stale after live-edit) ⚠️ **real, reproducible** `VoxelGenerator.cpp:503-524`: ```cpp thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX); thread_local ECaveGeneratorType CP_GenType; thread_local FStrateGenerationParams CP_Tunnel; // + CP_Slab, CP_Maze, CP_Surface, CP_Vert, // CP_Float, CP_Dist, CP_BiomeCtx, ... if (ChunkCoord != CP_Chunk) { /* refetch everything */ } ``` The key is **`ChunkCoord` alone** — no seed, no layout version. After `RebuildStrates()` or an `OnObjectModifiedInEditor` live edit, `StrateManager::Initialize` rebuilds the layout and bumps `PassagesVersion`, but a pooled worker thread whose `CP_Chunk` still equals the chunk it's now asked to regenerate **skips the refetch and generates with the old params**. Since `RegenerateAllChunks` reloads the *same* tile coords, often on the *same* workers, this is likely rather than exotic. Same pattern, same gap: - `OC_Chunk` — the `GetSurfaceHeightAt` oracle cache (`:2420`) - `BM_Chunk` — `GetBiomeMaterialAt` (`:3045`) - `FChunkBiomeCache::Contains` — keyed on `(box, ChunkZ, Seed)` but **not** the biome context, so editing a strate's `Biomes[]` leaves stale cells Note the asymmetry: the SDF cache (`CachedSeed`) and the surface-column cache (`GSurfColCache`, keyed on Seed) **are** seed-guarded, and the strate-index memo **is** version-guarded (`SI_Version`). So `ChangeSeed` mostly survives; **live-edit is where this bites**. Symptom: "I tweaked the strate asset, regenerated, and one patch kept the old shape." **Fix:** the getter already exists and is already used elsewhere — ```cpp const uint32 LV = StrateManager->GetLayoutVersion(); if (ChunkCoord != CP_Chunk || LV != CP_Version) { CP_Version = LV; /* refetch */ } ``` Three sites. Cheap, and it closes a whole class of "why didn't my edit apply". --- ### C3 — `FMath::PerlinNoise2D` survived the SIMD migration, on the per-voxel hot path `VoxelCaveMorphology.cpp:799-800`, inside `EvaluateSDFCached`'s **per-room, per-voxel** loop: ```cpp float N = FMath::PerlinNoise2D(FVector2D(Pos.X * RF + SF, Pos.Y * RF + SF * 1.7f)) * 0.65f + FMath::PerlinNoise2D(FVector2D(Pos.X * RF * 2.3f + SF * 3.1f, ...)) * 0.35f; ``` Two **double-precision, permutation-table** UE Perlin calls per voxel per in-range room. The entire T2.a pass existed to remove exactly this from the density path, and `ARCHITECTURE §8.10` states outright: *"Don't reintroduce `FMath::PerlinNoise3D` on the density path."* The 2D sibling slipped through the net. Gated by `FloorReliefStrength > 0` (default 0), so it's **dormant today** — but the moment you author a room floor with relief (the tooltip recommends 5-10 for "clear hills and basins — interesting to walk across"), every TunnelNetwork voxel near a room pays up to ~6 double-precision Perlin evals. That is plausibly larger than everything T2.a saved. **Fix:** `VoxelNoise::Perlin3D(x, y, 0.0f)` (or add a `VoxelNoise::Perlin2D`). One-time visual re-tune of the floor relief; nothing else moves. --- ### C4 — Streaming stops if the pawn is at exactly the origin, or before a pawn exists `VoxelWorld.cpp:453`: ```cpp FVector PlayerLastPos = GetPlayerPosition(); // returns ZeroVector when no PC/pawn if ((PlayerLastPos != FVector::ZeroVector)) { UpdateChunksAroundPosition(...); ... } ``` Two problems with using a value as a validity sentinel: - **(0,0,0) is a real, designed location** — it's the (0,0) descent spine, the landing column in every strate. A pawn resting exactly there freezes chunk streaming, atmosphere, decorations and the density volume for that frame. - **No pawn ⇒ permanent silent stall.** Early `BeginPlay`, a cinematic without a possessed pawn, or a future headless/dedicated path (§9 explicitly plans for this) gets a world that never generates and logs nothing. **Fix:** `bool bHasPlayer` out-param on `GetPlayerPosition`, or return `TOptional`. --- ### C5 — Shutdown spin-waits time out and then destroy objects workers are still reading `AVoxelWorld::EndPlay` (`:319-330`) waits 3 s on `ActiveTaskCount`, logs a warning, then **falls through to teardown** while a worker may still be inside `GenerateTileResult` holding `Generator`/`Mesher`/ `StrateManager`. `UVoxelContentManager::NotifyShutdown` does the same (3 s, then `break`), and its task lambda captures raw `this` (a UObject) — so after the timeout, `bShuttingDown.load()` and `DecoResults.Enqueue()` touch freed memory. `UVoxelDensityVolume` gets this right (`FillThread->Kill(true)` — unbounded, blocks until `Run()` returns). The other two should either be unbounded too, or hold a shared-ownership handle the workers keep alive. Low probability. High confusion cost when it does fire — it presents as a random PIE-exit crash. --- ### C6 — The diff layer is an unbounded append-only list of brush strokes `UVoxelDiffLayer` stores each `FVoxelModification` in **every chunk its AABB overlaps**, and `EvaluateMods` iterates **all** of a chunk's mods per voxel: ```cpp for (const FVoxelModification& Mod : Mods) { /* sphere/box/capsule falloff */ } ``` - `MaxModifications` defaults to **0 = unlimited**. - No compaction, no rasterization to a sparse voxel grid, no spatial subdivision within a chunk. - No serialization (`fable-idea` F4 save/load is unbuilt), so the entire player footprint dies with the session. Cost of re-meshing a chunk grows **linearly in the number of strokes ever made in it**, forever. For a game whose stated core verb is digging, this is the most significant architectural gap in the plugin. It's invisible today because nobody has dug 500 times in one chunk. The threading is correct (`FRWLock` + the version-stamped snapshot cache is a genuinely nice design — ~27 lock ops per tile instead of ~86k). It's the *data structure* that won't scale, not the concurrency. **When it matters:** the moment mining becomes a real loop. The natural answer is a per-chunk sparse `uint8`/`int8` delta grid that strokes rasterize into (O(1) per voxel, bounded memory, trivially serializable, and it's the same representation MP §9.5's "compacted diff snapshot" wants). --- ### C7 — The density volume runs by default for a feature that's switched off `bEnableDensityVolume = true` (`VoxelSettings.h:268`). What that buys, per frame while streaming: - a dedicated OS thread (`FVoxelDensityFillRunnable`), - a `CHUNK_SIZE³` quantize + 32 KB queue payload per capture-eligible level-0 tile, - a `TMap>` capture cache, - and the big one — `UploadDirtyTextures()` copies **the whole `Res³` array** and issues a full `RHIUpdateTexture3D` per dirty level. `BlitCaptureToWindow` sets `bGPUDirty = true` on *every* tile ingest, so level 0 is dirty essentially every frame a tile lands. At the default `Res = 128` that's **2 MB CPU copy + 2 MB GPU upload per level per frame** during streaming; at 192 it's ~7 MB. The code itself flags it: *"Sub-box upload (with toroidal wrap-splitting) is a later optimisation."* **CONFIRMED 2026-07-26 (Jahni): the lighting system is deprecated for now.** So this is not speculative — the volume, the fill thread, the capture path and the per-frame texture upload are all running to feed a consumer that no longer exists. **Action:** set `bEnableDensityVolume = false`. Straight cost deletion, no behavioural change. If the volume is revived later, the sub-box upload (with toroidal wrap-splitting) must land first — a full `Res³` re-upload per frame was never meant to be the steady state. Also worth deleting while it's cold rather than leaving it half-live: `UpdateTerrainMaterialParams` still recomputes and pushes `TVP0..TVP9` onto every terrain MID each Tick, and `ApplyMeshToTile` still routes every material through `GetOrCreateTerrainMID` when the volume is enabled. With `bEnable...` off, both self-disable — but the code paths stay as a trap for whoever reads them next. --- ### C8 — Documented invariant that isn't checked: `OriginRoomRadius` vs the COLLECT margin `BuildChunkCache` guarantees window-invariance by collecting rooms within `CollectMargin = 2·MaxTunnelLength + MaxInfluence`. The origin room is special-cased: it's also added when `RoomReachesSearchBox(...)` succeeds even if `(0,0)` is *outside* the collect box. In that case `NearestNeighbor[OriginIdx]` and the `OriginDowngraded` ranking are computed over a **different candidate set** than a chunk near `(0,0)` computes → different tunnel decisions → a discontinuity ring at roughly `CollectMargin` from origin. **Cannot fire at defaults** (`OriginRoomRadius = 20`, `MaxTunnelLength = 200` ⇒ margin ≈ 434). It becomes reachable only with a very large origin room *and* short tunnels. Flagging it because the tooltip invites "30+ → massive starting cavern" and there's nothing stopping someone typing 500. **Fix:** an `ensureMsgf(OriginRoomRadius < CollectMargin)` in `BuildChunkCache`, plus a line in `ARCHITECTURE §8.4`. --- ### C9 — The build uses `/fp:fast`, and the MP plan assumes bit-reproducible terrain ⚠️ **added 2026-07-27, measured not assumed** Found while chasing a 1-ULP difference between `GetMazeDensity` and its operator-stack port. The difference survived a full bisect down to code that is character-for-character identical, which pointed at the toolchain rather than the port. It is: ```csharp // UnrealBuildTool/Platform/Windows/VCToolChain.cs case FPSemanticsMode.Default: // Default is imprecise FP semantics. case FPSemanticsMode.Imprecise: Arguments.Add("/fp:fast"); break; ``` and UBT's own doc for that mode: *"FP math isn't IEEE-754 compliant: the compiler is allowed to transform math expressions in ways that might result in differently rounded results from what IEEE-754 requires."* The plugin sets no `FPSemantics` override, so it gets the default. **⚠️ And the default is not the same default everywhere.** `ClangToolChain.cs` — used by Linux, Mac, and Windows-with-Clang: ```csharp case FPSemanticsMode.Default: // Default to precise FP semantics. case FPSemanticsMode.Precise: Arguments.Add("-ffp-contract=off"); ``` `VCToolChain` additionally forces `Precise` when the Windows compiler is Clang. So **the same `FPSemanticsMode.Default` resolves to opposite float models per toolchain**, and Windows/MSVC is the only imprecise configuration in the engine's defaults. Two builds of identical source are therefore not merely *permitted* to diverge — they are compiled under different rules. **Two consequences, one benign and one not:** **Benign — refactors cannot be bit-identical.** The same expression compiled into two translation units may reassociate differently, worth ~1 ULP. So "the port reproduces the original exactly" is not an achievable bar for the op-stack work, and `OPSTACK-PLAN §2.6`'s bar (recognisably the same *place*, judged on a screenshot) was the right call for reasons beyond the ones it gave. The acceptance criterion is now encoded in `VoxelForge.OpStack.MazeEquivalence`: **hard-fail on any isosurface crossing, tolerate ULP-scale deltas, warn on anything larger.** **Not benign — `ARCHITECTURE §9.1`'s multiplayer model rests on this.** The plan is "replicate the seed + layout + diff, never the geometry; every peer regenerates identically." That guarantee is only as strong as the floating-point reproducibility of the density path, and under `/fp:fast` it holds **only between bit-identical binaries**. Same build, same platform: fine — `DensityPurity` proves the field is pure across threads and query order. But a different compiler version, a different optimisation level (Debug vs Shipping), or a different platform's toolchain may produce a subtly different world from the same seed. The docs already reject GPU density partly because *"cross-GPU float determinism is fatal for replicate-the-seed"*. The same argument applies to the CPU across build variants, and had not been stated. **How much it matters depends on a design question that is Jahni's, not the audit's:** - **If listen-server only, one binary per platform, and clients never regenerate authoritative geometry** — this is a non-issue. Ship as is. - **If a dedicated server on Linux and Windows clients both regenerate terrain and compare** — this is a real desync source, and it will present as rare, unreproducible, geometry-only divergence: approximately the worst bug class to diagnose. **Calibration, so this isn't over-feared.** In 20,000 Maze samples, **zero** crossed the isosurface under a 1-ULP perturbation. Divergence would therefore be occasional *single-voxel* surface differences, not visibly different terrain — sub-voxel, near-certainly imperceptible for collision-vs-visual mismatch. The case that would genuinely bite is **topological**: a cave pinch-point that connects on one build and not the other. Rare — but "rare and unreproducible" is the expensive kind of rare. **Even `Precise` everywhere would not buy guaranteed cross-platform bit-identity.** `FMath::Sqrt` is IEEE-exact and `VoxelNoise`'s hash-gradient core is integer + basic arithmetic, so those are safe. But `FMath::Sin`/`Cos` (disturbance bridge/ridge angles) route to platform libm, which is not bit-standardised across OSes. Aligning the FP model closes the large gap, not every gap. **The knob, if it turns out to matter:** `ModuleRules.FPSemantics = FPSemanticsMode.Precise` in `VoxelForge.Build.cs`, module-scoped. Note it would *align Windows with every other platform's default* rather than being a one-sided cost. **Still do not do this speculatively** — the density path is the plugin's hot loop, precise semantics block exactly the vectorisation and contraction T2.a's SIMD noise work was chasing, and the cost is unmeasured. Decision needs a profile in hand and a confirmed cross-platform requirement. **Verification, when it becomes relevant:** the cross-build claim above is *inferred*, not measured — no Linux build has been made. The cheap decisive test is to compile the plugin once with `FPSemantics = Precise` on Windows and re-run `VoxelForge.OpStack.MazeEquivalence`: if the residual 454-sample difference vanishes, the FP model is confirmed as the sole cause and the cross-toolchain risk is real. That is one build, and it settles it. --- ### Threading — what's *right*, for the record Worth stating plainly, because it's the part that's easy to get wrong and this doesn't: - `ProcessQueue` is correctly `Mpsc`, with the failure mode documented at the declaration. - The `FTaskGuard` RAII decrement covers every early-exit path in `LoadTile`. - Epoch carrying is consistent across all four async paths (tile gen, deco march, volume fill, band re-gen). - `DiffLayer`'s `FRWLock` + lock-free `bHasAnyMods` fast-reject + per-(chunk,version) snapshot is a genuinely good piece of engineering, and the comment records the AV it fixed. - `BuildCellSpawns` is correctly `static` — no implicit `this` on the worker. - `VoxelGenLOD::OctaveBias` is `TGuardValue`-scoped per tile, so deco snapping and volume fills can't inherit a stale bias. --- ## 2. Performance & algorithm choice ### Is Marching Cubes still the right call in 2026? — **Yes, for this game.** - **Transvoxel** ([transvoxel.org](https://transvoxel.org/)) remains the canonical answer to LOD seams, and `fable-idea` already schedules it correctly ("whenever LOD cracks become the loudest remaining flaw"). Your skirts are a legitimate substitute: they cost extra verts and some overdraw on boundary contour edges, but they're ~80 lines instead of a transition-cell rewrite, and they work for *any* level delta rather than only ±1. - **Surface Nets / Dual Contouring** would cut triangle count meaningfully and are faster per chunk, but naive Surface Nets can't hold a sharp 90° feature and DC brings QEF solving + self-intersection handling. You'd trade a rewrite for a benefit you aren't currently bottlenecked on. Correct to skip. - **GPU meshing** — `fable-idea` rejects it for readback latency, CPU-side collision, and cross-GPU float determinism. All three are still true, and the last one is fatal for a game whose MP model is "replicate the seed, regenerate identically on every peer". Right call, right reasons. **The verdict is: your mesher isn't the problem.** T1.a–f and T2.a–e genuinely took per-tile generation into diminishing returns. The remaining cost lives elsewhere (C3, C7) and in the scaling cliffs below. ### Scaling cliffs nobody has hit yet **`BuildChunkCache` is O(N²) in rooms, and N grows quadratically with the tunnel/spacing ratio.** `PickNeighbor` is O(N) per room and the connection loop is O(N²), over the COLLECT box which spans `2·MaxTunnelLength + MaxInfluence`. So room count `N ≈ ((2L + I)/S)²` and the graph build is **O((L/S)⁴)**: | `MaxTunnelLength` | `RoomSpacing` | ≈ rooms in COLLECT | pair iterations | |---|---|---|---| | 200 (default) | 80 (default) | ~40 | ~800 | | 300 | 60 | ~140 | ~10,000 | | 400 | 40 | ~500 | ~125,000 | Per cache rebuild, per worker. Amortised over 32,768 voxels it's survivable at defaults; at row 3 it isn't. Worth a comment on `MaxTunnelLength` and a soft clamp. **`AnyPassageNearBox`** walks every passage per tile with no spatial index. Fine at 10-20 passages; grows linearly with `TotalStrates × Connections`. **`FindSlotIndexForChunkZ`** is a linear scan called extremely often — per lattice-Z in `ClassifyTile`, per archetype param getter, on every `GetDensityAt` chunk change. The comment acknowledges it ("with ~10-20 strates this is fine"). It is. But it's the classic death-by-a-thousand-cuts profile entry. **`GetDominantBiomeAt`** is fully uncached: it builds an `FBiomeContext` (heap-allocating a `TArray`) and runs the warped Voronoi + up to 2 `ClassifyBiomeAtSite` (4 fBM evals). `AtmosphereManager::UpdateForPlayer` calls it **every Tick**, unconditionally. Small, but it's a per-frame game-thread allocation for a value that changes maybe once a minute. ### The external development you should know about: **RealtimeMeshComponent 6.0** You're on **RMC 5.3.2**. [RMC 6.0.0 shipped June 2026](https://triaxis.games/realtime-mesh/) with: - **Runtime Nanite** — "real cluster groups, full LOD DAG, per-page streaming. No cooked bake, no `UStaticMesh` round-trip." - **Lumen card generation** + **distance field support** (Pro tier, $49 one-time). - UE 5.2–5.7 support (you're on 5.7). This matters more to VoxelForge than to most plugins, because it potentially subsumes **three separate hand-built systems**: | Your system | What RMC 6.0 offers instead | |---|---| | Clipmap levels + `CoarseTileCells` + `LODOctaveDrop` + skirts | Nanite's own LOD DAG, no seams by construction | | F18 sheet ring (`GenerateSheetMesh`, XY hole, band re-queue) | far-field falls out of Nanite streaming | | Density volume + raymarched mini-sun shadows (currently gated off) | runtime Lumen cards + distance fields | I am **not** saying "rewrite on Nanite" — runtime Nanite on procedurally-remeshed geometry is new, and digging (constant re-meshing) is the adversarial case for cluster building. But this is the single biggest change in the landscape since your architecture was designed, and it argues strongly for a **timeboxed spike before building any more LOD machinery**. Measure: build time per tile, re-mesh cost on carve, and whether skirts/sheets become unnecessary. (For reference on the alternative path: [Voxel Plugin 2.0](https://docs.voxelplugin.com/) now targets 5.6/5.7 and its [VoxelCore](https://github.com/VoxelPlugin/VoxelCore) module is open-source — worth knowing exists, not worth switching to. Your strate/archetype model is the whole game; theirs isn't.) --- ## 3. Architecture & maintainability ### The structural risk: ~30 hand-rolled `thread_local` caches The density field is a *pure function by convention*, and that convention is enforced by roughly thirty hand-written memo caches, each with a hand-written invalidation key: `GSurfColCache` · `CP_Chunk`+9 param slots · `SDFCache`+`CachedSMinX/Y`+`CachedStrate`+`CachedSeed` · `SI_ChunkZ`/`SI_Version` · `DiffSlots[64]` · `CH_*` chasms · `BR_*` bridges · `RG_*` ridges · `SC_*` slab columns · `MZ_*` maze edges · `VS_*` shafts · `FI_*` islands · `OC_*` oracle · `BM_*` biome material · `TC_BiomeCache` · `Slots[2]` · `SL_*` passage shortlist · the mesher's `VertexMap`/`DensityGrid`/ `SurfColMemo`/`VertexClasses`/`GroundTris`/`CapTris`/`SheetCols`/`GroundIdx`/`CapIdx`. Every one is individually justified and individually documented. Collectively they mean **the correctness of the entire world rests on ~30 key lists each enumerating every input**, audited by hand. Two are provably incomplete (C2). This isn't a bug count — it's an *unbounded bug surface that grows with every feature*, and the failure mode is the worst kind: a seam or hole that appears only under a particular thread/edit interleaving. The existing mitigation, `ValidateDeterminism`, is the right instinct but a narrow test: one axis (X boundary), near the player, one alignment pair, **on the game thread**. It would not have caught C2 (worker-thread caches) or C8 (origin ring). **What would actually help**, in rough order of value-per-hour: 1. A `VF_CACHE_KEY(...)` macro that bundles `(coord, seed, layoutVersion)` so a new cache can't forget the standard fields. 2. A **debug-only "no-cache" mode** (`#if VF_VALIDATE_PURITY`) where every memo is bypassed, plus an automation test that samples N random points in both modes and asserts bit-equality. That converts *all* "bit-identical, I promise" claims — of which the docs make dozens — into a machine check. 3. Extend `ValidateDeterminism` to run its samples across **multiple worker threads** in shuffled order. ### No tests, no automation Zero test files. UE ships an automation framework you're not using. Three tests would cover most of the risk surface: - **Density purity**: sample 10k points, shuffle the query order, re-sample, assert bit-equality. - **`ClassifyTile` soundness**: for random tiles, if the verdict is `AllSolid`/`AllAir`, brute-force the lattice and assert every sample agrees. A false verdict is a *hole* — this is the highest-consequence function in the plugin and it's validated only by reasoning. - **`DiffLayer` under contention**: N reader threads + a writer, assert no crash and monotonic version. ### Function size (the open item that stays open) `REVIEW_FINDINGS.md` has listed these splits as open through three passes: | Function | Lines | Note | |---|---|---| | `GetDensityWithParams` | ~1,080 | listed as "~600-1000 L" — it has grown since | | `BuildChunkCache` | ~640 | | | `UpdateChunksAroundPosition` | ~300 | + 5 nested lambdas, 3 of them capturing by reference | | `BuildCellSpawns` | ~350 | contains a **175-line nested lambda** (`PlaceAtCrossing`) | | `ClassifyTile` | ~200 | | An item that survives three review passes isn't going to get done voluntarily. Either accept it explicitly (write "we are keeping these monolithic, here's why" in `REVIEW_FINDINGS`) or bind it to the next feature that touches each one. Leaving it as a permanent open checkbox just makes the checklist lie. ### Smaller structural notes - **`FStrateGenerationParams` is a 74-field god-struct**, and blend correctness depends on remembering to add every new field to `VF_STRATE_PARAM_FIELDS`. The X-macro was the right fix; make drift a *compile* error by putting `static_assert(sizeof(FStrateGenerationParams) == N, "add the new field to VF_STRATE_PARAM_FIELDS")` right next to it. - **Chunk-quantised param blending.** `GetGenerationParams` computes the Gradient/Interleaved blend `Alpha` from `ChunkCoord`, so within a transition band the density field has a **step at every chunk boundary**. MC samples corners from both sides, so it won't tear, but small ledges at chunk borders in Gradient zones are expected behaviour, not a mystery, if you ever see them. - **Dead knobs still shipping**: `ViewDistanceXY` (only `Up`/`Down` are read), `CeilingViewMultiplier` and `CeilingBandChunks` (the wide-ceiling system was removed by the clipmap), `DensityVolumeMaxTasks` (deprecated in its own tooltip). Each is a lever a designer will pull and get nothing. - **`EVoxelPassageType` vs `EVoxelPassageStyle`** — two overlapping passage-shape enums, both live. Already open in `REVIEW_FINDINGS`; still open. - **Leftover tutorial scaffolding** in `VoxelWorld.h/.cpp`: `// CHUNK MANAGEMENT - YOU IMPLEMENT THESE`, `// This one is tricky with Unreal's API, so I'll give you more help:`, and a 20-line `STEPS: 1. Try to dequeue...` block inside the now-finished `ProcessPendingChunks`. Cosmetic, but it's the first thing a reader hits. - **`.uplugin` metadata**: `"CreatedBy": "You"`, `"Description": "...built from scratch to learn"`. ### The documentation, honestly The prose is genuinely excellent — better than most commercial plugins. `ARCHITECTURE §8.10` in particular is a real asset: it records *why* each optimisation exists and what regressing it costs. Two problems: 1. **The numbers have drifted.** `CODEMAP.md` header says *"~8,300 lines of C++ across 25 files"*; reality is **~16,400 across 30**. §3.5's line anchors are badly stale (`LoadChunk :445` → actually ~1336; `ApplyModification :691` → ~1816). Since `CLAUDE.md` already instructs "trust symbol names over line numbers", I'd **delete the line column entirely** — it's a maintenance tax that buys nothing and actively misleads. 2. **The comment density is optimised for one kind of reader.** Blocks like the F17 row in `ARCHITECTURE §8.1` (a single table cell running ~40 lines, narrating v1 → v2 → v3 with rationale and quoted feedback) are superb context for an AI collaborator picking the file up cold, and a genuine liability for a human trying to find the code. You've made a real trade here; it's worth making it knowingly. A `HISTORY.md` for the "we tried X, it failed because Y" narrative would let the source comments shrink to *what the code does now*. --- ## 4. Process — what I'd fix first ### P1 — Track the design docs. Today. ⚠️ ``` .gitignore: * → !/Source/ !VoxelForge.uplugin !CODEMAP.md ``` `git ls-files` confirms **`ARCHITECTURE.md`, `fable-idea.md`, `REVIEW_FINDINGS.md`, `CLAUDE.md` and this audit are all untracked.** `fable-idea.md` even says so in its own header — *"the strict-whitelist `.gitignore` will ignore this file. Add `!fable-idea.md` if you want it tracked"* — and it never was. These documents *are* the project's memory. Losing them loses more than losing the code, because the code can be re-read and the reasoning can't. Two lines: ``` !*.md ``` ### P2 — Stop batching three weeks into `tmp` | Commit | Date | Content | |---|---|---| | `69fa73e` **tmp** | 2026-07-26 | **+3,903 / −1,029 across 20 files** — F17, F18, F19 anchors, F20 ph.1+2, companions, landmark merge, strate cut, sheet ring | | `cb61c8b` potential perf regression | 2026-07-04 | | | `6875614` Fix Decoration Placement | 2026-06-26 | | | `e6cd852` Another pass | 2026-06-23 | | | `db558d9` j | 2026-06-16 | | Seven commits in seven weeks, and the message quality (`j`, `tmp`, `Another pass`) means even the existing history isn't navigable. You don't need discipline theatre — one commit per *feature*, with the feature's name in the subject, would be enough to bisect "which change broke seed 42". This matters disproportionately *here* because determinism is the product. When a world stops matching, "which commit changed the field" is the only question, and right now it's unanswerable. ### P3 — Reconcile the "pending build" backlog before adding anything `fable-idea.md` and `ARCHITECTURE.md` currently carry, unreconciled: - F20 **phase 1** — "CODE-COMPLETE, PENDING BUILD (2026-07-08)" - F20 **phase 2** — "CODE-COMPLETE, PENDING BUILD (2026-07-08)" - §8.14 biome full-param redesign — "code-complete, pending build" - §8.15 F6 vertex palette — "C++ code-complete, pending in-editor build + the master material graph" - `REVIEW_FINDINGS` perf pass 2 + batch 3 — "CODE-COMPLETE, pending build" That's three weeks of unverified code stacked on unverified code, on a system where the failure modes (seams, holes, stale caches) are *visual* and only appear in-editor. **The honest statement of project state is: unknown for everything after 2026-07-04.** Suggested order before any new feature: 1. Build. Fix compile errors. 2. Run `ValidateDeterminism` at several locations (including far from origin). 3. `bEnableDensityVolume = false` → trace → compare (C7). 4. Set `Seed = 1234567` → look (C1). 5. Live-edit a strate asset, regenerate, look for patches that kept old params (C2). 6. Commit, per feature, with real messages. --- ## 5. Priority list *Re-ordered 2026-07-26 around the live 3D work.* | # | Item | Cost | Why | |---|---|---|---| | 1 | **§6.1** — `ClassifyTile` guard for 3D caves | ~30 lines | **blocks the current feature**; a false `AllSolid` is an invisible, collision-less cave | | 2 | **P1** — track the `.md`s (`!*.md`) | 2 min | irreplaceable if lost; the docs *are* the design record | | 3 | **C7** — `bEnableDensityVolume = false` | 1 min | confirmed dead weight — lighting is deprecated | | 4 | **§6.6** — branch the experimental 3D work | 1 min | restores a known-good world to diff against | | 5 | **C1** — bound `SeedF` | 1 line + re-tune | silent terrain collapse on large seeds | | 6 | **C2** — add `GetLayoutVersion()` to 3 cache keys | ~10 lines | closes "my edit didn't apply" | | 7 | **§6.2** — keep 3D caves *placed*, not *fielded* | a design call | decides whether T1.d keeps its 44 % | | 8 | **C4** — explicit no-player flag | ~5 lines | origin-standing / no-pawn stall | | 9 | **C5** — unbounded joins on shutdown | ~20 lines | random PIE-exit crashes | | 10 | Automation tests (purity, `ClassifyTile`, `DiffLayer`) | ~a day | `ClassifyTile` is now the highest-consequence function in the plugin | | 11 | **P2** — per-feature commits | ongoing | determinism without history is a contradiction | | 12 | **C3** — `PerlinNoise2D` off the SDF hot path | ~5 lines | dormant, but violates a stated invariant | | 13 | **RMC 6.0 spike** (runtime Nanite / Lumen cards) | timeboxed | may subsume clipmap LOD + sheets + density volume | | 14 | **C6** — diff-layer delta grid | a design pass | the scaling wall for the game's core verb | | 15 | Tick the stale "PENDING BUILD" markers in the docs | 20 min | they currently misreport project state | | 16 | Drop line numbers from CODEMAP §3; fix the header count | 30 min | the map misstates its own scale (8.3k → 16.4k) | | 17 | Delete dead settings + tutorial scaffolding | 30 min | knobs that do nothing mislead | --- ## 6. Going 3D — what the current architecture will fight you on *Added 2026-07-26 after Jahni confirmed the live direction: 3D density features — overhangs, caves inside mountains, volumetric generation inside `SurfaceWorld`. This is the section that matters most right now; everything above is maintenance by comparison.* ### 6.1 `ClassifyTile` will delete your caves. This is the one to get right first. ⚠️ T1.d proves a tile `AllSolid`/`AllAir` and **skips `GenerateMesh` entirely**. For `SurfaceWorld` the proof is (`VoxelGenerator.cpp`, `TestColumn`): ```cpp if (Z >= TerrainZ && Z <= CeilSurf) { bCanSolid = false; } // air side else { bCanAir = false; } // below terrain, or in the cap ``` So **any point below `TerrainZ` is treated as provably solid.** A tile entirely inside a mountain returns `AllSolid`, emits no mesh, and never calls the density function. Carve a cave in there and: - the cave's walls are **never meshed** — you get an unrendered void, - there is **no collision** (level-0 collision is per-section, and there are no sections), - and it is invisible until you enter it from a passage, at which point you fall through nothing. The code comment states the rule exactly: *"A false `Mixed` only costs CPU; the code must NEVER emit a false `AllSolid`/`AllAir` (that's a hole)."* This is the same failure that got T1.d **v1 reverted on 2026-06-26** — a global analytic ceiling bound that wasn't conservative. The existing guards are the template — each kills one hypothesis, cheaply, per tile: | Guard | Kills | Mechanism | |---|---|---| | `AnyPassageNearBox` | `AllSolid` | sphere-vs-AABB over the passage list | | origin spine | `AllSolid` | circle-vs-box in XY | | chasm disturbances | `AllSolid` | `ChasmDensity > 0` on the strate | | bridges / ridges | `AllAir` | density > 0 on the strate | | `HasAnyModInChunkRange` | both | one key walk of the diff map | | F20 overhang | both, in a band | `(TerrainZ, TerrainZ + OverhangMargin]` ⇒ `Mixed`, upward only | **Every new 3D feature needs one of these before it ships.** For hash-placed cave systems that means a per-tile shortlist exactly like `EvaluateModifierSDF`'s passage shortlist: collect the feature bounds that reach this tile once, and if the list is non-empty, `bCanSolid = false`. `fable-idea`'s own F20 phase-3 GOTCHA already says this for spikes/holes — it applies verbatim to caves. > **Test that catches it:** generate a mountain with caves, then stand outside and fly the camera through > the mountain volume. Any cave that renders from inside a passage but shows nothing when approached > through rock is a false `AllSolid`. ### 6.2 The T1.d economics invert as caves get more volumetric The 2026-07-05 trace justified T1.d with: **84 % of `GenerateMesh` calls produced empty tiles** (83,925 gens vs 13,227 meshes, ~500 s of 617 s worker CPU wasted), and shipping it cut worker CPU 44 %. The bulk of those `AllSolid` tiles **is deep rock below `TerrainZ`** — precisely the volume "caves inside mountains" occupies. Every tile a cave touches must now generate. So there's a direct, quantifiable tension: - **Bounded caves** (hash-placed systems with bounding volumes, like rooms/tunnels already are) wake only the tiles they actually intersect. The shortlist proves the rest of the mountain still solid. T1.d keeps most of its win. - **A global 3D noise field** (`if (noise3D(x,y,z) > t) carve`) is unprovable *everywhere* below terrain → every deep tile becomes `Mixed` → you hand back a large slice of that 44 %, plus you now mesh and cook collision for tiles that are 99 % solid rock. **Recommendation: make 3D caves placed, not fielded.** Same discipline as `FCachedRoom`/`FCachedTunnel` — they exist at hash-determined locations with known bounds. That keeps the classifier effective, makes the shortlist guard trivial to write, and gives you authored control over cave density per biome. It's also the only version that stays cheap at the render-distance ring. ### 6.3 Do not put Z-dependent data in `FSurfaceColumn` `FSurfaceColumnBox` is keyed `(XY box, StrateKey, Seed)` with **no `ChunkZ`**, held as a 6-box LRU, and deliberately **shared down the entire vertical chunk stack** — that's the whole T1.a win (the heightfield was being recomputed once per altitude). `ARCHITECTURE §8.10` states it: *"ZERO Z dependence... Don't re-introduce a ChunkZ key."* So the rule for every new 3D feature is: - **XY-pure data** (a per-column gate, amplitude, direction, a cave-system ID) → cache on `FSurfaceColumn`. - **Z-dependent evaluation** → do it per-voxel in `SurfaceDensityFromColumn`, reading the cached column. Your F20 phase-2 overhang is the correct template and worth copying deliberately: `OverhangAmp` (strength × slope-gate) and the unit uphill `(DirX, DirY)` are resolved **once per column** in `ComputeSurfaceColumn`; the actual warped-terrain union is per-voxel. Anything that violates this silently corrupts every chunk in the vertical stack, and `ValidateDeterminism` — which samples along an **X** boundary — would not catch it. ### 6.4 Window invariance: pick the cheap pattern where you can Two existing precedents, and the difference is worth being deliberate about: - **`Maze`** — edge identity is a pure hash of `(lower node, axis)`. Two adjacent chunks *cannot* disagree. No cache, no COLLECT region, no seam risk, zero invariant maintenance. - **`BuildChunkCache`** — because tunnel existence depends on a *connectivity decision over a neighbourhood*, it needs the two-region COLLECT/STORE split (§8.4), and that invariant is delicate enough to have its own documentation section. If your cave systems need connectivity (a cave network that must be traversable), you inherit the second pattern and its COLLECT-margin discipline. If each cave is independently placed, use the first — a pure `hash(cell, seed)` placement with per-voxel SDF evaluation and no cross-chunk decision at all. **Pick the Maze pattern unless connectivity is a gameplay requirement.** The complexity delta is large and it's all seam-risk. ### 6.5 Smaller things that will bite - **Route new per-voxel volumetric noise through `VoxelGenLOD::Eff(N)`.** That's the T2.b contract: per-voxel 3D noise gets the LOD octave drop, XY-field noise deliberately does not. New 3D code that calls `FractalNoise3D(..., 3)` directly silently opts out of far-tile savings. - **LOD-gate the expensive 3D ops.** Your own note on F20 phase 2 flags it: *"KNOWN v1 LIMIT: applies at ALL LODs (may alias far) — gate to fine tiles later."* With one op that's a wart; with several it's a far-field cost multiplier. A `Step > N` early-out per volumetric op is a few lines and compounds. - **F17 already handles cave roofs correctly** — the surface class is semantic, not geometric: down-facing but *below* `TerrainZ` ⇒ ground/rock, not sky-cap. This was designed for exactly your current case and needs nothing. Good call at the time. - **The F18 sheet ring won't show caves** — sheets are two heightfields, so carved features don't exist there. Already an accepted trade-off; just don't expect a cave mouth to be visible at render-distance. - **`SurfaceDensityFromColumn` is now the hot path**, not the SDF morphology. The overhang block already re-runs `SampleSurfaceStructuralZ` per lip voxel. Each new volumetric op that re-samples structural height per voxel stacks on that. Budget it consciously — the per-column cache saved you ~33×, and per-voxel structural re-evals spend it back. ### 6.6 Since it's experimental and uncommitted You said the tree is uncommitted *because* the 3D work is experimental. That's a reasonable instinct pointed at the wrong tool — experimental work is exactly what branches are for: ``` git switch -c 3d-density # experiment freely, commit noisily git switch main # a known-good world is always one command away ``` The specific thing you lose right now: when a seed stops matching or a hole appears mid-experiment, there is no known-good state to diff against, and the 3D features are the *highest*-risk changes in the project for exactly that failure mode (§7.1). A branch costs nothing and gives you the bisect back. --- ## 7. What I'd tell someone joining this project The generation core is strong and the perf work is honest and measured — the person who wrote this knows what they're doing and has the traces to prove it. The weakness is **everything around verification**: no history you can bisect, no tests, design docs outside version control, and a habit of stacking features on top of unbuilt features. Nothing in the code list above is hard to fix; the process items are the ones that decide whether the next three weeks are as productive as the last three. *Sources consulted for the external comparison: [transvoxel.org](https://transvoxel.org/) · [triaxis.games/realtime-mesh](https://triaxis.games/realtime-mesh/) · [TriAxis-Games/RealtimeMeshComponent](https://github.com/TriAxis-Games/RealtimeMeshComponent) · [docs.voxelplugin.com](https://docs.voxelplugin.com/) · [VoxelPlugin/VoxelCore](https://github.com/VoxelPlugin/VoxelCore) · [UE 5.7 release notes](https://www.unrealengine.com/news/unreal-engine-5-7-is-now-available)*