Files
VoxelForge/AUDIT-2026-07.md
T
Fr0zka ef5bda3d8a feat: TunnelNetwork stage A — the SDF spine, wrapping BuildChunkCache
The last archetype is ~1080 lines with 13 detail modifiers, a two-region
cache and a per-room op override. Porting it whole before anything can be
verified is ~600 unverified lines on top of ~200 — the pattern this
refactor has dodged six times. So: three stages.

Stage A = vertical scale, base rock, cave warp, room graph (+ pits and
chimneys), carve, worms, structural post. 6 ops. It is verifiable NOW
because every detail modifier is amplitude-gated and FStrateGenerationParams
already defaults them all to zero — zeroing SurfaceRoughness sends the
ORIGINAL down exactly the path stage A ported.

TunnelNetwork stays OFF in UsesOperatorStackForChunk until stage C.

The decision that matters: FRoomGraphSource CALLS BuildChunkCache and
EvaluateSDFCached rather than transcribing them. That is where §8.4's
two-region window-invariance discipline lives; a transcription would fork
it, and the fork would be "validated" by a test comparing it to the
original. Only the ~60 lines of glue are transcribed.

FRAME ops are retired. All three candidates are now ported and none needed
one: CaveWarp's scope is exactly one operator (pits/chimneys read unwarped
coords), VerticalScale is a one-line pure function, and the island warp was
already local. Not missing infrastructure — one idea seen three times from
a distance.

Also: check 3 was going to compare two interleaved param sets against the
original, which would have FAILED — the original's SDF cache key has no
params, so it serves B the rooms it built for A. Comparing there measures
its bug, not the port. Rewritten against each stack evaluated alone. The
same reasoning suggests a live production staleness across Gradient
transitions; filed in AUDIT §C2 as SUSPECTED with the check that would
confirm it, since it rests on a premise I have not verified.

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

56 KiB
Raw Blame History

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.:

// 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):

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.

// 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.1fWorldX * 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:

// 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:

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_ChunkGetBiomeMaterialAt (: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."

⚠️ SUSPECTED, NOT PROVEN, 2026-07-28 — the SDF cache may serve stale rooms within a strate

Found while porting TunnelNetwork, and stated as a suspicion on purpose: I have reasoned it, not measured it.

GetDensityWithParams' SDF cache key is (XY search box, StrateIndex, Seed). It contains no params and no chunk Z. Meanwhile GetGenerationParams blends params between neighbouring strates across a Gradient transition — so two chunks at different Z inside the same strate can carry different RoomSpacing/RoomDensity/… while sharing an XY box, a strate index and a seed.

If that is right, a worker descending a transition band gets no rebuild and evaluates the lower chunk against the upper chunk's room layout. Same family as §C2 above and as the overhang regression of 2026-07-27; the difference is that this one needs no live edit to trigger.

What would confirm it: call GetGenerationParams for two adjacent chunk Zs inside a Gradient-transitioned strate and compare the room-placement fields. If they differ, the cache is being reused across a real param change. Do that before acting — the whole thing rests on "Gradient blending actually varies within a strate", which I have not verified.

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 —

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:

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:

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:

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:

// 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:

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) 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 meshingfable-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 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 now targets 5.6/5.7 and its 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.1ClassifyTile guard for 3D caves ~30 lines blocks the current feature; a false AllSolid is an invisible, collision-less cave
2 P1 — track the .mds (!*.md) 2 min irreplaceable if lost; the docs are the design record
3 C7bEnableDensityVolume = 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 C3PerlinNoise2D 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):

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 · triaxis.games/realtime-mesh · TriAxis-Games/RealtimeMeshComponent · docs.voxelplugin.com · VoxelPlugin/VoxelCore · UE 5.7 release notes