b426cfcb0d
Was about to spec a Codex fix for OC_Chunk / BM_Chunk / FChunkBiomeCache, which three docs still list as open. Checked the sites first. All of them already carry a layout-version guard: CP_Chunk -> CP_Version OC_Chunk -> OC_Version BM_Chunk -> BM_Version TC_BiomeCache -> TC_SeenVersion FChunkBiomeCache::Invalidate() exists precisely because the validity box says nothing about the FBiomeContext its cells were classified against, and all four thread_local instances call it on a version change. The only other two instances in the tree (VoxelContentManager ~445, the height-stack test) are function-local, constructed per task, so staleness is impossible there. Seventh time in this project a confident premise reversed on reading. It cost a doc edit instead of a Codex run and a build cycle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1051 lines
61 KiB
Markdown
1051 lines
61 KiB
Markdown
# 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.
|
||
|
||
---
|
||
|
||
#### ⛔ CORRECTION 2026-07-27 — **the fix above is WRONG. Do not apply it.**
|
||
|
||
It bounds `SeedF` but leaves the **multiplier** in place, and the multiplier is where the magnitude
|
||
comes from. `SeedF · 97.7` with `SeedF ≤ 16383` still reaches **1.6e6**, where the float ULP is:
|
||
|
||
| | max coord term | ULP there | vs the ~0.02/voxel coordinate step |
|
||
|---|---|---|---|
|
||
| `Seed = 1000` today | 9.8e4 | 0.012 | 0.6× — fine |
|
||
| **audit's proposed fix, worst case** | **1.6e6** | **0.19** | **9.5× — still lattice-snaps** |
|
||
|
||
So the "fix" would have made the bug *less catastrophic* while leaving it live, and — worse —
|
||
**closed the ticket.** It would have cost a build and a world re-roll to discover that mid-range
|
||
seeds still degrade. (Found by doing the arithmetic before applying it, not after.)
|
||
|
||
**The actual fix: bound the OFFSET, not the seed — i.e. delete the multipliers.**
|
||
|
||
The `· 7.3f … · 97.7f` multipliers exist only to decorrelate the ~40 noise call sites from each
|
||
other. That is a *hashing* job, and hashing does it better: give each site its own offset drawn from
|
||
the full seed entropy, already in final units, with no multiplier to re-inflate it.
|
||
|
||
```cpp
|
||
// VoxelHash — one definition, so the bound cannot drift per site.
|
||
// Site-salted, final units, bounded: ULP at 16384 is 0.002 = 10% of a voxel step.
|
||
FORCEINLINE float SeedOffset(int32 Seed, uint32 Site)
|
||
{
|
||
return (float)(Mix((uint32)Seed ^ (Site * 2654435761u)) & 0x3FFF);
|
||
}
|
||
```
|
||
`WorldX * SF + SeedF * 83.1f` → `WorldX * SF + VoxelHash::SeedOffset(Seed, kSiteScallopX)`.
|
||
|
||
**Why this is strictly better, not just smaller:** bounding `SeedF` alone would squeeze all seeds
|
||
through 16384 distinct noise offsets *shared across every site*, so two colliding seeds would give
|
||
identical noise everywhere. Per-site salting means two seeds must collide at **all** ~40 sites to
|
||
produce the same world — which is never.
|
||
|
||
**Scope:** ~40 call sites, mechanical, plus the same bug now inherited by the operator stack
|
||
(`FSlabVoidSource::SeedF`, `VoxelDensityOpStack.cpp`). Must land in **both** paths in one change, or
|
||
the equivalence tests will (correctly) scream. **Cost of delay: every archetype ported copies it
|
||
again.**
|
||
|
||
---
|
||
|
||
#### ⚠️ REOPENED AND RE-CLOSED 2026-07-28 — the sweep missed one site, and the search pattern is why
|
||
|
||
`§C1` was reported fixed on 2026-07-27 (85 sites, *"0 left behind"*), and
|
||
`VoxelForge.Determinism.LargeSeedSurvives` went green on seeds up to 2e9. **One site had survived**,
|
||
found by reading `GetFloatingIslandDensity` line by line to port it:
|
||
|
||
```cpp
|
||
// VoxelGenerator.cpp — the floating-island domain warp
|
||
const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, ...));
|
||
```
|
||
|
||
**Why the sweep missed it:** the sweep matched the `SeedF * K` spelling. This site spells the same
|
||
thing `(float)S * K`, where `S` is the archetype's salted seed. A textual sweep finds a *spelling*,
|
||
not a *bug* — and the green property test could not compensate, because `LargeSeedSurvives` asserts
|
||
that the **heightfield** still varies, and this site perturbs an **island outline**. Neither the
|
||
comparison oracle nor the property oracle covered it; a human read did.
|
||
|
||
**Impact, before the fix:** at `Seed = 2e9` the term reaches ~1.4e6, where the float ULP is 0.125
|
||
against a per-voxel step of 0.04 — the warp flattens and every island silhouette snaps back to a
|
||
perfect circle. Cosmetic rather than catastrophic (the C1 failure mode for a *heightfield* is a flat
|
||
world), which is exactly why nothing screamed.
|
||
|
||
**Fixed** in both paths in one pass (`VoxelHash::SeedOffset(S, 0.0007f)` in `GetFloatingIslandDensity`
|
||
and in `FIslandBlobSource`), so `FloatingIslandEquivalence` stays a valid oracle.
|
||
|
||
**One sharp edge recorded:** `SeedOffset` quantises the site key by `×100 + 0.5`, so `0.0007f` maps to
|
||
site **0**. That is unique today — every other key in the plugin is ≥ 0.19 — but the next sub-`0.005`
|
||
key will collide silently. Two sites sharing an offset is a correlation, not a collapse; still, it is
|
||
a footgun in a helper whose whole job is decorrelation.
|
||
|
||
**Lesson, and it is the session's third instance of the same one:** *verify the premise before
|
||
reasoning from it.* "C1 is closed" was load-bearing for two days and was 1 site short. `grep` over a
|
||
spelling is evidence about the spelling.
|
||
|
||
---
|
||
|
||
### 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."
|
||
|
||
#### ✅ CONFIRMED BY READING, 2026-07-28 — the SDF cache DOES serve stale rooms *within* a strate
|
||
|
||
Raised as a suspicion while porting TunnelNetwork, then checked rather than believed. **It holds**,
|
||
on default settings, with no live edit and no unusual asset configuration.
|
||
|
||
The chain, each link read in the code rather than inferred:
|
||
|
||
1. **`GetGenerationParams` really does vary within one strate.** Its Gradient branch computes
|
||
`Alpha = 1 − DistFromBottom / EffectiveBlend` where `DistFromBottom = ChunkCoord.Z −
|
||
Slot.BottomChunkZ`, then returns `FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha)`.
|
||
`Alpha` is a function of **chunk Z inside the slot**, so two chunk Zs in the *same* slot get
|
||
genuinely different params. The top boundary mirrors it.
|
||
*(`VoxelStrateManager.cpp`, `GetGenerationParams`, the `case EVoxelStrateTransition::Gradient:`
|
||
arms — both of them.)*
|
||
2. **It varies in exactly the fields that place rooms.** `Lerp` expands the
|
||
`VF_STRATE_PARAM_FIELDS` X-macro, which lists `RoomSpacing`, `RoomDensity`, `MinRoomRadius`,
|
||
`MaxRoomRadius`, `RoomHeightRatio`, `RoomShapeVariety`, `SDFBlendRadius`, `CaveWarpStrength`,
|
||
the tunnel fields — every input `BuildChunkCache` reads.
|
||
3. **And it is the DEFAULT.** `UVoxelStrateDefinition::TransitionType = Gradient` and
|
||
`TransitionBlendChunks = 2`. So at each end of every strate there are two chunk-Z layers with
|
||
distinct params (`Alpha` 1.0 and 0.5), out of the box.
|
||
4. **`StrateIndex` cannot save it.** The memo resolves the *slot* index from the band centre, so it
|
||
is identical for every chunk Z within the slot. The cache key is `(XY box, StrateIndex, Seed)`
|
||
and the XY box does not change when the worker moves down a column. **No rebuild happens.**
|
||
|
||
⇒ A worker that generates `(X, Y, Z1)` and then `(X, Y, Z2)` in the same strate and the same XY box
|
||
evaluates the second chunk against **the room list baked from the first chunk's params**.
|
||
|
||
**Two consequences, and the second is the serious one:**
|
||
- a visible seam / wrong room sizes in the transition band, and
|
||
- **the result depends on which chunk that worker happened to build first.** That is a window-
|
||
invariance break (`ARCHITECTURE §8.4`) rather than a cosmetic bug, and in multiplayer it means two
|
||
peers can generate different geometry for the same chunk from the same seed — a direct
|
||
`OPSTACK-PLAN §2.6.1` violation on the *original* path.
|
||
|
||
**Why nothing caught it:** the test fixture sets `TransitionType = Hard` on every strate on purpose
|
||
(so "which archetype owns this chunk" stays unambiguous), which switches the blend off entirely.
|
||
The one configuration the tests never build is the default one.
|
||
|
||
#### ✅✅ SECTION FULLY CLOSED 2026-08-16 — the live-edit half was fixed too. Do not re-open.
|
||
|
||
The text above still reads as though `OC_Chunk`, `BM_Chunk` and `FChunkBiomeCache` were open. **They
|
||
are not.** Checked site by site on 2026-08-16, while about to spec a fix for them — the premise
|
||
reversed on reading, for the seventh time in this project. Every per-chunk cache in the plugin now
|
||
carries the layout version:
|
||
|
||
| cache | guard | site |
|
||
|---|---|---|
|
||
| `CP_Chunk` (density params + op stack) | `CP_Version` vs `GetLayoutVersion()` | `VoxelGenerator.cpp` ~613 |
|
||
| `OC_Chunk` (`GetSurfaceHeightAt` oracle) | `OC_Version` | ~2626 |
|
||
| `BM_Chunk` (`GetBiomeMaterialAt`) | `BM_Version` | ~3470 |
|
||
| `TC_BiomeCache` (the `ClassifyTile` grid) | `TC_SeenVersion` | ~2724 |
|
||
|
||
`FChunkBiomeCache` gained an explicit `Invalidate()` (`VoxelBiomeTypes.h` ~253) precisely because its
|
||
validity box says nothing about the `FBiomeContext` its cells were classified against; **all four**
|
||
`thread_local` instances call it on a version change. The only other two instances in the tree —
|
||
`VoxelContentManager.cpp` ~445 and the height-stack test — are **function-local**, constructed fresh
|
||
per task, so no staleness is possible by construction.
|
||
|
||
⇒ Both the determinism half and the live-edit half of C2 are closed. The remaining audit item on
|
||
this theme is **C9's library half** (`sinf`/`cosf` are not IEEE-754 specified), which is unrelated
|
||
and still open with 0 measured exposure.
|
||
|
||
#### ✅ FIXED 2026-07-28 (the SDF-cache half) — pending build
|
||
|
||
The params now travel into the key, and the shape of the fix is worth recording because the obvious
|
||
version of it was the wrong one.
|
||
|
||
`GetDensityWithParams` takes **two new required arguments**, `ParamsFingerprint` and `LayoutVersion`,
|
||
and both go into the `bNeedRebuild` test next to the existing `(box, strate, seed)`.
|
||
|
||
- **Required, not defaulted.** A caller that forgets must fail to compile rather than silently
|
||
inherit the hole — the same discipline that put `LayoutVersion` inside `FVoxelOpContext`.
|
||
- **The CRC is computed once per chunk, not per voxel.** `FCrc::MemCrc32` over ~300 bytes on the
|
||
hottest path in the plugin would have been a real regression; instead it is taken where the params
|
||
memo already lives (`CP_TunnelFP`, refreshed in the same block that refetches `CP_Tunnel`), so the
|
||
per-voxel cost is two integer compares.
|
||
- **Why not "add chunk Z to the key" (the alternative this section used to offer):** it is not
|
||
actually cheaper *and* it is not sufficient. `Interleaved` makes `Alpha` depend on chunk **XY**
|
||
too, and chunk XY is *not* pinned by the existing key — the box deliberately outlives the chunk so
|
||
that `WorldX ± 1` gradient probes don't thrash it (`ARCHITECTURE §8.10`). Pinning chunk XY to fix
|
||
the params would have destroyed that invariant. The fingerprint fixes the cause and leaves the box
|
||
reuse intact: what forces a rebuild now is a *real* params change, once per chunk in a transition
|
||
band, which is the number of rebuilds this cache should always have done.
|
||
|
||
The three test call sites pass `VF_FP(P)` so the **oracle no longer shares the defect under test** —
|
||
see the rewritten note at check 3 of `VoxelForgeOpStackTunnelTest.cpp`.
|
||
|
||
**Still open in this section:** the `OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache` sites listed above.
|
||
Those are the live-edit staleness half, not the determinism half, and they are untouched.
|
||
|
||
The operator-stack port does **not** inherit this: `FRoomGraphSource` folds a `FCrc::MemCrc32`
|
||
fingerprint of the params (plus `LayoutVersion`) into its key, so differing params force a rebuild.
|
||
`VoxelForge.OpStack.TunnelNetworkSpineEquivalence` check 3 exercises exactly that — and deliberately
|
||
does *not* compare against the original there, because the original would fail it.
|
||
|
||
**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<FVector>`.
|
||
|
||
---
|
||
|
||
### 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<FIntVector, TArray<uint8>>` 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 🔴 **NOW THE TOP OPEN RISK — promoted 2026-07-27**
|
||
|
||
> **Why this outranks everything else in this document now.** Jahni stated the actual acceptance bar:
|
||
> *"only having it 99.99% at worst reproducible if two people share the same seed, since everyone
|
||
> rebuilds it on multiplayer."* That is **precisely and only** the guarantee this entry says
|
||
> `/fp:fast` weakens. Fidelity to the old world was dropped as a requirement (`§C10`); peer agreement
|
||
> replaced it as the single bar. **This entry is now load-bearing.**
|
||
>
|
||
> **The concrete case to decide:** a **Linux dedicated server** generating collision geometry and nav
|
||
> against **Windows clients** compiles the same density code under *opposite* float models
|
||
> (`ClangToolChain` → precise, `VCToolChain` → `/fp:fast`). That is not a theoretical divergence.
|
||
> Same-platform peers on the same build are fine.
|
||
>
|
||
> ### ✅ HALF FIXED 2026-07-27 — and the other half is a different mechanism
|
||
>
|
||
> **`FPSemantics = Precise` is set** (IWYU debt cleared to allow it). MSVC now gets `/fp:precise`,
|
||
> Clang gets `-ffp-contract=off`; both are IEEE-754 with no contraction, so **the compiler half is
|
||
> closed by construction.** It also dissolved `§C10` entirely.
|
||
>
|
||
> **⚠️ THE LIBRARY HALF IS STILL OPEN, and I nearly missed it by assuming.** After setting Precise I
|
||
> reasoned the cross-platform risk was gone. It is not:
|
||
>
|
||
> **`sinf`/`cosf` are not specified by IEEE-754.** MSVC's CRT and glibc's libm may legitimately
|
||
> return different results (typically ≤ 1 ULP). And `FMath::Sin`/`FMath::Cos` are used throughout the
|
||
> density path — layer lines (`VoxelGenerator.cpp:2275`, `VoxelHeightOpStack.cpp:232`), ribs (`:1357`),
|
||
> room placement (`:1520`), rotations (`:362`, `:408`, `:1663`). So a Windows host and a Linux client
|
||
> can still disagree, just by ~1 ULP instead of by a whole reassociation.
|
||
>
|
||
> **Measured, not assumed:** `CrossPlatformDigest` now reports a *profile* — samples within 1e-4 /
|
||
> 1e-5 / 1e-6 of the isosurface. Only the tight band matters (a libm delta is ~1e-6 absolute on
|
||
> densities of magnitude ~10), and the first run showed **2 / 115 000 within 1e-4** — i.e. the wide
|
||
> band, which over-states the risk by ~100×. The tight-band number is the one to watch.
|
||
>
|
||
> **If it must be zero:** a deterministic in-house `sin`/`cos` in the density path (a polynomial with
|
||
> defined rounding), which costs one more world re-tune. **Not another build flag** — no compiler
|
||
> setting can make two different libm implementations agree.
|
||
|
||
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 are not bit-identical in practice.** See `§C10` for the measured detail: the
|
||
Maze port reproduces the original's SDF *bit for bit* but its final density differs by 1-2 ULP on
|
||
~2% of samples, with zero isosurface crossings. The acceptance criterion is 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.
|
||
|
||
---
|
||
|
||
### C10 — The op-stack ULP residue: ~~PARKED~~ **CLOSED PERMANENTLY**, 2026-07-27
|
||
|
||
> # ✅ SOLVED, 2026-07-27 — and the cause was `/fp:fast`, exactly as hypothesis 3 said.
|
||
>
|
||
> **Setting `FPSemantics = Precise` (see `§C9`) made both `MazeEquivalence` and `SlabEquivalence`
|
||
> report BIT-IDENTICAL.** The residue is gone — not tolerated, *gone*.
|
||
>
|
||
> **So hypothesis 3 had the right mechanism and every isolation experiment built on it was doomed.**
|
||
> Under `/fp:fast` the compiler may reassociate and contract *based on surrounding context*, with
|
||
> **no single isolable axis** — which is precisely why five carefully-designed one-variable tests
|
||
> (FVector round-trip, transcription, cross-TU, inlining, const-vs-runtime) all came back negative
|
||
> while the difference stayed. There was no variable to find. Removing the *permission* removed the
|
||
> difference.
|
||
>
|
||
> ### The lesson, and it is not the one I expected
|
||
>
|
||
> **The answer arrived for free, from work done for an unrelated reason.** Nobody solved C10; C9 got
|
||
> fixed because Jahni wants Linux/Windows cross-play, and C10 fell out of it. Six more builds spent
|
||
> hunting would have found nothing, because the thing that resolved it was a *build setting nobody
|
||
> was looking at* while I was busy bisecting source code.
|
||
>
|
||
> **The decision to stop was therefore right on its own terms and right in hindsight** — the
|
||
> information was not obtainable by continuing along that path at any price. Park a question whose
|
||
> every consequence is measured and benign; it may well be answered later by something else.
|
||
>
|
||
> **What this changes going forward:** ports can now aim for and *achieve* bit-identity, so the
|
||
> equivalence tests are much sharper instruments than they were — any diff at all is now a real
|
||
> finding rather than noise to be graded. The ULP-grading machinery in both tests is kept, because it
|
||
> is what would tell us if this ever regresses.
|
||
|
||
**Status: accepted and closed by decision (Jahni), not by explanation.** Do not reopen this without
|
||
reading the whole entry — five hypotheses have already been measured and refuted, and re-deriving
|
||
them costs a build each.
|
||
|
||
**The observation.** `VoxelForge.OpStack.MazeEquivalence`: the ported Maze operator stack differs
|
||
from `GetMazeDensity` on ~2% of samples (454/20000) by 1-2 ULP. Deterministic — same samples, same
|
||
delta, same coordinates on every run.
|
||
|
||
**What is PROVEN by measurement, and is the reason this is benign:**
|
||
|
||
- **Zero isosurface crossings out of 20000.** Not one triangle would move. The two are
|
||
*geometrically identical*.
|
||
- **The SDF is reproduced BIT FOR BIT** — 126/126 of the mismatches, and `stack SDF != verbatim SDF`
|
||
counted unconditionally came back **0**. So the lattice sweep, the edge hashes, the `{-1,0}³`
|
||
node set and `VoxelSDF::Capsule` are all exactly correct. The port has no logic error in the part
|
||
that shapes the world.
|
||
- The entire difference is born in the final SDF→density conversion, amplified because `Blend - Sdf`
|
||
cancels catastrophically at the edge of the blend shell.
|
||
|
||
**What was tested and REFUTED** (each cost a build; listed so nobody repeats them):
|
||
|
||
| # | Hypothesis | Refuted by |
|
||
|---|---|---|
|
||
| 1 | `FVector` float→double→float round-trip in the noise coords | identical result after the change |
|
||
| 2 | A transcription slip in the roughness window / carve blend / octaves | a four-stage bisect: residue survives into `corridors + carve ONLY` |
|
||
| 3 | `/fp:fast` reassociating across **translation units** | three-way test: generator TU == test TU exactly (0 differ) |
|
||
| 4 | Different **inlining context** (virtual call vs straight-line) | `FORCEINLINE` vs `FORCENOINLINE` in one TU: 0 differ |
|
||
| 5 | **Compile-time-constant** `Blend` vs runtime member | const and runtime forms bit-identical to each other; both miss the verbatim on the same 126 |
|
||
|
||
**Where that leaves it.** Two carve implementations, character-identical, in the **same translation
|
||
unit**, fed a **provably identical** input, produce outputs differing by 1 ULP on 126 of 5000. For
|
||
deterministic code that is only possible if they compile to different instruction sequences — which
|
||
is exactly what `/fp:fast` permits, based on surrounding context, with no single isolable axis. So
|
||
hypothesis 3 was right about the *mechanism* and wrong about every clean variable proposed for it.
|
||
|
||
**The one experiment that would settle it** is building this module with
|
||
`FPSemantics = FPSemanticsMode.Precise`. **It is blocked**: doing so costs VoxelForge the engine's
|
||
shared PCH and exposes ~30 missing includes across seven files (see the note in `VoxelForge.Build.cs`).
|
||
Clearing that IWYU debt is worth doing on its own terms; it is not worth doing to chase 1 ULP.
|
||
|
||
**The operational rule that DOES matter, and is the real takeaway:**
|
||
**never run the archetype `switch` and the operator stack in the same world, and never compare their
|
||
outputs for equality.** A half-migrated strate would produce a seam. This is **not** a client-desync
|
||
risk — within one binary the field is proven bit-pure across threads and query order
|
||
(`VoxelForge.Determinism.DensityPurity`) and every peer runs the same path. The genuine
|
||
cross-platform concern is `§C9`, which stands independently.
|
||
|
||
---
|
||
|
||
### 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)*
|