Files
VoxelForge/AUDIT-2026-07.md
T
Fr0zka 03ddcde334 feat(opstack): FRoomGraphSource::EffectOverBox answers spatially -- and AUDIT C2 is fixed
The chain no longer dies at the room source. The criterion is the per-voxel cull
lifted from point to box, which can fail for exactly one reason: Eval starts at
MinSDF = FLT_MAX and only lowers it through a primitive that survives its own
cull, so if no cached primitive survives its cull anywhere in the box, Sdf stays
FLT_MAX across the whole box.

FSdfConvertOp already returned Identity ("la source a repondu pour la paire") and
the twelve detail modifiers already inherited through VF_NoCaveOverBox. One
function learned to answer and fourteen operators became provable -- what the C1
wiring was built for.

Three deliberate choices, all erring toward CPU rather than toward a hole:
- |Perlin3D| <= 2, derived from GradDot + the convex hull of a trilinear lerp,
  instead of the header's observed "~[-1,1]". A verdict resting on an observation
  is the hole this file spends its life avoiding.
- the op pool is passed to BuildChunkCache, not nullptr: the bake reads OpParams
  to place pits and chimneys, so nullptr would under-bound the cache and could
  return Identity over a real pit.
- the search box is wider than Eval's, giving a superset of primitives.

The verdict is memoised per box (all twelve modifiers ask the same question), and
the cache is a SECOND per-worker cache so classification cannot disturb a live
generation's hot cache.

AUDIT C2, confirmed 2026-07-28, is fixed on the switch path in the same breath:
GetDensityWithParams now takes required ParamsFingerprint + LayoutVersion. The
alternative this audit section used to recommend -- add chunk Z to the key -- is
insufficient (Interleaved makes Alpha depend on chunk XY too) and destructive
(chunk XY is deliberately absent so gradient probes don't thrash the box, ARCH
8.10). The CRC is taken once per chunk where the params memo already lives, so
the per-voxel cost is two integer compares. The three test call sites pass it
too, so the oracle stops sharing the defect it tests.

Check 4 of the tunnel test no longer asserts "0 proved" -- that assertion would
now forbid the gain. It brute-forces every proved tile voxel by voxel instead and
reports the count, because a false verdict leaves no geometry and no collision
behind it.

The second debt (per-room ops can raise a modifier's amplitude above the strate
params a box bound reads) turned out to be DORMANT, not live: where the source
proves Identity the modifiers' gate never opens, and where it answers Both it
supplies no MaxCarveOverBox so nothing is provable anyway. It goes live the day
the source gains one. Written at the site.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:48:18 +02:00

1027 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
#### ✅ 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.af and T2.ae 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.25.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)*