The bisect settled it. The difference survives every stage removal down to
"corridors + carve ONLY", which is character-for-character transcribed code, so
it is not in anything the decomposition added.
Cause, read out of the engine rather than assumed (VCToolChain.cs):
case FPSemanticsMode.Default: // Default is imprecise FP semantics.
case FPSemanticsMode.Imprecise: Arguments.Add("/fp:fast"); break;
with UBT's own doc: "the compiler is allowed to transform math expressions in
ways that might result in differently rounded results". Identical source in two
translation units may reassociate differently, worth ~1 ULP. It shows up on
exactly the ~2% of samples inside the SDF blend shell, where Blend - Sdf
catastrophically cancels; outside it Carve is exactly 0 or 1 and both agree.
So MazeEquivalence now grades what it can actually assert:
- hard fail : any isosurface crossing (geometry moves)
- info : differences at ULP scale (the unavoidable floor)
- warn : anything larger, which IS port drift, and runs the bisect
A test that warns on every port would get ignored by the port that matters.
Recorded in OPSTACK-PLAN 2.6, and as AUDIT C9 for the part that outlives this
refactor: ARCHITECTURE 9.1's "every peer regenerates identically" holds only
between bit-identical binaries under /fp:fast. Fine for one build on one
platform; a real desync source for a Linux server plus Windows clients both
regenerating authoritative geometry. The FPSemantics::Precise knob exists but
must not be turned speculatively -- it blocks the vectorisation T2.a was chasing,
on the hot loop, for an unmeasured cost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
41 KiB
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
.gitignoreonly keepsSource/,CODEMAP.mdand 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:
- ~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". - The design record isn't in version control.
ARCHITECTURE.md,fable-idea.md,REVIEW_FINDINGS.md,CLAUDE.mdare all excluded by.gitignore. OnlyCODEMAP.mdsurvives. 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.mdandARCHITECTURE.mdstill 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.
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— theGetSurfaceHeightAtoracle cache (:2420)BM_Chunk—GetBiomeMaterialAt(:3045)FChunkBiomeCache::Contains— keyed on(box, ChunkZ, Seed)but not the biome context, so editing a strate'sBiomes[]leaves stale cells
Note the asymmetry: the SDF cache (CachedSeed) and the surface-column cache (GSurfColCache, keyed on
Seed) are seed-guarded, and the strate-index memo is version-guarded (SI_Version). So
ChangeSeed mostly survives; live-edit is where this bites. Symptom: "I tweaked the strate asset,
regenerated, and one patch kept the old shape."
Fix: the getter already exists and is already used elsewhere —
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 */ }
MaxModificationsdefaults to 0 = unlimited.- No compaction, no rasterization to a sparse voxel grid, no spatial subdivision within a chunk.
- No serialization (
fable-ideaF4 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 wholeRes³array and issues a fullRHIUpdateTexture3Dper dirty level.BlitCaptureToWindowsetsbGPUDirty = trueon every tile ingest, so level 0 is dirty essentially every frame a tile lands.
At the default Res = 128 that's 2 MB CPU copy + 2 MB GPU upload per level per frame during
streaming; at 192 it's ~7 MB. The code itself flags it: "Sub-box upload (with toroidal wrap-splitting)
is a later optimisation."
CONFIRMED 2026-07-26 (Jahni): the lighting system is deprecated for now. So this is not speculative — the volume, the fill thread, the capture path and the per-frame texture upload are all running to feed a consumer that no longer exists.
Action: set bEnableDensityVolume = false. Straight cost deletion, no behavioural change. If the
volume is revived later, the sub-box upload (with toroidal wrap-splitting) must land first — a full
Res³ re-upload per frame was never meant to be the steady state.
Also worth deleting while it's cold rather than leaving it half-live: UpdateTerrainMaterialParams
still recomputes and pushes TVP0..TVP9 onto every terrain MID each Tick, and ApplyMeshToTile still
routes every material through GetOrCreateTerrainMID when the volume is enabled. With bEnable... off,
both self-disable — but the code paths stay as a trap for whoever reads them next.
C8 — Documented invariant that isn't checked: OriginRoomRadius vs the COLLECT margin
BuildChunkCache guarantees window-invariance by collecting rooms within
CollectMargin = 2·MaxTunnelLength + MaxInfluence. The origin room is special-cased: it's also added when
RoomReachesSearchBox(...) succeeds even if (0,0) is outside the collect box. In that case
NearestNeighbor[OriginIdx] and the OriginDowngraded ranking are computed over a different candidate
set than a chunk near (0,0) computes → different tunnel decisions → a discontinuity ring at roughly
CollectMargin from origin.
Cannot fire at defaults (OriginRoomRadius = 20, MaxTunnelLength = 200 ⇒ margin ≈ 434). It becomes
reachable only with a very large origin room and short tunnels. Flagging it because the tooltip invites
"30+ → massive starting cavern" and there's nothing stopping someone typing 500.
Fix: an ensureMsgf(OriginRoomRadius < CollectMargin) in BuildChunkCache, plus a line in
ARCHITECTURE §8.4.
C9 — The build uses /fp:fast, and the MP plan assumes bit-reproducible terrain ⚠️ added 2026-07-27, measured not assumed
Found while chasing a 1-ULP difference between GetMazeDensity and its operator-stack port. The
difference survived a full bisect down to code that is character-for-character identical, which
pointed at the toolchain rather than the port. It is:
// 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.
Two consequences, one benign and one not:
Benign — refactors cannot be bit-identical. The same expression compiled into two translation
units may reassociate differently, worth ~1 ULP. So "the port reproduces the original exactly" is
not an achievable bar for the op-stack work, and OPSTACK-PLAN §2.6's bar (recognisably the same
place, judged on a screenshot) was the right call for reasons beyond the ones it gave. The
acceptance criterion is now encoded in VoxelForge.OpStack.MazeEquivalence: hard-fail on any
isosurface crossing, tolerate ULP-scale deltas, warn on anything larger.
Not benign — ARCHITECTURE §9.1's multiplayer model rests on this. The plan is "replicate the
seed + layout + diff, never the geometry; every peer regenerates identically." That guarantee is
only as strong as the floating-point reproducibility of the density path, and under /fp:fast it
holds only between bit-identical binaries. Same build, same platform: fine — DensityPurity
proves the field is pure across threads and query order. But a different compiler version, a
different optimisation level (Debug vs Shipping), or a different platform's toolchain may produce a
subtly different world from the same seed.
The docs already reject GPU density partly because "cross-GPU float determinism is fatal for replicate-the-seed". The same argument applies to the CPU across build variants, and had not been stated.
How much it matters depends on a design question that is Jahni's, not the audit's:
- If listen-server only, one binary per platform, and clients never regenerate authoritative geometry — this is a non-issue. Ship as is.
- If a dedicated server on Linux and Windows clients both regenerate terrain and compare — this is a real desync source, and it will present as rare, unreproducible, geometry-only divergence: approximately the worst bug class to diagnose.
The knob, if it turns out to matter: ModuleRules.FPSemantics = FPSemanticsMode.Precise in
VoxelForge.Build.cs restores IEEE semantics for this module only. Do not do this speculatively —
the density path is the plugin's hot loop, /fp:precise blocks exactly the vectorisation and
contraction that T2.a's SIMD noise work was chasing, and the cost is unmeasured. It is a decision to
take with a profile in hand and a confirmed cross-platform requirement, not a tidy-up.
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:
ProcessQueueis correctlyMpsc, with the failure mode documented at the declaration.- The
FTaskGuardRAII decrement covers every early-exit path inLoadTile. - Epoch carrying is consistent across all four async paths (tile gen, deco march, volume fill, band re-gen).
DiffLayer'sFRWLock+ lock-freebHasAnyModsfast-reject + per-(chunk,version) snapshot is a genuinely good piece of engineering, and the comment records the AV it fixed.BuildCellSpawnsis correctlystatic— no implicitthison the worker.VoxelGenLOD::OctaveBiasisTGuardValue-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-ideaalready 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-idearejects it for readback latency, CPU-side collision, and cross-GPU float determinism. All three are still true, and the last one is fatal for a game whose MP model is "replicate the seed, regenerate identically on every peer". Right call, right reasons.
The verdict is: your mesher isn't the problem. T1.a–f and T2.a–e genuinely took per-tile generation into diminishing returns. The remaining cost lives elsewhere (C3, C7) and in the scaling cliffs below.
Scaling cliffs nobody has hit yet
BuildChunkCache is O(N²) in rooms, and N grows quadratically with the tunnel/spacing ratio.
PickNeighbor is O(N) per room and the connection loop is O(N²), over the COLLECT box which spans
2·MaxTunnelLength + MaxInfluence. So room count N ≈ ((2L + I)/S)² and the graph build is
O((L/S)⁴):
MaxTunnelLength |
RoomSpacing |
≈ rooms in COLLECT | pair iterations |
|---|---|---|---|
| 200 (default) | 80 (default) | ~40 | ~800 |
| 300 | 60 | ~140 | ~10,000 |
| 400 | 40 | ~500 | ~125,000 |
Per cache rebuild, per worker. Amortised over 32,768 voxels it's survivable at defaults; at row 3 it
isn't. Worth a comment on MaxTunnelLength and a soft clamp.
AnyPassageNearBox walks every passage per tile with no spatial index. Fine at 10-20 passages; grows
linearly with TotalStrates × Connections.
FindSlotIndexForChunkZ is a linear scan called extremely often — per lattice-Z in ClassifyTile,
per archetype param getter, on every GetDensityAt chunk change. The comment acknowledges it ("with
~10-20 strates this is fine"). It is. But it's the classic death-by-a-thousand-cuts profile entry.
GetDominantBiomeAt is fully uncached: it builds an FBiomeContext (heap-allocating a TArray) and
runs the warped Voronoi + up to 2 ClassifyBiomeAtSite (4 fBM evals). AtmosphereManager::UpdateForPlayer
calls it every Tick, unconditionally. Small, but it's a per-frame game-thread allocation for a value
that changes maybe once a minute.
The external development you should know about: RealtimeMeshComponent 6.0
You're on RMC 5.3.2. RMC 6.0.0 shipped June 2026 with:
- Runtime Nanite — "real cluster groups, full LOD DAG, per-page streaming. No cooked bake, no
UStaticMeshround-trip." - Lumen card generation + distance field support (Pro tier, $49 one-time).
- UE 5.2–5.7 support (you're on 5.7).
This matters more to VoxelForge than to most plugins, because it potentially subsumes three separate hand-built systems:
| Your system | What RMC 6.0 offers instead |
|---|---|
Clipmap levels + CoarseTileCells + LODOctaveDrop + skirts |
Nanite's own LOD DAG, no seams by construction |
F18 sheet ring (GenerateSheetMesh, XY hole, band re-queue) |
far-field falls out of Nanite streaming |
| Density volume + raymarched mini-sun shadows (currently gated off) | runtime Lumen cards + distance fields |
I am not saying "rewrite on Nanite" — runtime Nanite on procedurally-remeshed geometry is new, and digging (constant re-meshing) is the adversarial case for cluster building. But this is the single biggest change in the landscape since your architecture was designed, and it argues strongly for a timeboxed spike before building any more LOD machinery. Measure: build time per tile, re-mesh cost on carve, and whether skirts/sheets become unnecessary.
(For reference on the alternative path: Voxel Plugin 2.0 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:
- A
VF_CACHE_KEY(...)macro that bundles(coord, seed, layoutVersion)so a new cache can't forget the standard fields. - 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. - Extend
ValidateDeterminismto 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.
ClassifyTilesoundness: for random tiles, if the verdict isAllSolid/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.DiffLayerunder 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
FStrateGenerationParamsis a 74-field god-struct, and blend correctness depends on remembering to add every new field toVF_STRATE_PARAM_FIELDS. The X-macro was the right fix; make drift a compile error by puttingstatic_assert(sizeof(FStrateGenerationParams) == N, "add the new field to VF_STRATE_PARAM_FIELDS")right next to it.- Chunk-quantised param blending.
GetGenerationParamscomputes the Gradient/Interleaved blendAlphafromChunkCoord, 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(onlyUp/Downare read),CeilingViewMultiplierandCeilingBandChunks(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. EVoxelPassageTypevsEVoxelPassageStyle— two overlapping passage-shape enums, both live. Already open inREVIEW_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-lineSTEPS: 1. Try to dequeue...block inside the now-finishedProcessPendingChunks. Cosmetic, but it's the first thing a reader hits. .upluginmetadata:"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:
- The numbers have drifted.
CODEMAP.mdheader 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). SinceCLAUDE.mdalready 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. - 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. AHISTORY.mdfor 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_FINDINGSperf 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:
- Build. Fix compile errors.
- Run
ValidateDeterminismat several locations (including far from origin). bEnableDensityVolume = false→ trace → compare (C7).- Set
Seed = 1234567→ look (C1). - Live-edit a strate asset, regenerate, look for patches that kept old params (C2).
- 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 .mds (!*.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):
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 becomesMixed→ 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 callsFractalNoise3D(..., 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 > Nearly-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.
SurfaceDensityFromColumnis now the hot path, not the SDF morphology. The overhang block already re-runsSampleSurfaceStructuralZper 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