Compare commits

..

105 Commits

Author SHA1 Message Date
Fr0zka 588f9e0294 docs: Sol investigation -- refutes my boundary-tile hypothesis, corrects my false accusation
Sol High investigated with Luna xHigh sub-agents; two approaches built in
isolated worktrees, main tree untouched.

Two corrections to my own assertions:

1. "One un-ticked neighbouring strate kills boundary tiles" is WRONG.
   UsesOperatorStackForChunk depends only on ChunkCoord.Z, and ClassifyTile's
   earlier Z loop already required the predicate for every sampled cave Z within
   one slot -- so the later XYZ sweep is redundant, not over-strict. Removing it
   unlocks nothing; boundary tiles still fail the one-slot/type/params guards,
   correctly.

2. I falsely wrote that VF-03's fixture citation was fabricated. It exists
   (VoxelForgeTestFixture.h ~134/146, explicit about CP_UseOpStack
   contamination). I had read only the 30-line header and asserted a negative
   from a partial read. Header corrected. VF-03's core claim is now CONFIRMED:
   GetDensityAt keys thread_local CP_* by (ChunkCoord, LayoutVersion) with no
   world identity, and every manager's version starts equal.

Also: the Cave Bail Not Op Stack counter I specced is ambiguous -- it fires
before the different-slot attribution, so it cannot distinguish "flown slot
un-ticked" from "boundary tile met an un-ticked slot first". My 80% reading was
over-confident. Same defect I fixed for TilesSkippedAllSolid, reintroduced one
layer down.

Approach A recommended (explicit opt-in + six-box LRU) over B (code-enforced
cutover): the code does not justify weakening ClassifyTile, and B would delete
the flag, which is the only same-route A/B lever available to prove the
refactor. Narrow A's warning to cave archetypes before adopting.

Nothing built. Worktrees left in place for review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:22:06 +02:00
Fr0zka 4ba53f2ede docs: T1.d is blocked by CONFIG not code (Cave Bail Not Op Stack = 80%); retract memo claim
Part B paid for itself on its first flight. Over the caves, Tiles Classified
== Tiles Meshed (nothing skipped at all) and CaveBailNotOpStack accounts for
~80% of cave-branch attempts. That counter has exactly one cause:
UsesOperatorStackForChunk returned false -- so either the strate flown over has
not ticked bUseOperatorStack, or a chunk in the tile's box belongs to an
adjacent strate that has not, since the per-chunk sweep requires every chunk to
agree. CaveBailStackVerdict being non-zero (0.32) corroborates: where the flag
did hold for the whole box, the stack built fine and merely returned Mixed.
The machinery works; it is mostly not being reached.

RETRACTION: I called the column-memo port a regression after comparing 14.7%
from one flight against 23.3% from another. Invalid -- the miss rate is
strongly location-dependent (~10-30%) and was in that band before the port.
Comparing single-flight averages from different routes measures the route. A
real before/after needs the same seed and route; parked until then, and not
urgent.

Kept while diagnosing: GSurfColCache is a 6-box LRU, not one box. My spec said
"a direct-indexed box" and Codex implemented one faithfully, so any recentre
now wipes all 6561 cells where the old table lost one entry. Plausible thrash
source, unmeasured. Port the LRU if the memo is revisited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:36:40 +02:00
Fr0zka 3ad2720d0a perf(opstack): direct-indexed column box (measured 14.7% miss); + T1.d bail attribution
BUILD RESULTS FIRST: 14/14 tests green, 0 violations. VerticalShafts box
verdicts went 0 -> 30 of 60 (zero was the number for the project's whole life).
TunnelNetwork production held at 11 proved / 14641 voxels, byte-identical --
that line was the isolated diagnostic for cab8e8f, so its stillness proves
Jahni's room/tunnel Min <= Max are correctly ordered. All eight equivalence
tests bit-identical, which TESTS the "bounds only, cannot reach density" claim
made on 7dbdf51 / eaa44bf / cab8e8f rather than asserting it.

PART A -- the column memo thrash is confirmed quantitatively. Measured 14.74%
miss rate against a prediction of 14.0% if thrashing and 1.4% if not; and 8,300
recomputes per tile against a healthy 1,225 (6.8x) from an independent
statistic. Replaced the 4096-slot direct-mapped hashed table with the
direct-indexed box scheme GSurfColCache has always used: no hash, no
collisions, every column computed exactly once. ParamsFingerprint is retained
in the box key -- its absence was the shipped bug that silently deleted the
overhang, and GSurfColCache's own omission of it was deliberately not copied.

PART B -- T1.d never fires in game: the two op-stack counters never appeared,
and since every displayed row has Min 1.00 rather than 0.00, rows only render
when a counter fires. The cave branch has 13 return-Mixed paths; guessing which
is the mistake this project keeps paying for. Six attribution counters now name
the bail category outright. Only edits there are brace-expansions so a counter
fits before each existing return -- every condition and returned value is
byte-identical.

Open falsifiable hypothesis: Tiles Meshed averages 2.13/frame, plausibly the
reason LOD rings update slowly. If misses drop ~10x and LODs speed up, the memo
was the ceiling; if not, they are separate problems, cleanly separated.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:28:46 +02:00
Fr0zka 49a9959aed fix(world): drain workers before mutating strate layout/passages (VF-01)
Initialize does StrateLayout.Empty() and Passages.Empty()/Add() -- freeing and
reallocating both -- with no guard, while mesher workers read them through
AnyPassageNearBox, EvaluateModifierSDF and FindSlotIndexForChunkZ. The epoch is
bumped AFTER, so previous-epoch workers are live during the mutation; the epoch
rejects a finished result, it cannot make a read of a freed allocation safe.
Same class as the DiffLayer.ChunkMods carve-vs-stream AV that ModsLock fixed.

Chose the drain over the two alternatives:
  - FRWLock on the arrays: correct, exact in-repo precedent, but a read lock on
    the per-voxel hot path (~43k/tile) would contaminate the perf measurement
    CODEX-TASK-001/002 are queued to take. Worst possible timing.
  - Immutable generation snapshot (Sol's proposal): right long-term, refactors
    the whole StrateManager API surface. A design conversation, not an agent's
    unprompted call.
  - Drain: zero hot-path cost, reuses the machinery EndPlay already proved, and
    its stall lands only on human-initiated editor actions, never during play.

FScopedGenerationPause raises a new bGenerationPaused (deliberately NOT
bShuttingDown, which means teardown) and drains both reader populations: chunk
tasks via ActiveTaskCount and decoration tasks via a new
WaitForDecorationTasks, whose refactor also removed NotifyShutdown's duplicate
spin loop. On timeout it does NOT mutate -- logs an Error and leaves the world
consistent. A timeout that proceeds is what the bug already does.

RebuildStrates, OnObjectModifiedInEditor and ChangeSeed cannot reach Initialize
without the pause. BeginPlay untouched (no tasks yet). EndPlay untouched --
VF-02's 3s timeout is a separate deliberate decision.

Verified: four files, no density/mesher file, so terrain cannot move.
ShouldAbortWork replaced three existing checks, adding none. No worker path
waits on the game thread, so the drain is bounded.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:51:19 +02:00
Fr0zka bc0bf83c64 docs: VF-01 confirmed (worker race on rebuild) + VF-10 triaged; hold further code
VF-01 CONFIRMED and it is the most serious find of the day. Initialize does
StrateLayout.Empty() and Passages.Empty()/Add() -- freeing and reallocating both
-- with no lock, barrier or drain anywhere in that file, while worker threads
read the same arrays through AnyPassageNearBox (:460), EvaluateModifierSDF and
FindSlotIndexForChunkZ, reached from GetDensityAt/ClassifyTile. RegenerateAllChunks
bumps the epoch AFTER the mutation, so previous-epoch workers are live during it;
the epoch rejects a finished result, it cannot make a read of a freed allocation
safe. Same class already fixed once here: DiffLayer.ChunkMods got ModsLock after
a carve-vs-stream AV. The dangerous call site is OnObjectModifiedInEditor, which
fires automatically on a strate asset edit while streaming -- routine here.

VF-10 confirmed real (74 fields, per near-surface sample) but Sol missed the
conclusion: the copy is INHERITED from GetDensityWithParams, so both paths pay it
and it does NOT explain the op-stack regression. The op stack improved it by
memoising across eleven detail ops. Future optimisation, not the answer.

Deliberately NOT fixing VF-01 now. Four code changes are stacked unbuilt and
three have "nothing should move" as their acceptance signal; a fifth change to
streaming lifecycle would make an odd build result unattributable. The three
fix options (drain / snapshot / RWLock) trade a possible crash for an editor
hitch or hot-path lock traffic -- a product decision, not an agent's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:38:09 +02:00
Fr0zka cab8e8fe55 fix(morphology): BuildChunkCache under-bounds room/tunnel collection when Min > Max
Third instance of the same class, and the worst one -- found by the Sol-High
read-only audit (VF-05) and verified against the code before acting.

MaxInfluence, RoomZBuffer and EvaluateSDF's Margin all derive from
MaxRoomRadius / TunnelMaxRadius, while the radii are Lerp(Min, Max, hash), which
yields up to max(Min, Max). A room able to reach a chunk can therefore sit in a
cell the collect region never visited.

Worse than the two fixed earlier today because:
  - it is TunnelNetwork, the largest archetype;
  - BuildChunkCache is called by BOTH density paths (FRoomGraphSource calls it
    rather than transcribing it), so this was never an op-stack bug -- it is in
    the shipped original code and always has been;
  - the failure mode is a window-invariance break (ARCHITECTURE 8.4): whether a
    room exists depends on which chunk you queried from, which in multiplayer
    means two peers generate different geometry from the same seed.

Four bound sites now use RoomRadiusEnvelope / TunnelRadiusEnvelope; the two
duplicated copies of the formula still compute the identical expression. No
Lerp, placement, hash or bStore line changed -- with correctly ordered params
max(Min,Max) == Max, so this is bit-identical. A no-op at correct values is the
acceptance signal.

Also adds a reviewer's header to AUDIT-2026-08-CODEX.md marking which findings I
verified (VF-05 confirmed, VF-02 premise confirmed, VF-03 evidence overstated --
its cited fixture corroboration does not exist) and which are unverified leads.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:32:09 +02:00
Fr0zka 8ef4d7dc09 docs: CORRECTION -- bUseOperatorStack is ON in the game's data assets
The handoff said "No strate asset has the box ticked -- that is my call and I
still haven't made it." Stale, and I reasoned from it all session. Jahni: "the
data assets in game have the switch on."

Everything downstream flips. The operator stack is the PRODUCTION density path,
so the measured perf regression is one players feel, and any unsound
EffectOverBox is a live hole rather than a latent one. The two correctness fixes
committed today (7dbdf51, eaa44bf) both say "nothing in the running game was
affected" in their bodies -- that sentence is wrong. Those commits are pushed
and stay as written; OPSTACK-PROGRESS 2026-08-16 (h) is the correction of record.

Concrete consequence: 7dbdf51 did not fail in practice only because this
Perlin's empirical sup (~1.0-1.1) happened to stay under the margin the wrong
bound left (1.27 for shafts, 1.40 for maze) -- not because the code was right.
And eaa44bf is worth checking against the real assets: if any strate has
ColumnMinRadius > ColumnMaxRadius or ShaftMinRadius > ShaftMaxRadius, it WAS
producing tiles with no geometry and no collision.

Process failure worth keeping: the flag lives in .uasset binary data, which is
not greppable from here, so the docs carried a hand-written claim about it that
nobody re-checked. A fact that lives outside the repo cannot be maintained
inside it -- record what it was and when it was checked, never assert it as
current. Rule added to the handoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:12:01 +02:00
Fr0zka 21f62c4a70 docs(opstack): two comments still described a half-finished port that is finished
Both said the refactor was mid-flight and both contradicted the code around them.

1. BuildTunnelNetworkStack: "stage A of three, the twelve detail modifiers and
   the per-room override are not ported yet, which is why
   UsesOperatorStackForChunk returns false for TunnelNetwork." All three stages
   are done, the twelve modifiers are added a dozen lines below the comment, and
   that function returns true.

2. FIslandBlobSource: "two of the three frame users are not ported yet; revisit
   when TunnelNetwork brings the second real use." TunnelNetwork is ported, and
   BuildTunnelNetworkStack already records the verdict: CaveWarp wraps exactly
   one operator and VerticalScale is a pure scalar function, so ZERO frames out
   of three candidates. The question was answered; only this comment still asked
   it, and it would have sent the next reader off to build frame infrastructure
   that was explicitly decided against.

A comment that contradicts the code beneath it is the "read the code, not the
comment" trap in reverse. This file already carries the forward version of that
lesson, at the cliff modifier whose comment promises a gradient it never samples.

Comments only -- no code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:06:24 +02:00
Fr0zka f737488d88 test(opstack): three tile scans sampled <1 lattice period; fix the CLASS this time
The "a sampler must cover at least one period" bug was found in the tunnel test
(+/-32 vs RoomSpacing 80) and again in the shaft test (+/-48 vs ShaftSpacing 55).
Both were fixed. The class was not: three box-verdict scans still ran the
original RandRange(-6,6)*Extent at Step 1 / Cells 8, i.e. +/-48 voxels.

  Island  +/-48 vs IslandSpacing 95 = 0.51 periods  (worse than either fix)
  Slab    +/-48 vs ColumnSpacing 60 = 0.80 periods
  Maze    +/-48 vs CellSize      40 = 1.20 periods

Verified the fixture does not override any of those spacings, so the header
defaults are what these tests really ran against.

This matters now specifically: 7dbdf51 changed the island and maze verdicts and
eaa44bf changed the slab column verdict, and these are the tests that guard
them. Widening before the build is what makes that build's green mean anything.

The rule the fixed tests already encoded, now explicit: Extent = 8, so
SpanCells = the lattice spacing gives exactly 8 periods -- which is where the
shaft test's 55 came from. Island 95, Slab 60, Maze 40, each printing the ratio
computed LIVE from the params struct so a future narrowing cannot be silent.

No assertion, tolerance, AddError, brute-force loop, seed, Step, Cells or tile
count was touched: this changes what the measurement looks at, never what it
demands. Proved counts will move (expected); NumUnsound must stay 0.

Test-only -- git diff lists three files under Private/Tests/. Not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:04:55 +02:00
Fr0zka c993e6e877 docs(opstack): handoff heads with the four-things-on-one-build table
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:50:56 +02:00
Fr0zka eaa44bf0c0 fix(opstack): two cell-sweep pads assumed MinRadius <= MaxRadius; a third already didn't
Second defect from the full 28-op EffectOverBox audit. Three ops roll a radius
as Lerp(MinRadius, MaxRadius, hash01), which lands in [min(A,B), max(A,B)] --
Lerp does not require A <= B. Their EffectOverBox sweeps lattice cells padded by
the largest radius a cell could hold, so a pad below the true maximum means the
cells are never examined and the op reports Identity for a box its own Eval will
fill or carve: no geometry, no collision.

  FGridColumnMod   ~1086  Max(MaxRadius, 0)                        exposed
  FShaftFieldSource ~1436 Max(ShaftMaxRadius, ConnectorRadius)     exposed
  FIslandBlobSource ~1803 Max(IslandMinRadius, IslandMaxRadius)    already correct

The third one is the argument: the concern was met and guarded once in this same
file, and the other two shipped without it. Fixed with FMath::Max3, a spelling
already used here (~2481) and in VoxelCaveMorphology.cpp.

Shipped defaults are ordered correctly (2/5, 2/7, 5/11), so this is a NO-OP at
defaults -- that is its acceptance signal, and any moved number means the diff
did more than intended. It needs a mis-ordered asset value, which nothing
prevents: ClampMin is a per-property floor and UE cannot express "<= that other
property". ColumnMinRadius is also settable per-room via UVoxelTerrainOpDefinition.

Deliberately NOT fixed by normalising the params: swapping the Lerp endpoints
maps the same hash to a different radius, changing geometry and breaking the
eight equivalence tests. Only the bound becomes conservative; Eval is byte-
identical -- verified, no Eval/Roll/GetCells line in the diff.

Also audited and found sound (recorded so they are not re-chased): FPassageCarveOp
and the passage bounding sphere, FOriginSpineOp, FBoundarySealOp, FShaftLedgeMod,
FCaveArchMod, FDomeMod.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:50:01 +02:00
Fr0zka 108982d135 docs(opstack): the proved-bound rule now names VF_PerlinAbsBound and the FBM normalisation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:36:45 +02:00
Fr0zka 7dbdf51b44 fix(opstack): three ExtraReach box verdicts used sup|FBM| = 1.0; the proved bound is 1.5
CORRECTNESS on a box verdict, not perf. This file derives |Perlin3D| <= 1.5
rigorously, exposes it as PerlinAbsBound, uses it for the tunnel warp, and says
outright that the header's "~[-1,1]" is an observation and that a box verdict
resting on one is a hole. Three ExtraReach formulas in the same file assumed
sup|FBM| <= 1.0 -- and VoxelNoise::FBM normalises (Total / MaxValue), so
sup|FBM| = sup|Perlin3D| exactly. The bound was wrong by 1.5x.

At shipped defaults, soundness needed B <= 1.27 (VerticalShafts, Rough 3.0) and
B <= 1.40 (Maze, Rough 2.0); both are violated at B = 1.5. Break-even roughness
is 1.6. FloatingIslands survives only because its SDFBlendRadius is 5 -- sound
by parameter luck, not construction.

Nothing in the running game was affected (no strate has bUseOperatorStack
ticked) and the empirical Perlin sup ~1.0-1.1 is why nothing surfaced. But the
tile scans SAMPLE, and for shafts they sampled a source that proved zero tiles
until e002bd4 -- which is what makes this reachable rather than latent.

Fix: PerlinAbsBound hoisted to file scope as VF_PerlinAbsBound (one definition,
not a class-static plus three implicit 1.0s) and multiplied into all three
reaches; the three comments now state the real justification instead of the
refuted one.

Cannot change density: ExtraReach is read only inside EffectOverBox, verified
mechanically -- no Eval/GetCells line appears in the diff. Direction is strictly
conservative, so it can only cost CPU. Expect FEWER proved tiles for Maze and
VerticalShafts; that is the correct outcome, not a regression.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:36:04 +02:00
Fr0zka b5294b2d5b docs: close C2, sharpen the column-memo prediction, reassess REVIEW_FINDINGS
Reading session -- no build available, so: verify things instead of writing code.

- Cross-checked the eight stat declarations against their definitions and use
  sites. Identical. The one compile risk I had only eyeballed is now retired.

- REVIEW_FINDINGS: the GetDensityWithParams and BuildChunkCache splits are now
  counter-productive rather than optional -- the op stack is replacing the
  first, and FRoomGraphSource deliberately CALLS the second so a restructure
  forks what was kept unforked. The two passage enums are not duplicates and
  are UMETA-serialised into authored assets; merging them rewrites content for
  tidiness. Judged: leave, and close the item.

- CODEX-TASK-002's acceptance said "20-30% of lookups", a guess wearing a
  number. Replaced with arithmetic from the real grid: 1225 columns/tile over
  35 Z planes, load factor 0.299, ~317 colliding columns, ~12000 vs 1225
  computations = ~9.8x. Plus the reading rule that matters -- the overhang and
  cliff mods re-query the same XY and add HITS only, so compare the ratio of
  the two hypotheses, not an absolute percentage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:14:18 +02:00
Fr0zka b426cfcb0d docs(audit): C2 is FULLY closed -- the live-edit half was fixed too
Was about to spec a Codex fix for OC_Chunk / BM_Chunk / FChunkBiomeCache,
which three docs still list as open. Checked the sites first. All of them
already carry a layout-version guard:

  CP_Chunk -> CP_Version      OC_Chunk -> OC_Version
  BM_Chunk -> BM_Version      TC_BiomeCache -> TC_SeenVersion

FChunkBiomeCache::Invalidate() exists precisely because the validity box says
nothing about the FBiomeContext its cells were classified against, and all four
thread_local instances call it on a version change. The only other two
instances in the tree (VoxelContentManager ~445, the height-stack test) are
function-local, constructed per task, so staleness is impossible there.

Seventh time in this project a confident premise reversed on reading. It cost
a doc edit instead of a Codex run and a build cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:11:54 +02:00
Fr0zka eb317d9933 feat(stats): stat VoxelForge -- make tile skipping and the column memo observable
The plugin had ZERO stat counters, so every claim this refactor makes was
harness-only: "skipped correctly" and "skipped nothing" render identically.
CODEX-TASK-001 and -002, executed by Codex (gpt-5.6-luna xhigh), reviewed
against the source.

Eight DWORD counters in a new stat group:
  GenerateTileResult -- TilesClassified / SkippedAllSolid / SkippedAllAir / Meshed
  ClassifyTile bAnyCave exit -- TilesOpStackSolid / TilesOpStackAir
  FSurfaceColumnSource::GetColumn -- ColumnMemoHit / ColumnMemoMiss

The op-stack counters are separate from the lumped skip counters on purpose:
ClassifyTile also proves AllSolid on its hand-written bedrock-gap path with no
strate opted in, so the lumped number cannot show a before/after. The op-stack
pair is zero BY CONSTRUCTION until a strate ticks the flag.

GetColumn is instrumented and deliberately NOT fixed -- the instrument and the
fix in one build would make each other unreadable.

Verified against UE_5.7 Stats.h rather than assumed (first stats use in this
plugin, no in-repo precedent): all four macro arities, and INC_DWORD_STAT ->
FThreadStats::AddMessage, so the worker-thread safety both sites need holds by
mechanism. Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:01:26 +02:00
Fr0zka 7909c4f2ca docs(opstack): handoff -- 001 chains into 002, and PERF is now aimed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:46:30 +02:00
Fr0zka 7313201e91 docs: spec CODEX-TASK-002 (column-memo thrash) and aim the PERF work
Three orchestration findings on the parked PERF item, none measured yet:

1. The perf A/B needs no new code -- two Insights traces on a deterministic
   world are a clean before/after. But tile skipping changes how many times
   GenerateMesh runs, so totals conflate "cheaper per tile" with "fewer
   tiles". TilesMeshed from task 001 is the denominator. 001 is therefore a
   PREREQUISITE for the perf work, not a parallel item as the handoff had it.

2. The suspects don't share an archetype: SurfaceWorld is the column memo,
   TunnelNetwork is 19 virtual calls per voxel (16 + 3 structural post).
   One lumped number can't be acted on -- measure one strate at a time.

3. GetColumn is a direct-mapped hashed table where GSurfColCache is a
   direct-indexed box, and the mesher pre-samples Z-OUTERMOST (verified,
   VoxelMarchingCubesMesher.cpp ~226) so every column is revisited ~34x per
   tile. The table's sizing comment reasons about fullness; a direct-mapped
   table evicts on collision. At load factor 0.28 that is still ~25% of
   columns recomputing the full height stack every Z plane -- derived ~9x.

Task 002 measures that and fixes nothing, so the instrument and the fix
cannot land in the same build and make each other unreadable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:45:44 +02:00
Fr0zka 57002b35cf docs(opstack): log the CODEX-TASK-001 acceptance-bar correction
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:40:48 +02:00
Fr0zka 6bd5589d53 docs(codex-001): the lumped AllSolid counter cannot prove T1.d -- add the op-stack site
Reviewing the spec against ClassifyTile reversed its acceptance bar. The function
reaches a non-Mixed verdict two independent ways: the hand-written path (a bedrock
gap sets bCanAir=false, VoxelGenerator.cpp ~2835, and the tile resolves AllSolid)
and the operator-stack path (the bAnyCave block). The first fires with NO strate
opted in, so the spec's baseline -- "TilesSkippedAllSolid stays 0 underground" --
was never going to hold, and the before/after would have been unreadable.

Adds site B at the bAnyCave exit (~3009) with TilesOpStackSolid / TilesOpStackAir.
Those are zero BY CONSTRUCTION without an opted-in strate, since the branch returns
Mixed at the UsesOperatorStackForChunk gate -- a stronger baseline than the one it
replaces. Deliverable is now: tick the box, TilesOpStackSolid goes non-zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:40:23 +02:00
Fr0zka 251a1288e0 docs: spec CODEX-TASK-001 (tile-skip stats) and refresh the handoff for the Codex tandem
Jahni built a world and said "I don't know if it dropped any meshing? but it
looks alright by the eye." That sentence is the honest state of this refactor:
everything proved so far was proved in an automation harness on 40 sampled
tiles, and in the running game tile-skipping is unobservable.

grep INC_DWORD_STAT over Source/ returns nothing -- the plugin has zero stat
counters -- and "skipped correctly" renders identically to "skipped nothing", so
no visual check can separate them. The tests got the "coverage is a number, not
a boolean" discipline this session; the game never did.

CODEX-TASK-001-tile-skip-stats.md specs a stat VoxelForge group with
TilesClassified / TilesSkippedAllSolid / TilesSkippedAllAir / TilesMeshed. Solid
and air are split deliberately: cave archetypes prove AllSolid, so that counter
is the one that says whether the op-stack work did anything real. Its deliverable
is the before/after that constitutes the PRODUCTION proof of T1.d, which does not
exist today.

The spec carries the invariants rather than just the task, which is the point of
a spec here: bTrivialEmpty decides whether a tile has COLLISION, the five-clause
gate is load-bearing, and GenerateTileResult runs on worker threads -- so a plain
static int32++ is a data race while INC_DWORD_STAT is not.

Handoff updated: a "How we work now" section (Codex Model Luna xHigh writes most
code, Claude orchestrates -- hand over the INVARIANT, review against the code and
not the description), first actions split into the Codex task and the pending
e002bd4 build, and the T1.d heading qualified as harness-measured rather than
game-proven. Also records the Insights interim answer and its trap: ~84% of tiles
were already rejected by the hand-written surface/bedrock paths, so surface skips
drown the cave ones unless you are underground in an opted-in strate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:36:29 +02:00
Fr0zka e4d1fdd7ea docs(opstack): experimental is pushed and tracked -- correct the flat "never push" rule
The rule said "Never push" without qualification, which was true while
experimental existed only locally and became misleading the moment it did not:
a future session would read it and let origin/experimental drift.

Now: push experimental freely (tracked since 2026-07-29), never push main, which
stays pinned at the known-good commit. Both statements of the rule are updated
(OPSTACK-HANDOFF.md and OPSTACK-PROMPT.md's crash-safe discipline).

Also added at both sites, because it is the failure mode this creates: a pushed
commit is NOT a "verified green" marker. This branch carries unbuilt work by
design, so OPSTACK-PROGRESS.md remains the only record of what was actually
built and measured, and the remote records only what was written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:41:00 +02:00
Fr0zka 41ba3c34a9 docs(opstack): rewrite the handoff for the T1.d-delivered state
The old handoff's "first action" (confirm the Underwater coverage number) and its
"one task everything is waiting on" (make FRoomGraphSource::EffectOverBox answer
spatially) are both done, so most of it was actively misleading.

What it says now:

- T1.d is real and measured: 11 of 40 tiles proved AllSolid at production
  defaults, 14641 voxels brute-forced, 0 violations; dense fixture correctly 0.
- A boxed invariant near the top, because it is the most dangerous thing in the
  current code: Identity from the room source means Sdf >= T, not FLT_MAX, and
  any new Sdf consumer with a bigger threshold silently produces tiles with no
  geometry and no collision.
- First action is now "build e002bd4 and read ONE line" (VerticalShafts box
  verdicts), including what to check first if it is still 0 -- ExtraReach --
  rather than re-deriving, which is what cost three rounds on TunnelNetwork.
- Both old debts restated with their real status: the per-room-op bound is
  DORMANT (checked, premise reversed), and AUDIT C2 is FIXED on the switch path
  with a note on why the audit's own suggested alternative was the wrong fix.
- The warp squeeze is parked WITH its measured ceiling and a recorded negative
  result, so the obvious next attempt (local Lipschitz warp bound) is not
  repeated: 8.5 * 0.206 = 1.75 exceeds the global range bound of 1.5.

New method lessons, led by the one this session actually paid for: instrument
what you ASSUMED, not just what you changed. Also that a sampler must cover a
period of what it samples, that a fixture tuned for coverage can be antagonistic
to what you are measuring, that diagnostics must report this run rather than
carry narrative, and that you must not assert a number you intend to improve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:36:36 +02:00
Fr0zka e002bd4e2e feat(opstack): VerticalShafts' connector branch never tested a connector -- test the real capsules
Jahni's call: bank T1.d at 11/40 and take VerticalShafts rather than squeeze the
Perlin sup. Right call -- a clearly-scoped defect with no hole-risk maths, on an
archetype that was getting nothing.

The defect is stated in its own comment. FShaftFieldSource::EffectOverBox ended
with a "connectors" branch that never looks at a connector: it returns CarveOnly
because a shaft EXISTS within Spacing*1.6 + Pad. At ShaftSpacing 55 and
ShaftDensity 0.6 that box spans ~4x4 cells and ~10 shafts, so the condition is
true essentially everywhere -- exactly the reported 0 proved of 60. Conservative,
never wrong, completely sterile; the same shape as the worm's unconditional
CarveOnly one archetype over.

It now rebuilds the connectors the way GetCells does and tests the real capsule.
Two things had to be right and both were read in the source rather than assumed:

- The enumeration is a superset. Eval reads connectors from the 3x3 of ITS
  query's cell, so any pair visible from a point in the box has both shafts in
  the 3x3 of some cell the box touches, i.e. in [box cells] +- 1 -- the range
  swept here. Pairs no query ever sees may be produced: extra CarveOnly, never a
  hole.
- The pair order matches, so the hash matches. VoxelHash::Pair is fed in
  insertion order and GetCells inserts over (dy, dx), row-major; this sweeps
  (cy, cx), and row-major order restricted to a sub-grid preserves the relative
  order of two cells. So the same pair gets the same hash WITHOUT assuming
  Pair() is symmetric -- which was never verified and now need not be.

The capsule test splits the axes because a connector is HORIZONTAL at height Zc:
Z is exact, XY is point-to-segment from the box centre minus the XY half
diagonal. Tighter than the 3-D half-diagonal the tunnel version had to settle for.

Also the same sampler trap, caught before the build this time: tile XY was drawn
from +-48 voxels against ShaftSpacing 55 -- under one period of the pattern,
identical in kind to the +-32-vs-80 bug that cost the tunnel test three runs.
Widened to +-440, and the report prints its own extent in units of ShaftSpacing.

The safety net was already there and is untouched: this test brute-forces the
full lattice of every proved tile, both hypotheses, and AddErrors on the first
violation. If the superset or hash-order argument is wrong, the assertion fails
rather than a player falling through the floor.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:36:34 +02:00
Fr0zka 861dc8e109 docs(opstack): WARP SHARE measured -- half the blocking is the box; plus two corrections
The instrument landed on its first run. At production defaults, rooms reaching
go 0.9 -> 0.4 and tunnels 2.4 -> 1.1 when the warp dilation is set to zero: more
than half of all remaining blocking is the query box rather than cave geometry.
Hypothesis confirmed, and the number is permanent output now.

CORRECTION 1. Last entry I claimed BuildChunkCache's Expansion =
CaveWarpStrength + 2 means "the whole plugin bets on |Perlin3D| <= 0.8", and used
it to argue 2.0 was needlessly conservative. False. Six lines further down,
bNeedRebuild tests WarpedX < CachedSMinX || WarpedX > CachedSMaxX -- the cache
REBUILDS when the warped query leaves the box, so that expansion is a
rebuild-frequency heuristic and nothing bets on 0.8. The 2.0 -> 1.5 change stands
on its own derivation. Sixth premise this refactor that reversed on checking, and
I asserted this one in the same entry where I diagnosed the habit.

CORRECTION 2 / NEGATIVE RESULT. The obvious next move -- evaluate the warp at the
box centre, SHIFT the box, dilate only by the variation across it -- does not pay
here, worked out before writing it. A rigorous per-axis Lipschitz bound for this
Perlin3D is |dV/dfx| <= 4*1.875 + 1 = 8.5 per unit cell (fade derivative times
the spread of GradDot, plus GradDot's own linear term, u and v being distinct
axes). The half-box is 0.206 in noise units, so the local variation bound is 1.75
against a GLOBAL range bound of 1.5. The local bound is worse than the global
one. Recorded so nobody spends a build rediscovering it.

That leaves one route for the warp term: tightening the sup of |Perlin3D| from
the proved 1.5 toward its apparent true value ~1.0-1.1, worth ~27% of the
dilation. Spot-checking a grid is not a proof, and a sup proved wrong is a tile
with no geometry and no collision -- so that is a judgement call about appetite,
not a technical unknown.

State: T1.d delivered and verified. 11 of 40 tiles (27.5%) proved AllSolid at
production defaults, 14641 voxels brute-forced, 0 violations. The dense fixture
correctly proves nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:28:49 +02:00
Fr0zka b2938d38f1 fix(opstack): the warp bound was the dominant term all along -- 2.0 -> 1.5, and MEASURE it
Jahni asked whether I was in a loop. I was, and re-reading the original found it
in one line.

PerlinAbsBound was set to 2.0 in the first commit of the spatial EffectOverBox
and never revisited. The dilation is CaveWarpStrength * VOXEL_NOISE_SCALE *
PerlinAbsBound, and CaveWarpStrength is 8.0 by default:

  8 * 1.25 * 2.0 = 20 voxels, applied +/- on every axis
  tile 10 voxels -> query box 50 per side -> 125x the tile volume

So the tunnel test I called "an order of magnitude tighter" actually needed
DistToAxis >= 78 against a cull rejecting at ~107. 27% tighter, not 10x -- and
the run said so: tunnels reaching went 78.0 -> 58.5, a 25% cut. The instrument
was right and I credited the tunnels.

Four rounds -- worm, columns, sampler, tunnel disjunction -- each individually
correct, every one of them tightening around a term nobody had measured. I kept
instrumenting what I had just changed and never instrumented what I had assumed.

What re-reading showed: BuildChunkCache is called everywhere with
Expansion = CaveWarpStrength + 2.0f, which is only correct if |Perlin3D| <= 0.8.
The whole plugin has always bet on 0.8. I picked 2.0 -- 2.5x more conservative
than the assumption the cache's own correctness already rests on.

The corrected bound is derived, not guessed. GradDot returns +-u +-v with u and
v two DISTINCT components (checked on all four hash branches). Splitting the
eight corners by i gives, per axis, sum w*|dx| = (1-su)*fx + su*(1-fx) <= 0.5
(max at fx = 0.5). Hence |Perlin3D| <= S_x + S_y + S_z <= 1.5, with no case
analysis on the hashes. Dilation 20 -> 15.

And the instrument that should have existed from the start: EffectOverBox now
also runs every room/tunnel test with the warp dilation set to ZERO and reports
both, so Hit* - Hit*NoWarp is exactly the blocking caused by my box rather than
by geometry. The report says, in the output, that if that gap dominates the next
move is the warp bound and not the primitives.

Separately: the diagnostics had turned into narrative, printing hardcoded numbers
from previous runs next to live ones ("32 of 34 tiles vs 21 for rooms" while the
live figures said 21 of 28). Unreadable, and I wrote all of it. Diagnostics now
report THIS run; the history stays in OPSTACK-PROGRESS.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:21:37 +02:00
Fr0zka 9733179723 feat(opstack): tunnels get a threshold test -- T1.d is measured at 6 of 40 tiles, 0 violations
THE PRIZE LANDED. At production defaults: 6 of 40 tiles proved AllSolid, 7986
voxels brute-forced, 0 violations. 15% of tiles skip GenerateMesh entirely, one
BuildChunkCache traded against 30000+ density evaluations each, and not one
verdict is asserted -- every voxel was re-evaluated and came back on the claimed
side. The dense fixture still reports 0, exactly as the arithmetic said it must,
so this is a measurement of production rather than of a widened test.

The breakdown finally isolated the tunnels: of 34 unproved tiles, 32 were
blocked by a tunnel and 21 by a room, so >=13 were tunnel-only. That is what
justified the deferred disjunction, and why it was deferred rather than guessed.

A primitive no longer matters if it misses its cull OR its own SDF stays >= T+K
over the box. Each branch wins on a different class, by calculation: for rooms
the cull (Rmax+3K) is tighter than the threshold (Rmax+T+K), so rooms keep the
cull alone; for tunnels the cull is a capsule BOUNDING SPHERE, radius ~107 for a
200-long tube of radius 7, against a true segment distance. Order of magnitude.

The bound is exact, not cautious. TaperedCapsule was read, not assumed --
Dist(P, ClosestOnSegment) - Lerp(Ra,Rb,t) -- so SDF >= dist(P,segment) -
max(Ra,Rb); and dist(box,segment) >= dist(centre,segment) - half-diagonal by
triangle inequality. VF_DistPointSegment is written locally rather than taken
from FMath: five lines, and "I think that function does that" is not good enough
under a correctness bound.

Identity CHANGED MEANING, from "Sdf stays FLT_MAX" to "Sdf >= T" with
T = max(3*SDFBlendRadius, WormNetworkRange). Sound only because all three
consumers of the SDF channel were read one by one: FSdfConvertOp's Blend is
MakeSdfCarve(P.SDFBlendRadius, ...) => K; the twelve modifiers gate at 3K;
FWormFieldSource at WormNetworkRange. Plus the one that could have bitten --
FCaveTerraceMod re-probes the SDF at Z+-1, OUTSIDE the box, but its gate is line
13 and the probes are lines 28-29, so a gate false everywhere never emits one.
ANY NEW CONSUMER OF THE Sdf CHANNEL MUST HAVE A THRESHOLD <= T OR JOIN THAT MAX,
or it gets tiles with no geometry and no collision. Written at the site.

Why -K suffices for any N: SmoothMin's penalty is exactly zero once |A-B| >= K,
so the running minimum saturates at K below the smallest term and cannot descend
further. Sdf >= min_i(SDF_i) - K for ANY number of primitives, not - N*K/6 --
without which the slack would scale with the ~88 tunnels and be worthless.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:45:56 +02:00
Fr0zka 87a0b996ec test(opstack): measure the box verdict on BOTH densities -- the fixture cannot prove a tile
The widened sampler worked: 39 of 40 tiles now land clear of the (0,0) spine.
The verdict did not move -- rooms still hit 40 of 40. By my own pre-registered
rule that means the room-lattice model was wrong, and it was, for a reason the
report had been printing at me for three runs: "= 7.6 x RoomSpacing 42".

RoomSpacing is 42 here, not the 80 I did the arithmetic with. I read the
UPROPERTY default out of the header instead of the fixture that overrides it.
Fifth premise this refactor that reversed on being checked, and the plainest:
the number was on screen.

EnableTunnelFeatures densifies on purpose -- RoomSpacing 80 -> 42, RoomDensity
0.35 -> 0.85 -- because at the defaults only 1.1% of samples were in open cave
and the equivalence compared solid rock to solid rock. That densification is what
makes check 1 mean anything, and it is exactly antagonistic to provability:

  room cull radius = max(R*1.5, R*HeightRatio) + 3*SDFBlendRadius
                   = 1.5R + 12  for R in [10,30]  =>  27..57, mean ~42
  lattice spacing  = 42 at 85% occupancy

The mean cull radius equals the lattice spacing, so cull spheres cover that world
~3.6x over and NO box can be outside all of them. "6.3 of 8.3 rooms reach" is not
a loose test, it is a saturated world. No sampler change and no tunnel tightening
can move it -- which is why widening the sampler correctly changed nothing.

So 0 proved on this fixture is the RIGHT answer, and informative: the prize
shrinks to nothing as caves saturate. It is simply not an answer about
production.

Check 4 is now a lambda run twice -- parameterised rather than copy-pasted,
because two copies of the criterion would drift and the second would lie:
[dense fixture] as before, expected to stay ~0 and now documented as correct;
[production defaults] the same 40 tiles against RoomSpacing 80 / RoomDensity
0.35, the real UVoxelStrateDefinition defaults. Both brute-forced voxel by voxel.
Not "widen it until it passes": the dense run stays in the report, must stay ~0,
and a false verdict in either still fails. The two stacks deliberately share
FRoomGraphSource's thread_local caches, so this run and check 3 now watch each
other through the params fingerprint in the key.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:37:45 +02:00
Fr0zka f2fefade4c test(opstack): the tile sampler never left the (0,0) spine -- widen it to 4x RoomSpacing
The per-class breakdown refuted my own hypothesis on its first run. I predicted
"tunnels >> rooms, so it is capsule bounding spheres". Tunnels ARE wildly
over-counted (69.2 of 80.4 reach), but rooms hit all 40 tiles too, so fixing
tunnels alone would have moved the number by exactly zero. Fourth confident
chain this refactor that reversed on contact with a measurement.

The real finding is that the measurement was invalid. The sampler drew tile XY
from RandRange(-4,4)*8, i.e. +/-32 voxels, against RoomSpacing = 80 and a
guaranteed OriginRoomRadius = 20 room at (0,0) whose cull radius is 42, with the
(0,0) spine descending exactly there. All 40 tiles sat inside the origin room's
cull sphere, in the most cave-riddled spot in the world. "4.9 of 7.2 rooms
reach" was measuring the spine hub, not the world's cave density -- every
conclusion about whether deep rock is provable was answering another question.

Widened to +/-320 voxels (4x RoomSpacing). The report now prints its own
sampling extent and how many tiles landed clear of the spine, because a sampler
whose extent you cannot quote is one nobody is watching. This changes what the
measurement LOOKS AT, never what it demands: every verdict is still brute-forced
voxel by voxel, so a wider sampler that produced a false verdict still fails.

Also worked out, before writing any code, why rooms cannot be tightened and
tunnels can. SmoothMin's penalty is exactly zero once |A-B| >= K, so the running
minimum saturates at K below the true minimum and Sdf >= min_i(SDF_i) - K for
ANY number of primitives. With K=4, WormNetworkRange=24, mods gating at 3K=12,
the threshold T is 24. For rooms the cull rejects at Rmax+3K=57 while an
"Sdf >= T+K" test rejects only at Rmax+T+K=73 -- the cull is strictly better.
For tunnels the cull is a capsule BOUNDING SPHERE, radius up to ~107 for a
200-long tube of radius 7, while the real segment distance rejects at 35. An
order of magnitude, and sound because TaperedCapsule is a genuine distance
function (verified, not assumed).

That disjunction is NOT in this build on purpose: it changes what Identity means
here, from "Sdf stays FLT_MAX" to "Sdf >= T", which is only sound if every
consumer threshold is <= T. Three premises still need reading rather than
assuming -- VF_NearCaveSurface's constant, the Blend passed to FSdfConvertOp,
and whether any modifier re-probes the SDF outside the box before its gate.
Fix the measurement first; it costs nothing and it is wrong today.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:25:43 +02:00
Fr0zka 8043a613c3 perf(opstack): drop the redundant column test, and measure WHICH primitive class blocks the box
The worm fix worked -- attribution now reads "AllSolid killed by:
RoomGraphSource x40", with WormFieldSource gone from the list. The blocker moved
one operator upstream.

And my own warning is now the thing to distrust. It said "if it names
RoomGraphSource, the tiles genuinely straddle cave", which is a hypothesis
wearing the costume of a conclusion -- the same mistake as the previous warning,
one level down. RoomGraphSource has four primitive classes behind it whose
bounds differ enormously in quality. No third guess.

PROVED, not guessed: the columns test is removed. It treated columns as infinite
cylinders in Z (the cache gives them no vertical bound), so a box hundreds of
voxels below the owning room answered Both over a shared XY circle. It was
REDUNDANT rather than conservative: columns are read by exactly one operator,
FRoomColumnMod, whose Eval opens with VF_NearCaveSurface(InOut.Sdf, ...). In a
box no room/tunnel/pit/chimney reaches, Sdf stays FLT_MAX everywhere, so no
column can execute wherever it sits in XY. Removing it tightens the verdict
without touching correctness.

THE INSTRUMENT: EffectOverBox no longer early-outs on the first hit, it counts
all four classes -- stopping at the first gives the right verdict and no
information, which is exactly why "RoomGraphSource x40" was unactionable.
Free at the scale that matters: BuildChunkCache has just run and dwarfs a walk
over ~100 structs, and the verdict is memoised so the walk happens once per box.

GetLastRoomBoxDiagnostic reads back what the operator computed rather than
letting the test re-derive the criterion. The test could replay it -- and that
second definition would drift from the real one and lie on the day it was
believed. Same reason VF_BuildOpStackForChunk exists.

The hypothesis it exists to kill or confirm: a tunnel is culled per voxel by its
BOUNDING SPHERE, an enormous over-estimate for a long thin capsule, while a
room's cull sphere is a fair fit. Tunnels >> rooms would mean the box test is
losing to capsule bounding spheres rather than to real cave.

NOT done deliberately: tightening tunnels to a true capsule test is not free
correctness -- the per-voxel cull IS the bounding sphere, so a capsule test would
be tighter than the cull and would break the stated criterion. Making it sound
needs "no primitive can bring Sdf below max(Blend, SDFBlendRadius*3,
WormNetworkRange)", which needs a bound on how far SmoothMin of N primitives dips
below min. Real 0.2 design work, and doing it blind before knowing whether
tunnels are even the problem is the C10 mistake verbatim.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:16:23 +02:00
Fr0zka f537648867 fix(opstack): the worm inherits the room source's box verdict -- the actual blocker
First build of the spatial EffectOverBox came back green with 0 tiles proved of
40. The warning written for exactly that case fired, and then was not good
enough: it offered two candidate causes and BOTH were wrong. The real one was a
third operator neither candidate mentioned.

FWormFieldSource answered CarveOnly unconditionally with a provably correct
amplitude bound of WormStrength. But BaseDensity = 8 and WormStrength = 10 are
the DEFAULTS -- the field's own comment requires the worm to exceed BaseDensity
or it could never carve air. So SolidMargin = 8 - 10 < 0 on every tile of every
strate with worms on, before the fold reached anything the room source proved. A
numerically correct bound that is structurally always fatal.

The fix was already written three lines up in the worm's own Eval: NetworkMask
is 0 when CaveSDF >= WormNetworkRange, which FLT_MAX always satisfies. The worm
IS spatially bounded -- by the room source's bound, exactly like the twelve
detail modifiers -- so where the source proves Identity it does not execute at
all. Thirteen inheritors instead of twelve. Verified FVoxelOpSample::Sdf really
does initialise to FLT_MAX rather than assuming it; the inheritance inverts into
a hole otherwise.

Deliberately NOT VF_NoCaveOverBox: that helper answers "identity" for a null
Rooms, correct for the twelve modifiers and wrong for an op a future assembly
could place behind a different SDF writer. No room source means we do not know,
which must cost CPU rather than a hole.

And the instrument, because the guess is the thing that cost a build: a
diagnostic that lists candidate causes without measuring them is still a guess
wearing rigour. FVoxelOpStack::ClassifyBoxAttributed reports the index of the
first op that kills each hypothesis -- same loop, same early-out, verdict
identical to ClassifyBox, because a diagnostic that takes a different path than
the thing it explains sends you hunting in the wrong place.
IVoxelDensityOp::DebugName gives them names and touches no cache key, so it
cannot change the world. Check 4 now always prints "AllSolid killed by: <op>
xN", and the zero-proved warning says to read it instead of re-deriving.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:10:51 +02:00
Fr0zka 03ddcde334 feat(opstack): FRoomGraphSource::EffectOverBox answers spatially -- and AUDIT C2 is fixed
The chain no longer dies at the room source. The criterion is the per-voxel cull
lifted from point to box, which can fail for exactly one reason: Eval starts at
MinSDF = FLT_MAX and only lowers it through a primitive that survives its own
cull, so if no cached primitive survives its cull anywhere in the box, Sdf stays
FLT_MAX across the whole box.

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

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

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

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

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

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

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:48:18 +02:00
Fr0zka e55f12a9de docs(handoff): ce409e7 is built -- turn the Underwater section from a task into a question
Two green builds now, so the handoff's most prominent instruction (build ce409e7, it is unverified)
was stale, and a stale first action is worse than no first action: it sends a fresh context looking
for a bug that may already be fixed.

What I did NOT see is the number. The floor-division fix probably resolved the 0% cave coverage, but
'the suite is green' is not evidence of that -- a bit-identity over solid rock is green for exactly
the wrong reason, which is why that counter exists at all. So the section now states both branches
explicitly: non-zero coverage closes it and says so in the log; still 0.0% and the diagnosis line
names which of the three causes it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:27:19 +02:00
Fr0zka b533294f5d docs(opstack): record the green build and rewrite the handoff for a fresh context
OPSTACK-PROGRESS gets the measured entry: every number the run produced, what each one settles, and
the single open warning. OPSTACK-PLAN's status header goes from UNVERIFIED to BUILT AND GREEN.

OPSTACK-HANDOFF is rewritten end to end. It no longer describes a transition in progress but a
completed one, and it leads with the two things a cold context needs: the Underwater 0%-coverage
warning (with the truncation-vs-floor finding behind it, and the reminder that a green bit-identity
over solid rock is not evidence), and the one task everything else now waits on -- making
FRoomGraphSource::EffectOverBox answer spatially.

It also carries forward the two debts that must be paid BEFORE that lands rather than after: box
bounds computed from strate params can be too optimistic once a per-room op raises them, and AUDIT
C2 is confirmed but unfixed on the switch path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:13:59 +02:00
Fr0zka ce409e7bb1 test(opstack C2): floor-divide the sampled chunk range, and DIAGNOSE the Underwater 0% instead of guessing
The build is green: 14 tests, TunnelNetwork A+B bit-identical over 6000 samples, all twelve group
probes non-zero, all 8 roughness variants identical, 0 gate leaks, C1 proved (10 Terrace rooms,
1119 samples inside them). One warning fired, and it is the one the test exists to fire:

  Underwater (stage C2): 2000 samples, 0 of them in open cave (0.0%)

A GREEN BIT-IDENTITY OVER 2000 SAMPLES OF SOLID ROCK IS NOT EVIDENCE. It is exactly what two
agreeing voids look like -- the 1.1% run of stage A, again, in a different slot.

A REAL BUG FOUND WHILE DIAGNOSING IT
The sampled chunk-Z range used Z / CHUNK_SIZE, and C++ integer division TRUNCATES TOWARD ZERO.
TunnelNetwork sits at the top of the layout in positive Z, where truncation and floor agree, so it
could not show there. Underwater sits at the BOTTOM, in NEGATIVE Z: -1 / 32 is 0 by truncation and
-1 by floor, so the upper chunk bound starts one notch too high and the Clamp that follows piles the
excess onto the strate's very last voxel -- inside the top seal band, i.e. solid rock. Fixed in both
point builders via FloorDivChunk. Same family as the DivideAndRoundDown lesson already in the
project notes: truncation costs a build cycle every time it is assumed to be a floor.

That is a CANDIDATE cause, not a conclusion, so the commit does not stop there.

NEW CHECK 5b -- ASK, DO NOT INFER
Three causes produce "no sample in open cave" and they are fixed differently, so each now has its
own number: rooms baked for the Underwater strate index (cause: the bake), samples landing inside
the seal-free interior (cause: the sampled Z range), and the two together (cause: the XY spread).
The info line says explicitly how to read them. Sampling also widens from 8 clusters to 24, matching
the main scan.

Fourth application of the rule this archetype keeps teaching: the check that explains a zero must be
able to fail for exactly one reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:09:54 +02:00
Fr0zka 46b507a261 docs(opstack): close the unattended run -- the post-8/8 batch and the reviewer pass
Four commits after 8 of 8, logged in OPSTACK-PROGRESS with the same shape as the first entry:
what each touches, what breaks first, and the numbers to read before the colours.

Records three things that only became visible while doing the work:

1. DECOMPOSITION 0.2 frames the worm as THE blocker for tile skipping. It is one of three. The
   subtraction also had no first term (nothing declared how solid the rock was), and the twelve
   detail modifiers were the bigger drag -- not through their amplitudes but because they declared
   a direction where they are provably Identity, being gated on the SDF the room source writes.

2. A safety property that was checked rather than assumed: no box query anywhere in the operator
   library touches per-worker memo state. That is what makes it safe for ClassifyTile to build and
   fold its own stack without clobbering the caches the density path depends on.

3. The reviewer pass over every ported operator found no transcription error. It did confirm the
   four things a future reader would otherwise have to re-derive: the operator order matches the
   original line for line, EffectiveZ is recomputed per op but bit-identically, the terrace's
   SDFBlendRadius is equal on both paths because no ApplyTo writes it, and LocalParams() is lazy so
   it reproduces the original's cost profile rather than merely its value.

Next action is now one thing rather than a queue: make FRoomGraphSource::EffectOverBox answer
spatially. Both of this batch's mechanism commits were built to receive it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:08:16 +02:00
Fr0zka 6a8390f11f feat(opstack): ClassifyTile consumes ClassifyBox for cave archetypes -- the T1.d prize, behind the same opt-in
⚠️ THIS IS THE ONE COMMIT OF THIS RUN WHOSE FAILURE MODE IS A HOLE, NOT A REGRESSION. A non-Mixed
verdict makes the world skip GenerateMesh entirely: no triangles, no collision, invisible until a
player falls through. Read VoxelForge.OpStack.ClassifyTileSoundness before trusting it.

WHAT CHANGED
ClassifyTile used to `return Mixed` without a call for every cave archetype ("archétype cave [...]
pas prouvable en v1"). It now builds that strate's stack and folds ClassifyBox. SurfaceWorld and
bedrock gaps keep their hand-written proofs untouched -- the exact-lattice column test is strictly
better than any box bound, so the stack has nothing to add there.

ONE DEFINITION OF THE STACK MAPPING, and that is the load-bearing part
GetDensityAt's ~90-line build switch is extracted into VF_BuildOpStackForChunk and both callers now
use it. A second copy would be the worst bug available here: a tile skipped on the verdict of a
stack that is not the one producing its density is precisely a hole. A "keep these in sync" comment
would not have been enough; there had to be only one. Params are passed in by pointer, never fetched
inside, because both callers already have them.

SIX GUARDS, ALL FAILING TO MIXED, none giving the benefit of the doubt
1. The strate must actually opt in -- on EVERY chunk coord the box touches, not just the one that
   triggered the attempt. Otherwise the classifier judges a field the mesher will not produce.
2. One cave slot per tile. Two slots means two param sets, and a stack answers only for its strate.
3. No mixed cave/surface/gap tile: the cave stack's box would cover Z belonging to another strate.
4. The params must be BIT-IDENTICAL across every chunk coord the box touches. This is the guard that
   matters, and it exists because of the finding committed earlier today: GetGenerationParams blends
   params inside a strate (Alpha depends on chunk Z for Gradient, and on chunk XY as well for
   Interleaved), so one stack genuinely cannot represent a tile that straddles a transition band.
   Memcmp on POD: differing padding can only produce a false MISMATCH, i.e. one Mixed too many.
5. A 27-chunk-coord cap, so a very wide tile does not pay for the check. We give up the gain, never
   the safety.
6. The disturbances are folded by hand (chasm ⇒ CarveOnly, bridge/ridge ⇒ FillOnly), because
   DECOMPOSITION 10.2 leaves them OUTSIDE the stack -- GetDensityAt applies them after the negate.
   A verdict that ignored them would be wrong exactly where they act. Same inequalities the
   SurfaceWorld branch already uses.

The diff layer needs no new guard: ClassifyTile already returns Mixed for any tile with player mods
in range, before any of this.

TEST: VoxelForge.OpStack.ClassifyTileSoundness -- the same brute-force oracle as the existing
ClassifyTileSoundness, on a world where every strate opted in. It does not check the fold (that is
BoxVerdictFold) or the operators (those are the eight equivalence tests); it checks the WIRING. The
number to read first is the count of tiles actually brute-forced: zero non-Mixed verdicts would mean
the test proved nothing, so that case is an ERROR rather than a quiet pass. Its failure message
lists the four suspects in the order worth checking.

FIXTURE: FTestWorld::Build gains a bUseOperatorStack parameter (default false, so the thirteen
existing tests still exercise the switch), and every test world now gets a PROCESS-UNIQUE
LayoutVersion. That second change fixes a real cross-test hazard that was only ever hidden by an
accident: PassagesVersion is per-instance and starts at 0, so two FTestWorlds both reported 1, and
GetDensityAt's per-chunk caches are keyed on (ChunkCoord, LayoutVersion) -- one world could be
served the previous world's params AND its CP_UseOpStack flag. Invisible while every world agreed
the flag was false. The first world that ticks it removes that coincidence, in both directions.

WHAT THIS BUYS TODAY: Maze, FlatPlain/CrystalChamber, VerticalShafts and FloatingIslands can now
prove tiles in production, which is where the measured skipping (Maze 23/60, slabs 36-40/60) turns
into frames. TunnelNetwork and Underwater still prove nothing: their chain dies at FRoomGraphSource,
which answers Both with unknown amplitude. Making it answer spatially means building the SDF cache
for the queried box -- now worth doing, since a skipped tile saves 30k+ density evaluations, and the
amplitude fold plus the modifiers' Identity inheritance are already in place to receive it. That is
the next piece.

And nothing here changes Jahni's current world: no strate asset has bUseOperatorStack ticked, so
every guard above is unreachable in his project until he ticks one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:05:42 +02:00
Fr0zka 239c037f35 feat(opstack): the twelve detail modifiers inherit the room source's box verdict
Follow-on to the numeric fold, and the reason that commit does not yet pay off on TunnelNetwork.
Separate commit because it is a separate mechanism with a separate revert story: the fold change
was about AMPLITUDES, this one is about IDENTITY.

THE OBSERVATION
Every one of the twelve detail modifiers is gated on bNearCaveSurface, i.e. on Sdf < BlendRadius*3,
and Sdf only becomes finite if the room graph wrote something. So wherever the source proves that no
room and no tunnel reaches the box, Sdf stays FLT_MAX across the whole box and all twelve are
IDENTITY -- not merely bounded, not merely CarveOnly-with-a-small-number. Identity.

Each of them already knew this per voxel (it is their first `if`) and still declared Both / FillOnly
/ CarveOnly per box, which killed deep bedrock's AllSolid hypothesis exactly as hard as an operator
that genuinely acts there. Twelve over-cautious declarations, one cause: they were not asking the
source they depend on, although since C1 they already hold a pointer to it.

VF_NoCaveOverBox(Rooms, Box, Ctx) short-circuits each EffectOverBox to Identity when the source
itself answers Identity. Strictly conservative: it never returns Identity on its own authority, only
where the source already did. A null pointer also means Identity, and that is correct rather than
convenient -- with no room source in the stack, Sdf is FLT_MAX everywhere and the gate never opens.

FCaveRoughnessMod gains the pointer purely for this. It is named RoomsForBox and documented as
NOT for Eval, because that op deliberately reads STRATE params rather than LocalParams() and mixing
the two up is the exact mistake C1's note exists to prevent.

⚠️ WHAT THIS BUYS TODAY: almost nothing, and that is worth stating rather than implying.
FRoomGraphSource::EffectOverBox still answers Identity only when RoomDensity <= 0. The short-circuit
becomes the deep-bedrock switch on the day the source answers SPATIALLY -- its room and tunnel
bounds are already in the SDF cache; what it costs is building that cache for the queried box, which
only pays once ClassifyTile actually consumes ClassifyBox. The wiring is put in now so that day
touches ONE place instead of thirteen.

No test change: this can only turn Both/FillOnly/CarveOnly into Identity where the source already
returned Identity, so no existing verdict can move. The TunnelNetwork test still asserts 0 proved
verdicts over 40 tiles, and it should still hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:55:39 +02:00
Fr0zka 353d504169 feat(opstack): the box fold carries NUMBERS -- amplitude bounds alongside EVoxelOpEffect (DECOMPOSITION 0.2)
Its own commit, nothing else in it, because it changes the contract all thirteen tests rest on.
VoxelForge.OpStack.BoxVerdictFold is extended in the same commit, as required.

THE PROBLEM, restated because the fix only makes sense against it
Direction alone can never recover a FIELDED carve. A noise-threshold carve (TunnelNetwork's worms,
wall roughness) has no spatial bound: it answers CarveOnly on every box of every strate that enables
it, and that answer is TRUE. So it kills AllSolid everywhere and the archetype skips zero tiles. No
refinement of EffectOverBox can fix that.

But the AMPLITUDE is bounded, and for the worm it is trivial: the block only runs below the
threshold, so t = 1 - WormValue/WormThreshold is in [0,1] and NetworkMask is in [0,1], hence the
carve is at most WormStrength. "The rock is solid by more than the sum of every remaining carve" is
therefore provable.

THE CONTRACT
- IVoxelDensityOp gains MaxCarveOverBox / MaxFillOverBox (density units, FLT_MAX = unknown) and
  ForcedMarginOverBox (how far the density is guaranteed from zero, 0 = unknown).
- FVoxelBoxHypotheses gains SolidMargin / AirMargin. A forcing op sets one; each carve subtracts its
  amplitude; the hypothesis dies when the margin is no longer strictly positive.
- VF_ForceHypotheses and VF_FoldEffect take the new values as DEFAULTED parameters.

BACKWARDS COMPATIBILITY IS THE POINT, and it is structural rather than promised: the defaults are
FLT_MAX and 0, so an op that overrides nothing subtracts FLT_MAX from a margin of 0 and kills the
hypothesis exactly as the purely directional fold did. Not one existing verdict moves. The float
arithmetic is deliberately unguarded: 0 - FLT_MAX is -FLT_MAX, -FLT_MAX - FLT_MAX saturates to -inf,
-inf > 0 is false, and no NaN is reachable because both terms share a sign.

BOUNDS DECLARED (each proved from the code, not estimated -- over-estimating costs CPU,
under-estimating is a hole)
- FConstantFieldSource::ForcedMarginOverBox = |Value|. This is the missing FIRST TERM: without a
  source that states how solid the rock is, there is nothing for a bounded carve to be subtracted
  from, and every bound would still kill the hypothesis.
- FWormFieldSource: MaxCarve = WormStrength (the bound that had been written and unused since stage
  A), MaxFill = 0.
- FCaveRoughnessMod: 1.4 * SurfaceRoughness * VOXEL_NOISE_SCALE both ways.
- FLayerLineMod (LayerLineDepth), FRibbingMod (RibbingDepth), FScallopMod (ScallopStrength),
  FCaveOverhangMod (SCALE * Depth * Strength).

WHAT THIS DOES *NOT* DO YET, said plainly rather than implied
TunnelNetwork still proves ZERO tiles. The chain dies at FRoomGraphSource, which answers Both with
unknown amplitude, before any of the bounded ops are reached. Making it answer spatially means
building the SDF cache for the queried box, which only pays once ClassifyTile actually consumes
ClassifyBox -- so it belongs with that work, not here. The one case that changes today is a tunnel
strate with RoomDensity <= 0: the room source returns Identity and the bounded worm + roughness can
now leave AllSolid standing.

Seven ops keep the FLT_MAX default (terrace, cliff, arch, column, dome, pinch, floor bias). Their
amplitudes depend on room-relative data, and bounding them would change no verdict while the room
source is unbounded. Writing bounds nobody can consume is how a bound goes stale unnoticed.

ONE PRE-EXISTING DEBT MADE SHARPER, noted at every site: these bounds are computed from STRATE
params, and a per-room terrain op can write a LARGER amplitude (ApplyTo overwrites even where the
strate had 0). So a bound can be too small -- the dangerous direction. Same root as the too-optimistic
EffectOverBox flagged in C1, same fix, and it MUST land before ClassifyTile consumes ClassifyBox.

TEST: eight new blocks in BoxVerdictFold. The first two are the ones to read -- they assert that the
defaults reproduce the old fold exactly, and that "unknown" is not "zero". Also asserted: carve
amplitudes accumulate; equality loses the tile (the > is strict on purpose, since zero counts as air
at the mesher); a bounded Both no longer kills a margin it cannot cross; and nothing bounded can
resurrect an unprovable box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:53:02 +02:00
Fr0zka 64d0e11821 docs(audit C2): the within-strate stale-room suspicion is CONFIRMED by reading -- and it is the default config
No code changed. AUDIT-2026-07.md only: §C2's "SUSPECTED, NOT PROVEN" sub-item becomes "CONFIRMED BY
READING", with the four links of the chain and where each one is in the source.

THE CHAIN, checked instead of believed
1. GetGenerationParams' Gradient arms compute Alpha = 1 - DistFromBottom / EffectiveBlend, where
   DistFromBottom = ChunkCoord.Z - Slot.BottomChunkZ. Alpha is therefore a function of chunk Z
   WITHIN the slot -- so two chunk Zs in the same strate really do get different params. The top
   boundary mirrors it.
2. They differ in exactly the fields that place rooms: Lerp expands VF_STRATE_PARAM_FIELDS, which
   lists RoomSpacing, RoomDensity, MinRoomRadius, MaxRoomRadius, RoomHeightRatio, RoomShapeVariety,
   SDFBlendRadius, CaveWarpStrength and the tunnel fields -- every input BuildChunkCache reads.
3. It is the DEFAULT: TransitionType = Gradient, TransitionBlendChunks = 2. Two chunk-Z layers with
   distinct params at each end of every strate, out of the box.
4. StrateIndex cannot save it: the memo resolves the SLOT index from the band centre, identical for
   every chunk Z in the slot, and the cache key's XY box does not change as a worker walks down a
   column. No rebuild happens.

So a worker that builds (X,Y,Z1) then (X,Y,Z2) in one strate evaluates the second chunk against the
room list baked from the first chunk's params.

WHY IT MATTERS MORE THAN A SEAM
The result depends on which chunk that worker happened to build first. That is a window-invariance
break (ARCHITECTURE 8.4), not a cosmetic one, and in multiplayer two peers can generate different
geometry for the same chunk from the same seed -- a direct 2.6.1 violation on the ORIGINAL path.

WHY NOTHING CAUGHT IT
The test fixture sets TransitionType = Hard on every strate deliberately, so that "which archetype
owns this chunk" stays unambiguous. That switches the blend off entirely. The one configuration the
tests never build is the default one.

NOT FIXED HERE, deliberately: this is the switch path, and the fix (fold a params CRC + LayoutVersion
into the SDF cache key, exactly as FRoomGraphSource already does) is a behaviour change to live
generation that Jahni should land with a build in front of him. The operator stack does not inherit
the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:48:01 +02:00
Fr0zka 10dbe64b06 feat(opstack C3): TunnelNetwork + Underwater in the ported list -- 8 of 8
UsesOperatorStackForChunk now returns true for both, so the archetype switch has a complete
operator-stack twin: per-strate opt-in, every archetype equivalence-tested bit for bit against its
original function.

This changes nothing by itself. The flag still requires bUseOperatorStack ticked on a strate asset,
which is Jahni's call and was deliberately NOT done. What HAS changed is that the flag is no longer
a no-op anywhere: ticking it on any strate now really switches that strate onto the stack.

Not done, and it is the next real prize: ClassifyTile still uses hand-written guards and does not
consume ClassifyBox. That is where measured tile-skipping becomes frames.

DOCS
- CODEMAP 3.2d: ported list 6 of 8 -> 8 of 8; the BuildTunnelNetworkStack row rewritten (19 ops, one
  builder for two archetypes); six new rows for the detail modifiers, each carrying the thing a
  reader would otherwise have to rediscover -- roughness reads STRATE params, terrace re-queries the
  SDF, the cliff's comment disagrees with its code, columns have no strate parameter at all, and
  LocalParams() is the override whose EffectOverBox is too optimistic on a strate with an op pool.
  Also corrected the stale "never compare the two paths" line: C10 is closed and all eight
  equivalence tests compare bit for bit.
- CODEMAP 3.3 UsesOperatorStackForChunk row: same list, plus the warning that the flag is now a real
  switch rather than a harmless tick.
- OPSTACK-PLAN: status header and the Phase 2 order both updated; the three-stage TunnelNetwork
  breakdown and the calls-not-transcribes rule recorded there rather than only in the code.
- OPSTACK-PROGRESS: the closing entry for this unattended run -- every commit in order, the five
  original-code findings ported as-is, the two decisions that are not reversible by taste, the
  ClassifyBox optimism C1 introduced and that must be fixed before ClassifyTile consumes it, what
  breaks first per group, and the likely compile-error spots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:46:17 +02:00
Fr0zka e479fcdf7d feat(opstack C2): Underwater reaches the tunnel stack from a second case + its own equivalence check
STAGE C group 2. No operator, no parameter, no new stack: Underwater IS TunnelNetwork.

PREMISE VERIFIED BEFORE BUILDING ON IT (the lesson this refactor has paid for four times)
OPSTACK-DECOMPOSITION §8 claims there is no density difference at all. Both halves were re-read
rather than assumed:
  - GetDensityAt already puts `case Underwater:` and `case TunnelNetwork:` in the SAME arm, both
    calling GetDensityWithParams (VoxelGenerator.cpp, the production switch);
  - a repo-wide search for WaterLevelRelative shows FStrateGenerationParams' copy is read only by
    UVoxelStrateManager's water-level query (render side) and by the SurfaceWorld beach op through
    a DIFFERENT struct. GetDensityWithParams never reads it.
So the wiring is one more `case` above the existing one, sharing CP_Tunnel.

ONE REAL DIFFERENCE FROM THE FIVE CASES ABOVE IT, stated in the code
No degenerate-strate guard. The other five archetypes have one because their density functions
early-out to `return 1.0f` on zero height and the stack has no such early-out by design.
GetDensityWithParams has no early-out at all -- read line by line, not assumed. Adding a guard here
would make the stack diverge from the switch on degenerate strates, in exactly the direction the
guard exists elsewhere to prevent.

TEST (same commit): new check 5 samples 2000 points in the Underwater SLOT, with the same room-op
pool attached to that definition before anything is evaluated. This is not tautological even
though both paths call the same function: the Underwater slot is a different strate, hence a
different StrateIndex, hence a different bake seed and a different entry in the strate-index memo.
It exercises what six chunks of slot 0 cannot -- that the stack follows the right room set when
two strates of the same archetype coexist in one world.

The failure message says explicitly that if the TunnelNetwork equivalence is green and only this
one fails, the finding is a real density difference between the two archetypes, which contradicts
§8 and is worth more written down than patched.

UsesOperatorStackForChunk still returns false for both. That is C3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:42:07 +02:00
Fr0zka 9591088d34 feat(opstack C1): per-room op override -- eleven detail ops read LocalParams() from the room source
STAGE C group 1. No operator added: nineteen ops before, nineteen after. What changed is what
ELEVEN of them READ.

THE MECHANISM, AND WHY §2's "NO CLEAN HOME" PROBLEM DISSOLVED
FRoomGraphSource gains LocalParams(): the strate params with the nearest room's hash-rolled
UVoxelTerrainOpDefinition applied on top, exactly as the original's
`FStrateGenerationParams LocalTerrainParams = Params; ... NR.RoomOp->ApplyTo(...)` shadow does.
Every detail op that read the shadowed copy now reads that.

OPSTACK-DECOMPOSITION §2 called this the piece with no clean home and proposed giving each modifier
an "only inside room N" predicate. The difficulty came entirely from assuming each modifier must
OWN its params. One op owns the shared state, the rest read it -- and that pattern was already
established here twice (FOverhangShelfMod <- FSurfaceColumnSource, FShaftLedgeMod <-
FShaftFieldSource). No scoping predicate was invented. Same shape as the pit/chimney resolution.

ELEVEN, NOT TWELVE, AND THAT IS READ FROM THE CODE
Surface roughness (4b) does NOT read the per-room copy: the original's
`const FStrateGenerationParams& Params = LocalTerrainParams;` is declared INSIDE the
`if (bNearCaveSurface)` block, which begins after step 4b. Ported that way and flagged in the op so
it does not get "uniformised" later.

MEMOISED PER VOXEL, AND WHY THAT IS FIDELITY RATHER THAN OPTIMISATION
The original builds the copy ONCE per voxel inside the gate. Eleven ops calling LocalParams()
would build it eleven times. So FState carries a valid-flag that FRoomGraphSource::Eval clears at
the top of every voxel (before any early-out, so nothing can read the previous voxel's room), and
the first modifier that asks pays for it. Deep rock pays nothing, exactly as before.
The ~74-field copy itself is TRANSCRIBED AS-IS. It is a real per-voxel cost; recorded in
OPSTACK-PROGRESS as a perf item, not "improved" here.

ONE THING THIS EXPOSES, WRITTEN DOWN BEFORE IT BITES
EffectOverBox still answers from the STRATE params, because a box spans many rooms and a per-voxel
copy has no meaning there. Since ApplyTo writes the op's value even where the strate's was 0, a
room op can ENABLE a modifier the strate had off -- so a box verdict can now be too optimistic on
a strate that has a terrain-op pool. Harmless today (nothing consumes ClassifyBox in production)
and it MUST be fixed before ClassifyTile does. Noted at FLayerLineMod::EffectOverBox and in
OPSTACK-PROGRESS.

TEST (same commit) -- this is the acceptance stage B could not make
- The pool gains a Terrace op with values (3.0 / 0.95 / 1.4) deliberately far from the strate's
  (6.0 / 0.6 / 0.5). A stack that ignored the override now CANNOT be bit-identical.
- New check 3c asks the structure, not the density, because there is still no params probe for a
  pool (the pool is not in the SDF cache key): how many baked rooms carry a Terrace op, and how
  many samples fall inside one of those rooms' influence radius. Both must be non-zero, or check
  1's green means only that two paths agree where the override never applies -- which is what
  stage B already proved. Fourth application of the pit lesson.

IF THIS GROUP IS WRONG: check 1 fails with diffs clustered inside a subset of rooms (those that
drew Terrace) and terrace-shaped Z banding, while check 3c still reports non-zero coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:39:38 +02:00
Fr0zka 8a303cc085 test(opstack B5): prove the bNearCaveSurface gate -- a voxel outside it is bit-identically untouched
STAGE B group 5 of 5, and the only one that adds no operator. Stage B is complete: 19 ops, all
twelve detail modifiers ported.

THE DECISION, restated where it is paid for
The gate is a repeated early-out inside each operator (VF_NearCaveSurface), not a scoping
container. Reasons live in the code at that function; the short form is that the stack is a flat
list ClassifyBox folds op by op, a container would have to re-implement VF_FoldOp and would hide
its children from the fold, and an op that only exists inside a container cannot become a Phase-3
asset. What that choice buys in composability it pays for in risk: TWELVE places to forget the
gate instead of one. Hence a check aimed at exactly that.

NEW CHECK 1d
Classify each sample by its final SDF (nothing downstream of the room source writes that channel),
then compare the full stack against one with the eleven controllable amplitudes zeroed:
  - outside the gate the two must be BIT-IDENTICAL -- asserted, and the leak count is printed;
  - inside the gate they must differ often -- printed, and ZERO is an error, because "nothing
    leaked" is worthless if the answer is "nothing happened anywhere". Same trap as a coverage
    guard that only fires at zero, one level up: a check can be vacuous as well as a counter.
  - the outside-gate sample count is printed and warns below 10%, since deep rock is the common
    case in production and a sample set that never leaves the cave does not exercise the gate.

WHAT THIS CHECK CANNOT COVER, stated in the output rather than left implicit: the column operator
(STEP 4d) has no amplitude to zero, so it cannot appear in the zeroed stack. Its gate rides on
check 1's bit-identity against the original instead. That is sound but invisible, which is why the
info line says so.

Also corrected in the test header: the modifier count is TWELVE, not the thirteen that had been
carried in the notes, and only ELEVEN of them read the per-room param copy -- surface roughness
(4b) sits before the shadow declaration and reads strate params. Both numbers matter for C1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:33:34 +02:00
Fr0zka b063d437c1 feat(opstack B4): port Columns (4d), Domes (4g), Pinch (4h), FloorBias + Column op in the test pool
STAGE B group 4 of 5 -- and with it, ALL TWELVE detail modifiers are ported. 19 ops:
  ConstantRock -> RoomGraph -> SdfCarve -> Roughness -> Terrace -> LayerLines -> Ribbing
  -> Overhang -> Cliff -> Scallop -> Arch -> RoomColumn -> Dome -> Pinch -> FloorBias
  -> Worms -> [structural x3]
Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork. What is missing is C1,
the per-room op override -- which adds NO operator, it changes what eleven of them READ.

TRANSCRIBED
- FRoomColumnMod (4d): walks SDFCache.Columns. Not FGridColumnMod -- that one rolls cylinders on a
  world grid for FlatPlain/CrystalChamber; this one iterates a per-room bake.
- FDomeMod (4g): upward half-ellipsoid, up to 2 per room, capped at 0.85 * room radius.
- FPinchMod (4h): perimeter-anchored ellipsoid with the SideFactor term that leaves the passage
  axis clear.
- FFloorBiasMod: quadratic fill below room centre, only in definite air. LAST in the chain and not
  interchangeable -- it exists to undo what roughness (4b) did to floors. That is why the operator
  order in BuildTunnelNetworkStack is the original's order line for line.

THE TRAP IN THIS GROUP, WHICH IS THE PIT LESSON A THIRD TIME
Columns have NO strate parameter. The per-voxel loop has no `if (ColumnDensity > 0)`, and the bake
reads OpParams (a FRESH struct with only the room's op applied), so FStrateGenerationParams::
ColumnDensity is read by nothing. Consequences, all three of which shaped this commit:
  1. The `ColumnDensity = 0` line in the old DisableStageBModifiers was inert. It was never what
     kept columns off; the absence of a Column op in the pool was.
  2. No FeatureProbes entry can cover columns -- there is no param to switch off. And a second
     stack built on a pool without the Column op would SHARE the thread_local SDF cache (the pool
     is not in its key) and return identical densities, i.e. it would lie. Exactly the trap that
     was documented for pits.
  3. So the stage-A guard that ERRORED on TotalColumns > 0 is inverted in this same commit: it now
     REQUIRES TotalColumns > 0. Asking the bake what it baked is the only sound check here.

DisableStageBModifiers is deleted rather than left empty: its emptiness is the measure of stage B
being complete, and an empty function still being called is how a step gets forgotten.

TEST (same commit): op count 15 -> 19; MakeShaftOpPool renamed MakeRoomOpPool and given a third
entry (Column, Probability 1.0 on all three so every room draws one of the three evenly); three
more param probes (dome/pinch/floor bias); the column guard inverted.

⚠️ THE POOL MUST STAY Pit/Chimney/Column UNTIL C1. Eleven of the twelve modifiers read the per-room
param copy in the original and the strate params here; those agree only while no room carries a
DETAIL-type op, and ApplyTo(Pit/Chimney/Column) writes only pit/chimney/column fields. A Terrace
entry would break the equivalence -- which is precisely the test C1 adds, and the only possible
proof that the override works.

IF THIS GROUP IS WRONG: dome/pinch/arch diffs land inside open cave near room centres and
perimeters; a column error shows up as a ring of diffs at fixed XY through the whole room height.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:31:55 +02:00
Fr0zka 6ec60099d6 feat(opstack B3): port cave Overhang, Cliff, Scallop, Arch (STEP 4c)
STAGE B group 3 of 5. The stack is now 15 ops:
  ConstantRock -> RoomGraph -> SdfCarve -> Roughness -> Terrace -> LayerLines -> Ribbing
  -> Overhang -> Cliff -> Scallop -> Arch -> Worms -> [structural x3]
Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork.

TRANSCRIBED
- FCaveOverhangMod: fBM with Z frequency at 0.15x of XY, positive lobe only (rock extends INTO
  the cave, never away), quadratic fade. Note this is the THIRD unrelated thing called "overhang"
  in the plugin -- FOverhangShelfMod (SurfaceWorld) borrows the uphill terrain height and shares
  nothing with it but the name.
- FScallopMod: cellular noise, positive lobe carved out. Second consumer of VoxelNoise::Cellular3D,
  which is the second reason B1 shared that body instead of copying it.
- FCaveArchMod: the first ROOM-RELATIVE modifier -- it needs the room's hash, centre and radii, so
  it is what makes FRoomGraphSource::GetNearestRoomIdx() necessary. Its gate is NOT the shared one:
  CaveSDF < SDFBlendRadius (inside the cave), not < SDFBlendRadius*3.

PORTED AS-IS THOUGH THE ORIGINAL'S OWN COMMENT DISAGREES WITH ITS CODE
FCaveCliffMod's comment promises "sample density at Z+1 and Z-1, compute vertical gradient". The
code samples nothing: it draws a Perlin whose Z frequency is 3x its XY frequency and names the
result VertGrad. It is a gradient-shaped PROXY, decorrelated from the wall's actual slope; the
multiply by CaveSDF still gives it the right SIGN either side of the surface, which is why it
produces steeper faces at all. Fixing it would change the world, and saying nothing would leave it
to be "fixed" later by someone reading the comment instead of the code. Ported, documented in the
op, recorded in OPSTACK-PROGRESS.

ONE DELIBERATE ADDITION
FCaveArchMod bounds-checks NearestRoomIdx with IsValidIndex before indexing; the original tests
only >= 0. The index comes from EvaluateSDFCached so it is valid by construction, which is exactly
why this can never change output -- only prevent a crash if that invariant ever breaks. Same class
of decision as the params fingerprint in the cache key: err on cost, never on result.

Overhang is FillOnly, Scallop is CarveOnly, Arch is FillOnly; only Cliff needs Both. Four of the
eight modifiers ported so far keep a usable direction for the fold, which matters for the
DECOMPOSITION 0.2 work later.

TEST (same commit): op count 11 -> 15, four params groups move into EnableTunnelFeatures, four
more per-operator coverage probes. ArchDensity is set to 0.9 rather than its 0.1 default and the
reason is written down: at 0.1 a room draws ~0.3 arches and the probe would report zero with
nothing wrong. Test coverage settings are not production values.

IF THIS GROUP IS WRONG: diffs concentrate inside open cave (arch) or in a band |SDF| < range
(the other three), and the roughness/terrace probes stay green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:26:40 +02:00
Fr0zka ab1a996cc5 feat(opstack B2): port Terrace, LayerLines, Ribbing (STEP 4c) + hoist the room source's per-worker state
STAGE B group 2 of 5. The stack is now 11 ops:
  ConstantRock -> RoomGraph -> SdfCarve -> CaveRoughness -> Terrace -> LayerLines -> Ribbing
  -> Worms -> [structural x3]
Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork.

STRUCTURAL CHANGE THIS GROUP FORCED
FRoomGraphSource's thread_local SDF cache moved out of Eval into a `static FState& State()`
accessor, because terracing re-queries the cache at Z+-1 and (from B4 on) columns walk it and the
room-relative ops need NearestRoomIdx. Kept `static`, i.e. SHARED BETWEEN INSTANCES, on purpose:
that is what the original does, and the test's stale-cache check (check 3) rests on two stacks
sharing this cache while the params CRC in the key keeps them from serving each other's rooms.
Making it a member would have turned that check green for the wrong reason.
This is the established pattern in this file, not a new one: FOverhangShelfMod reads
FSurfaceColumnSource, FShaftLedgeMod reads FShaftFieldSource, both via a non-owning pointer
handed over at build time. Terrace now reads FRoomGraphSource the same way.

TRANSCRIBED
- FCaveTerraceMod: the Z-only SDF gradient orientation gate, the optional noise displacement of Z
  before the staircase, the Edge = lerp(0.45, 0.02, Hardness) stair profile, the quadratic fade.
- FLayerLineMod: sine along Z, half-wave rectified, CUBED, subtracted.
- FRibbingMod: same sine shifted by PI/2, rectified, SQUARED, added.

THINGS THAT LOOK WRONG AND WERE PORTED AS-IS
- Terrace's two gradient probes use UNWARPED X/Y and RAW Z, while the field they probe was
  evaluated at WARPED coords and effective Z, and they exclude pits/chimneys. So the probe does
  not sample exactly the field whose slope it measures. Real, in the original, load-bearing for
  the look; noted in OPSTACK-PROGRESS rather than fixed.
- Terrace/LayerLines/Ribbing use raw WorldZ while roughness uses EffectiveZ. Deliberate in the
  original (geology stays horizontal whatever the strate's vertical scale).
- `&& CaveSDF < FLT_MAX` is redundant under the bNearCaveSurface gate; kept.
- NearestRoom is now reset to -1 unconditionally, which the original does not do (its thread_local
  keeps the previous voxel's value when RoomDensity <= 0). Unobservable there because no consumer
  runs; here the consumers are separate objects, and a stale value crossing an operator boundary
  is not something you find later.

Ribbing and LayerLines are FillOnly / CarveOnly rather than Both -- two detail modifiers that keep
a usable direction for the fold, which is more than the roughness or the terrace can say.

TEST (same commit): op count 8 -> 11; the three params move from DisableStageBModifiers into
EnableTunnelFeatures with spacings deliberately small against the sample window; three MORE
coverage probes, one per operator rather than one per group -- a single "B2" probe would stay
green while two of the three were wrong.

IF THIS GROUP IS WRONG: diffs cluster where |SDF| < spacing*1.5 near horizontal surfaces
(terrace) or in thin Z bands (lines/ribs). If instead the whole equivalence collapses everywhere,
suspect the FState hoist, not the three new ops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:22:21 +02:00
Fr0zka 6e29cbfe4c feat(opstack B1): port TunnelNetwork surface roughness (STEP 4b) + share the cellular noise
STAGE B of TunnelNetwork, group 1 of 5. The stack is now
  ConstantRock -> RoomGraph -> SdfCarve -> CaveRoughness(4b) -> Worms -> [structural x3]
8 ops (was 7). Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork.

TRANSCRIBED
- FCaveRoughnessMod (VoxelDensityOpStack.cpp): STEP 4b literally -- two octave sets
  (main + fine x3 with the +2000/+2500/+3000 offsets), the optional domain warp, the four-way
  noise switch, the min(TotalRough, 0) anti-fill clamp inside definite air, and the quadratic
  fade by distance from surface. Reads EffectiveZ, not WorldZ.
- VoxelNoise::Cellular3D moved verbatim from VoxelGenerator.cpp's `static CellularNoise3D` into
  VoxelCaveMorphology.h; the generator keeps a one-line forwarder, exactly as FractalNoise3D and
  RidgedNoise3D already do since T2.a. It lands in the cave header rather than VoxelNoise.h
  because it needs VoxelHash, and VoxelNoise.h must not depend on the cave header. Forking a pure
  function is how AUDIT C1 happened.
- HRidged3D added next to HFractal3D (same FVector round-trip, deliberately).

TWO THINGS THAT LOOKED WRONG AND WERE PORTED AS-IS
- The domain warp computes ONE offset and adds it to BOTH noise positions, so the main and fine
  octave sets are warped identically. Two independent warps would be tidier and a different world.
- Roughness reads the STRATE params, NOT the per-room override. The original's
  `const FStrateGenerationParams& Params = LocalTerrainParams;` shadow is declared INSIDE the
  `if (bNearCaveSurface)` block that begins after step 4b. So eleven of the twelve detail
  modifiers read the room copy and this one does not -- flagged in the op so C1 does not
  "uniformise" it. (The handoff's "all 13 modifiers read the shadowed copy" is off on both counts:
  it is twelve modifiers, and one of them is outside the shadow.)

STAGE B5 DECIDED HERE, WITH REASONS (VF_NearCaveSurface)
The gate is a repeated early-out per op, NOT a scoping container: the stack is a flat list that
ClassifyBox folds op by op, a container would have to re-implement VF_FoldOp and would hide its
children from the fold, and an op that only exists inside a container is not a Phase-3 asset.
Cost stated rather than hidden: the original tests once and skips twelve, the stack tests twelve
times. Measured before optimised -- that is the C10 lesson.

TEST (same commit)
- Op count 7 -> 8.
- SurfaceRoughness/DomainWarp moved from DisableStageBModifiers into EnableTunnelFeatures.
- NEW check 1b, group coverage: rebuild the stack with the group OFF and count moved samples.
  Zero is an ERROR, not a warning -- a ported group that never executes is exactly how a bad
  transcription survives a whole green run (the PitDensity lesson). This diff-of-stacks is sound
  here although it lied for the op pool, because these are params and the params CRC IS in the
  SDF cache key.
- NEW check 1c: all 4 noise types x {warp off, warp on} compared to the original over 1500 points.
  The main equivalence only ever takes the FBM branch; three of four cases were untested.

IF THIS GROUP IS WRONG, what breaks first: the main equivalence reports diffs concentrated in
open cave and within |SDF| < SurfaceRoughness*2; if instead only check 1c fails, the fault is
inside one branch of the noise switch and nothing else is implicated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:18:21 +02:00
Fr0zka 7a87cdda14 docs: handoff — take the unattended queue all the way to 8 of 8
Jahni, twice and explicitly: he is fine with unverified work as long as it
is committed per group, and he wants the successor working toward full op
stack completeness. So the queue no longer stops at stage B.

Removed the hard stops on stage C and on flipping the ported list. Two facts
make that reasonable rather than reckless, and both are now written down:
committing per group IS the safety net (he reverts by sha), and reaching
8 of 8 does not change his world by itself — UsesOperatorStackForChunk still
returns false unless a strate has bUseOperatorStack ticked, so the flag flip
is inert until he ticks a box.

Queue is now B1-B5 (13 detail modifiers), C1 (per-room op override), C2
(Underwater, which §8 establishes has no density difference at all), C3
(both into the ported list = 8 of 8), then the §C2 audit question, the worm
amplitude cap, and making ClassifyTile consume ClassifyBox.

C1's design is settled here rather than left open, because an unattended run
should not be improvising architecture: take §2's option (a), and use the
mechanism this codebase already has three instances of — one op owns shared
state, others read it through a non-owning pointer (overhang <- column
source, ledge <- shaft source). FRoomGraphSource exposes LocalParamsAt();
detail ops read their fields from it. Same resolution as the pit/chimney
"fiddliest thing in the decomposition": the difficulty came from assuming
each op must own its params.

Still off limits: build, editor, push, ticking bUseOperatorStack on an
asset, and Phase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:05:33 +02:00
Fr0zka 473bccb43f docs: handoff — add an unattended-run work queue for stage B
Jahni may start a session and leave, then have it shut the PC down. The
successor still cannot build, so the section is about shaping ~680 lines of
mechanical transcription for ONE informative build afterwards rather than
pretending verification is available.

Work queue B1-B5 (roughness / terrace-layerlines-ribbing / overhang-cliff-
scallop-arch / columns-domes-pinch-floorbias / the bNearCaveSurface gate),
one commit per group with the group named in the subject line, since Jahni
reverts by group. Transcribe literally, note anything that looks wrong in
the log and port it as-is anyway — that is how C1 and the MinDivisor split
were both found without breaking anything.

Records the subtlety that lets B precede C: the 13 modifiers read a copy of
the params with the nearest room's op applied, so porting them against
strate-level params is equivalent only while the test's room pool stays
Pit/Chimney-only.

Hard stops: do not flip TunnelNetwork or Underwater on, do not start stage C
(design freedom without feedback), do not start the worm amplitude cap (it
changes the fold contract all 13 tests rest on), do not build or push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:02:43 +02:00
Fr0zka b888b86729 docs: stage A verified with real coverage; rewrite the handoff
The run that matters: bit-identical over 6000 samples with 23.9% of them in
open cave, and a bake carrying 49 rooms / 56 pits / 28 chimneys / 0 columns
— so the pit and chimney loops finally ran on real data, and 0 columns
confirms STEP 4d stayed dormant as stage A requires.

Same code and same colour as the previous green run, three different
strengths of evidence. That is the argument for printing coverage numbers
rather than pass/fail.

Handoff rewritten for a fresh context: the three-stage TunnelNetwork plan
and why stage A is verifiable while incomplete, the calls-not-transcribes
rule for BuildChunkCache, FRAME ops recorded as retired (0 of 3 candidates
needed one), the per-room override reclassified as load-bearing rather than
polish, and the method lessons regrouped around the three coverage traps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 02:58:33 +02:00
Fr0zka 68e43238bd test: pits and chimneys never ran — ask the bake, not the density
Check 3b earned its keep on its first run: 0 of 1500 points moved when
PitDensity was zeroed, because BuildChunkCache bakes pits inside
`if (!CR.RoomOp) continue;` and then reads a FRESH param struct with only
that room's terrain op applied. FStrateGenerationParams::PitDensity is
never read by the bake. Pits, chimneys and columns exist only through a
per-room UVoxelTerrainOpDefinition, and the fixture had none.

Not a product bug: those fields carry no UPROPERTY, so no editor surface
offers a setting that quietly does nothing. My reading was wrong.

Three attempts, and the second is the instructive one. Zeroing the params
tests fields nothing reads. Building a second stack with an empty op pool
would have LIED — the op pool is not in the SDF cache key (LayoutVersion
covers pool edits in production), so both stacks share the thread_local
cache and report "no contribution" for a third wrong reason. So: ask the
bake what it baked. Rooms > 0, pits > 0, chimneys > 0, columns == 0.

The test now attaches a real Pit/Chimney op pool. Safe at stage A because
ApplyTo(Pit) writes only pit fields, so none of the 13 unported detail
modifiers wake up — a Terrace op there would break it, which is exactly
what stage B adds.

Also reworded the green equivalence message, which claimed pits and
chimneys were exercised while zero existed. A success message that asserts
coverage instead of reporting it reads as evidence while measuring nothing.

Cave coverage 1.1% -> 21%, fingerprint 0.75% -> 95.8%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:22:07 +02:00
Fr0zka 5bf61c1815 test: stage A is bit-identical — strengthen the test its own counters indicted
The port is correct across 6000 samples. The only failure was arithmetic in
the assertion: 4 ops + 3 structural = 7, and I wrote 6.

But two numbers that PASSED are the real finding. 65 of 6000 samples landed
in open cave (1.1%), so the equivalence mostly compared solid rock to solid
rock — the carve, pits, chimneys and worm carve only run near the network.
And 3 of 400 probe points genuinely differed between the two param sets, so
the stale-cache check asked its question three times and said "fine" 397
times about points that could never have answered it.

Both counters were written to say exactly this, and did. Both guards only
fired at ZERO, so the run went green with the coverage of a much smaller
test. A coverage guard that only trips at zero does not measure coverage,
it notices absence. Both are now fraction thresholds that print a percent.

RoomSpacing 80 -> 42, RoomDensity 0.35 -> 0.85.

New check 3b: rebuild the stack with PitDensity = ChimneyDensity = 0 and
count the points that move. Enabling a feature in the params is not evidence
it fired — SDFCache.Pits can come back empty and the test stays green — and
these are the two loops DECOMPOSITION §2 calls the fiddliest in the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:41:56 +02:00
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
Fr0zka 96e75abe57 feat: port FloatingIslands — the stack that runs backwards
6 of 8 archetypes ported. This one starts from VOID and FILLS where the
other four start from ROCK and CARVE, which is what it was worth doing:
neither end of the pile needed a new operator, only the opposite sign.

  FConstantRockSource -> FConstantFieldSource(+/-Base)   AllSolid <-> AllAir
  FSdfCarveOp         -> FSdfConvertOp(Sign = +/-1)      carve    <-> fill
  FSdfRoughnessMod                                       4th archetype, unchanged

Only the island blob source is new. Multiplying by +/-1 is exact in
IEEE-754, so the three already-green ports are bit-for-bit untouched.

ClassifyBox can return AllAir for the first time in the plugin, and an
island strate is by construction mostly empty — the test counts AllSolid
and AllAir separately so an aggregate cannot hide whether that fired.

Two bounds that would have been holes if assumed rather than derived:
the island bound is one-sided (a hairline thread of matter hangs below
each island down its axis, so only the TOP may reject), and the domain
warp displaces X and Y independently, so the pad needs WarpAmp*sqrt(2).

Also: AUDIT C1 was NOT closed. The 2026-07-27 sweep matched `SeedF * K`
and this archetype's warp spells it `(float)S * K`, so one site survived
— at seed 2e9 the warp flattens and every island snaps back to a perfect
circle. Fixed in both paths in one pass so the equivalence test stays a
valid oracle. Expect island silhouettes to change at large seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:50:36 +02:00
Fr0zka f5d5a03ad1 docs: end-of-day handoff — 5 of 8 ported, 11 tests green
Rewrites OPSTACK-HANDOFF.md for a fresh context: the port table, what is left in
order, the two things Phase 2 invented that were not in the original design (height
space as a second operator family, and IVoxelBiomeField so ops depend on a capability
rather than the generator), and the method lessons that cost build cycles to learn.

Open items, none blocking: perf (parked by Jahni), C9's library half (no measured
risk), VerticalShafts' pessimistic box verdicts, and ClassifyTile still using
hand-written guards while ClassifyBox sits verified but unconsumed — which is where
the measured tile-skipping would actually become frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:17:35 +02:00
Fr0zka d0a9ce3018 fix: the column key omitted the params — my perf fix broke the overhang
VerticalShaftEquivalence is bit-identical (966 samples inside a shaft), so operator
reuse across archetypes is measured now, not intended.

But SurfaceHeightEquivalence failed: 69/20000 overhang samples differ, 1 crossing the
isosurface. Cause is the ColumnKey from f3faa3b, which was
hash(StrateBottomWorldZ, LayoutVersion, Seed) and omitted the params. Two stacks of
the same strate with different overhang settings therefore shared a key, and the
second read the first's columns, computed with OverhangAmp = 0. The overhang silently
vanished wherever a column was already cached.

Not a test artifact: this is the weakness the codebase already documents for
GSurfColCache (extended AUDIT C2 note) — a live edit that changes params without
moving the strate leaves the key unchanged and serves stale columns. Production masks
it because RebuildStrates bumps LayoutVersion; my key inherited the hole.

Fixed by folding an FCrc::MemCrc32 fingerprint of the params, and of every per-biome
param set, into the key. FSurfaceGenerationParams is verified pure POD, so a memory
CRC cannot produce a false hit; padding can only cause a false miss, i.e. a recompute.

A perf optimisation introduced a correctness bug and the tests caught it the same
day. The failure was invisible to inspection and produced plausible terrain.

Known and not fixed: VerticalShafts proves 0 of 60 tiles because EffectOverBox
returns CarveOnly whenever any shaft sits within a Spacing*1.6 halo rather than
testing real connector capsules. Pessimistic, not wrong — lost CPU, never a hole.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:13:50 +02:00
Fr0zka 3acb3fbc6b feat: port VerticalShafts — three ops reused from Maze unchanged
The port that tests the thesis rather than the fidelity. Previous ports asked whether
the decomposition reproduces the original; this one asks whether operators actually
get reused across archetypes, which is section 2.5's claim and the only reason to do
this refactor instead of tidying the switch.

ConstantRock, SdfRoughness and SdfCarve are Maze's, reused without a line changed.
In the switch, GetMazeDensity and GetVerticalShaftDensity are two ~100-line functions
with nothing visibly in common; as operators they are the same three ops with a
different source and different tuning (freq 0.1 vs 0.12, window rough+4 vs R+rough+2).

New: FShaftFieldSource (infinite cylinders + hash-gated connectors into the SDF
channel) and FShaftLedgeMod (banded shelves on the +X/+Y half so the shaft stays
climbable).

Deviation from section 6, stated: it suggested splitting the source so the XY-pure
cylinder half could get an exact box verdict. Kept as one op because the connectors
derive from the same 3x3 roll and the ledge mod needs the shaft list anyway, so
splitting means rolling twice or sharing a cache between two ops. Forfeited: the exact
verdict on the cylinder half. Kept: a conservative EffectOverBox testing circles and
connector reach.

FShaftLedgeMod gates on the POST-roughness Sdf as the stack left it; re-deriving it
would use the pre-roughness value and shift every ledge. Reading the channel rather
than recomputing is what the two-channel sample is for.

Compile fix: FCells was declared below the functions returning it. Member bodies are
deferred, return types are not.

Ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld (biomes included),
VerticalShafts — 5 of 8.

UNVERIFIED: not compiled past the FCells fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:11:07 +02:00
Fr0zka f3faa3b5c2 perf: the column memo threw itself away every chunk
Jahni measured what I had only flagged: generation is slower on the op-stack path.
Two compounding causes.

The memo was keyed on InstanceId, which changes on every stack rebuild — every chunk.
GSurfColCache, the cache this path replaced, is keyed on (XY box, StrateKey, Seed,
LayoutVersion) with no ChunkZ, deliberately shared down the whole vertical strate
stack. So a 4-chunk strate recomputed every column four times, including the cliff's
four extra structural samples per column.

And the table held 256 entries where a chunk is CHUNK_SIZE^2 = 1024 columns, so it
thrashed against itself within a single tile before any cross-chunk question arose.

PrepareChunk now derives a shared ColumnKey from (StrateBottomWorldZ, LayoutVersion,
Seed) — the same identity GSurfColCache uses — and the table is 4096 entries
(~150 KB/worker, in line with GSurfColCache's 6 x 59 KB). The memo is thread_local so
it already survived rebuilds; only the key was discarding the contents.

Sharing across chunk Z is sound because heights are XY-pure by type and the biome
field is documented Z-independent — the same justification GSurfColCache rests on.

ColumnKey starts at InstanceId rather than 0: slots initialise to Key = 0, so a zero
key would falsely hit the pristine slot at (0,0). Without PrepareChunk you get
per-instance caching, which is less sharing but still correct.

This may not close the gap entirely and I am not claiming it does. Virtual dispatch
and the hashed lookup vs a direct-indexed box both remain; they are smaller than a 4x
column recompute, but "smaller" is a guess until measured.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:59:37 +02:00
Fr0zka c277931a08 feat: SurfaceWorld complete — biome blending wired, guard removed
The two biome checks added last commit printed nothing on success, so a passing run
was indistinguishable from a block that never executed — the exact flaw I flagged
twice this session and then wrote myself. Both now report their coverage.

Step 2c closes SurfaceWorld:

- FSurfaceColumnSource takes per-biome params and an OWNED IVoxelBiomeField. Empty
  params leaves the original path bit-for-bit unchanged.
- The field is owned by the stack rather than borrowed: the adapter points at
  GetDensityAt's thread_local biome context and cache, and the stack is itself
  thread_local rebuilt in the same refetch block, so all three live and die together.
  Structural ownership beats a convention the next reader has to infer.
- The overhang amp blends across biomes — Lerp(Amp(PD), Amp(PN), W) with slope and
  threshold from the dominant only, as ComputeSurfaceColumn does. Interpolating the
  slope would be meaningless; it measures the terrain rather than configuring it.
- FGeneratorBiomeField lives in VoxelGenerator.cpp, on the side that knows the
  generator. The op sees a capability, never an owner — which is what lets it become
  an asset in Phase 3.
- The no-biome guard is removed from UsesOperatorStackForChunk.

Also: the two constructors now delegate to one body with one id counter. The first
draft had two competing counters, one tagged with a high bit to avoid collision,
which is a smell rather than a design.

5 of 8 archetypes ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:51:18 +02:00
Fr0zka 1a3f6b6a72 fix: same stray-brace error as 7cd2bed — and label the boundary so it stops recurring
I anchored on the FACTORIES banner again, which sits after the anonymous namespace's
closing brace, so FBiomeBlendHeightSource landed at file scope and the `}` added with
it closed nothing. Identical to 7cd2bed, in the same file, for the identical reason.

Removed the early closer so the class keeps internal linkage with the other six.

The root cause is that the closing brace was unlabelled, so it reads as noise when
scanning for an insertion point. Both op files now mark it explicitly with what goes
above and what happens if you insert below. Cheap, and it turns a repeatable mistake
into a visible one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:46:18 +02:00
Fr0zka 6ad9f37a65 feat: the Mask combiner — biome blending in height space
Biome blending needs to ask which biome is at an XY, and the real answer is a warped
Voronoi with a per-chunk cache on UVoxelGenerator. The op must not hold a generator
pointer — Phase 3 wants ops to become assets, and one that owns a generator never
can. So it depends on IVoxelBiomeField, a two-line interface returning (dominant,
neighbour, weight), and the adapter that knows the generator stays on the generator's
side. Same move as cliff -> structural: depend on the capability, not the owner.

FBiomeBlendHeightSource holds one complete height stack per biome and lerps the
HEIGHTS in the border band. Each biome's stack computes its own relief and gates its
own terrace, exactly as the original makes two independent full calls and blends only
the outputs. Blending heights rather than params is what keeps borders continuous
across any param difference.

The ceiling SELECTS the dominant instead of blending, because that is what the
original does. Reproduced as-is rather than improved — a blended sky cap changes the
world's silhouette and a port is not where that gets decided.

Tested against a synthetic field rather than the real resolver: the resolver has its
own coverage, while a synthetic field sweeps the weight 0 -> 1 continuously, which is
where an inverted lerp hides. Five weights x 400 points, bit-exact against FMath::Lerp
of the two full stacks, plus a check that the ceiling still returns the dominant's at
weight 1.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:43:01 +02:00
Fr0zka 353c023dfd test: C9 is only half fixed — the libm half survives FPSemantics
All 9 tests green. LargeSeedSurvives proves C1 fixed by property rather than by
comparison: seed 2e9 (what FMath::Rand produces) now yields 400 distinct heights
over 400 samples where it previously gave a constant field. The three equivalence
tests stayed green through an 85-site rewrite.

The digest's NearIso warning fired at 2/115000 and its text blamed the /fp:fast vs
precise split, which is fixed. First instinct was "stale warning, soften it". Checked
instead, and the risk is real by a different mechanism:

sinf/cosf are not specified by IEEE-754. FPSemantics = Precise makes MSVC and Clang
agree on expression evaluation and says nothing about the math library; MSVC's CRT
and glibc's libm may differ by ~1 ULP. FMath::Sin/Cos are used throughout the density
path — layer lines, ribs, room placement, rotations. So C9's compiler half is closed
by construction and its library half is not, and no build flag can close it.

The measurement was also over-stating by ~100x: a single 1e-4 band is far too wide
for a libm-scale delta (~1e-6 absolute at densities of magnitude ~10). Replaced with
a three-band profile; only the tight band warns.

UNVERIFIED: the reworded test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:38:19 +02:00
Fr0zka cd4cf216f5 fix: AUDIT C1 — bounded, site-salted seed offsets across all 85 noise sites
The op stack had already inherited this three times and every remaining port would
copy it again, so fixing it now is cheaper than after.

The audit's documented fix was wrong: bounding SeedF while keeping the * 97.7f
multiplier still reaches 1.6e6, where the ULP is 0.19 — 9.5x the per-voxel step.
Less spectacular, still broken, ticket closed.

VoxelHash::SeedOffset(Seed, SiteKey) inverts the roles: the multiplier no longer
decorrelates by amplifying, it IDENTIFIES the site, and the hash decorrelates.
Output is in final units, bounded to [0, 16383], so the ULP is 10% of a voxel step.
Site-salted, so two seeds must collide at all ~50 sites rather than sharing one
global bucket.

Safe to apply without compiling because the transformation is a pure regex and the
literal stays visible at the call site, so each line remains eye-checkable against
the original. Applied to all three files in one pass so the archetype switch and the
ported ops changed identically — had they not, the three equivalence tests would say
so. 62 + 7 + 16 sites, none left, plus two bare `+ SeedF` worm sites by hand.

New test VoxelForge.Determinism.LargeSeedSurvives (seeds up to 2e9) because the
equivalence tests are structurally blind to this: they compare the stack against the
switch, both read the same faulty expression, so at a large seed both collapse
identically — bit-identical, green, and both flat. An oracle that shares the bug
cannot see it. This test asserts a property instead of a comparison.

EXPECT EVERY WORLD TO LOOK DIFFERENT: this re-rolls every noise offset in the plugin.
Intended, and covered by OPSTACK-PLAN 2.6.1.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:33:36 +02:00
Fr0zka f7ed9407bf feat: wire SurfaceWorld (biome-less) + fix a column-memo perf trap
All six checks green, 9399 samples inside the overhang window.

The single-entry column memo was correct only if the caller walks a Z column before
changing XY, which the mesher does not promise. Iterating X first within a Z slice
would miss on every voxel and re-run the whole height stack per voxel, cliff
resamples included — an order of magnitude on the plugin's most expensive archetype.

The tests could not have caught it: they sample random XY, where a one-entry memo
and a 256-entry one behave identically. Only reading the access pattern finds this.

Replaced with a direct-mapped 256-entry thread_local table hashed on the XY bit
patterns, full key compared on hit, so a collision costs a recompute and never
returns the wrong column.

Wiring: UsesOperatorStackForChunk returns true for SurfaceWorld only when the strate
has no biomes. The original blends heights toward the neighbouring biome across the
border band; the stack evaluates one param set, so a biome strate would get a hard
seam at every border rather than a subtle shift. The guard sits beside the archetype
list so "can this strate take the stack?" stays one question in one place, and
GetDensityAt keeps a defensive CP_BiomeCtx check that falls back if the two disagree.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:27:26 +02:00
Fr0zka 4dc55b1af3 feat: SurfaceWorld step 2b — the overhang shelf, the one op that is genuinely 3D
Step 2a green on all four counts, including FSurfaceColumnSource bit-identical to
GetSurfaceDensity over 20000 samples.

FOverhangShelfMod is the case that justifies where the two spaces were split: its
uphill reach grows with altitude (Frac = (Z - TerrainZ) / OverhangHeight), so it is
essentially Z-dependent and could not have lived in VoxelHeightOp.h. The boundary
falls where the code changes nature.

It needs TerrainZ plus a per-column gate (amp, uphill dir) the source computes.
Recomputing per voxel would pay the cliff's four resamples per lip voxel; adding a
third channel to FVoxelOpSample would put a COLUMN property in a per-voxel slot and
pollute a shared contract (section 11 has that open). Instead the source memoises
the column and the overhang reads it — the same shape as cliff -> structural.

The memo is keyed on (InstanceId, X, Y) with InstanceId from a monotonic atomic
counter, not on `this`: a freed stack and a newly allocated one can share an address,
a never-decreasing counter cannot collide. The stack evaluates every Z of a column at
one XY, so the hit rate is ~1 and this recovers per-column reuse without inventing a
second cross-chunk cache.

ComputeSurfaceColumn and SurfaceDensityFromColumn are now public: they are the only
oracle for the overhang, since GetSurfaceDensity passes OverhangAmp = 0. Private
declarations removed.

The test's third pass places half its samples inside the overhang window on purpose —
a uniform Z draw would almost never hit it and the test would pass having never run
the op, the same trap as WaterLevelRelative in the height pass. The in-window count is
reported and warns at zero.

Still missing before wiring: biome blending (the Mask combiner, section 5's Phase 3
prototype). Do not tick bUseOperatorStack on a SurfaceWorld strate with biomes yet.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:23:27 +02:00
Fr0zka 7cd2bed237 fix: stray brace — the sky cap class landed outside the anonymous namespace
My edit anchored on the FACTORIES banner, which sits AFTER the anonymous namespace's
closing brace. So FSkyCapHeightSource was inserted at file scope and the `}` I added
with it closed nothing — C2059 at the following `}`.

Removed the early closer instead of the late one, so the class keeps internal
linkage alongside the other five rather than leaking into the TU's global scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:18:13 +02:00
Fr0zka 2b2afacd9e feat: SurfaceWorld step 2a — the bridge to density space; fix IsXYPure on the slab
Height stack is green: bit-identical to ComputeSurfaceTerrainZ on both passes,
including all four F20 terrain ops on. MaxDisplacement is loose (27% used) and left
that way — loose only costs CPU, tight-but-wrong is a hole.

FIX: FSlabVoidSource::IsXYPure() returned true and that was wrong. The contract is
"Eval does not depend on Z", and Eval computes min(Z - floor, ceil - Z). Section 3.1
made the SURFACES XY-pure; the density is a distance to them and never can be. I
conflated the two while writing the operator that quotes the warning against it.

Latent only because nothing reads the flag yet — and step 2b is where it would have
gone live, since a generic T1.a column cache keyed without ChunkZ would have shared
one density down the whole vertical chunk stack. AUDIT 6.3 says that corrupts every
chunk silently and ValidateDeterminism would not catch it.

That is also the clearest argument for the height-space split: what is XY-pure is
the HEIGHT, and in VoxelHeightOp.h it lives in a type with no Z to get wrong.

Step 2a:
- FSkyCapHeightSource: the ceiling is an altitude, so it belongs in height space
  rather than density space as section 5 had it — same category slip as the terrain
  ops. The subtraction happens later, in the combine.
- FSurfaceColumnSource: consumes both height stacks, IsXYPure false.
- BuildSurfaceStack: source + 3 structural, no per-column memo inside the op since
  T1.a already exists one level up and a second cache key is a second thing to get
  wrong.

NOT covered, and the test header now says so: the overhang (GetSurfaceDensity passes
OverhangAmp = 0, so only the cached path computes it) and biome blending. Both are
step 2b; do not wire SurfaceWorld into a biome or overhang world before then.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:14:39 +02:00
Fr0zka 921a9fb666 feat: height-space operator family — SurfaceWorld step 1, and C10 is solved
Maze and Slab now report BIT-IDENTICAL: FPSemantics = Precise, set for
cross-platform play, dissolved the ULP residue. Hypothesis 3 had the right
mechanism all along — under /fp:fast the compiler transforms by surrounding
context with no isolable axis, which is exactly why five one-variable experiments
all came back negative. Removing the permission removed the difference. Nobody
solved C10; C9 got fixed for an unrelated reason and C10 fell out of it.

SurfaceWorld step 1 forced an architectural decision. DECOMPOSITION section 5 notes
the height ops operate on Z values rather than density, then lists them as children
of FHeightfieldSource. Writing them made the consequence unavoidable: they do not
fit IVoxelDensityOp. No input Z (they produce one), XY-pure per column rather than
per voxel, and they write neither channel. Forcing them in would need a per-voxel
channel for a column property, or one opaque op — section 2.5's failure mode.

So height space gets its own contract: VoxelHeightOp.h (FVoxelHeightSample with
Height + Relief, IVoxelHeightOp, FVoxelHeightStack) and five ops. Relief is the
original's M — produced by the structural source, consumed by the terrace gate.
Section 0.1 found density needed a second channel; this found terrain needs a
second space.

The type system now forbids for free what AUDIT 6.3 warns about: a height stack
cannot hold Z-dependent data because there is no Z in the signature.

Deliberately staged — this touches nothing on the density path. If height space had
not decomposed cleanly, it shows up here for one test rather than after building the
adapter, the column cache integration and the dispatch on top.

The test runs twice; the second pass is load-bearing because the F20 terrain ops are
off by default, so a defaults-only run leaves all four modifiers untested. It also
brute-forces MaxDisplacement, since a false bound would later be a hole.

ComputeSurfaceTerrainZ moved private -> public for the test, same justification as
GetSlabDensity. Old declaration removed.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:06:50 +02:00
Fr0zka 25df4fceff build: complete the IWYU tail — APawn in VoxelWorld.cpp
VoxelWorld.cpp:526 dereferences the pawn, so APawn must be complete; Casts.h only
forward-declares it. Adds GameFramework/Pawn.h, and PlayerController.h which was
complete transitively only — the same fragility this change removes.

My earlier scan covered Public/ only. The shared PCH served .cpp files too, and
APawn was named in Build.cs's own error list. Everything else in the module
compiled, so this is the entire tail.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:56:06 +02:00
Fr0zka 6a60732c98 build: FPSemantics = Precise + clear the IWYU debt that blocked it
Jahni: cross-platform play (Linux and Windows, either side hosting) is a product
requirement, so fix C9 at the cause instead of measuring the symptom.

Verified in UE 5.7 source rather than assumed:
  VCToolChain.cs:1264    Default -> /fp:fast          Precise -> /fp:precise
  ClangToolChain.cs:712  Default -> -ffp-contract=off Precise -> -ffp-contract=off
Default really did mean opposite float models per platform; Precise collapses them
onto the same one, so a Windows host and a Linux client agree by construction.

Losing the shared PCH is what the IWYU debt hid behind. Every use turned out to be a
pointer, TWeakObjectPtr or TSubclassOf parameter, so forward declarations suffice;
only the templates and macros needed real includes. Seven headers fixed.

VoxelDensityVolume.h was the one worth catching: it tests ENABLE_DRAW_DEBUG in an
#if, and an undefined macro there is silently 0 — the debug block would have
vanished without a warning rather than failing the build. Include paths verified
against the engine tree, not guessed.

Expect a residual tail; the shared PCH hid these for years and only a build
enumerates them all. Build.cs now says so, and says the fix is to add the include
rather than revert FPSemantics.

Also adds VoxelForge.Determinism.CrossPlatformDigest: SHAPE digest (sign of density
= the world) and FIELD digest (bit-for-bit) over a fixed integer grid, plus NearIso
to bound how many samples could flip sign at all. Reports rather than asserts until
pinned. The cross-platform comparison itself is deferred per Jahni.

Expect a perf regression from losing reassociation and contraction on a noise-heavy
hot path — measure against ARCHITECTURE 8.10.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:53:23 +02:00
Fr0zka f1fd1e0b05 docs: new acceptance bar (peer agreement, not fidelity) — and C1's documented fix is wrong
Jahni: "I do not need your work to be identical or near identical to what I had
before, only having it 99.99% at worst reproducible if two people share the same
seed, since everyone rebuilds it on multiplayer."

Recorded as OPSTACK-PLAN 2.6.1, superseding 2.6's "recognisably the same place".
Consequences, each recorded where it will be found:

- C10 closed permanently rather than parked: it measures old-path vs new-path
  agreement and the two never coexist in a shipped world.
- The equivalence tests keep their value as PORT-CORRECTNESS checks, not fidelity
  checks. Isosurface hard-fail stays; ULP grading is diagnostic only.
- C9 promoted to top open risk. "Two people share a seed" is exactly what /fp:fast
  weakens across toolchains, and a Linux dedicated server against Windows clients
  compiles the density path under opposite float models. FPSemantics = Precise is
  the fix and the IWYU debt now blocks something that matters.
- C1 unblocked: it was deferred only because it re-rolls the world's noise.

Then, doing C1's arithmetic before applying its documented one-liner: THE FIX IS
WRONG. It bounds SeedF but keeps the * 97.7 multiplier, so the coordinate term
still reaches 1.6e6 where the ULP is 0.19 — 9.5x the ~0.02/voxel step. It would
have left the bug live for mid-range seeds while closing the ticket.

The real fix deletes the multipliers: they only decorrelate the ~40 noise sites,
which is a hashing job. VoxelHash::SeedOffset(Seed, Site) gives a site-salted,
bounded, final-units offset. Bounding SeedF alone would also funnel every seed
through 16384 offsets shared by all sites; per-site salting requires a collision at
all ~40 sites instead.

The op stack has already inherited the bug via FSlabVoidSource, so it must land in
both paths at once — and every further port copies it again.

Docs only; the C1 fix is not written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:43:23 +02:00
Fr0zka 51d8db842b test: fix the ULP yardstick — it measured output density, not where the error is born
The tuned pass warned: 121/20000 differ, 6 over the bound, worst 1.72e-05, still 0
isosurface crossings. The port is fine; the bound was wrong.

It was 16 * max(|Old|, 1) * FLT_EPSILON — ULPs on the OUTPUT density. But density is
min(Z - Floor, Ceil - Z), so near the isosurface the output tends to 0 while the
intermediates are in the hundreds. Rounding born at scale ~400 judged against a
yardstick of scale 1: 400x too tight, and tightest exactly where the test looks
hardest. Large amplitudes are what expose it, which is why the tuned pass earned
its place immediately.

Measured rather than assumed: amplitudes rose x2.25-3.33 and the deltas rose x4.5,
with the worst delta at 0.345 ULP of |Z| — sub-ULP at the scale it is born in. Error
proportional to amplitude is ordinary rounding. A wrong noise offset or a missing
abs() would move the surface by voxels, four orders of magnitude above this.

The bound now scales with max(|Old|, |Z|, strate Z bounds), and the warning prints
the discriminator instead of just the alarm: the density at the offending sample and
the delta in ULPs of the working scale. A few ULP at near-zero density is
cancellation; thousands is drift. That distinction is now readable rather than
re-derivable at a build apiece.

The box verdicts held under the worst case: 32/60 proved uniform, 0 unsound, under
tripled ceiling roughness and 3x the columns — exactly the case that stresses the
Max(CeilZ - noise, FloorSurface + 2) clamp.

Also recorded in DECOMPOSITION section 3: FlatPlain and CrystalChamber render
identical in the live world because nothing in the content distinguishes them. The
merge loses no distinction; it reveals there was none.

UNVERIFIED: the corrected bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:39:03 +02:00
Fr0zka 85993199fb test: the slab test proved less than it claimed — add the pass that varies CeilingRoughness
SlabEquivalence came back green (FlatPlain 36/60 and CrystalChamber 40/60 tiles
proved uniform, vs zero for today's ClassifyTile; 52/20000 ULP-scale diffs, 0
isosurface crossings). But both archetypes reported the SAME 52 and the same worst
delta, which pointed at the fixture: FTestWorld::Build sets only GeneratorType, so
both slots carry DEFAULT slab params.

So the two passes were the same configuration at two depths. The test claimed to
demonstrate "one op, two archetypes" while never varying CeilingRoughness — the
only field that actually distinguishes CrystalChamber. The differing tile counts
come from the slots' Z ranges, not from the archetypes.

Third pass added: CrystalChamber(tuned), CeilingRoughness 6 -> 20, rougher floor,
3x the columns. It varies what matters and doubles as the worst case for the
ClassifyBox amplitude bounds — a large CeilingRoughness widens the ceiling band and
makes the FloorSurface + 2 clamp far more likely to bind, which is precisely where
a false verdict would be a hole. The default params were too gentle to stress it.

The ULP residue is left alone: deterministic, 0 isosurface crossings, and the same
shape C10 already cost six builds to prove not worth chasing.

UNVERIFIED: the third pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:34:16 +02:00
Fr0zka 644339def5 feat: Phase 2 first port — FlatPlain + CrystalChamber collapse into one op
Jahni closed OPSTACK-DECOMPOSITION 3.1: the slab noise Z term was not
intentional character. Phase 1 also closed — the visual A/B on Maze passed.

Two changes, deliberately together, kept attributable by the test:

1. Design: GetSlabDensity's floor and ceiling noise lose their Z terms. A floor
   height no longer depends on the altitude you sample it from. The ceiling keeps
   its + 3000.0f, which is a decorrelation offset, not a Z term. The world
   re-tunes once — a different slice of the noise field, not a worse one.

2. Refactor: the now-XY-pure function ports to FSlabVoidSource + FGridColumnMod
   plus the three structural ops. BuildSlabStack has NO branch on archetype
   because GetSlabDensity never had one — CrystalChamber is FlatPlain with a
   bigger CeilingRoughness. 8 archetypes -> 7.

SlabEquivalence compares against the reference AS IT IS NOW and runs the whole
battery on both slots, so green means the port is a pure refactor and any visual
delta is attributable to the Z-term removal alone. The attribution comes from the
test, not from splitting it across two builds.

The payoff 3.1 was actually about: FSlabVoidSource::ClassifyBox is exact and needs
no sampling. FBM is contractually [-1,1], so both surfaces live in Z bands with
known bounds — a tile below the floor band is provably solid, a tile between the
bands provably air. ClassifyTile proves zero tiles for these archetypes today.
FGridColumnMod answers Identity when no column reaches the box, which is what lets
the source's AllAir verdict survive the fold.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:30:26 +02:00
Fr0zka 974e795b66 fix: call PrepareChunk and guard the degenerate strate on the wired path
Read the step-3 wiring against the equivalence test before spending a build.
The symbols all line up; the two PATHS did not.

- GetDensityAt never called FVoxelOpStack::PrepareChunk, though the test does.
  All seven concrete bodies are empty today so behaviour is unchanged — which is
  the reason to fix it now: the first op to hoist real per-chunk work would have
  been green in test and silently wrong in game. Builds an FVoxelOpContext in the
  same refetch block (chunk, seed, layout version, strate Z bounds). Step stays 1;
  GetDensityAt does not know the mesher's sampling step (T2.b).

- GetMazeDensity early-outs to air on a degenerate strate (height <= 0) and the
  stack has no such early-out by design. Unguarded that is air on one path and
  spine/seal-of-a-zero-height-band on the other, so the wired path now falls back
  to the switch there — the reference behaviour is the behaviour.

Docs: VoxelDensityOpStack.h's banner still claimed nothing here feeds the game,
and CODEMAP 3.2d repeated it. Both now state what is wired (GetDensityAt) and
what is not (ClassifyTile, hand-written guards, Phase 2), with C10's never-compare
rule at the point of use. CODEMAP gains UsesOperatorStackForChunk and
bUseOperatorStack rows, and BuildMazeStack's degenerate-strate precondition.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:15:59 +02:00
Fr0zka f7cccb044b docs: add OPSTACK-HANDOFF.md; install the 39 Unreal skills
Handoff: a pasteable resume prompt at the plugin root -- read order, exact state
(Phase 0.5 green, Phase 1 done and measured, step 3 written but not compiled),
the immediate next action, hard rules, open items, and the method lesson from
this session.

Skills: Jahni's UE library was at .claude/skills/core/<name>/SKILL.md, two levels
deep, where Claude Code discovers skills one level deep -- so none of the 39 were
loading. Flattened; 124 reference files intact, all frontmatter valid, folder
names already matched their name: field. core/category.md left as documentation.
Confirmed loading.

They are untracked and cannot be tracked without un-ignoring .claude/ itself
(git cannot re-include a file whose parent directory is excluded). Same shape as
AUDIT P1; flagged, not actioned.

Note for the next session: module-and-build-system documents PCHUsage, shared
PCHs and IWYU -- the exact mechanism that blocked C10's settling experiment. That
skill was in the repo, undiscovered, while it was worked out the slow way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:11:26 +02:00
Fr0zka 256a262140 feat: Phase 1 step 3 — wire the operator stack into GetDensityAt behind an opt-in
One branch on the density path, as OPSTACK-PLAN section 4 specified, and both
systems coexist.

- UVoxelStrateDefinition::bUseOperatorStack: the A/B switch section 2.6's
  acceptance bar needs. Flip it, regenerate, judge on a screenshot.
- UVoxelStrateManager::UsesOperatorStackForChunk(): the ported-archetype list,
  written down in exactly one place. An unported archetype ignores the flag and
  falls back to the switch, so ticking the box anywhere is harmless today and
  only Maze changes behaviour.
- GetDensityAt: CP_OpStack / CP_UseOpStack are resolved inside the SAME refetch
  block as the params, so the existing chunk + LayoutVersion key already covers
  them and there is no new invalidation logic to get wrong.

Hot-path cost is one bool test per voxel; the stack is built per chunk, the same
cadence as the param refetch. ApplyDisturbances and the diff layer stay outside
the stack and run once for both paths, so the tail of the pipeline is unchanged.

If UsesOperatorStackForChunk ever returns true for an archetype with no builder,
the code clears the flag and falls back to the switch rather than generating an
empty stack. An unported world is recoverable; a wrong one is not.

UNVERIFIED: not compiled. Likely spots: the `else switch` form, FVoxelOpStack as
a thread_local (move-only, reset by move-assigning a temporary), and the new
include.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:03:00 +02:00
Fr0zka 34f06dfea7 docs: park the ULP residue as AUDIT C10; strip the diagnostic scaffolding
Jahni's call to pin it and move on, and the right one -- six builds spent and the
information stopped being worth the cost.

The final run closed it as far as it can be: SDFs identical everywhere (counted
unconditionally, 0 differ), yet two character-identical carve implementations in
the SAME translation unit fed a provably identical input differ by 1 ULP on
126/5000. That is only possible if they compile to different instruction
sequences, which /fp:fast permits based on surrounding context with no single
isolable axis. Hypothesis 3 was right about the mechanism and wrong about every
clean variable proposed for it, which is why four well-designed isolation tests
came back negative.

AUDIT C10 records the observation, what is proven (SDF bit-exact 126/126, zero
isosurface crossings), the five refuted hypotheses in a table so nobody repeats
them at a build each, why the settling experiment is blocked (shared-PCH / IWYU
debt), and the rule that actually matters: never run both density paths in one
world and never compare them for equality. That is NOT a client-desync risk --
within a binary the field is proven bit-pure and every peer runs the same path --
the cross-platform concern is C9, which stands on its own.

Corrected OPSTACK-PLAN 2.6 and C9: my earlier "/fp:fast across translation units"
explanation was measurably wrong and is removed rather than softened.

MazeEquivalence keeps the permanent value (equivalence with ULP grading,
window-invariance, box-verdict brute force) and drops the verbatim copy,
three-way, bisect, inlining and constness experiments.

Phase 1 closed: Maze decomposes into 7 ops, SDF bit-exact, 0 isosurface
crossings, window-invariant, and 23 of 60 tiles proved uniform where ClassifyTile
proves zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:00:46 +02:00
Fr0zka b6ccb1c9f1 test: constness isn't it either — add the one unambiguous check, then stop chasing
CONST-Blend == runtime-Blend exactly (0 differ) and both miss the verbatim on the
same 126, so Blend's constness is not the variable. Five hypotheses, five dead.

Worse: one number I reasoned from was circular. "FORCENOINLINE == operator stack
: 5000/5000" cannot fail by construction -- it feeds S.Sdf to a carve and compares
against the density the stack computed from that same S.Sdf. It measures nothing,
and I read it as corroboration.

The two carve bodies are now dumped from the file and diffed: character-identical,
same translation unit. So one of expression / TU / input is not actually
identical, and the counters can't say which because the SDF comparison only ran
inside the mismatch branch.

Added: feed my carve the SDF the verbatim reports using and compare to the
verbatim's own output, plus count S.Sdf != VerbSdf directly with no enclosing
condition. That distinguishes "same function, same input, different output"
(measurement artefact) from "the SDFs were never equal outside the mismatch set"
(fault back in the lattice).

Proportion: this is the last build worth spending here. The port is already
verified where it matters -- SDF bit-exact 126/126, 0 isosurface crossings out of
20000, geometry identical, window-invariant, every box verdict brute-forced. The
open question is why the final rounding differs by 1-2 ULP, and no decision in
this project turns on it. If the check doesn't resolve it: accept, correct the
docs, strip the scaffolding, resume Phase 1 step 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:57:21 +02:00
Fr0zka 7189d51b7b test: the variable is compile-time-constant vs runtime Blend — one line to confirm
The inlining experiment partitioned everything, just not along the axis it was
framed on:

  inlined != FORCENOINLINE  : 0          inlining is not the variable
  FORCENOINLINE == op stack : 5000/5000  test-TU carve == other-TU op, always
  inlined == verbatim       : 4874/5000  126 differ, in the SAME TU

The test-TU carve matches the operator stack across a TU boundary perfectly and
disagrees with the verbatim inside its own TU, so neither TU nor inlining is it.
Sorting the five implementations by the one remaining difference splits them
exactly: A (GetMazeDensity) and C (verbatim) hold Blend as a compile-time
constant; B (FSdfCarveOp, a member) and both parameter versions hold it as
runtime data. A == C, B == Inl == Noi, and the groups differ. Every observation
today fits that and nothing else does.

Mechanism: under /fp:fast, folding Blend * 2.0f to the literal 4.0f enables a
contraction in SmoothStep01's 3.0f - 2.0f*x -- one rounding instead of two --
that the runtime form cannot get.

This matters beyond the bug: an op's parameters are DATA by design, which is the
entire point of the refactor, so they can never be compile-time literals again.
The ULP difference is therefore inherent and permanent for every archetype port,
and no care in transcription will remove it. That is the real reason bit-identity
is unachievable here -- the earlier /fp:fast note named the right compiler flag
for the wrong reason.

CarveConstBlend added: identical to CarveInlined except Blend is a compile-time
constant. Predicted to match the verbatim 5000/5000 and differ from the runtime
form on exactly 126.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:53:02 +02:00
Fr0zka 34f8ca7953 test: localised to FSdfCarveOp — now testing inlining context, the right variable
The SDF is bit-identical on 126 of 126 mismatches (0xBFE1C886 both sides), so the
lattice, hashes, edge set and VoxelSDF::Capsule are exactly right. The entire
difference is in FSdfCarveOp, whose expression is character-identical to the
original and whose inputs are bit-identical.

Identical inputs plus identical expression plus different output means the
arithmetic is being EVALUATED differently. SmoothStep01 is x*x*(3.0f - 2.0f*x),
and 3.0f - 2.0f*x is exactly the shape MSVC fuses into an FMA: one rounding
instead of two, ~1 ULP.

Why the three-way missed this, recorded because it is a reasoning error rather
than a coding one: A and C are both straight-line inlined code, while B goes
through a virtual IVoxelDensityOp call, so FSdfCarveOp::Eval is compiled
out-of-line and can get a different contraction decision. The three-way tested
whether the TRANSLATION UNIT boundary changes the result -- it does not -- but the
real variable is the OPTIMISATION CONTEXT. I built a clean experiment for the
wrong variable and then believed its answer. Hypothesis 3 was right about the
mechanism and wrong about the test.

The new experiment isolates exactly that: the same carve expression, same TU,
once FORCEINLINE and once FORCENOINLINE.

  differ    -> contraction confirmed, the port has NO bug, accept the ULP floor
  identical -> contraction is not it, and FSdfCarveOp has a real logic bug that
               has survived four readings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:48:56 +02:00
Fr0zka 210602586e test: three-way says the fault is mine — compare the SDF channels directly
A vs C = 0 differ. Identical source in two different translation units produces
identical results, so the compiler was never the cause and the operator stack
differs for a logic reason. That kills the /fp:fast story for the fourth time
running, and for the first time points at code I own.

Reading has failed three times: the ctor clamps, cell FloorToInt, NodeCenter,
EdgeOpen's hashes and salts, the {-1,0}^3 sweep and its add order, the capsule
fold, and the carve are all identical to the verbatim copy line by line. So stop
reading and measure one level deeper.

MazeCoreVerbatim now optionally returns its SDF and edge count, and the three-way
compares SDF channels directly instead of inferring from densities:

  SDF identical, density differs -> fault is in FSdfCarveOp
  SDF differs                    -> fault is in FLatticeCorridorSource

It reports the split across all 126 mismatches and dumps the first one in hex
with the verbatim edge count, so a differing edge SET (a cache-key bug) shows up
as a count mismatch rather than needing to be inferred.

Docs to walk back once the cause is known, listed in OPSTACK-PROGRESS so they are
corrected once with the right explanation: OPSTACK-PLAN 2.6's "bit-identity is
unachievable" note, AUDIT C9's first consequence, and this test's own INFO text.
C9's second half -- that UBT's FP default differs by toolchain and the MP model
assumes bit-reproducible terrain -- stands; it came from the engine source, not
from this test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:44:11 +02:00
Fr0zka d5c71d6b68 build: revert FPSemantics (drops the shared PCH); answer the FP question in the test instead
Setting FPSemantics on VoxelForge broke the build with ~30 "undefined type"
errors -- UMaterialInterface, USoundBase, TSubclassOf<AActor>, APawn,
ENABLE_DRAW_DEBUG -- none of them FP-related. UBT can only share a precompiled
header between modules whose compile environments match, so changing FPSemantics
cost the module the engine's shared PCH and with it ~30 includes the plugin has
always relied on getting for free.

That is a genuine latent IWYU debt in seven files, and worth fixing on its own
terms one day, but not inside an unrelated diagnostic. Reverted, with the reason
recorded in Build.cs so nobody retries it blind.

The question it was meant to settle is now answered without touching any build
setting: MazeEquivalence compiles a verbatim copy of the Maze core into the
TEST's translation unit and compares three implementations of identical source --
the generator's TU, the op stack's TU, and the test's own.

  A != C          -> same source, different TU, different result: the compiler.
                     Nothing to fix in the port.
  A == C, B != C  -> source is TU-stable, so the op stack differs for a LOGIC
                     reason, and it is in FLatticeCorridorSource or FSdfCarveOp.

Duplicating code is normally a fault. Here it is the only instrument that can
answer the question, because three careful readings all concluded "identical" and
the test keeps disagreeing. Marked diagnostic-only; it comes out once answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:39:09 +02:00
Fr0zka a49d440bf6 build: put FPSemantics=Precise on the VoxelForge module — the experiment never ran
FPSemantics is a per-module ModuleRules property. It had been set on the VoxelM
GAME module, while every line of density code lives in VoxelForge, which kept
compiling /fp:fast. So the run that supposedly "reproduced the residue under
precise semantics" ran under fast semantics and proves nothing.

Retracting the previous commit's conclusion: /fp:fast is NOT eliminated as the
cause, and the notes claiming AUDIT C9 and OPSTACK-PLAN 2.6 are falsified are
withdrawn with it. Those documents were fine.

My error, and the third of its kind today: I reasoned a confident conclusion from
an unverified premise, one paragraph after writing that the lesson was to
instrument rather than assume. Checking took one grep and I only ran it after
Jahni suggested it.

The line is marked TEMPORARY with removal instructions and a guide to reading the
result. Combined with the WORST-POINT DUMP already committed, one build now
separates the two possibilities cleanly:
  454 -> 0   : FP model was the cause; keeping precise then needs a profile,
               because it costs the vectorisation T2.a's SIMD work was buying.
  454 -> 454 : real logic difference; read the dump.

Either way the line comes back out afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:34:50 +02:00
Fr0zka cca9e83182 test: /fp:precise reproduced the residue byte for byte — instrument instead of guessing
An /fp:precise build returned the identical 454 samples, identical max delta,
identical coordinate. A different float model producing byte-identical output is
proof that rounding is not the cause, so the /fp:fast explanation is dead. That
is three failed hypotheses on one discrepancy (FVector round-trip, then "check
the roughness window", then /fp:fast), each reasoned from plausibility and each
costing a build.

So: stop reasoning, print. FVoxelOpStack::EvalSample exposes the full sample, and
MazeEquivalence now dumps the worst point in raw hex -- both densities, the
stack's internal SDF, and the carve factor reconstructed from each side. The
recovered carve localises it: identical carve + differing density means the fault
is after the conversion; differing carve means it is in the SDF (lattice edges or
VoxelSDF::Capsule) or in SmoothStep01.

Note for whoever reads the docs next: AUDIT C9 and OPSTACK-PLAN 2.6 currently
assert the /fp:fast story as the explanation for THIS residue. That specific
claim is falsified and needs walking back once the dump identifies the real
cause. C9's other half -- that UBT's FP default differs by toolchain and the MP
model assumes bit-reproducible terrain -- stands independently; it was read out
of VCToolChain.cs and ClangToolChain.cs, not inferred from this test.

Phase 1 step 3 (wiring the stack into GetDensityAt) is paused until this is
understood. Small unexplained numeric differences do not get smaller when you
build on top of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:32:22 +02:00
Fr0zka 0379d59c1c docs: AUDIT C9 — the FP default differs BY TOOLCHAIN, not just "fast is allowed to drift"
Jahni asked the right question: does the same seed produce identical results
across OS builds today? Checking the other toolchain made the answer sharper and
worse than what C9 originally said.

ClangToolChain.cs (Linux, Mac, Windows-with-Clang):

    case FPSemanticsMode.Default: // Default to precise FP semantics.
    case FPSemanticsMode.Precise: Arguments.Add("-ffp-contract=off");

and VCToolChain forces Precise when Windows uses 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 not merely permitted to diverge -- they are
compiled under different rules.

Also added, so the entry does not over-fear itself:
- Calibration: 0 of 20000 samples crossed the isosurface under a 1-ULP
  perturbation, so divergence means occasional single-voxel surface differences,
  not different terrain. The case that bites is topological (a cave pinch-point
  connecting on one build and not the other), which is rare and unreproducible --
  the expensive kind.
- Precise everywhere still would not guarantee cross-platform bit-identity:
  FMath::Sin/Cos route to platform libm, which is not bit-standardised. It closes
  the large gap, not every gap.
- The knob would ALIGN Windows with every other platform rather than being a
  one-sided cost -- but still must not be turned speculatively.
- The claim is inferred, not measured. The cheap decisive test is one Windows
  build with FPSemantics = Precise: if MazeEquivalence's 454-sample residue
  vanishes, the FP model is confirmed as the sole cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:19:42 +02:00
Fr0zka af5f2103b3 test: the Maze residue is /fp:fast, not port drift — encode the real bar
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>
2026-07-27 14:15:43 +02:00
Fr0zka 23605d5350 test: bisect the residual Maze difference instead of guessing at it again
All six tests are green as of the 12:02 run, so Phase 0.5's gate is met.

MazeEquivalence still reports 454 differing samples, the same max delta, at the
same coordinate as before -- byte for byte the previous result. So the FVector
float->double->float hypothesis from the last commit is dead: that detour is a
no-op, exactly as /fp:precise says it should be. It stays (harmless, and it
documents the original's shape) but it explains nothing.

Rather than propose a third guess, MazeEquivalence now bisects: it re-runs the
comparison with roughness, then seal, then spine, then passages disabled ON BOTH
SIDES, and reports which stage's removal makes it bit-exact. One run answers what
two hypotheses failed to.

Standing hypothesis for the bisect to confirm or kill: compiler float
contraction across translation units under /fp:fast, worth ~1 ULP. It fits the
~2% hit rate -- only voxels inside the narrow SDF blend shell have an unsaturated
carve factor; everywhere else Carve is exactly 0 or exactly 1 and both paths
agree bit for bit. If confirmed, bit-identity is not achievable in principle for
these ports and the bar for every later archetype is "zero isosurface
crossings", which is what OPSTACK-PLAN 2.6 asked for anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:07:14 +02:00
Fr0zka 826a8c99dc fix: first-green-build follow-ups (diff-layer assertion + Maze float rounding)
The build passed and six tests ran; five green. Details in OPSTACK-PROGRESS.md.

1. DiffLayerContention's failure was the test, not the plugin.
   GetTotalModificationCount() sums STORED ENTRIES, not operations -- a stroke is
   filed under every chunk its AABB overlaps, so 400 radius-6 spheres straddling
   chunk corners store 3200 entries. The assertion now compares the stored count
   against the chunk fan-out ApplyModification itself returned, which also checks
   that the re-mesh list handed to the caller describes what was actually written.
   Everything the test exists for had already passed: 7 readers, 28.7M read
   rounds against 760 writes and 6 Clear()s, no crash, monotonic version.

2. MazeEquivalence: 454/20000 samples differed by at most 1.907e-06 -- exactly
   one ULP at magnitude 16 -- with ZERO crossing the isosurface, i.e. not one
   triangle would move. Leading hypothesis: the original routes noise coords
   through an FVector (double in UE5) and back to float, rounding twice, while
   the op passed floats straight through; under /fp:fast those round differently.
   The op now reproduces the detour on purpose, with a comment against
   "simplifying" it. Unverified -- it predicts 0 differences next run. If drift
   remains, next candidate is FMA contraction across translation units.

Also recorded, because it is the perf half of the whole refactor: the Maze op
stack proved 23 of 60 tiles uniform. ClassifyTile proves ZERO for any cave
archetype today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:40:51 +02:00
Fr0zka 62e3d5a933 fix(build): FVoxelOpStack is move-only, so it must not be a dllexported class
MSVC C2280 on TArray<TUniquePtr<IVoxelDensityOp>>'s copy path. Putting
VOXELFORGE_API on the class forces the compiler to instantiate every implicit
member, including copy-assignment -- which cannot exist for a move-only element
type. The export moves to AppendStructuralPost, the only out-of-line method.

Also declares the move-only-ness explicitly rather than leaving it implied. That
is the right semantics independently of the compiler: a stack uniquely OWNS its
operators, and copying one would mean cloning polymorphic ops, which is
meaningless here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:36:08 +02:00
Fr0zka 8a33bcb42a docs: CODEMAP rows + progress entry for the Phase 1 Maze port
CODEMAP gains 3.2c (VoxelDensityPrimitives), 3.2d (the operator stack and its
factories), FVoxelOpSample under 3.2b, and the two new tests under 3.12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:33:00 +02:00
Fr0zka 4c53d3bbed feat: Phase 1 — Maze ported to the operator stack, OFF the hot path
Maze decomposes into seven ops with no contortion:

  ConstantRockSource -> LatticeCorridorSource -> SdfRoughnessMod -> SdfCarve
  -> OriginSpine -> BoundarySeal -> PassageCarve

That is the answer to Phase 1's actual question (OPSTACK-PLAN section 4's
stop-trigger: "does the source/modifier split fall out naturally?"). It does.
Three of those ops are already shared: ConstantRockSource is the first line of
TunnelNetwork, Maze AND VerticalShafts; SdfCarve is the same six lines in all
three; the structural post is identical across all six density functions.

GetDensityAt and ClassifyTile are NOT touched. The archetype switch is still the
only path feeding the game, so nothing in a running world can change. The port
is validated instead by VoxelForge.OpStack.MazeEquivalence, which compares the
stack against GetMazeDensity over 20k points, re-checks purity across worker
threads, and brute-forces every box verdict the stack emits.

Two contract decisions, delegated and taken:

1. Eval is now two-channel (FVoxelOpSample { Density, Sdf }). Maze forces it:
   its roughness perturbs the SDF, not the density, and on density the same
   noise scales with the local gradient and is a visibly different effect. It is
   also what lets two different sources SmoothMin together later, which is the
   difference between a composed idea belonging somewhere and being punched into
   it.

2. The stack's density channel is INTERNAL convention (positive = solid),
   negated once by the caller. This REVERSES what the header said yesterday.
   Every archetype body is already written that way, so each port becomes a
   literal transcription instead of a sign-flip of every line -- on the plugin's
   documented #1 source of confusion. The SDF channel keeps standard SDF
   convention, so min() means opposite things on the two channels; the header
   says so loudly.

Also extracts spine/seal/passage from VoxelGenerator.cpp into
Public/VoxelDensityPrimitives.h so the generator and the ops share ONE copy of
three world invariants. Forwarders keep the local names, so not one of the ~20
call sites changes; bodies are byte-identical.

One thing found while writing the seal's ClassifyBox and NOT silently fixed: at
the inner edge of a seal band, 1 - Dist/Thickness can round to exactly 0.0f, so
SealFactor*BaseDensity is 0, internal density lands on 0, and the mesher counts
that as AIR. Claiming AllSolid there would be a hole. The new op keeps a 1-voxel
safety margin before it forces. Today's ClassifyTile has no such margin -- the
window is hairline and needs the archetype to produce air at exactly that z, but
it is real. Reported rather than patched, since Phase 1 does not touch that path.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:32:09 +02:00
Fr0zka 3e4ee198fe docs: progress marker before the Phase 1 Maze port
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:23:19 +02:00
Fr0zka beb66e06d4 test: unit-test the op-stack fold, and make the build actually see VoxelDensityOp.h
VoxelDensityOp.h was included by no .cpp, so the compiler would never have
looked at it -- a header committed as "ready to build" that the build ignores.
The ClassifyTile test now includes it, which is also the right home for the
fold's own test: the fold claims to reproduce the hand-written ClassifyTile, and
that claim is pure logic with no world, noise or threads behind it.

VoxelForge.OpStack.BoxVerdictFold walks the correspondence case by case,
including the one that justifies ClassifyBox existing at all: a box entirely
inside the top seal band, where the source says AllAir and the seal forces
AllSolid. A pure FillOnly would resolve that to Mixed and silently lose a tile
T1.d skips today.

Also casts UE_ARRAY_COUNT to int32 in the fixture -- signed/unsigned comparison
in a loop condition is a warning, and UE builds warnings as errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:09:17 +02:00
Fr0zka f8194dbf92 docs: correct an inaccurate claim in an earlier progress entry
The "starting Phase 0.5" entry said the ClassifyTile test self-skips when the
fixture fails to build. It does not -- all four tests fail loudly with a message
identifying it as a fixture failure. Corrected by appending, since the log is
append-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:07:49 +02:00
Fr0zka 6267af86a7 docs: tick OPSTACK-PLAN phases + final progress entry for the unattended session
Phase 0.5 and the Phase 1 skeleton marked WRITTEN / NOT COMPILED (not "done" --
the gates are not met until the tests actually run). Section 8 independent fixes
2, 5 and 6 ticked. Section 9 "Resume here" now says BUILD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:07:32 +02:00
Fr0zka 831ee2fbf7 docs: Q3 tick the stale PENDING BUILD markers + CODEMAP rows for the new symbols
Q3: fable-idea F20 phases 1/2 and F18, REVIEW_FINDINGS perf pass 2 and batch 3,
and ARCHITECTURE's biome full-param redesign were all still carrying
"CODE-COMPLETE, PENDING BUILD" markers dated 07-04/-06/-08. Jahni confirmed on
2026-07-26 that everything is built and working (AUDIT-2026-07.md §0), so the
documents were misreporting project state. Ticked with the date they were
ticked, not just the date they were built.

Deliberately NOT ticked: ARCHITECTURE's F6 master material graph. Its C++ half
is built, but the material graph itself is editor-side work that is genuinely
still open, and ticking it would recreate the problem this queue item fixes.

CODEMAP discipline for this batch: new §3.2b (the VoxelDensityOp contract), new
§3.12 (Private/Tests), the EVoxelTileClass move into §3.2, and
FChunkBiomeCache::Invalidate under the biome types row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:06:32 +02:00
Fr0zka c188ee8262 docs: OPSTACK-DECOMPOSITION.md — the per-archetype op breakdown (Q1 + Q2)
All 8 archetypes read line by line and broken into field source / combiners /
detail modifiers / structural post, with every FStrateGenerationParams field
traced to the op that will own it.

Three findings that change the sequencing:

- The op contract needs an SDF channel alongside density. Rooms, pits and
  chimneys are SmoothMin'd in SDF space before a single carve, and three of the
  four SDF archetypes add roughness to the SDF rather than to density. A
  single-channel Eval can only overwrite, which is also why cross-source
  SmoothUnion -- 'a maze inside a mountain that looks like it belongs' -- is not
  expressible without it. Recommended before the Maze port; not applied, it is
  Jahni's call.

- Worm tunnels are why TunnelNetwork can never skip a tile. A fielded 3D-noise
  carve with no bounds forces CarveOnly everywhere, killing AllSolid for the
  most-used archetype. But its amplitude is trivially bounded by WormStrength,
  so a scalar cap recovers deep-rock skipping -- suggests one numeric bound
  belongs in Phase 2, not Phase 3 as the plan has it.

- Disturbances already carry lattice bounds that ClassifyTile discards (it only
  tests ChasmDensity > 0 strate-wide). Making them ops with real Identity tests
  is a tile-skipping win for SurfaceWorld available independently of everything
  else.

Q2 param audit: every field claimed except WaterLevelRelative, which is a
render/water property misfiled in the density struct and Lerp'd across strate
boundaries. Also flags three op names that mean different things in the cave and
surface structs and will collide the moment ops become assets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 02:04:00 +02:00
Fr0zka a820f140e1 docs: progress log — batch A+B landed, build gate reached
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:58:47 +02:00
Fr0zka b4d13e09ad test: regression test for AUDIT C2 (live-edit cache invalidation)
VoxelForge.Determinism.LiveEditInvalidation: sample a SurfaceWorld column,
triple the heightfield params, Initialize() again, and require the density to
have MOVED at the same point on the same thread.

The edit is chosen so it does NOT move the strate — StrateBottomWorldZ, and
therefore StrateKey, the seed and every chunk coord stay identical. The layout
version is the only thing that changes, so the test fails on the pre-fix code
and can only pass because the version is now part of the key.

Then re-checks purity on the edited world: a half-warm cache after invalidation
would show up as order dependence.

UNVERIFIED: not compiled, not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:58:20 +02:00
Fr0zka 73f6b26f4d fix: AUDIT C2 — per-chunk caches now key on the strate layout version
Five caches under the density path were keyed on ChunkCoord (or an XY box)
alone. After RebuildStrates or an editor live edit, StrateManager rebuilds the
layout and bumps PassagesVersion, but a pooled worker whose cache is still warm
for the chunk it is asked to regenerate skips the refetch and generates with the
OLD params. RegenerateAllChunks reloads the same tile coords, often on the same
workers, so this is likely rather than exotic. Symptom: "I tweaked the strate
asset, regenerated, and one patch kept the old shape."

Fixed:
  - CP_* in GetDensityAt          (the archetype params + biome context)
  - OC_* in GetSurfaceHeightAt    (the height oracle)
  - BM_* in GetBiomeMaterialAt    (per-vertex palette)
  - TC_BiomeCache in ClassifyTile (survives across calls)
  - GSurfColCache boxes           (see below)

Two things beyond what the audit listed:

1. GSurfColCache. Its key is (XY box, StrateKey, Seed) where StrateKey is
   round(StrateBottomWorldZ). A live edit that changes terrain params WITHOUT
   moving the strate — noise frequency, mountain strength, a biome — leaves that
   key identical and serves stale columns down the whole vertical stack. This is
   the most visible form of the bug, so LayoutVersion joins the box key.

2. FChunkBiomeCache validity is a world-XY box, which says nothing about the
   FBiomeContext its cells were classified against. Refetching the context
   without invalidating the grid would leave the fix half-done, so the four
   caches call the new FChunkBiomeCache::Invalidate() on a version change.

No behavioural change at a static layout: the version only moves on Initialize.

UNVERIFIED: not compiled, not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:58:20 +02:00
Fr0zka d41d34ecd3 feat: Phase 1 skeleton — the density operator stack contract (header only)
Public/VoxelDensityOp.h: IVoxelDensityOp (PrepareChunk / Eval / EffectOverBox /
ClassifyBox / IsXYPure), EVoxelOpEffect, EVoxelOpRole (the four roles),
EVoxelOpCombine, FVoxelOpContext, and the box-verdict fold.

Nothing is wired in: GetDensityAt is untouched, the archetype switch is intact,
no operator exists yet. This is the contract plus the reasoning behind it.

Two things worth flagging beyond OPSTACK-PLAN §3:

- ClassifyBox is NOT source-only. ApplyBoundarySeal does Max(D, SealFactor*Base)
  inside its band, i.e. it FORCES solid regardless of input. Pure direction
  (FillOnly) cannot express that: over a box that sits entirely in the top seal
  band above the terrain the source says AllAir, FillOnly then kills AllAir, both
  hypotheses die and the tile becomes Mixed — whereas ClassifyTile returns
  AllSolid there today. Not a hole, but a silent loss of exactly the trivial
  tiles T1.d exists to skip. So forcing ops override the fold, and ops after them
  still apply (a passage crossing that box takes the verdict back, as today).

- The fold reproduces the current hand-written ClassifyTile line for line; the
  mapping is written out in the header. That correspondence is the evidence the
  abstraction fits this codebase rather than being imposed on it.

Also moves EVoxelTileClass from VoxelGenerator.h to VoxelTypes.h (CODEMAP 3.2:
foundational, no UClass, everyone includes it) so the op header needs no UCLASS
dependency. All existing users reach it through VoxelTypes.h transitively.

Types are plain C++ on purpose — no UHT, no .generated.h. They become
UENUM/USTRUCT in Phase 3 when ops turn into data assets.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:55:05 +02:00
Fr0zka 6eec796403 test: Phase 0.5 — the three automation tests (density purity, ClassifyTile, DiffLayer)
The plugin had zero tests, and the docs make dozens of "bit-identical" and
"conservative verdict" claims that nothing machine-checks. OPSTACK-PLAN §4
Phase 0.5 asks for these before any op-stack work is built on top.

- VoxelForgeTestFixture.h — headless world (transient strate definitions ->
  UVoxelSettings -> a real UVoxelStrateManager::Initialize), so the tests hit
  UVoxelGenerator::GetDensityAt where the ~30 thread_local caches actually live.
  One strate per archetype, pinned via FixedStrates so slot index -> archetype
  is stable across seeds.

- DensityPurity — 10k points re-sampled in shuffled order on the same thread and
  on N worker threads, asserting BIT equality. AVoxelWorld::ValidateDeterminism
  is game-thread only and structurally cannot see worker-cache divergence, which
  is how AUDIT C2 stayed hidden. Includes a flat-field canary so a collapsed
  noise field (AUDIT C1) can't make the test pass vacuously, and a diff-layer
  pass that exercises the direct-mapped DiffSlots cache.

- ClassifyTileSoundness — scans tiles for a non-Mixed verdict, then brute-forces
  the exact mesher lattice (g in [-1, Cells+1], margin included) and asserts every
  sample is on the claimed side of IsoLevel 0. A false AllSolid/AllAir is an
  invisible collisionless hole; T1.d v1 was reverted for exactly that in June.
  Errors out rather than passing if no tile yielded a verdict to check.

- DiffLayerContention — N reader threads running the worker call mix while the
  game thread writes and Clear()s, asserting survival and a monotonic ModsVersion.

UNVERIFIED: never compiled — this module has never pulled in AutomationTest.h
and Private/Tests/ is new. See OPSTACK-PROGRESS.md for the likely error spots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:50:26 +02:00
Fr0zka 3128852d4e chore: track the design docs in git (.gitignore !*.md)
AUDIT P1: every markdown design doc except CODEMAP.md was untracked, so
ARCHITECTURE / AUDIT / OPSTACK-PLAN / fable-idea / REVIEW_FINDINGS lived
only on disk. Replaces the single !CODEMAP.md exception with !*.md.

Also makes OPSTACK-PROGRESS.md commits actually record something, which
the unattended crash-safety discipline depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:39:21 +02:00
61 changed files with 22592 additions and 323 deletions
+3 -1
View File
@@ -10,4 +10,6 @@
!/Source/**
!VoxelForge.uplugin
!CODEMAP.md
# Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked)
!*.md
+762
View File
@@ -0,0 +1,762 @@
# VoxelForge — Architecture & Design Deep-Dive
> The 2026 redesign in detail: archetypes, the (0,0) spine, disturbances, content/atmosphere,
> biomes, and the **performance invariants** (the `§8.10` "don't regress" list). Read this
> before touching generation / strates / passages.
> Navigation, file index & conventions live in [CODEMAP.md](CODEMAP.md). Section numbers (`§8.x`)
> are preserved so existing cross-references keep resolving.
## 8. Archetypes, spine, disturbances, content & carving (2026 redesign)
A large A-to-Z expansion. The world is a stack of strates the player descends through;
each strate can be a fundamentally different *archetype*, connected at (0,0).
### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h)
Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated
by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispatched by the
`switch` in `GetDensityAt`.
| Archetype | Params struct | Density fn | Idea |
|-----------|---------------|------------|------|
| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) |
| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) |
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | tight corridors on a 3D lattice (per-voxel, no cache; edge = lower node + axis hash) |
| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: domain-warped continents+ridged mtns+detail, a low-freq **relief map** (`M`) that scales mountains/elevation for plains↔highland variety, **F20 heightfield terrain ops** (`Surface|Ops` — all default off ⇒ byte-identical): **Cliff** (slope-gated steepening — where the analytic structural slope > `CliffSlopeThreshold`, push the height away from the local mean by `CliffSharpness` ⇒ gentle slopes become sheer walls/canyon faces that hug real steep ground, gentle areas untouched; 4 structural resamples only when enabled), **Terrace** (relief-gated plateau quantize + `TerraceHardness` soft-round↔crisp-mesa), **LayerLines** (sedimentary sine shelves, slope-expressed) — pure per-column height remaps in the single oracle `ComputeSurfaceTerrainZ` (`SampleSurfaceStructuralZ` = pre-op raw height, re-sampled at an XY offset for Cliff's slope), biome-selected + border-blended for free via each biome's `SurfaceParams` + the height output-lerp; plus **phase-2 Overhang** (the first VOLUMETRIC op — real jutting shelves like a cliff lip): in `SurfaceDensityFromColumn`, for AIR voxels in the window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height and unioned in. Low in the window the shift is ~0 (borrows nearby low rock ⇒ stays air over the void); high up it reaches the far cliff (solid) ⇒ a shelf attached to the cliff, tapering out over the void with air underneath. Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill dir `(DirX,DirY)` are resolved once in `ComputeSurfaceColumn` — the gradient sampled at the REACH scale (`OverhangReach`) so a point out over the void can "see" the cliff to know which way is uphill — and cached on `FSurfaceColumn`. It's genuine 3D (per-voxel structural re-eval), so it's gated hard to steep overhang columns; the union only ADDS rock (never removes), capped at `TerrainZ+OverhangHeight`, so `ClassifyTile` forces Mixed only in `(TerrainZ, TerrainZ+OverhangMargin]` (upward-only; margin = max `OverhangHeight`) — a shelf never holes a trivially-skipped tile, and only the thin cliff-edge band of air tiles is woken (NOT far-field like phase-3 spikes/holes). Overhang undersides classify as ground rock by the F17 rule (down-facing but below `TerrainZ`). Known v1 limit: applies at all LODs (Step-agnostic) — may alias far; gate to fine tiles later. Beaches at water line, high sky-cap ceiling. The cap is shapeable terrain in its own right (`ComputeSurfaceCeiling`, `Surface|Sky` params: `CeilingUndulation` broad inverted hills/valleys, `CeilingRidgeStrength` hanging ridgelines, `CeilingRoughness`+freq fine bumps, `CeilingWarp*`) — defaults (strengths 0, freq 0.04) = old flat-ish cap. (`Surface|Macro` params = the cheap precursor to biomes; `ReliefStrength=0` ⇒ old uniform terrain.) **Sky-cap vs ground is a PER-TRIANGLE surface class (F17), not a per-tile verdict**: the mesher classifies each unique vertex on the **worker** — only down-facing verts (`N.Z<-0.1`) pay a memoized `GetSurfaceHeightAt` column query; nearer `CeilSurf` ⇒ sky-cap, nearer `TerrainZ` ⇒ terrain overhang stays ground (a future SurfaceWorld cave roof — down-facing but below `TerrainZ` — also lands ground by the same rule; non-surface strates always ground). Triangles take the majority class of their 3 verts and are packed as **two contiguous index runs** (ground then cap, `FVoxelMeshData::NumCeilingTriangles`; skirts inherit their source triangle's run) → RMC **polygroups 0/1** → one **section per non-empty group** (`ApplyMeshToTile`, material slot = group index): slot 0 = strate `OverrideMaterial`/default (min-corner chunk), slot 1 = `CeilingMaterial` resolved at the tile's **TOP chunk** (mid as gap fallback, then min; fallback: ground material) — a coarse tile is 2^level chunks tall, so its min corner can sit in a lower strate/gap while the cap belongs to the strate above (this was the "far cap = ground material" residue), so the shadowless overhead rock is tinted separately instead of reading flat/bright. Shadow is **per section** via `FRealtimeMeshSectionConfig::bCastsShadow` (NOT the component `SetCastShadow` — RMC's proxy ignores the component flag; this is also why level≥2 far tiles only stopped casting once the section flag was wired): ground casts at level≤1, the cap section never casts, so the rock ceiling never shadows the terrain below it. History: v1 was a game-thread centre height-oracle (misclassified coarse far tiles → terrain material on the cap underside); v2 a whole-tile worker normal VOTE — which painted **mixed coarse tiles** (one far tile spanning terrain AND cap) entirely with the winner's material, and put sky material under terrain overhangs. The per-triangle class fixes both and is the identity channel caves/F8 will reuse. |
| VerticalShafts | `FVerticalShaftParams` | `GetVerticalShaftDensity` | full-height shafts + horizontal connectors + partial ledges |
| FloatingIslands | `FFloatingIslandParams` | `GetFloatingIslandDensity` | asymmetric islands: flat land top + underside tapering to a point, lobed (domain-warped) outline, in an open void |
| Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table |
All density fns share the convention: internal **positive=solid**, apply origin spine →
boundary seal → inter-strate passages, then `return -Density` (MC: negative=solid).
StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk`
(macro `VF_ARCHETYPE_PARAMS_GETTER`) — no cross-boundary blend (Hard transitions between archetypes).
On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a
strate via a window-invariant XY field — currently wired into SurfaceWorld.
⚠️ **The `switch` above is no longer the only density path.** 6 of the 8 archetypes (all but
TunnelNetwork and Underwater) also exist as **operator stacks**, selected per strate by
`bUseOperatorStack` and evaluated instead of the `switch`; each is bit-identical to the function in
its row. The design lives in `OPSTACK-PLAN.md` / `OPSTACK-DECOMPOSITION.md`, the symbol index in
`CODEMAP §3.2d` — not repeated here. What matters for *this* document: the archetype table describes
what the world IS, and both paths compute it.
### 8.2 (0,0) spine & hybrid connections
- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical
column at XY (0,0) in every strate's **interior** (seals untouched). Radius =
`UVoxelGenerator::OriginSpineRadius``VoxelSettings::OriginSpineRadius`. Called before
every `ApplyBoundarySeal`.
- Descent is **player-dug** through the thin seals at (0,0). The single auto-opened
connection is the **surface entry shaft** at (0,0) through the top of strate 0
(`GeneratePassages`, `bOpenSurfaceEntry`).
- **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0).
Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape).
### 8.3 Disturbance layer (the "wow" post-process)
`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances`
(VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch:
chasms (carve air), bridges (solid spans), ridges (solid blades). Stays inside seal bands.
Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
### 8.4 Cross-chunk determinism (the seam-prevention invariant)
`BuildChunkCache` (VoxelCaveMorphology.cpp) uses **two regions**: a wide *COLLECT* region
(`2*MaxTunnelLength + MaxInfluence`) over which connectivity is decided (NN filtered to
`<= MaxTunnelLength`, origin cap = deterministic top-N by hash), and a tight *STORE* region
(`+MaxInfluence`) kept for per-voxel eval. This makes the room/tunnel graph window-invariant.
**If you add a connectivity rule with longer edges, the COLLECT region must still cover the
max edge reach, and decisions must not depend on the stored window.**
### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems:
**(A) DECORATIONS — distance-based WORLD GRID (the no-pop system, 2026-06-17).** Decorations are placed
on a fixed world XY cell grid (**1 cell = 1 chunk footprint**, `DECO_CELL_VOXELS = CHUNK_SIZE`) and streamed
by DISTANCE from the player, **fully decoupled from clipmap tiles / LOD**. **THE MARCH RUNS ASYNC ON WORKER
THREADS** (mirrors mesh gen — `GetDensityAt` is thread-safe; the synchronous-on-game-thread first cut was a
perf disaster + starved streaming → seams, so it was moved off-thread). Driven by `AVoxelWorld::Tick →
UpdateDecorations(playerWorldPos)`, three phases: **(1)** recompute the desired cell set (`RebuildDesiredCells`)
only when the player crosses a cell boundary OR changes strate — clears out-of-range loaded cells, queues
cells that are NOT loaded and NOT in flight (`PendingLaunch`, nearest-first). A loaded region is NEVER
re-streamed in place while it stays in range (only cleared when it leaves), so decorations don't FLICKER as
the player moves. **TWO STREAMING GRIDS (`FStrateDecoration::StreamTier`, 2026-06-26):** the stream RADIUS is a
property of the GRID, never of an entry — mixing radii inside one grid would force an in-place re-stream when
the player crosses an entry's radius (the original tier system's flicker bug). So there are exactly two
self-contained grids (`FDecoGrid`, each with its own region map + builds + queues + HISMs): **NearGrid**
(`DecorationNearRadiusChunks`, FINE `DecorationSpacingVoxels` column grid — dense groundcover near the player)
and **FarGrid** (`DecorationRadiusChunks`, COARSE `DecorationFarSpacingVoxels` column grid — trees/landmarks/RARE
props visible everywhere; the coarse grid is what makes a rare prop cheap, since the worker march cost scales
with column count). An entry picks a grid via `StreamTier` (default Far); the palette is partitioned by tier so
each grid marches only its own subset. A given world XY is covered by a Far region always, plus a Near region
when close (separate HISMs → crossing the near boundary never touches the far region). **(2)** `LaunchDecoTasks`
(per grid, sharing ONE `MaxConcurrentDecorationTasks` budget — each throttles against the other's in-flight count):
snapshot the update's decoration palette (built once on the GAME thread — biome, see below) then fire an async `UE::Tasks` march
(`BuildCellSpawns`, `BackgroundNormal`, capped at `MaxConcurrentDecorationTasks` in flight via `InFlightCells`).
**(3)** `ProcessDecoResults`: drain finished tasks' results (`Mpsc` queue → `ReadyResults`), epoch-guarded
(`DecoEpoch`, bumped on clear/strate-change so stale in-flight results are discarded) + range-checked, and
**apply (spawn) budgeted** (`MaxDecorationCellsPerFrame` — the only game-thread cost, SpawnActor/AddInstance).
`BuildCellSpawns` (worker) finds each column's surface point(s) and rolls the entries there (shared
`PlaceAtCrossing`). Candidate columns are **snapped to INTEGER voxel XY** (integer jitter) so the generator's
surface-column cache (T1.a, §8.10) applies — FRACTIONAL XY bypasses it and recomputes the noise-heavy
heightfield+biome on every sample. **TWO column strategies by archetype:**
**(a) SurfaceWorld → HEIGHT ORACLE (`Ctx.bSurfaceWorld`), NO marching.** `Generator::GetSurfaceHeightAt(x,y,
chunkZ → TerrainZ, CeilSurf)` returns the heightfield surface + sky-cap ceiling in O(1) (it shares the density
path's `ResolveSurfaceChunkParams`/`ComputeSurfaceColumn` via its own thread_local per-chunk cache, so it's
bit-identical to the rendered ground). Per column: query centre + 4 neighbours (gradient → floor/ceiling
normals), place a Floor crossing at `TerrainZ` and a Ceiling crossing at `CeilSurf` (if open space below). A
single `GetDensityAt` at the surface verifies the column isn't CARVED (passage/spine/diff make it air → skip;
the oracle is the raw heightfield and doesn't know carving). ~5 height evals + 1-2 density samples/column vs
hundreds marched. **(b) other archetypes (caves/shafts/islands) → ray-march** the strate Z-band
(`GetStrateUnrealZRange`, voxel coords) via `GetDensityAt` at a COARSE step (`DecorationMarchStepVoxels`), each
air↔solid sign change **bisection-refined** (4 iters → accuracy independent of step). Either way **a prop sits at
the SAME world position at every LOD → no pop**. (march) The top cap/seal + the open air are always marched first; the scan only stops after
`DecorationColumnDepthVoxels` of CONTIGUOUS solid once it has ENTERED the open space (trims dead bedrock below
the ground without ever stopping short of it — a "below the first crossing" cap was wrong: on a surface world
the first crossing is the high CEILING, so it stopped mid-air before reaching the ground = no floor props).
Outward normal = normalized density gradient (solid→air, matches the mesher), classified
Floor/Wall/Ceiling by `normal.Z`. Each crossing rolls every `FStrateDecoration` independently: surface-type,
density gate (`DecoHash(cell,column,crossing,entry,seed)`), water-relative, align/yaw/scale, per-cell
`MaxPerChunk` + global actor cap → a `FDecoSpawn{EntryIdx, bInstanced, Xf}`. The game thread spawns from the
result's `Entries` snapshot. `DecorationMaxCrossingsPerColumn` caps cave columns (surface worlds have 1).
**Shutdown:** `NotifyShutdown()` (called from `AVoxelWorld::EndPlay`) flags + spin-waits on the in-flight
task count before UObject teardown (tasks read the Generator); `BeginDestroy` is the backstop. **Determinism:**
pure hash of (cell, column, crossing, entry, seed) + the density surface snap. **Decorations exist ONLY in
the player's current strate** (march is strate-bounded) → a strate change wipes + rebuilds them, and there
is **no cross-strate light bleed to cull** (the old `SetActiveStrate` light-culling pass is SUBSUMED — gone).
**Render paths:** `ActorClass` → real actors (lights/logic, pricey game-thread spawn); `InstancedMesh` → HISM
(no tick/actor/collision, emissive glows far), per-cell-per-entry. **Per-entry HISM tuning for dense groundcover**
(`FStrateDecoration`, only the InstancedMesh path): `CullDistance` (cm; 0 = no cull — the lever that makes dense
grass affordable: placed thickly, drawn only near → GPU cost bounded by area-within-cull, NOT the stream radius),
`bCastShadow` (default true; turn OFF for grass — dense instanced shadows are the dominant foliage cost),
`MaxSlopeAngle` (deg from flat = acos(|N.Z|); 90 = no filter, ~35 keeps grass off cliffs — applied in the worker's
`PlaceAtCrossing`). **Placement-constraint gates (all in `PlaceAtCrossing`, deterministic, zero-cost at defaults):**
`MinSlopeAngle` (lower companion to Max — band a prop onto a tilt range, e.g. 30..70 = slopes only),
`bWallExcludeOverhangs` (wall-only-upright: drop normals with N.Z < 0 so downward overhangs don't take wall props).
**Shared vocabulary (2026-07-06):** these gates + spawn/transform/render fields now live on `FPlacementProfile`
(embedded as `Profile` on `FStrateDecoration`, `FStrateLandmark`, and the coming `FStrateSetPiece`), so all three
scatter primitives are authored identically. Rotation unified to `RotationOffset` (fixed) + `RandomRotation`
(per-axis hash-random) — decoration's ctor defaults `RandomRotation.Yaw = 360` (full random heading, replacing the
old `bRandomYaw`/`MinYaw`/`MaxYaw`; banded yaw = offset + a smaller random range). Distribution is unchanged; the
exact per-instance yaw values reshuffle once (different hash mix).
**F7 AWARE PLACEMENT (2026-07-06):** `FPlacementProfile::Conditions` = a list of `FTerrainCondition`
DERIVED PREDICATES (relief / moisture / biome-border weight, each an inclusive [Min,Max] band, optional
invert), AND-ed and evaluated by `Generator::EvaluateTerrainConditions` at the candidate XY — "conditions,
not annotations": nothing is stored, the phenomenon is queried from the analytic fields (SampleRelief /
SampleMoisture / SampleBiomeAt) on demand, so it's deterministic + worker-safe (the caller hands over the
strate's already-resolved `FBiomeContext`, so no re-resolve). Opt-in per entry (empty list = zero cost);
wired into both the deco worker (`BuildCellSpawns`) and landmark placement (`SpawnLandmarkInstance`). This
is the shared core of the coming quest `FindFeature` locator (same predicate, run as an outward search) and
the anchor gate for `FStrateSetPiece`. The BP bridge `AVoxelWorld::GetVoxelSurfaceHeightAt` exposes the
trace-free deterministic ground/ceiling height so authored ruin/set-piece Blueprints self-arrange on the
real surface before it meshes. Next types (water-edge band, relief-peak local-max, slope) are additive.
**F7 COMPANIONS (relational decoration, `FDecoCompanion`):** each `FStrateDecoration` may list `Companions`
(satellites: rocks/mushrooms around a tree). When `PlaceAtCrossing` emits a parent spawn, it immediately rolls
each companion (probability → count in `[CountMin,CountMax]` → disk offset `RadiusMin/MaxVox`), all hashed off
the PARENT's hash `H` → a pure function of the parent, so NO "did a tree spawn here?" search. Each satellite offsets from the parent in the XY plane (voxel space) and then, by default (`bSnapToSurface`),
**re-snaps to the real surface at its OWN XY** via `FindLandmarkColumn` (cheap `GetSurfaceHeightAt` oracle on
SurfaceWorld, a short ray-march in caves; worker-safe — reads only `Gen`) — this kills floaters on uneven
ground; a satellite that finds no surface at its spot is skipped. Turn `bSnapToSurface` off to inherit the
parent's exact height+normal (cheapest, flat ground only). Satellites can also be **gated by their own
`Profile.Conditions`** evaluated at the satellite XY (e.g. a unique mushroom only at a biome border). They carry
their own `Profile` transform/render. `FDecoSpawn` gained `CompanionIdx` (1 = the entry; else index into
`Companions`) so `MergeCellResult`/`ApplyRegion` resolve the satellite's mesh/actor + render tuning from
`Companions[ci].Profile` (the region mesh bucket now stores an `FPlacementProfile`, not a whole entry). **Two levels:** a companion may carry `SubCompanions` (`FDecoSubCompanion` — a distinct type, since UHT can't
reflect a self-recursive `FDecoCompanion`) that spawn ON each level-1 satellite (moss on a rock); level-2
**inherits the L1 satellite's snapped point** (no re-snap — the cost lever that keeps nesting cheap) and can
still gate on its own `Conditions`. `FDecoSpawn::SubIdx` routes L2 back to `Companions[ci].SubCompanions[sj]`.
A hard **per-parent budget** (`GMaxCompanionsPerParent`, 256) caps the total L1+L2 count so a misconfiguration
can't blow up regardless of authored counts. Bounded (count × parents, ≤ budget), deterministic, streams inside
the existing two-grid deco system untouched. Depth beyond 2 (and per-entry references for arbitrary nesting) is
the remaining flagged follow-up.
**F7 SET-PIECES — FOLDED INTO LANDMARKS (2026-07-06).** Set-pieces (ruins/shrines/monuments) and landmarks
(mini-suns) had no real reason to be separate once both shared the spawn core and gained `Conditions`, so
`FStrateSetPiece`/`UpdateSetPieces` were MERGED into `FStrateLandmark`/`UpdateLandmarks` — one primitive, one
`UVoxelStrateDefinition::Landmarks` list. Each entry has an `AnchorMode`: **HashLattice** (scatter on a coarse
lattice, `SpacingChunks`) or **PassageMouth** (enumerate `StrateManager->GetPassages()`, keep endpoints whose
Upper/LowerStrateIndex == the current strate — `bAtDescentMouths` = the hole going down, `bAtArrivalMouths` =
where you land; endpoints are global VOXEL coords). Feature-conditioning is just `Profile.Conditions` on either
mode. **Exclusion (self-awareness, optional):** `ExclusionRadiusChunks` (0 = OFF, the default — mini-suns don't
exclude) + `Priority`; a candidate is suppressed if a HIGHER-RANKED one's disk covers it (rank = Priority, then
hash — deterministic). `UpdateLandmarks` now gathers all candidates → resolves exclusion (SKIPPED entirely when
no entry opts in, so pure scatter keeps its old O(n) cost; HashLattice keeps the original hash salt 0x1A2D5u so
mini-sun positions are unchanged) → spawns survivors via `SpawnFromProfile` (+ the orb wrapper).
**Exclusion is now POP-FREE:** each entry is gathered in `StreamRadius + MaxExcl` (MaxExcl = the strate's largest
`ExclusionRadiusChunks`); the extra "ring" candidates carry `bSpawnable = false` and only SUPPRESS (never spawn),
so every conflictor of an in-range candidate is always present regardless of player position → a candidate's fate
is a pure function of (seed, layout), no edge-of-radius flicker.
**Decoration footprint (`bSuppressDecorationsUnder` + `SuppressRadiusChunks`):** a landmark mesh doesn't change
density, so the deco placer can't see it — instead, on spawn it calls `RemoveDecorationsInSphere` to clear grass
in its footprint, stores the footprint on `FLandmarkInstance`, and `ApplyRegion` re-clears under any loaded
suppressing landmark when a deco region streams in fresh (so temple floors stay clear as you leave/return).
**PLAYER DIG → GRASS REMOVAL:** `AVoxelWorld::ApplyModification` (the single funnel for every carve/fill brush)
calls `RemoveDecorationsInSphere(Center·VOXEL_SIZE, Radius·VOXEL_SIZE)` after the diff+remesh — instant,
flicker-free (only the affected HISM instances go, via `GetInstancesOverlappingSphere`+`RemoveInstances`; nothing
is cleared+rebuilt), so grass never floats over a dug hole. This only patches the LIVE instances; the placer's
existing density check (`D(VX,VY,hC) <= 0.5f`) already keeps any future natural rebuild correct. (Editing a
Static-mobility HISM re-caches its proxy — fine for player-paced digging.) Not handled by immediate removal: new
grass on a freshly-exposed ledge / regrowth after fill-back — both self-correct on the next natural re-stream. **Freeze note:** a huge set-piece mesh hitches on register (game-thread proxy/distance-field build —
NOT async-fixable; the spawn is game-thread by engine rule; the asset is a hard ref so already resident) →
mitigate ASSET-side (Nanite on the mesh, bake distance fields), optionally budget spawns across frames.
`ApplyDecoResult` buckets spawns per entry and builds each HISM with ONE batched `AddInstances`
(single cluster-tree build, set cull/shadow BEFORE `RegisterComponent`) — the game-thread hitch-killer for dense cells.
**Per-entry tier (`StreamTier`, default Far)** picks NearGrid or FarGrid; radius + column spacing are PER-GRID
settings, never per-entry (a per-entry radius would re-introduce the in-place re-stream flicker — see the two-grid
rationale above). This REPLACES the vestigial `MaxLODLevel`; the dead `DecorationActorRadiusChunks` setting is
repurposed as `DecorationNearRadiusChunks`. `CullDistance` still bounds GPU draw on top (orthogonal to which grid
streams the entry). **No LOD area-density compensation** (placement is per real
surface point, density-stable with distance). **SpawnDensity semantics CHANGED** vs the old vertex scatter: it
rolls per column surface-point (not per mesh vertex) → expect a one-time density re-tune. **Settings
(`Voxel|Content`):** `DecorationRadiusChunks` (6 — FAR reach in cells), `DecorationNearRadiusChunks` (3 — NEAR
reach), `DecorationSpacingVoxels` (4 → 8×8 cols/cell — NEAR/fine grid), `DecorationFarSpacingVoxels` (4 by default
→ raise to 816 for a cheap coarse FAR grid; the rare-prop lever), `DecorationRegionSizeCells` (4 — RxR cells per
region/HISM), `DecorationMarchStepVoxels` (2 — coarse, bisection-refined; cave march only), `DecorationMaxCrossingsPerColumn`
(4 — cave march only), `DecorationColumnDepthVoxels` (160 — bedrock march cap; cave march only),
`MaxDecorationCellsPerFrame` (2 — apply/spawn budget), `MaxConcurrentDecorationTasks` (4 — in-flight task cap;
0 disables decorations). **COST:** surface worlds now use the O(1) oracle (cheap); caves ray-march. The work is
OFF the frame (worker threads) — game thread only pays the budgeted spawn. If streaming slows, lower
`MaxConcurrentDecorationTasks` / raise `DecorationMarchStepVoxels` / shrink radii/spacing. Default
`DecorationRadiusChunks=6` ≈ props ~48 m out — raise for far flora (cost ~r²).
**(A2) LANDMARKS — rare large objects on a COARSE HASH LATTICE (`UpdateLandmarks`, 2026-06-26).** The right
primitive for sparse, far-visible objects like the underground "mini-suns" — where the per-chunk decoration
grid fails: at a 2048-chunk radius that grid enumerates ~13M cells per cell-crossing on the game thread and
FREEZES. Landmarks instead live on a per-entry hash lattice (cell = `FStrateLandmark::SpacingChunks` chunks),
so a radius-R disk holds only ~(R/Spacing)² candidates (≈16 at R=2048, Spacing=512). Listed strate-wide on
`UVoxelStrateDefinition::Landmarks`. Each Tick, for each entry, walk the small lattice box around the player
within `StreamRadiusChunks`: `DecoHash(cell,entry,seed)` rolls existence (`SpawnProbability`), a jittered XY
(`JitterFraction` — effective min spacing ≈ Spacing·(1Jitter)), then a SINGLE-column surface find
(`FindLandmarkColumn`: SurfaceWorld height oracle, else one density ray-march) snaps to the chosen
`SurfacePlacement` (default Ceiling = sky-cap). Gates mirror decorations (biome via `GetDominantBiomeAt`,
slope band, water-relative). Foliage-style transform tweaks: `LocationOffset` (world XYZ), `RotationOffset` +
per-axis `RandomRotation`, `Min/MaxScale`, `bAlignToSurface`. Spawned as a real actor (`ActorClass`, for a
sun's light) or one Static `UStaticMeshComponent` (`InstancedMesh`, `CullDistance`=0 → never cull). All
SYNCHRONOUS on the game thread (so few candidates it never hitches); a cell's surface-find runs only the
first frame it enters range, then is cached in `LandmarkInstances` (keyed `FIntVector(cellX,cellY,entry)`,
null entry = "evaluated, nothing placed" so it isn't retried). Deterministic (hash → no pop, infinite reach).
Strate-bounded (wiped on strate change, like decorations). This is the real home for "rare prop at all
distances" — the job the decoration FarGrid could approximate at moderate range but not at extreme radius.
**(B) WATER — tile-driven, level-0 only (continuous plane, never pops).** `PopulateTileWater(tile)` in
`ApplyMeshToTile` (level-0 tiles), `ClearTileWater(tile)` in `UnloadTile`. One scaled engine plane
(`/Engine/BasicShapes/Plane`) per water-surface chunk (per-chunk-Z plane logic assumes a single chunk's
vertical span — hence level-0 only), keyed `TMap<FIntVector, UStaticMeshComponent*>` (reflected UPROPERTY).
Water Z: `bHasWater` + `WaterLevelRelative``StrateManager::GetWaterLevelWorldZForChunk`. Biome
`WaterMaterial` overrides `UVoxelStrateDefinition::WaterMaterial` (level stays strate-global).
`ClearAll`/`SetSeed` on `ChangeSeed`/regenerate clears both subsystems (decorations re-stream on the next
Tick via the INT_MIN sentinels). **Per-biome content (§8.14):** decorations resolve the dominant biome **PER COLUMN** on the worker
(`ResolveBiomeSampleAt` via the strate's `FBiomeContext`, box-cached → one rebuild per chunk footprint). The
update builds ONE flat decoration palette (every biome's list concatenated; `CurrentEntryBiome[i]` tags entry
`i` with its context-biome index, -1 = strate fallback), and `PlaceAtCrossing` rolls only the entries the
column's biome owns. This replaced the old per-CELL `GetDominantBiomeAt` (one biome for a whole 8 m cell →
axis-aligned border snapping); borders now follow the warped-Voronoi field at column resolution, no straight
lines. Water still uses the chunk-centre `GetDominantBiomeAt` for its material. `Initialize` now also takes
`UVoxelSettings*` (for the grid tunables). `ContentMaxLevel` is now legacy/dead for decorations.
### 8.6 Atmosphere — `VoxelAtmosphereManager.h/.cpp` (NEW)
`UVoxelAtmosphereManager` (owned by `AVoxelWorld`, gated by `bManageAtmosphere`).
`UpdateForPlayer(pos)` each Tick, reacts only on strate change. Drives a managed
`UExponentialHeightFogComponent` + movable `USkyLightComponent` from the player's strate
(`FogColor/FogDensity/bVolumetricFog/AmbientLightColor/AmbientLightIntensity`), and spawns
PERSISTENT ceiling/floor "layer" actors (`Def->CeilingLayerActor`/`FloorLayerActor` + ZOffsets
+ rotations) that follow the player in XY — the sky-island sea-of-clouds / two-sided fog.
`Def->AtmosphereActor` (a full BP with your own fog/sky/postprocess) OVERRIDES the managed
fog+sky for that strate. `Reset()` on ChangeSeed/EndPlay. (Skylight ambient underground is
weak — captures a dark scene; fog is the strong visual.)
**Per-biome atmosphere (§8.14):** `UpdateForPlayer` also resolves the player's dominant biome and,
when the biome has `bOverrideAtmosphere`, its fog/sky beats the strate's (reacts on biome change, not
just strate change). `ApplyFogSky(Def, Biome)` is the shared path; layer actors + the full `AtmosphereActor`
BP stay strate-level. Needs the generator injected (`Initialize(..., Generator)`).
### 8.7 Inter-strate bedrock gap
`VoxelSettings::InterStrateGapChunks` (N) inserts N chunks of SOLID bedrock between consecutive
strates (`StrateManager::Initialize` leaves the gap in the layout). `IsGapChunk` detects it;
`GetDensityAt` renders gap chunks as solid + passages only (no caves/spine/seal) so the player
digs (0,0) through the gap to descend. `GetStrateUnrealZRange` gives a strate's cm Z range.
### 8.8 Inter-strate passages — PER-STRATE (`FStratePassageConfig` on the definition)
Each strate's `PassageConfig` (VoxelStrateTypes.h) controls its descent tunnels to the layer
below: `Connections`, `Style` (`EVoxelPassageStyle`: Straight/Worm/Spiral/Cascading),
`MouthRadius`/`MidRadius` (tapered width → `FVoxelPassage::ControlRadii` + `VoxelSDF::TaperedCapsule`),
`ReachMin/Max` (depth into each strate), `DistanceMin/Max` (from the (0,0) spine), `Wander`,
`Segments`, `VerticalWobble`, Spiral/Cascade params. Built in `StrateManager::GeneratePassages`
as control-point chains. **Worm = independent fBM per horizontal axis** (`PassageFBM` static)
with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2-channel spiral).
`EvaluateModifierSDF` (per voxel) first builds a `thread_local` **per-chunk shortlist** of passages
whose bounds reach this chunk (rebuilt on chunk change / `PassagesVersion` bump) — chunks with no
passage near return `FLT_MAX` immediately — then **bounding-sphere-culls** each shortlisted passage
(`FVoxelPassage::BoundCenter/BoundRadiusSq`). Both are perf-critical (§8.10). The (0,0) surface entry
is a simple straight tube. Global passage settings were removed from `VoxelSettings`.
### 8.9 Carving — brush shapes + editor controls
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
`Falloff` + `GetWorldBounds`. `UVoxelDiffLayer::GetDensityOffset` switches per shape; chunk
overlap uses the shape AABB. `AVoxelWorld`: `CarveBox/FillBox/CarveCapsule/FillCapsule/
ApplyModification` (BlueprintCallable) + `EditorCarveSphere/EditorFillSphere` (CallInEditor)
driven by `EditorBrush*` props.
### 8.10 Performance invariants (DON'T regress)
- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set ONLY when the
player crosses a chunk boundary (`LastUpdateCenter`); use the `DesiredSet` TSet for the cull;
idle via `bAllChunksLoaded`. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.)
- **LOD** changes HOT-SWAP (`LoadChunk` only, never unload-first) → no holes. LOD
reconciliation lives in the PERSISTENT per-frame submit loop (same loop as new-chunk loads),
NOT as a one-shot on the boundary-cross frame — a one-shot drops every chunk past the task
budget and strands it at a stale LOD. Idle (`bAllChunksLoaded`) only when a full scan finds
no loads AND no LOD mismatches outstanding.
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
sampling must not thrash the (expensive) rebuild.
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
thread-locally per chunk; don't move the fetch/blend back to per-voxel.
- **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX +
ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is
noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep
the box halo (≥ CHUNK_SIZE) + cell margin (warp + CellSize) so the 3x3 lookup never misses.
- **Passage cull** (§8.8) + **morphology two-region** (§8.4): both are per-voxel-cost critical.
- **Per-chunk passage shortlist** (`EvaluateModifierSDF`): runs per voxel and is called from every
archetype's `ApplyPassageCarving`. Keeps a `thread_local` shortlist (passage INDICES) of passages
whose bounds reach the current chunk, rebuilt only on chunk change or `PassagesVersion` bump
(incremented in `GeneratePassages`). Most chunks have NO passage near → instant `FLT_MAX` return
instead of walking the whole `Passages` array per voxel. Conservative superset (chunk bounding
sphere vs passage bound) ⇒ bit-identical carve. Store indices + version, never pointers (the array
is rebuilt on `RebuildStrates`).
- **Gen tasks run at `UE::Tasks::ETaskPriority::BackgroundNormal`** (`LoadTile`): worker gen yields
to foreground game/render tasks. Without it, raising `MaxConcurrentTasks` past the spare-core count
saturates the scheduler and starves the frame (the "concurrency > ~12 = stutter" symptom). Keep gen
at background priority so the frame keeps its cores.
- **Mesher density grid + margin ring** (`GenerateMesh`): sample each grid point ONCE into a flat
`(CHUNK_SIZE/Step + 1 + 2)³` array (the `+2` is a 1-point MARGIN ring, indices 1..GridDim, for
T1.b normals). The cell loop reads 8 corners from it; per-cell sampling would call `GetDensityAt`
~8× too often. Geometry is bit-identical (edge positions unchanged). Don't refactor back to
per-corner `GetDensity` and don't drop the margin ring (normals + seamless borders need it).
The cell loop is **two-pass**: pass 1 reads the 8 corner densities + builds the MC case index and
`continue`s on no-surface cells (≈70% of cells); pass 2 computes the 8 positions + grid-gradients
ONLY for surface cells. Don't hoist position/gradient back above the case-index test. The
`DensityGrid` and vertex-dedup `TMap` are `thread_local` and reused per worker (Reset / keep
capacity) — don't make them per-call locals (re-allocates ~170 KB + a hash map every tile).
**CAPTURE-DURING-MESHING (sanctioned reuse, doesn't regress the above):** `GenerateMesh`'s optional
`OutCaptureGrid` copies the already-filled `DensityGrid` interior (`CHUNK_SIZE³`, quantized via
`VF_QuantizeDensity`) out for the density clipmap (mini-sun shadows) — a PURE READ added after the
grid loop. It does NOT touch the grid shape, the two-pass loop, the margin ring, or the thread_local
reuse. Only level-0 full-res tiles request it (`Step==1<<Level`, 1:1 with a clipmap level). The
clipmap (`UVoxelDensityVolume`) reuses these bytes instead of re-evaluating `GetDensityAt` on its own
fills — see `IngestTileCapture` / `BlitCaptureToWindow` (cache keyed by tile coord) and the
chunk-aligned level-0 recenter. The volume fill that captures DON'T cover runs on **one dedicated
thread** (`FVoxelDensityFillRunnable`, off the UE::Tasks pool — the old shared-pool `BackgroundLow`
fill starved behind mesh-gen, ~10 s to resolve shadows; the dedicated thread fills full-speed without
stealing a mesh-gen core) and is the backstop for cache misses (vertical strate gaps, cold start,
carves). Captures are bit-identical to a fill of the same cells (same `GetDensityAt`, same
`VF_QuantizeDensity`).
- **Normals from the density grid (T1.b)** (`GenerateMesh`): corner gradients = central differences
on the (margin) grid; edge normals interpolate the two corner gradients by the SAME `t` as the
position → seamless across chunk borders (both sides use identical pure samples). NO per-vertex
`GetDensityAt` (was ~6/vertex, often as costly as the whole grid). `ComputeGradientNormal` is now
unused. Only NORMALS changed vs the old path; geometry is identical.
- **Surface column cache (T1.a)** (`FSurfaceColumnCache` = LRU of `FSurfaceColumnBox`, `GetDensityAt`
SurfaceWorld branch): the heightfield + sky-cap + biome blend are a PURE function of (XY, seed,
strate) — **ZERO Z dependence** (climate/Voronoi are pure-XY; surface params are per-strate constant
under Hard transitions) — yet sampled ~33× per column (once per Z grid-point). Cached per integer XY
(box-valid, like the SDF cache) and reused down the column. **Keyed by (XY box, StrateKey, Seed), NOT
ChunkZ** (`StrateKey = round(StrateBottomWorldZ)`, taken from the params so it can't disagree with
them) and held as a small **LRU of 6 boxes** so the WHOLE vertical view-distance stack — and XY
neighbours the scheduler interleaves — share one another's heavy column noise instead of each
recomputing it ~once per vertical chunk (this was the dominant `GenerateMesh` cost: the same 2D
heightfield recomputed per altitude). It also makes pure-air / pure-solid chunks cheap (they hit the
shared box). Box `Halo = CHUNK_SIZE + 8` each side so the T1.b margin ring stays inside (no thrash).
**Used ONLY for integer-XY queries**; fractional queries compute directly → bit-identical. Don't
re-introduce a ChunkZ key, don't feed it fractional coords. (`GetSurfaceHeightAt`'s own `OC_*` oracle
cache is separate and still per-chunk — lower volume, not worth the LRU.)
- **Collision only at LOD0 (T1.c)** (`ApplyMeshToChunk`): `UpdateSectionConfig(..., LOD==0)`.
LOD1/2 chunks are unreachable (the §8.10 reconciliation hot-swaps to LOD0 before the player
arrives), so cooking their Chaos collision is waste. Don't force collision on for all LODs.
- **CHUNKED-LOD CLIPMAP — the streaming model** (`FVoxelTileKey` in VoxelTypes.h; `UpdateChunksAroundPosition`
/ `BuildDesiredTiles` / `IsTileInClipRange` / `LoadTile` / `UnloadTile` / `ApplyMeshToTile`; mesher
`GenerateMesh(OriginVoxels, Step)`). Replaces the fixed-32³-chunk + LOD-step-on-fixed-extent model
AND supersedes the old region-batching / strate-Z-clamp / wide-ceiling (all removed). A **level-L tile**
spans `CHUNK_SIZE<<L` voxels meshed at `step 1<<L` → constant 32³-cell mesh, ONE component, ONE draw,
covering 8^L× the volume. Streaming loads **concentric shells** (level 0 near, each coarser level a 2×
larger shell beyond; inner hole of level L = the region the finer level covers). **Total tile count
stays ~flat regardless of view distance** — that's why see-far (ceiling, horizon) is cheap AND why
per-tile components are fine for the game thread (no batching: ~1-2k tiles, not 40k). **Load-before-
unload cull** (no holes, STRICT): out-of-range tiles cull now; in-range LOD-transition tiles cull only
once EVERY desired tile overlapping their footprint is loaded — tested as "no UNLOADED desired tile
overlaps T" (`ReplacementsReady` + `FootprintsOverlap` vs the `DesiredPending` list, built once per
crossing = desired-minus-loaded, usually tiny). Scanning all of `DesiredSorted` per candidate was an
O(loaded×desired) game-thread spike when fast movement turned many tiles non-desired at once. A coarse
tile is replaced by several finer tiles, so the old center-owner
check (`ReplacementLoaded`) dropped it as soon as the ONE tile over its centre loaded → the not-yet-
ready edges flashed a hole; the full-coverage check keeps the old tile at its current resolution until
the better mesh is wholly in, then swaps. In-flight (pending) tiles are NEVER cancelled on a rebuild —
they finish, apply, and are culled later if no longer desired. Collision level-0 only; water level-0
only; shadows off for level≥2. **Decorations are NO LONGER tied to tiles** — they stream on a fixed world
grid by distance (§8.5), so they don't pop on LOD swaps. Settings: `VoxelSettings::ClipRadius` (full-res near radius, tiles/level),
`MaxClipLevel` (far reach). **NEAR-FIELD GEN COST levers** (`LoadTile`): levels `< FullResClipLevels`
mesh at full `CHUNK_SIZE` cells (≈35³ `GetDensityAt` incl. margin ring), coarser levels at
`CoarseTileCells` (Step = Extent/Cells) for far-cheaper gen. A level-1 tile at `FullResClipLevels=2`
costs the SAME gen as a level-0 tile (same cell count, 8× extent) — set `FullResClipLevels=1` to drop
level 1 to `CoarseTileCells` (~6× cheaper) when the near field is gen-bound (slightly harder L0→L1
seam, hidden by skirts). `ClipRadius` bounds the full-res level-0 tile COUNT independently of reach.
**STRATE CONTENT CUT** (`StrateContentCutMinLevel`, default 0 = all levels — tested: the level 0/1
straddler tiles were the visible mixers, a higher floor read as "no improvement"): a level-L tile is 2^L chunks TALL and
can straddle a strate boundary — at coarse Steps the thin seal/gap solid between two strates' airs
falls between lattice points (holes into the neighbour strate at far LOD) and one tile mixes both
strates' materials. From that level up, `GenerateMesh(..., BandZMin/MaxVox)` only meshes cells inside
the PLAYER's strate chunk-Z band (exact bounds, no margin — the view clamp handles selection; in the
inter-strate gap: no band). The band travels on `FChunkResult::BandChunkLo/Hi` so `ApplyMeshToTile`
clamps its strate/material lookups into the meshed content. On band change (strate transition) the
affected loaded coarse tiles re-queue via `BandRemeshQueue` (budgeted, re-gen in place, no pop).
Meshed cells stay bit-identical (§8.4 — same pure world samples; the cut only selects cells).
**Too-coarse skip (ultra levels)**: the cut is CELL-granular, so once one cell (Step voxels tall)
is taller than the whole band (level ≥7 at CoarseTileCells=16 with typical strate heights), a
band-overlapping cell still samples both strates' airs — same holes/mixing as uncut. `LoadTile`
detects `Step > band height` and emits an EMPTY tile (it could only render garbage); an empty
re-gen result also releases the tile's old component in `ProcessPendingChunks` (otherwise the
previous strate's geometry would linger after a band change).
**Horizon past that point = RENDER DISTANCE** (`RenderDistanceChunks`, default 0 = off): set
`MaxClipLevel` to the coarsest level that still renders strates correctly (one cell must fit
inside a strate band), then the OUTERMOST shell keeps generating tiles outward until it covers
the requested distance (`VF_OuterShell`, shared by `BuildDesiredTiles` and `IsTileInClipRange`
so the cull sees the same horizon; the ring's dz sweep is pre-clamped to the vertical strate
band). As MC tiles the ring cost grows with (distance/2^MaxClipLevel)² — which is why the ring
defaults to **F18 SHEETS** (`bFarSheetRing`): in an open strate the far field is exactly two
heightfields (TerrainZ + CeilSurf, per-column oracle), so the ring streams tiles at level
`MaxClipLevel + FarSheetSpanLevels` (one sheet = 2^span MC footprints per axis → 4-16× fewer
components) meshed by `GenerateSheetMesh` as two displaced grids — ground polygroup 0 / cap
polygroup 1, tags true by construction, same materials/UVs/F6 masks, ~3-6× cheaper gen per
area than band-cut MC. **XY hole**: a partially-covered sheet renders its whole footprint, so
the MC-covered box around the player (level-MaxClipLevel box, shrunk 1 tile for a seam-overlap
ring) is CUT out of the sheet at cell granularity (`SheetHole*Vox`, passed to
`GenerateSheetMesh`); when the player crosses a MaxClipLevel tile the hole moves and the
overlapping sheets re-queue via `BandRemeshQueue` — the shrink means the newly-cut area's MC
tiles were already desired one crossing earlier (loaded before uncovered). Sheet tiles take
the strate from the BAND (mid chunk): band unarmed
(inter-strate gap) ⇒ empty ring until landing; non-open strates ⇒ empty sheets (their far
ring was enclosed rock anyway); carved features (passages/spine/chasms/diff) don't show at
sheet distance — the MC shells keep them near. (A "step cap" variant — raising CoarseTileCells
per level so far levels keep a fine Step — was tried and rejected 2026-07-06.)
Trade-offs accepted: other strates simply don't render at far LOD (they're sealed/enclosed —
invisible except through passage mouths, which read as dark holes); passage tubes crossing the gap
are cut at coarse levels only (near levels mesh full).
`GetLODForChunk` / `LODToStep` / `IsChunkInRange` / `GetStrateChunkZBounds` and the
ViewDistance/LOD/strate-Z/ceiling settings are now DEAD/unused (left in place).
- **SKIRTS — LOD-seam crack filler** (`GenerateMesh`, after the cell loop; `VoxelSettings::bGenerateSkirts`
+ `SkirtCells`, wired onto the mesher at setup). Neighbouring shells mesh at different resolutions so
their iso-surfaces don't meet along the shared face → a thin see-through crack. After meshing, every
triangle edge whose BOTH endpoints lie on one of the tile's 6 outer boundary planes (exact float compare
— MC keeps the face-axis coordinate fixed) is a surface-contour edge on that face; a skirt quad hangs
from it INTO the solid along the inverted vertex normals by `SkirtCells × Step × VOXEL_SIZE` (~one cell,
≥ the gap to a one-level-coarser neighbour). Emitted DOUBLE-SIDED (both windings) so it shows regardless
of camera side / material two-sidedness; buried elsewhere → invisible. Adds verts/tris ONLY on boundary
contour edges (small). Tune `SkirtCells` up if cracks persist, down if skirts peek out on convex edges.
- **Delta cull (stamped desired set)** (`DesiredStamped` + `DesiredStamp` + `TransitionHold`,
`BuildDesiredTiles`/`UpdateChunksAroundPosition`): the desired set is a `TMap<key, stamp>`;
each crossing bumps the stamp, upserts the new set, and ONE map sweep yields the **leavers**
(stale stamp — removed + returned). The cull then considers ONLY leavers + `TransitionHold`
(tiles kept by load-before-unload from earlier crossings) instead of re-scanning EVERY loaded
tile per crossing — that scan was the measured ~1.6 ms/crossing `CullTiles` spike (2026-07-05
trace, plus `BuildDesiredTiles` 0.66 ms on the same frames). In-flight tiles that finish while
no longer desired are caught at apply time (`ProcessPendingChunks` adds them to the hold);
`UnloadTile` drops hold entries; the settled cull (everything loaded) keeps its full scan as
the safety net. `DesiredPending` for the overlap test is now built LAZILY (only when an
in-range transition candidate exists). **The hold is re-evaluated on a ROTATING BUDGET**
(`TransitionHoldQueue` + cursor, ~256 tiles/crossing): re-scanning the whole hold each
crossing degenerates back to the O(loaded) scan whenever streaming never settles (measured
2.47 ms/crossing in the first packaged capture) — keeping a tile a few crossings longer is
always hole-safe, and every held tile passes under the cursor within a few crossings. The
SET is authoritative membership; the queue may hold stale keys (lazily dropped on scan).
Same hole-free semantics, O(delta + budget) per crossing.
- **Budgeted teardown** (`PendingUnload` + `ProcessUnloadQueue`, called from `Tick` after the apply
drain; `VoxelSettings::MaxUnloadsPerFrame`): the cull APPROVES removals (strict load-before-unload) but
doesn't destroy in place — it queues them. `ProcessUnloadQueue` runs at most `MaxUnloadsPerFrame`
`UnloadTile`s/frame (scaled up to 4× with backlog, capped so a huge backlog can't re-spike). WHY: a
fast traversal culls a whole shell's worth of tiles in ONE frame, and each `UnloadTile` does
`DestroyComponent` + (level 0) `ContentManager::ClearChunk``Destroy()` of every decoration actor —
an unbudgeted burst = a game-thread spike ("stuff torn down behind you" at speed). Mesh APPLIES were
already budgeted; this matches it for DESTROYS. Re-desired tiles are cancelled out of the queue (still
loaded → no reload). `PendingUnload` is cleared in `RegenerateAllChunks`/`EndPlay` (tiles already gone).
- **Collision only at LEVEL 0** (`ApplyMeshToTile`): `UpdateSectionConfig(..., Tile.Level==0)`. Far tiles
are unreachable; cooking their Chaos collision is waste. (Was T1.c, now per-tile-level.)
- **No shadows on far tiles (draw cut)** (`ApplyMeshToTile`): `SetCastShadow(Tile.Level <= 1)`. Each
shadow-casting tile emits a second shadow-pass draw; the far coarse tiles don't need it. NOTE: fps is
RENDER-side (draws ≈ visible tile count × passes); generation cost (workers) and tile *resolution*
(cuts triangles, not draws/components) don't move the game thread — tile COUNT does (hence the clipmap).
- **Trivial-empty tile reject (T1.d) — v2 SHIPPED (2026-07-05); v1 was reverted 2026-06-26.**
`UVoxelGenerator::ClassifyTile(Origin, Step, Cells)` runs on the gen worker BEFORE the density
pre-sample (`LoadTile` task, scope `VoxelForge_ClassifyTile`); `AllSolid`/`AllAir` ⇒ GenerateMesh is
skipped and the tile stays `bEmpty`. Motivation: the 2026-07-05 Insights trace showed **84 % of
GenerateMesh calls produced empty tiles** (83 925 gens vs 13 227 meshes — ~500 s of 617 s worker CPU
wasted) and gen throughput had become the felt gameplay limit ("standing waiting for generation").
**Why v1 failed & how v2 avoids it:** v1 used a GLOBAL analytic ceiling bound — not conservative when
the cap hangs low (`CeilingRoughness`/`RidgeStrength`) → holes in the roof. v2 makes NO amplitude
guesses: it evaluates `ComputeSurfaceColumn` (the SAME function as the density path, via the SHARED
`GSurfColCache`) **on the exact lattice the mesher would sample** (margin ring included) — same
functions + same inputs ⇒ the same floats ⇒ the verdict is exact at the lattice, not an estimate.
Per-z rules: gap chunk = solid; surface seal band (ApplyBoundarySeal inequalities, `BaseDensity>0`)
= solid; surface interior z: air ⇔ `TerrainZ ≤ z ≤ CeilSurf` (MC `D ≥ 0`); ANY other archetype /
out-of-layout chunkZ ⇒ `Mixed` (cave interiors are not provable in v1 of this classifier — a future
extension could use "SDF cache empty + worm network mask" for deep TunnelNetwork rock).
**Conservative guards** (anything that can carve/fill): player mods (`HasAnyModInChunkRange`) ⇒
Mixed; passages (`AnyPassageNearBox`, bounding spheres + carve blend pad) and the (0,0) spine
(circle/box XY) kill AllSolid; disturbance chasms kill AllSolid, bridges/ridges kill AllAir.
A false `Mixed` only costs CPU; the code must NEVER emit a false AllSolid/AllAir (that's a hole).
Capture tiles (`bWantCapture`, density-volume shadow window) always generate — the volume wants the
grid even for uniform cells. A sparse ~5×5 column pre-pass exits Mixed fast on surface-crossing
tiles; a Mixed verdict leaves its columns warm in `GSurfColCache` for the GenerateMesh that follows.
- **Worker-built StreamSet (T1.f)** (`BuildTileStreamSet`, `LoadTile` task → `FChunkResult::Streams`):
the RMC vertex/index buffers (`FRealtimeMeshStreamSet`) are built ON THE GEN WORKER, not on the game
thread. The per-vertex builder loop was the dominant game-thread streaming cost (measured: game
thread >6 ms while moving, GPU/Draw idle — purely game-bound). `BuildTileStreamSet` touches ONLY the
POD `FVoxelMeshData` arrays (no UObject, no generator) so it's worker-safe; `ApplyMeshToTile` now only
resolves material/ceiling (O(1)), gets/creates the component, and hands the finished streams to
`CreateSectionGroup(MoveTemp(...))` (which already uploads async via its `TFuture`). `FChunkResult`
carries the streams as a `TSharedPtr` (forward-declared in the header) so it stays movable through the
MPSC queue; the worker `Enqueue(MoveTemp(Result))` (no payload copy). Don't move the builder loop back
onto the game thread. Geometry is byte-identical — only WHERE it's built changed. Empty/all-air tiles
carry no streams (`bEmpty`) → no component. NOTE: RMC collision is already async-cooked
(`bUseAsyncCook=true`), so level-0 collision (T1.c) is NOT a game-thread spike. Remaining per-apply
game cost is `NewObject`+`RegisterComponent` for new tiles → component pooling (T2.c) is the next lever
IF a trace still shows `ApplyMeshToChunk` cost.
- **Insights scopes** `VoxelForge_GenerateMesh` / `VoxelForge_BuildStreams` (worker) /
`VoxelForge_ApplyMeshToChunk` (game-thread apply, Perf 0) bracket the worker gen + stream build +
game-thread upload — capture a trace to see if we're density-, build-, or upload-bound.
- **Float SIMD noise core (T2.a)** (`Public/VoxelNoise.h`): the density hot path uses
`VoxelNoise::Perlin3D` (single-sample, float, table-free hash-gradient) and `VoxelNoise::FBM` /
`Ridged` (octaves evaluated **4-wide via SSE** `Perlin3D_x4`) — NOT `FMath::PerlinNoise3D`
(double-precision, the old ~6.6 ms/chunk noise cost). `FractalNoise3D` / `RidgedNoise3D` in
`VoxelGenerator.cpp` are now thin wrappers over it; every call site is unchanged. It's a
DIFFERENT noise field than FMath's ⇒ a ONE-TIME world re-tune (fBm/Ridged contracts/[-1,1] are
identical). Pure function of (x,y,z) ⇒ every box-validity cache stays valid. Scalar `Perlin3D`
and SSE `Perlin3D_x4` are op-for-op identical (bit-identical on x86) — the SIMD path is a free
speedup; `#define VF_NOISE_USE_SIMD 0` falls back to scalar with no re-tune if a toolchain
rejects the SSE4.1 intrinsics. StrateManager's passage/transition Perlin calls were left on
`FMath` (layout-time, not per-voxel). Don't reintroduce `FMath::PerlinNoise3D` on the density path.
- **LOD-aware octave drop (T2.b, opt-in)** (`VoxelGenLOD` in `VoxelGenerator.h`, guard in
`GenerateMesh`): coarse tiles (Step>1) drop `Settings->LODOctaveDrop × log2(Step)` octaves from
the generator's PER-VOXEL volumetric noise via a `thread_local` bias — sub-cell octaves can't
shape a coarse isosurface. Default **0 = off = byte-identical**; LOD0 is never biased. The bias
is `TGuardValue`-scoped to the tile, so deco snapping / density-volume fill / game-thread
queries always see 0. Deliberately NOT applied to XY-field noise (heightfield, ceiling, relief,
moisture): those feed box-validated caches that outlive a tile task on the same thread, and
climate/biome must stay LOD-independent. Keep any new per-voxel fractal call site on
`VoxelGenLOD::Eff(N)` and any new cached-field call site OFF it.
- **Tile component pool (T2.c)** (`TileComponentPool` + `Acquire/ReleaseTileComponent`,
`VoxelWorld`): unloading parks the tile's RMC component (geometry+collision stripped via
`RemoveSectionGroup`, hidden, still registered) instead of `DestroyComponent`; applies pop from
the pool instead of `NewObject`+`RegisterComponent`. Also: `ApplyMeshToTile` now reuses the
component's existing `URealtimeMesh` (`GetRealtimeMeshAs`) — `InitializeRealtimeMesh` allocates
a NEW mesh object every call, so calling it per apply (the old code) orphaned one UObject per
re-mesh to the GC. A parked component MUST have its section group removed (hidden ≠ collision
off) — don't "optimize" that away. Pool is bounded (`MaxPooledTileComponents`); overflow is
destroyed for real.
- **`ProcessQueue` MUST be `EQueueMode::Mpsc`** (`VoxelWorld.h`): up to `MaxConcurrentTasks`
`ChunkGen` worker threads `Enqueue` concurrently; the game thread is the sole consumer.
The default `Spsc` is single-producer — concurrent enqueues race the tail link and silently
DROP results, leaking `PendingChunkCoord` slots until the budget is exhausted and streaming
stalls for good (intermittent; worst during the completion bursts right after the player moves).
### 8.11 Live tuning & debug (`AVoxelWorld`, CallInEditor / PIE)
- `RebuildStrates` — re-reads ALL of `VoxelSettings` and rebuilds layout/gap/passages/spine +
regenerates. Use after changing those (plain `RegenerateAllChunks` keeps the old layout/passages).
- `ValidateDeterminism` (F2) — one-click §8.4 regression test: samples chunk-boundary points under
two different thread_local cache alignments (left-chunk warm vs right-chunk warm) + a repeat
pass; every delta must be EXACTLY 0. Run it after any hot-path refactor that claims
bit-identity (~1 s, game thread, PIE).
- `bDebugDrawPassages` — draws every passage (cyan path, green=upper / red=lower endpoints).
- `EditorCarveSphere`/`EditorFillSphere` + `EditorBrush*` props — manual carve/fill in PIE.
### 8.12 Authoring a strate (data asset)
1. Create `UVoxelStrateDefinition`, pick `GeneratorType` → its param group appears; tune it.
2. `PassageConfig` → how THIS strate connects DOWN (count / style / tapered width / length / placement).
3. `Disturbances` for chasms/bridges/ridges; `bHasWater`+`WaterMaterial`(+`WaterLevelRelative`) for water.
4. Atmosphere: `FogColor/Density`, `AmbientLight*`, `bVolumetricFog`, or a full `AtmosphereActor` BP;
`CeilingLayerActor`/`FloorLayerActor` (+offsets/rotations) for cloud seas.
5. `Decorations`/`AmbientActors` (placement rules) for content + lights.
6. (Optional) `Biomes[]` + `BiomeMapParams` to vary terrain/content within the strate (§8.14).
Author `UVoxelBiomeDefinition` assets (climate box + modulation + content), then tune layout
with `AVoxelWorld::BakeBiomePreview`. Turn `ReliefStrength` down when biomes drive elevation.
7. Reference from `VoxelSettings` (`StratePool`/`FixedStrates`). Global knobs there:
`OriginSpineRadius`, `bOpenSurfaceEntry`, `InterStrateGapChunks`, view distances, LOD, carving budget.
### 8.13 New files this redesign
`Public/Private/VoxelContentManager.h/.cpp` (§8.5) · `Public/Private/VoxelAtmosphereManager.h/.cpp` (§8.6) ·
`Public/VoxelBiomeTypes.h` + `Public/VoxelBiomeDefinition.h`/`Private/VoxelBiomeDefinition.cpp` (§8.14).
Everything else extended existing files: `VoxelStrateTypes.h` (archetype params, disturbance,
`FStratePassageConfig`, enums), `VoxelStrateDefinition.h`, `VoxelGenerator.h/.cpp` (archetype
density fns + spine/disturbance/param-cache), `VoxelStrateManager.h/.cpp` (per-archetype getters,
passages, gap, atmosphere Z helper), `VoxelWorld.h/.cpp` (managers, streaming perf, brush API,
editor buttons), `VoxelDiffLayer.h/.cpp` (brush shapes), `VoxelSettings.h`, `VoxelCaveMorphology.cpp`
(two-region determinism). Status: compiles & runs in-editor.
### 8.14 Biome system (Stage 1 — climate-driven, full-param overrides)
Biomes vary terrain **and** content WITHIN a strate. A biome is a **"mini-strate-variant"**: it
can carry a FULL archetype param override (its own `FSurfaceGenerationParams`, …) plus a content
profile, placed by a deterministic, window-invariant world-XY field. Empty `Biomes[]` ⇒ bit-identical
to the pre-biome world. (Replaces the earlier `FBiomeModulation` scalar bag — full params let a biome
change *anything*, e.g. frequencies, which scalar multipliers couldn't.)
- **Assets/data.** `UVoxelBiomeDefinition` (one per biome): `DebugColor`, climate box (relief,
moisture), `bOverrideTerrain` + `GeneratorType` + the matching archetype param struct (Surface
wired), content profile (decorations/atmosphere/water). + `UVoxelStrateDefinition::Biomes[]` &
`BiomeMapParams`. Types in `VoxelBiomeTypes.h` (§3.8).
- **The field (pure XY, window-invariant — §8.4).** `SampleBiomeAt` (VoxelGenerator.cpp): warped
**Voronoi** over a jittered grid → dominant cell + nearest neighbour (F1/F2) + border blend weight.
Each cell's biome is chosen by `ClassifyBiomeAtSite` from the site's **climate** = `SampleRelief`
(the relief map M, shared with SurfaceWorld terrain) + `SampleMoisture`, matched against each
biome's (relief, moisture) box → coherent geography. **Climate must vary much slower than
`CellSize`** (~4-6 cells/feature) or it's salt-and-pepper.
- **Per-chunk resolution (perf — §8.10).** `ResolveBiomeSampleAt`/`RebuildBiomeGrid` build a
`FChunkBiomeCache`: the expensive cell classification is done ONCE into a small grid; per voxel only
a warp + 3x3 lookup, returning `FBiomeSample` (dominant + neighbour + weight). **Cache validity is a
world-XY BOX + ChunkZ + Seed (NOT a chunk key)** — gradient-normal + boundary samples stay inside
the box and don't thrash the noise-heavy rebuild (same as the SDF cache). Bit-identical to
`SampleBiomeAt`, so the baked preview matches the terrain. `GetBiomeContextForChunk` supplies the
flattened POD context per chunk (thread-local `CP_BiomeCtx`).
- **Consumption — SURFACE (output-blend).** Per chunk, `CP_SurfaceBiomeParams[]` holds each biome's
resolved surface params (its override when `bOverrideTerrain` + GeneratorType matches, else the
strate's) with **structural fields forced from the strate** (Z bounds, seal, base density, water
level). Per voxel: `ResolveBiomeSampleAt` → dominant `PD` (+ neighbour `PN`); `GetSurfaceDensity`
computes `ComputeSurfaceTerrainZ` for `PD` and, in the border band, for `PN`, and **lerps the
resulting HEIGHTS**. Blending heights (not params) is seamless across *any* difference (frequencies
included) — what per-param blend never could. `PD==PN`, weight 0 ⇒ bit-identical, no biomes.
- **Consumption — CAVES: structural overrides are NOT applied (determinism).** Rooms/tunnels are
decided over a wide COLLECT region spanning chunks (§8.4); making room params vary by region would
need the biome sampled per *room site* inside `BuildChunkCache`, or it breaks window-invariance
(a room near a border resolves differently per querying chunk → seams/holes). So SDF archetypes
(Tunnel/Maze/Shaft/Islands) keep strate-level structure; biomes affect them via **content +
atmosphere only** (below). Per-room-site biome params = a future deep task.
- **Consumption (content/atmosphere).** ContentManager DECORATIONS resolve the biome **per column** on the
worker (`ResolveBiomeSampleAt`, box-cached) → organic borders (§8.5); a column rolls only its biome's
decorations (else the strate's). `GetDominantBiomeAt(x,y,chunkZ)` (game-thread, uncached) → biome ASSET is
still used for the cheaper single-point picks: ContentManager water material + AtmosphereManager player
dominant biome fog/sky (`bOverrideAtmosphere`). Works for ANY archetype.
Water LEVEL stays strate-global (continuous plane); biomes retint material only.
- **Preview tool.** `AVoxelWorld::BakeBiomePreview()` (CallInEditor) bakes biome / relief / moisture
to `Saved/BiomePreview.png` via a transient generator (no PIE). Needs the `ImageWrapper` module.
- **Status:** A (field+asset+preview), B (terrain), C (content/atmosphere) verified in-editor.
Full-param redesign (surface output-blend) ✅ BUILT & WORKING (ticked 2026-07-27). Cave structural biomes
deferred (determinism, see above). Per-voxel biome warp (+2 Perlin) & content `GetDominantBiomeAt`
are future T1.a column-cache candidates.
### 8.15 Biome material identity — vertex-colour palette (F6, Stage 1)
A biome re-skins the terrain SURFACE (not just content/atmosphere) through a single master material,
with NO extra draw calls / material slots and NO per-tile material swap (which would seam at tile
borders). The biome's `MaterialPaletteIndex` (0-255) is **baked into the mesh vertex colour** and a
master triplanar material switches/blends its layers on it. Works for ANY archetype (it rides the
generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colour ⇒ bit-identical look.
- **Vertex-colour layout** (`FVoxelMeshData::Colors`, packed in `UVoxelMarchingCubesMesher::GenerateMesh`
`GetOrCreateVertex`): **R** = dominant biome `MaterialPaletteIndex`; **G** = slope (`1-|N.z|`: 0 flat
floor/ceiling, 1 vertical wall — for rock-on-cliffs); **B** = biome border blend weight (0 deep in a
cell → ~0.5 at the border); **A** = NEIGHBOUR biome `MaterialPaletteIndex`. The master material does
`lerp(layer[R], layer[A], B)` for a seamless cross-fade along the biome field's own border (B peaks at
~0.5 = 50/50 at the border; the identities swap across it, so 50/50 both sides ⇒ no discontinuity —
do NOT rescale B to reach 1.0 or the swap becomes a hard seam).
Height/snow-line is derived in-material from `WorldPosition.Z` (no channel needed). Skirt verts inherit
their source vertex's colour (`AddSkirtVert` takes the colour) so the `Colors` array stays parallel.
- **Data path.** `UVoxelBiomeDefinition::MaterialPaletteIndex``FBiomeResolved::MaterialPaletteIndex`
(set in `StrateManager::GetBiomeContextForChunk`) → `UVoxelGenerator::GetBiomeMaterialAt(x,y,z →
dominant/neighbour palette + weight)`. That method mirrors `GetDensityAt`'s biome caching: a
thread_local per-chunk `FBiomeContext` + box-validated `FChunkBiomeCache`, so the noise-heavy classify
is reused across a tile's vertices. Resolved per UNIQUE vertex (after dedup), not per triangle corner.
Window-invariant (`ResolveBiomeSampleAt`, bit-identical to `SampleBiomeAt`).
- **Apply.** `AVoxelWorld::ApplyMeshToTile` calls `Builder.EnableColors()` + `Vertex.SetColor(...)`.
The terrain material slot is still strate `OverrideMaterial` / `Settings->VoxelMaterial` — author THAT
as the master palette material. No biome terrain-material asset field (palette index is the contract).
- **Perf.** Free where a strate has no biomes (`GetBiomeMaterialAt` early-outs to palette 0). Otherwise
one biome resolve per unique vertex, bounded by the per-chunk biome cache (don't feed it a chunk key —
keep the box validity, §8.10). Coarse far tiles have few vertices.
- **Status:** C++ ✅ BUILT & WORKING (ticked 2026-07-27). The master material graph is still
editor-side work and is deliberately NOT ticked — that half is Jahni's, not the code's.
## 9. Multiplayer model (listen-server first, dedicated-friendly)
> **Status: DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a
> single `GetPlayerPosition()` center; a local diff layer). This section locks in the invariants so the
> streaming / AI / carve systems are built network-aware from the start instead of retrofitted. Target
> **now = listen server** (the host is a player AND the authority); **dedicated server = future / out of
> scope**, but the abstractions below (anchor *policy* + *role*) already cover it so it's additive later.
### 9.1 The core invariant — determinism means you NEVER replicate geometry
The world is a pure function of **(seed, strate layout)** (§8.4). So terrain is reconstructed identically
on every peer from a tiny amount of shared state — it is **never streamed as geometry over the wire**:
- Replicate the **effective seed + strate layout** ONCE (at join). Every client's `UVoxelGenerator` +
`UVoxelStrateManager` then generate byte-identical terrain locally. (Today the seed lives on the data
asset / `UVoxelSettings::Seed`; MP must propagate the *host's* effective seed to joiners so their
generators match — a mismatch = divergent worlds. The strate layout is deterministic from seed, so it
syncs implicitly once the seed does.)
- The **diff layer is the ONLY non-deterministic terrain state** (§3.9, [[voxelforge-difflayer-threading]])
→ it is the only thing that must sync. Since carving is a minor feature, this traffic is small.
### 9.2 Authority — server-authoritative diff, deterministic local re-mesh
- A carve/fill is a **request**: client → `Server_RequestModification(FVoxelModification)` → the authority
(the host) validates it (`DiffLayer` budget / anti-cheat, §3.9 `CanModify`) → applies to the
**authoritative diff layer****multicasts the small `FVoxelModification`** (center/radius/strength ~a
few floats) → every peer applies it to its LOCAL diff layer and re-meshes locally via the existing
`ApplyModification` path (§3.5). Geometry never crosses the wire; only the edit event does.
- On the **listen server** the host is also a player, so a host carve applies directly (still through
validation) then multicasts. Remote clients only ever send requests.
- **Season / seed change** (`ChangeSeed`, `RegenerateAllChunks`) bumps a **local** `GenerationEpoch` today
— in MP this must become a **server-driven multicast event** (everyone bumps epoch + regenerates from the
new seed). Epoch stays a per-peer local counter; the *trigger* is networked, the counter is not.
### 9.3 Multi-anchor streaming — the backbone, not just an AI feature
**IMPLEMENTED 2026-07-07 (the streaming/collision half; the collision-only render-skip §9.4 is still
pending).** `AVoxelWorld::RegisterStreamingAnchor(Actor, Policy, RadiusChunks)` / `UnregisterStreamingAnchor`
(BlueprintCallable) add an actor to `StreamingAnchors`. `UpdateChunksAroundPosition` prunes dead anchors +
detects chunk crossings (rebuilds the desired set when any anchor crosses a level-0 boundary — same cadence
as player movement, coalesced into one rebuild). `AddAnchorDesiredTiles` (inside `BuildDesiredTiles`, after
the player clipmap + sheet ring) folds each anchor's Chebyshev box of **level-0** tiles into the SAME
`DesiredStamped`/`DesiredSorted` set (deduped vs the clipmap by stamp) → the existing delta cull releases an
anchor's tiles automatically when it moves away / unregisters. Zero cost when no anchors (empty loop). The box
defaults to a THIN shape (its chunk + 1 horizontal ring + 1 chunk below for ground safety, nothing above —
`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`, per-register). `CollisionOnly` anchor tiles are hidden (§9.4).
Anchor tiles sort by distance-to-*player*, so one far from every player streams last (fine for now; a
per-anchor priority is a later tweak).
The general model MP requires — **N centers**: the authority streams around **every connected player + every
AI**, because that's how a remote pawn gets server-side collision / movement authority. The registry of
**anchors**:
- **Anchor = { actor, policy }**, `policy ∈ { CollisionOnly, FullVisual }`, plus a **role** on the world
(client / listen-host / [future] dedicated).
- **Listen-host role:** `FullVisual` anchor on its OWN camera (it renders for itself) + **`CollisionOnly`**
anchors around every REMOTE player + AI (it needs their collision for authority, not their pixels).
- **Remote-client role:** `FullVisual` anchor on its own camera + collision around its own pawn (local
prediction). It does not stream other players' far tiles.
- **[Future] dedicated role:** ALL anchors `CollisionOnly` — no visual mesh anywhere server-side. The
listen-host's `CollisionOnly` path IS this path, so dedicated is just "no local FullVisual anchor."
- Keep today's single-player fast path exactly when the registry has one FullVisual anchor and no others.
### 9.4 Collision-only tiles — render-skip (IMPLEMENTED 2026-07-07)
A level-0 tile that ONLY a `CollisionOnly` anchor wants (the player clipmap did not stamp that exact key this
crossing) goes into `CollisionOnlyTiles`; `ApplyMeshToTile` cooks its collision but `SetVisibility(false)`
**no draw, no VSM, no shadow** — killing the cost of terrain around AI / remote players far from the local
camera. If the clipmap (or a `FullVisual` anchor) also wants the tile, it renders normally. Visibility flips
on ALREADY-LOADED tiles (player walks toward/away from a cluster) are handled by `ReconcileAnchorTileVisibility`
diffing `CollisionOnlyTiles` vs its previous set each crossing (bounded by the small anchor set, no O(loaded)
scan). Collision is independent of visibility in UE, so a hidden tile still collides.
- **Still on the frame:** the geometry streams are built on the worker (`BuildTileStreamSet`) even for hidden
tiles — off the frame, but it's CPU+memory. A deeper "cook collision without building render streams" path
(true `CollisionOnly`, and the future dedicated-server terrain) is a later optimization.
- `SetCanEverAffectNavigation(false)` stays — nav is function-based (§9.6), not Recast, so collision tiles
never feed a navmesh.
### 9.5 Late join
A joiner receives the seed/layout (regenerates everything locally) + a **compacted diff snapshot** replayed
into its diff layer. Nothing else needs transfer — the rest of the world is a function. The diff layer is
already chunk-keyed and lock-guarded ([[voxelforge-difflayer-threading]]); a serialize/replay path is the
main new piece.
### 9.6 AI is authority-side + function-based nav (see the AI-nav plan)
AI runs on the authority (host now, dedicated later). Nav is **function-based** — a coarse A* + funnel +
spline route over `GetVoxelSurfaceHeightAt`/`GetDensityAt` (+ the diff layer so AI sees carves), followed by
a steering component for smooth (non-robotic), cheap locomotion. Crucially it queries the world FUNCTION, so
it needs **zero loaded geometry** — ideal for the authority side and mandatory for a future headless
dedicated server (which has no meshes). Recast is rejected: it would need cooked collision everywhere AI
roams, server-side, re-cooking on every dig. Build order: multi-anchor collision streaming (§9.3) FIRST
(now MP-foundational), then the function nav.
### 9.7 What's NOT built (greenfield checklist)
Seed/layout replication at join · `Server_RequestModification` RPC + multicast of applied mods · ~~anchor
registry~~ (DONE 2026-07-07, §9.3) + ~~`CollisionOnly` render-skip~~ (DONE 2026-07-07, §9.4 — hide-based; the
deeper no-stream-build path still open) + role awareness · networked season/seed (epoch multicast) ·
diff-layer serialize/replay snapshot for late join · server-side AI + function nav. All additive on top of
today's deterministic single-player core.
+1050
View File
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
> ## ⚠️ REVIEWER'S NOTE — Claude, 2026-08-16. Read before acting on anything below.
>
> This report was produced by a **read-only Codex pass (`gpt-5.6-sol`, high effort)**. It is a
> *lead list*, not a verified defect list. Every item is labelled "Verified by reading" **by its own
> author**; that label is the author's claim, not an independent check.
>
> **What I checked myself, and what came of it:**
>
> | finding | my verdict |
> |---|---|
> | **VF-05** (radius envelope in `BuildChunkCache`) | ✅ **CONFIRMED and FIXED** — `CODEX-TASK-006`. Real, and the worst of three instances of this class: it is `TunnelNetwork`, it is in code **both** density paths share, and it breaks window invariance (`ARCHITECTURE §8.4`). Genuinely good find. |
> | **VF-02** (3-second shutdown timeout) | ✅ **premise confirmed** — `VoxelWorld.cpp:327` literally reads *"Timeout after 3 seconds to avoid hanging the editor."* Note `CLAUDE.md` states the invariant more strongly than the code implements it ("EndPlay blocks on `ActiveTaskCount → 0`" — it blocks *with a deadline*). Worth deciding deliberately. |
> | **VF-03** (TLS caches omit the owning world) | ⛔ **MY EARLIER VERDICT HERE WAS WRONG — corrected 2026-08-16.** I wrote that its fixture citation was fabricated. **It is not.** `VoxelForgeTestFixture.h` lines ~134/146 explicitly document `CP_UseOpStack` contamination between worlds; I had read only the file's 30-line header comment and asserted a negative from a partial read. VF-03's core claim is **CONFIRMED**: `GetDensityAt` keys its `thread_local CP_*` state by `(ChunkCoord, LayoutVersion)` with **no generator/world identity**, and every manager's version starts at the same value — so a second world on the same worker can inherit the first's params, `CP_UseOpStack` and stack. The *breadth* of VF-03 (the `OC_*`/`BM_*`/passage/biome/diff caches) is still unproven and should be audited as one owner-identity task. Original note kept below for the record: ~~substance plausible, evidence overstated~~ |
> | ~~VF-03 (superseded)~~ | ~~**substance plausible, EVIDENCE OVERSTATED.**~~ It claims *"the test fixture explicitly documents observed cross-world contamination."* It does not. `VoxelForgeTestFixture.h` documents that the `thread_local` caches exist and flags an unrelated `TSoftObjectPtr` risk. The underlying point (caches keyed on chunk/seed/layout but not on which generator owns them) may still hold — but it needs checking on its own merits, not on this citation. |
> | **VF-01** (live rebuild races streaming workers) | ✅ **CONFIRMED — the most serious finding here.** `UVoxelStrateManager::Initialize` does `StrateLayout.Empty()` (:~36) **and** `Passages.Empty()` (:171) then `Passages.Add()`, i.e. it frees and reallocates both arrays. There is **no lock, no barrier, no drain** anywhere in that file. Worker-side readers of the same arrays: `AnyPassageNearBox` (:460, range-for over `Passages`), `EvaluateModifierSDF` (indexes `Passages[...]`), `FindSlotIndexForChunkZ` (iterates `StrateLayout`) — all reached from `GetDensityAt`/`ClassifyTile` on mesher workers. And `RegenerateAllChunks()` (which bumps the epoch) runs **after** `Initialize`, so previous-epoch workers are still live during the mutation. **This is the same class already fixed once in this codebase** — `DiffLayer.ChunkMods` got `ModsLock` after a carve-vs-stream access violation. Four call sites, incl. `OnObjectModifiedInEditor` (:309), which fires automatically when a strate asset is edited while the world streams. **NOT fixed — see the note below.** |
> | **VF-10** (~74-field per-voxel params copy) | ✅ **confirmed real, but Sol missed the conclusion that matters.** The 74 fields are real and the copy is per near-surface sample. **However it is INHERITED from the original path — `GetDensityWithParams` does the same copy — so both paths pay it equally and it does NOT explain the op-stack perf regression.** The op stack actually *improved* it (memoised so eleven detail ops don't each repeat it), and the site says so in its own comment. Genuine future optimisation for both paths; **not** the answer to "why is the op path slower". |
> | VF-04, VF-06, VF-07, VF-08, VF-09 | **NOT independently verified.** Read them as leads. |
>
> **Do not treat an unverified row as actionable.** The lesson this project keeps paying for is that a
> confident chain resting on an unchecked premise reverses about half the time — and VF-03 is an
> instance of exactly that, inside an audit written to find them.
# VoxelForge code quality and efficiency audit — August 2026
| Finding | File | Severity | Tier | Evidence status |
|---|---|---:|---|---|
| VF-01 — Live rebuilds mutate generation state while workers read it | `VoxelWorld.cpp`, `VoxelStrateManager.cpp` | Critical | Async lifecycle / per-tile workers | Verified by reading |
| VF-02 — Shutdown timeouts allow tasks to outlive their owners | `VoxelWorld.cpp`, `VoxelContentManager.cpp` | Critical | Async lifecycle | Verified by reading |
| VF-03 — Function-static TLS caches omit the owning world/generator | `VoxelGenerator.cpp`, `VoxelStrateManager.cpp`, `VoxelDensityOpStack.cpp` | Critical | Per-voxel caches; per-chunk refill | Verified by reading |
| VF-04 — Box/capsule edits bypass the intended budget and use the wrong live-deco removal volume | `VoxelDiffLayer.cpp`, `VoxelDiffLayer.h`, `VoxelWorld.cpp` | High | Per modification | Verified by reading |
| VF-05 — Cave collection bounds can be smaller than generated geometry when min/max fields are reversed | `VoxelCaveMorphology.cpp`, `VoxelStrateTypes.h` | High | Per-chunk cache construction / skip bound | Verified by reading |
| VF-06 — A fixed-only strate configuration silently disables the strate system | `VoxelWorld.cpp`, `VoxelStrateManager.cpp` | High | Initialization | Verified by reading |
| VF-07 — World origin is used as the “no player” sentinel | `VoxelWorld.cpp`, `VoxelWorld.h` | Medium | Per frame / streaming gate | Verified by reading |
| VF-08 — Decoration palettes are rebuilt every tick and deep-copied into every cell task | `VoxelContentManager.cpp`, `VoxelContentManager.h`, `VoxelStrateTypes.h` | Medium | Per frame and per decoration cell | Verified by reading |
| VF-09 — Clearing decoration builds forgets still-running tasks and defeats the concurrency cap | `VoxelContentManager.cpp` | Medium | Per rebuild / async scheduling | Verified by reading |
| VF-10 — Per-room terrain params are reconstructed for every near-surface sample | `VoxelGenerator.cpp`, `VoxelDensityOpStack.cpp`, `VoxelCaveMorphology.cpp` | Medium | Per near-surface voxel | Verified by reading |
## Scope and evidence
This was a static, read-only review. I read `CODEMAP.md`, `ARCHITECTURE.md` including §8.10, `REVIEW_FINDINGS.md`, the relevant public contracts, implementations, and tests. I did not build, compile, or run the plugin. Every item below is therefore marked **Verified by reading**: the cited control flow or cache-key omission is present in the source. Runtime frequency and timing impact are reasoned from that source, not measured in this review. No “suspicious only” item is included.
The deliberate old/new density-path duplication and every settled decision listed in the review request are excluded.
## Findings
### VF-01 — Live rebuilds mutate generation state while workers read it
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp``AVoxelWorld::RebuildStrates` (lines 140153), `AVoxelWorld::OnObjectModifiedInEditor` (238316), `AVoxelWorld::ChangeSeed` (20642114), `AVoxelWorld::LoadTile` (13451478), and `AVoxelWorld::GenerateTileResult` (1481 onward); `Source/VoxelForge/Private/VoxelStrateManager.cpp``UVoxelStrateManager::Initialize` (28163).
**What is wrong:** chunk tasks capture `this` and call `GenerateTileResult`, which reads `Generator`, `Mesher`, and through them `StrateManager`. Meanwhile, each live-rebuild path mutates the same objects on the game thread. `Initialize` empties and repopulates `StrateLayout`, empties and repopulates `Passages`, and changes cached seed/settings fields. `ChangeSeed` also writes the generator's plain `Seed`/`OriginSpineRadius`. There is no lock, immutable snapshot, or worker quiescence around those writes.
The order makes the race especially direct: `RebuildStrates` and `OnObjectModifiedInEditor` call `StrateManager->Initialize(...)` before `RegenerateAllChunks()` increments `GenerationEpoch`. `ChangeSeed` also changes generator and manager state before regeneration. The epoch only rejects a finished result; it does not make concurrent reads of reallocating `TArray`s safe and cannot repair undefined behavior that happened while producing the result. Decoration and density-volume workers also read the generator and need to be included in the same transition.
**Why it matters:** an edit or seed change during active streaming can race a worker iterating or indexing storage that `Initialize` has freed/reallocated. Outcomes range from a tile built from mixed old/new settings to an access violation. This is a correctness and lifetime issue, not merely stale-result work.
**Concrete change:** introduce an immutable generation snapshot containing the seed, layout, passages, resolved definitions/op data, and a unique generation ID. Atomically publish the new snapshot and have every task capture a strong reference to one snapshot. The smaller alternative is a rebuild barrier: stop new chunk/deco/density work, wait without timeout for all generator readers, mutate the state, bump the epoch, then resume. Incrementing the epoch before mutation is useful but is not sufficient without snapshotting or quiescence.
### VF-02 — Shutdown timeouts allow tasks to outlive their owners
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp``AVoxelWorld::EndPlay` (320375) and the `[this, ...]` task in `AVoxelWorld::LoadTile` (14561478); `Source/VoxelForge/Private/VoxelContentManager.cpp``UVoxelContentManager::BeginDestroy` (5863), `NotifyShutdown` (6580), and the `[this, ...]` task in `LaunchDecoTasks` (384404).
**What is wrong:** both shutdown drains stop waiting after three seconds and continue teardown while work may still be running. Chunk tasks retain a raw `this` and even their RAII guard holds a reference to `AVoxelWorld::ActiveTaskCount`. Decoration tasks retain a raw content-manager `this`, read its `bShuttingDown`, and may access its queue. `BeginDestroy` sets a flag but does not establish task completion before calling `Super::BeginDestroy`.
The decoration counter is also file-global (`GActiveDecoTasks`), so it is neither an ownership handle nor a per-manager proof that this manager's tasks are finished.
**Why it matters:** if the timeout is reached, later task reads, queue writes, or the guard decrement can target an object whose EndPlay/destruction has advanced. The timeout converts a slow task into a possible use-after-free. The shutdown flag reduces ordinary latency but does not cancel a task already inside generation or marching.
**Concrete change:** retain `UE::Tasks::FTask` handles per owner and make UObject destruction contingent on their completion. Stop submissions first, request cancellation, and either wait unconditionally in a safe shutdown phase or defer final destruction through `IsReadyForFinishDestroy` until the owner's task group is empty. Replace the global decoration count with per-instance task ownership. A watchdog may log a long wait, but it must not release the objects that unfinished tasks can still touch.
### VF-03 — Function-static TLS caches omit the owning world/generator
**Evidence status:** Verified by reading. The test fixture explicitly documents observed cross-world contamination.
**Location:**
- `Source/VoxelForge/Private/VoxelGenerator.cpp``UVoxelGenerator::GetDensityAt`: `CP_*` cache (583622), `GSurfColCache` access (738), and `DiffSlots` (808826); `ClassifyTile`: `TC_BiomeCache`/`TC_SeenVersion` (27202731); `GetBiomeMaterialAt`: `BM_*` cache (34703485).
- `Source/VoxelForge/Private/VoxelStrateManager.cpp``GeneratePassages` (169173, 352353) and `EvaluateModifierSDF`: `SL_*` shortlist plus unchecked `Passages[PIdx]` (372420).
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp``FRoomGraphSource::Eval`: `SI_*` strate-index memo (21722188).
- `Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h``FTestWorld` construction (126152).
**What is wrong:** these are function/file-static `thread_local` caches, so one worker-thread cache is shared by every VoxelForge instance evaluated on that thread. Their keys use coordinates and per-instance counters such as `LayoutVersion` or `ModsVersion`, but omit the owning generator/manager/diff layer. Two freshly initialized worlds normally both report layout version 1; two diff layers also start with the same modification version. Equal coordinates and versions therefore make the second world reuse the first world's params, operator stack (including its manager pointer), biome context, surface columns, or modification snapshot.
This is not hypothetical test hygiene. `VoxelForgeTestFixture.h` states that two test worlds both reporting version 1 caused the second world to receive the first world's params and `CP_UseOpStack`; it works around the problem by repeatedly initializing each test manager until its version is process-unique. Production has no such workaround.
The passage cache has a more severe failure mode. `SL_Nearby` stores indices from manager A, then manager B with the same `(chunk, PassagesVersion)` can execute `Passages[PIdx]` without `IsValidIndex`. Also, `GeneratePassages` empties `Passages` and returns for an empty layout before incrementing `PassagesVersion`, so the same manager can retain stale indices after an empty rebuild.
**Why it matters:** multiple VoxelWorld actors, PIE worlds, tests, previews, or address-reused objects can produce density/materials/modifications from the wrong world. The passage case can read out of bounds. This affects the per-voxel tier—up to roughly 35³ = 42,875 base samples per full-resolution tile—although the bad selection occurs at cache-refill granularity.
**Concrete change:** give each immutable generation context a process-unique, monotonic cache ID and include it in every shared TLS key. Give each diff layer its own unique ID as well. Prefer a per-worker cache object scoped to that context over scattered function statics. Move the passage-version increment so every clear/rebuild, including the empty-layout exit, invalidates the cache; retain `Passages.IsValidIndex(PIdx)` as defense in depth. Remove the test fixture's serial-bump workaround once production keys express owner identity.
### VF-04 — Box/capsule edits bypass the intended budget and use the wrong live-deco removal volume
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelDiffLayer.cpp``UVoxelDiffLayer::CanModify` (2045) and `ApplyModification` (63133); `Source/VoxelForge/Public/VoxelDiffLayer.h``FVoxelModification::GetWorldBounds` (97121); `Source/VoxelForge/Private/VoxelWorld.cpp``AVoxelWorld::ApplyModification` (18391871), `CarveBox`/`FillBox` (18741893), and `CarveCapsule`/`FillCapsule` (18961915).
**What is wrong:** budget validation knows only a scalar radius and always charges `4/3*pi*r^3`. `ApplyModification` clamps `Mod.Radius`, but leaves `BoxExtent`, `CapsuleEnd`, and `Falloff` unchanged. The shape-aware AABB then uses those unchanged values. Consequently:
- a box with huge extents is stored across its full huge AABB even though only its proxy radius was clamped and sphere volume was charged;
- a capsule of arbitrary length is charged only as a sphere of its tube radius;
- an untrusted or accidental large shape can enumerate and allocate entries for an enormous number of chunks despite `MaxBrushRadius`/`MaxTotalVolume` being presented as safety limits.
The live decoration cleanup is inconsistent in the other direction. It always removes a sphere centered at `Modification.Center` with the original `Modification.Radius`. For a capsule this is only endpoint A, leaving decorations floating along most of the segment. For a box, `max(half extent)` does not cover the corners and ignores falloff. It also does not use the clamped modification that was actually stored.
**Why it matters:** the budget can be bypassed precisely by the shapes most able to create a large remesh/storage burst. Separately, box/capsule edits leave visibly invalid live content until a later decoration rebuild.
**Concrete change:** make validation and accounting accept the complete `FVoxelModification`. Validate finite, non-negative geometry; enforce extent/tube-radius and capsule-length limits; and charge a documented shape volume (or a deliberately conservative support-AABB volume including falloff). Return the normalized/applied modification or its actual bounds from `ApplyModification`. Use those applied bounds for decoration invalidation—prefer a shape-aware removal query, or at least a conservative sphere centered on the bounds center with the bounds half-diagonal. Keep `CanModify` and `ApplyModification` on the same normalization/accounting function so UI/server decisions cannot drift.
### VF-05 — Cave collection bounds can be smaller than generated geometry when min/max fields are reversed
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelCaveMorphology.cpp``VoxelCaveMorphology::BuildChunkCache`: `MaxInfluence` (127130), `CollectMargin` (152), `RoomZBuffer` (171), room radius generation (260265), and tunnel radius generation (464468); `EvaluateSDF` margin (871874). Authoring fields are in `Source/VoxelForge/Public/VoxelStrateTypes.h``MinRoomRadius`/`MaxRoomRadius` (490/498) and `TunnelMinRadius`/`TunnelMaxRadius` (595/602).
**What is wrong:** `FMath::Lerp(Min, Max, t)` produces values up to `max(Min, Max)` even when an asset has the endpoints reversed. The collection/influence math assumes the field named `Max*` is numerically largest: it uses only `MaxRoomRadius` and `TunnelMaxRadius`. The properties have no cross-field validation enforcing `Min <= Max`.
If `MinRoomRadius > MaxRoomRadius`, actual generated rooms can be larger than `MaxInfluence`, `CollectMargin`, and `RoomZBuffer` assume. If `TunnelMinRadius > TunnelMaxRadius`, the same applies to tunnel reach. `RoomReachesSearchBox` uses the actual radius, but it cannot test a room whose anchor cell was never collected because the collect region was too small. The convenience wrapper repeats the underestimated margin.
**Why it matters:** this is an under-bound, not a conservative overestimate. A room/tunnel able to affect a chunk may not be created in that chunk's cache, producing window-dependent density, seams, missing mesh, or missing collision. Reversed ranges are authorable and can also arise transiently while live-editing the two fields.
**Concrete change:** derive bound-only envelopes as `Max(MinRoomRadius, MaxRoomRadius)` and `Max(TunnelMinRadius, TunnelMaxRadius)` and use them in `MaxInfluence`, collection margins, vertical room buffer, and the wrapper margin. Do not reorder the endpoints passed to `Lerp`, because that would change deterministic room/tunnel assignment; only make the bounds cover every value the existing interpolation can produce. Add asset validation that warns on reversed or non-positive ranges.
### VF-06 — A fixed-only strate configuration silently disables the strate system
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp``AVoxelWorld::BeginPlay` (413421); `Source/VoxelForge/Private/VoxelStrateManager.cpp``UVoxelStrateManager::Initialize` (4355, 76105).
**What is wrong:** `BeginPlay` creates the strate manager only when `Settings->StratePool.Num() > 0`. The manager itself explicitly supports fixed entries independently: it loads `FixedStrates` and selects a fixed definition before consulting the shuffled pool. A valid setup in which every requested slot is fixed and `StratePool` is empty therefore never constructs the manager.
**Why it matters:** the generator silently falls back to generic TunnelNetwork terrain, while content and atmosphere receive a null manager. Authored fixed strata are ignored without an initialization error.
**Concrete change:** initialize the manager when either `StratePool` or `FixedStrates` is non-empty. Validate that every index in `[0, TotalStrates)` can resolve either a fixed definition or a pool fallback, and emit a clear error for uncovered slots rather than silently changing generation mode.
### VF-07 — World origin is used as the “no player” sentinel
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelWorld.cpp``AVoxelWorld::Tick` (456484) and `GetPlayerPosition` (529537); declaration/comment in `Source/VoxelForge/Public/VoxelWorld.h` (634635).
**What is wrong:** `GetPlayerPosition` returns `FVector::ZeroVector` when there is no pawn, but a pawn at the real world origin returns the same value. `Tick` tests `PlayerLastPos != FVector::ZeroVector` before all terrain streaming, atmosphere, decorations, landmarks, water, and density-volume updates.
**Why it matters:** origin is a common initial spawn. While the pawn is exactly there, no initial terrain/content streaming is submitted; behavior begins only after it moves away.
**Concrete change:** return success separately from the coordinate (`bool TryGetPlayerPosition(FVector& Out)` or an optional), or obtain the controller/pawn in `Tick` and gate on pointer validity. Treat every finite coordinate, including zero, as a valid position.
### VF-08 — Decoration palettes are rebuilt every tick and deep-copied into every cell task
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelContentManager.cpp``UVoxelContentManager::UpdateDecorations` (147256) and `LaunchDecoTasks` (366398); `Source/VoxelForge/Public/VoxelContentManager.h``FDecoCellResult::Entries` (151158); nested decoration arrays in `Source/VoxelForge/Public/VoxelStrateTypes.h``FDecoCompanion::SubCompanions` (20732076) and `FStrateDecoration::Companions` (21202123).
**What is wrong:** `UpdateDecorations` is called every tick. Before it checks whether the player changed cell or stratum, it resets both flattened palettes, walks every biome and decoration, and copies every `FStrateDecoration`. Those structs contain nested `TArray`s, so this is not a trivial POD copy. `LaunchDecoTasks` then deep-copies the same grid palette and biome-tag array once for every cell task and moves that copy through the result solely so `EntryIdx` can be decoded on the game thread.
At the default 4x4 region size, one new region is 16 cell tasks carrying 16 copies of the same immutable palette. The per-frame rebuild also contradicts the nearby “cheap no-op unless the player crosses a decoration cell boundary or changes strate” expectation.
**Why it matters:** this creates allocator traffic and memory bandwidth on both the steady game-thread path and every decoration-streaming burst. Large biome palettes with companion/sub-companion trees amplify the cost.
**Concrete change:** build an immutable resolved palette snapshot only when its inputs change (stratum/layout/asset revision, tier assignment, or relevant settings). Capture a thread-safe shared reference in cell tasks and carry that same reference in results, or resolve spawn commands to a compact immutable profile table once. Continue draining tasks/results each tick, but do not destroy and reconstruct unchanged nested arrays.
### VF-09 — Clearing decoration builds forgets still-running tasks and defeats the concurrency cap
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelContentManager.cpp``ResetGridBuildState` (8894), `LaunchDecoTasks` (344404), `ProcessDecoResults` (763774), and `ClearAllDecorations` (983994).
**What is wrong:** `ClearAllDecorations` abandons builds and calls `ResetGridBuildState`, which clears `InFlightCells` even though the corresponding tasks are not cancelled or awaited. A new build can immediately launch another task for the same cell. When the old result arrives, `ProcessDecoResults` removes `R.Cell` from `InFlightCells` before checking its `BuildId`; this can remove the new task's marker. Payload merging is protected by `BuildId`, but scheduling ownership is not.
The throttle uses `NearGrid.InFlightCells.Num() + FarGrid.InFlightCells.Num()`, not `GActiveDecoTasks`, so forgotten/incorrectly removed markers allow actual worker count to exceed `MaxConcurrentDecorationTasks`. Repeated clear/rebuild cycles can compound the excess precisely when live editing or regeneration is already generating other work.
**Why it matters:** the configured cap is documented as preventing decoration marching from crowding mesh-generation workers. This bookkeeping path invalidates that guarantee and can create avoidable CPU/memory bursts. It can also cause redundant same-cell work, though `BuildId` prevents duplicate applied decorations.
**Concrete change:** track an in-flight token that includes grid, cell, and build ID (for example, `TMap<FIntPoint, uint32>`), and remove it only when the completing result owns that exact token. Do not erase live tokens when abandoning build payloads; retain them until completion/cancellation. Better, keep per-instance task handles/counts and throttle on the actual running count, with build identity used only for result relevance.
### VF-10 — Per-room terrain params are reconstructed for every near-surface sample
**Evidence status:** Verified by reading.
**Location:** `Source/VoxelForge/Private/VoxelGenerator.cpp``UVoxelGenerator::GetDensityWithParams`, per-room block (12941328); `Source/VoxelForge/Private/VoxelDensityOpStack.cpp``FRoomGraphSource::FState`/`LocalParams` (19932012, 20412073); `Source/VoxelForge/Private/VoxelCaveMorphology.cpp` — room-op selection and existing per-room feature pre-bake (653678).
**What is wrong:** for every `bNearCaveSurface` sample, the original path copies the roughly 74-field `FStrateGenerationParams` and makes a virtual `RoomOp->ApplyTo` call for the nearest room. The operator stack preserves one such copy per sample through `LocalParams()`—correctly memoized so eleven detail operators do not each repeat it—but the work is still invariant for all samples whose nearest cached room is the same.
`BuildChunkCache` already selects `RoomOp`/weight per cached room and calls `ApplyTo` once per room to pre-bake pits, chimneys, and columns. The remaining detail-op parameters can be resolved at that same per-room tier.
**Why it matters:** this is inside the density hot path. A full-resolution tile has about 42,875 base grid samples, plus density calls used for surface normals; only near-surface samples pay this block, but those are exactly the samples concentrated around generated geometry. Copying a large struct and dispatching virtually per sample adds bandwidth and instruction cost that could be per-room/per-chunk.
**Concrete change:** add a compact `FResolvedRoomDetailParams` to `FCachedRoom`, containing only fields consumed by the eleven per-room detail stages, and populate it once during `BuildChunkCache` from the chunk's base params plus the selected op. Both the oracle path and operator-stack path should reference that shared resolved payload. Keep the existing operation order and verify bit-for-bit equivalence; this is a hoist of loop-invariant data, not a split or transcription of `BuildChunkCache`.
## Priority order
Fix VF-01 through VF-03 first: they are memory-model/lifetime/cache-identity problems and can produce crashes or cross-world corruption. VF-04 through VF-06 are deterministic correctness failures with bounded, local fixes. VF-07 is a small but user-visible initialization defect. VF-08 through VF-10 are worthwhile efficiency changes after the correctness hazards are closed; VF-08 and VF-09 should be addressed together because an immutable palette snapshot and explicit task ownership naturally simplify both paths.
+43
View File
@@ -0,0 +1,43 @@
# CLAUDE.md — VoxelForge
UE5 **density-field voxel terrain** plugin (strates / Marching Cubes / async streaming).
Don't re-read the whole plugin — use the map.
## ⛔ #1 rule: NEVER build or compile
Jahni runs every build himself (he has the editor open; running it via Claude just burns cost).
When code changes are done, **stop and say "ready to build"** + list likely compile-error spots,
then wait for his results / pasted errors. Same for in-editor checks — ask for a screenshot, don't
try to run anything.
## Read first (in order, load only what the task needs)
- **[CODEMAP.md](CODEMAP.md) — ALWAYS.** Navigation: what it is, data-flow (§2), symbol→`file:line`
index (§3), "I want to change X → go here" (§5), conventions & gotchas (§6). Trust **symbol names
over line numbers** (lines drift).
- **[ARCHITECTURE.md](ARCHITECTURE.md) — when touching generation / strates / passages / biomes.**
The deep design (archetypes, (0,0) spine, disturbances, content/atmosphere, biomes) **and the
`§8.10` performance invariants ("don't regress").**
- **[fable-idea.md](fable-idea.md) — before planning PERF or FEATURE work.** Ranked perf+feature
roadmap with verified hot spots; check it so you don't re-propose done/known work. (A perf pass
already shipped: game thread solved ~3.94 ms, CullTiles spiral fixed, region-granular foliage.)
- **[REVIEW_FINDINGS.md](REVIEW_FINDINGS.md)** — open quality/cleanup items (cross-check vs fable-idea
before acting; some may already be addressed).
## Hard rules (these prevent real bugs — verify against code, don't assume)
- **Density sign: negative = solid, positive = air** (MC convention at the mesher).
`FVoxelModification::Strength` negative = carve. *The #1 source of confusion.*
- **Density functions take VOXEL coords, not cm.** Conversions live in `VoxelTypes.h`.
- **Determinism:** all randomness = hash of (coord, seed, strateIndex). No RNG state — same seed ⇒
same world. Player edits (diff layer) are the only non-deterministic overlay.
- **Async safety:** worker tasks only READ Generator/Mesher, must check `bShuttingDown`, and return via
`ProcessQueue` (must stay `EQueueMode::Mpsc`). `EndPlay` blocks on `ActiveTaskCount → 0`.
- **Carry the `Epoch`** through any new async path (stale results are dropped on mismatch).
- **Don't "optimize" the perf invariants** in ARCHITECTURE `§8.10` — the `thread_local` box-valid
caches, two-pass MC loop, SSE noise, and clipmap streaming are intentional.
- **Never edit** `Binaries/`, `Intermediate/`, `*.generated.h`. Comments are French + English — match
the surrounding file.
## Discipline (keep the map alive)
- Add / rename / move a symbol → update its **CODEMAP §3** row (symbol-first; line is a hint).
- Change generation/strate design → update **ARCHITECTURE §8**.
- Resolve a checklist item → tick it in **REVIEW_FINDINGS.md**.
- Don't duplicate content across these files — each has one job (map / design / findings / rules).
+128 -3
View File
@@ -79,6 +79,7 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
| `../../VoxelForge.uplugin` | Plugin manifest. One Runtime module `VoxelForge`. Beta. |
| `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. |
| `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). |
| `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. |
### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it)
| Symbol | Line | Notes |
@@ -90,8 +91,104 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
| `LocalToIndex` / `IndexToLocal` / `IsValidLocalCoord` | 107-131 | Flat-array 3D↔1D indexing. |
| `SmoothStep01` | 140 | 3x²-2x³ — used everywhere for blends. |
| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. |
| `EVoxelTileClass` enum (`Mixed`/`AllSolid`/`AllAir`) | — | T1.d verdict. **MOVED here from `VoxelGenerator.h` 2026-07-27** so `VoxelDensityOp.h` can share it without a UCLASS dependency. A false `AllSolid`/`AllAir` is a HOLE; a false `Mixed` only costs CPU. |
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
### 3.2b Density operator stack contract — `Public/VoxelDensityOp.h` (plain C++, no UHT)
**Phase 1 skeleton, added 2026-07-27. Nothing is wired in yet** — `GetDensityAt`'s archetype
`switch` is untouched and no operator exists. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for the plan and
[OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md) for the per-archetype breakdown.
| Symbol | Notes |
|--------|-------|
| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→seal→passage→diff, appended automatically, never author-omittable). |
| `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. |
| `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. |
| `FVoxelOpContext` | Chunk-constant inputs. **Carries `LayoutVersion` by construction** so a new op cannot forget it (AUDIT C2). |
| `IVoxelDensityOp` | `PrepareChunk` / `Eval` / `EffectOverBox` / `ClassifyBox` / `IsXYPure`. |
| `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the boundary seal inside its band) overwrite the input entirely, which pure direction cannot express. |
| `FVoxelOpSample` | The state threaded through the stack: **two** channels, `Density` (INTERNAL convention, **positive = SOLID**, negated to MC once by the caller) and `Sdf` (standard SDF, negative = inside). ⚠️ `min()` therefore means opposite things on the two channels. |
| `FVoxelBoxHypotheses` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`. Reproduces today's hand-written `ClassifyTile` line for line — the mapping is written out in the header. |
### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h`
`VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` — the three world
invariants every archetype appends, **moved here 2026-07-27** so the generator and the operator
stack share ONE copy. `VoxelGenerator.cpp` keeps same-named `static FORCEINLINE` forwarders so its
~20 call sites are unchanged; bodies are byte-identical. Also `VoxelDensityReach::SpineBlend` /
`PassageBlend`, the blend radii `ClassifyTile` currently hand-duplicates.
**Convention: INTERNAL (positive = solid).**
### 3.2d Operator stack — `Public/VoxelDensityOpStack.h` + `Private/VoxelDensityOpStack.cpp`
⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack
in its per-chunk refetch block and evaluates it *instead of* the `switch` only when
`UVoxelStrateManager::UsesOperatorStackForChunk` says so — strate ticked `bUseOperatorStack` **and**
archetype in the ported list, which is now **all 8 of 8**: Maze, FlatPlain, CrystalChamber,
SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater. A strate that has not
ticked the box still takes the `switch`, unchanged — the flag is the only thing that switches paths.
**`ClassifyTile` IS wired now**, for CAVE archetypes only and behind the same per-strate opt-in:
where it used to `return Mixed` without a call, it builds the strate's stack through the *same*
factory `GetDensityAt` uses (`VF_BuildOpStackForChunk`) and folds `ClassifyBox`. SurfaceWorld and
bedrock gaps keep their hand-written proofs — the exact-lattice column test is better than any box
bound. Guards, all failing to `Mixed`: one cave slot per tile, no mixed cave/surface/gap tile, the
opt-in true on *every* chunk the box touches, the params **bit-identical** across every chunk coord
the box touches (blended transition bands make one stack unable to represent the tile — `AUDIT §C2`),
a 27-chunk-coord cap, and the disturbances folded in by hand since they are applied after the stack.
Brute-forced end to end by `VoxelForge.OpStack.ClassifyTileSoundness`.
⛔ Never run both paths in one world. **Comparing them IS legitimate now** — the ~1 ULP residue of
AUDIT §C10 is gone since `FPSemantics = Precise`, and all eight equivalence tests compare bit for
bit. They are port-correctness oracles, not fidelity checks: the acceptance bar is §2.6.1 (same seed
⇒ same world on every peer), which does not require resembling the pre-refactor world.
| Symbol | Role | Notes |
|--------|------|-------|
| `FVoxelOpStack` | — | Ordered `TUniquePtr` list. `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold, with an early-out when both hypotheses die). |
| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → seal → passage **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. |
| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox`**AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). |
| `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox`**AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. |
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). Its `EffectOverBox` answers for the source+carve **pair** (Phase 1 simplification) so it must be told the downstream `ExtraReach`. |
| `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. |
| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. |
| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. |
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. |
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)`**any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|AB\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. |
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. |
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). |
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
| `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. |
| `FLayerLineMod` / `FRibbingMod` (internal) | 3 | The same sine along Z: cubed and subtracted (grooves) vs quarter-phase-shifted, squared and added (ribs). `CarveOnly` / `FillOnly` — two of the few detail modifiers that keep a usable direction for the fold. |
| `FCaveOverhangMod` / `FCaveCliffMod` / `FScallopMod` (internal) | 3 | STEP 4c. ⚠️ The cliff's own comment promises a sampled vertical gradient; the **code** uses a Z-stretched Perlin as a proxy and samples nothing. Ported as written — fixing it would change the world. |
| `FCaveArchMod` / `FDomeMod` / `FPinchMod` / `FFloorBiasMod` (internal) | 3 | Room-relative: they read `FRoomGraphSource::GetNearestRoomIdx()` and the cached room. Their gate is `SDF < SDFBlendRadius`, **not** the shared `·3` one — they live in the cave's void, not its wall. |
| `FRoomColumnMod` (internal) | 3 | STEP 4d, and **not** `MakeGridColumnMod`: it walks `SDFCache.Columns`, pre-baked per room. ⚠️ It has **no strate parameter at all** — neither the bake nor the loop reads `FStrateGenerationParams::ColumnDensity`. Columns exist only through a `Column` terrain-op asset in the strate's pool, and the only way to prove they fired is to look at `SDFCache.Columns.Num()`. |
| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod``FSurfaceColumnSource`. ⚠️ `EffectOverBox` still answers from STRATE params, so a box verdict can be **too optimistic** on a strate with a terrain-op pool; harmless until `ClassifyTile` consumes `ClassifyBox`, and it must be fixed before that. |
| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. `EffectOverBox``FillOnly` when a blob reaches the box, `Identity` otherwise; its pad must cover warp·**√2** (two independent noise axes), roughness, fill blend and the `SmoothMin` dip. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. |
| `VoxelDensityOps::BuildFloatingIslandStack` | — | 7 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. |
| `VoxelDensityOps::BuildMazeStack` | — | The 7-op Maze stack. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (topbottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp`
⚠️ **Feeds nothing yet** — built and exercised only by `VoxelForge.OpStack.SurfaceHeightEquivalence`.
**A SECOND op family, and it exists for a reason worth knowing:** SurfaceWorld's terrain ops (cliff /
terrace / layer lines / beach) read and write an **altitude**, not a density. They have no input Z
(they produce one), are XY-pure (once per column, not per voxel), and touch neither density nor SDF —
so they do not fit `IVoxelDensityOp` at all. Forcing them in would need a per-voxel channel for what
is a **column** property, or one opaque op (`OPSTACK-PLAN §2.5`'s failure mode). Same lesson as
`§0.1` one step further: some things are not another channel, they are another **space**.
| Symbol | Notes |
|--------|-------|
| `FVoxelHeightSample` | Two channels: `Height` (voxel Z) + `Relief` (the original's `M`). Relief is produced by the structural source and consumed by the terrace gate — threading it beats resampling it. |
| `IVoxelHeightOp` | `Eval(X, Y, FVoxelHeightSample&)`. No `IsXYPure` (XY-purity is structural here — there is no Z to wrongly put in), no `PrepareChunk` (already per-column). `MaxDisplacement()` is the conservative vertical bound for a future heightfield `ClassifyBox`. |
| `FVoxelHeightStack` | Move-only, like `FVoxelOpStack`. `EvalHeight` / `EvalSample` / `MaxTotalDisplacement`. |
| `VoxelHeightOps::MakeStructuralHeightSource` | Continents + mountains + detail under a warp frame. Hands back a **non-owning pointer** so the cliff mod can resample it. |
| `VoxelHeightOps::MakeCliffHeightMod` | Slope-gated steepening; 4 resamples of the **structural** field (never the modified height — that would feed back). |
| `VoxelHeightOps::MakeTerraceHeightMod` | Relief-gated plateaus. The `* Relief` is the original's `* M`. |
| `VoxelHeightOps::MakeLayerLineHeightMod` / `MakeBeachHeightMod` | Sine bands; flatten toward the water line. Both have exact `MaxDisplacement`. |
| `VoxelHeightOps::BuildSurfaceHeightStack` | 5 ops in `ComputeSurfaceTerrainZ`'s order — structural → cliff → terrace → layer lines → beach. **Order is not negotiable.** |
### 3.3 Chunk identity
`VoxelChunk.h` (the old `FVoxelChunk` coord wrapper) was DELETED — dead since the tile
redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
@@ -166,11 +263,12 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. |
| `InitializeSettings` | 211 | Copies seed from settings. |
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. |
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. |
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. |
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. |
| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. |
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. |
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
@@ -230,7 +328,9 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system.
`TransitionType`(79)/`TransitionBlendChunks`(89), `GeneratorType`(102),
`GenerationParams`(113), `SlabParams`(124), `Biomes[]`+`BiomeMapParams` (the biome list +
field tuning — empty ⇒ unchanged world, §8.14), `TerrainOperations`(147), visuals/fog/light,
content lists, audio, `GameplayTags`(223). EditConditions show/hide param groups by generator type.
content lists, audio, `GameplayTags`(223), `bUseOperatorStack` (the OPSTACK A/B opt-in — only bites
if the archetype is in `UsesOperatorStackForChunk`'s ported list). EditConditions show/hide param
groups by generator type.
**`Public/VoxelStrateManager.h` + `.cpp`** — `UVoxelStrateManager : UObject` (h:108).
Maps depth→strate at runtime; owns passages.
@@ -247,6 +347,7 @@ Maps depth→strate at runtime; owns passages.
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. |
| `GetStrateForChunk` | 466 | Chunk → definition. |
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list — **now all 8 of 8** (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater). **That list is written down here and nowhere else.** With every archetype ported the flag is now the *only* thing that decides the path, so ticking the box is no longer a no-op anywhere — it is a real switch onto the operator stack for that strate. |
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). |
| `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. |
| `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). |
@@ -263,6 +364,10 @@ border warp+blend / climate field freqs), `EBiomePreviewChannel` (preview-bake s
`FVoxelBiomeQuery` (BlueprintType result of `GetBiomeAtWorldLocation` — dominant/neighbour asset,
climate, blend weight, deco count), and plain runtime PODs `FBiomeResolved` / `FBiomeContext` /
`FBiomeSample` / `FChunkBiomeCache` (the box-validated per-chunk grid cache). See §8.14.
`FChunkBiomeCache::Invalidate()` (added 2026-07-27, AUDIT C2) — force a rebuild when the strate
layout version moves. The validity BOX says nothing about the `FBiomeContext` the cells were
classified against, so after a `RebuildStrates` the grid is stale even though the box still covers
the query. Called by all four callers on a `GetLayoutVersion()` change.
**`Public/VoxelBiomeDefinition.h` + `.cpp`** (NEW) — `UVoxelBiomeDefinition : UPrimaryDataAsset`.
One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMin/Max`,
@@ -313,6 +418,26 @@ Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs ed
---
### 3.12 Automation tests — `Private/Tests/` (added 2026-07-27, `#if WITH_DEV_AUTOMATION_TESTS`)
The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor's
**Session Frontend → Automation**, filter `VoxelForge`.
| File | Test name | What it proves |
|------|-----------|----------------|
| `VoxelForgeTestFixture.h` | — | `FTestWorld`: a headless world (transient strate definitions → `UVoxelSettings` → a real `UVoxelStrateManager::Initialize`) so tests hit `GetDensityAt`, where the thread_local caches live. One strate per archetype, **pinned via `FixedStrates`** so slot index → archetype is stable across seeds (`SlotSurfaceWorld` etc.). |
| `VoxelForgeDensityPurityTest.cpp` | `VoxelForge.Determinism.DensityPurity` | 10k points re-sampled in shuffled order, same thread **and** on N workers, asserting BIT equality. `ValidateDeterminism` is game-thread only and cannot see worker-cache divergence. Includes a flat-field canary (AUDIT C1) and a diff-layer pass. |
| ″ | `VoxelForge.Determinism.LiveEditInvalidation` | AUDIT C2 regression: triple the heightfield params, `Initialize` again, require the density to MOVE. The edit does not move the strate, so only `LayoutVersion` changes. |
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.Determinism.ClassifyTileSoundness` | Scans for a non-`Mixed` verdict, then brute-forces the exact mesher lattice (`g ∈ [-1, Cells+1]`). **A false verdict is an invisible, collisionless hole** — T1.d v1 was reverted for exactly this. Errors out rather than passing if it found nothing to check. |
| `VoxelForgeDiffLayerTest.cpp` | `VoxelForge.Determinism.DiffLayerContention` | N readers running the worker call mix while the game thread writes and `Clear()`s. Survival + monotonic `ModsVersion`. |
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.OpStack.BoxVerdictFold` | Pure-logic walk of the fold in `VoxelDensityOp.h`, case by case — including the seal-forces-AllSolid case that justifies `ClassifyBox` existing. Also the only `.cpp` that includes the op header, so the build actually sees it. |
| `VoxelForgeHeightStackTest.cpp` | `VoxelForge.OpStack.SurfaceHeightEquivalence` | The height-space stack vs `ComputeSurfaceTerrainZ`, in **altitudes**. Runs twice: defaults, then **all F20 terrain ops ON** — the load-bearing pass, since the ops are off by default and the defaults pass exercises only the structural source. Also brute-forces `MaxDisplacement` (a false bound would be a hole). Bar is bit-identity; a height delta is a visibly different world, not rounding. |
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. |
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. |
## 4. The density pipeline (most-edited hot path)
### 4.1 `GetDensityWithParams` (TunnelNetwork) — VoxelGenerator.cpp:277
+161
View File
@@ -0,0 +1,161 @@
# Codex task 001 — make tile-skipping observable in the running game
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
---
## Why this exists
Jahni built the world, looked at it, and said: *"I don't know if it dropped any meshing? but it looks
alright by the eye."*
He's right to be unsure — **there is no way to answer that question from inside the game.** The
plugin has **zero** stat counters (`grep INC_DWORD_STAT` → nothing). Tile-skipping is the largest
perf item in the whole plan and it is currently unobservable in production; it has only ever been
measured in an automation harness, on 40 sampled tiles.
And a visual check cannot answer it: *skipped correctly* and *skipped nothing* render identically.
This codebase has paid repeatedly for exactly that confusion — see the "coverage is a number, not a
boolean" lessons in `OPSTACK-HANDOFF.md`.
**The real prize:** with no strate opted in, cave-archetype skips must read **0**. After ticking
`bUseOperatorStack` on one `TunnelNetwork` strate and flying underground, they must become non-zero.
That is the **production-side proof of T1.d**, which does not exist today.
## The sites — there are TWO, and the second one is the one that answers the question
### Site A — the skip itself
`Source/VoxelForge/Private/VoxelWorld.cpp`, in **`AVoxelWorld::GenerateTileResult`** (~line 1501).
Trust the symbol, not the line number.
```cpp
bool bTrivialEmpty = false;
if (!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel == 0.0f)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile);
bTrivialEmpty = (Generator->ClassifyTile(OriginVoxels, Step, Cells) != EVoxelTileClass::Mixed);
}
FVoxelMeshData MeshData;
if (!bTrivialEmpty)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
MeshData = bSheetTile ? Mesher->GenerateSheetMesh(...) : Mesher->GenerateMesh(...);
}
```
### Site B — where the OPERATOR STACK's verdict is produced
`Source/VoxelForge/Private/VoxelGenerator.cpp`, in **`UVoxelGenerator::ClassifyTile`**, at the **exit
of the `if (bAnyCave)` block** (~line 3009) — the last two lines of that block:
```cpp
if (bCanSolid == bCanAir) { return EVoxelTileClass::Mixed; }
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
```
**Why site A alone cannot answer the question — this is the correction that makes the task
meaningful.** `ClassifyTile` has *two* independent ways to reach a non-`Mixed` verdict:
- the **hand-written** path, which predates all of this work: a chunk in a **bedrock gap** sets
`bCanAir = false` (VoxelGenerator.cpp ~2835) and, absent a passage or the origin spine, the tile
resolves **`AllSolid`**. Likewise the SurfaceWorld column scan. This fires with **no strate opted
in at all**;
- the **operator-stack** path, the `if (bAnyCave)` block, which is the only thing T1.d added.
So `TilesSkippedAllSolid` at site A **will already be non-zero underground before any strate is
ticked** — the bedrock between strates guarantees it. A single lumped counter would make the
before/after unreadable, and that is exactly the "when a zero has several possible causes, give each
one its own number" lesson this project already paid for.
Site B's counters have the opposite property, and it is a strong one: `ClassifyTile` returns `Mixed`
outright at the cave branch when `UsesOperatorStackForChunk(CC)` is false (~line 2809, and again per
chunk of the box at ~2914). **With no strate opted in, the site-B counters are zero by
construction, not merely by observation** — so a non-zero reading after ticking the box cannot come
from anywhere else.
## What to build
1. **A stat group.** New header `Source/VoxelForge/Public/VoxelStats.h`:
`DECLARE_STATS_GROUP(TEXT("VoxelForge"), STATGROUP_VoxelForge, STATCAT_Advanced);` plus
`DECLARE_DWORD_COUNTER_STAT_EXTERN` for each counter below. `DEFINE_STAT` for each goes in **one**
`.cpp` — put them in a new `Source/VoxelForge/Private/VoxelStats.cpp`.
2. **Six per-frame counters** (`DWORD_COUNTER`, so `stat VoxelForge` shows a rate, not a total):
| counter | site | incremented when |
|---|---|---|
| `TilesClassified` | A | the classifier gate was entered (the `if` above ran `ClassifyTile`) |
| `TilesSkippedAllSolid` | A | verdict was `AllSolid` |
| `TilesSkippedAllAir` | A | verdict was `AllAir` |
| `TilesMeshed` | A | `GenerateMesh` / `GenerateSheetMesh` actually ran |
| `TilesOpStackSolid` | B | the `bAnyCave` block returned `AllSolid` |
| `TilesOpStackAir` | B | the `bAnyCave` block returned `AllAir` |
Splitting solid from air is the point, not decoration: **cave archetypes prove `AllSolid`**.
Splitting site B from site A is the whole deliverable — see "Why site A alone cannot answer the
question" above. `TilesOpStackSolid ≤ TilesSkippedAllSolid` always, and the difference is the
pre-existing bedrock/surface skipping.
At site B, increment on the `return` line only — **not** before the
`if (bCanSolid == bCanAir) return Mixed;` guard, which is where the block bails out with no
verdict.
3. To get the verdict you need it as a value, not a bool. Changing
`bTrivialEmpty = (Classify(...) != Mixed)` into a stored `EVoxelTileClass Verdict = Classify(...)`
followed by `bTrivialEmpty = (Verdict != Mixed)` is **fine and expected**.
## ⚠️ Invariants — a violation here is not a bug, it is a hole
1. **DO NOT change `bTrivialEmpty`'s value or the control flow.** That bool decides whether a tile
gets geometry **and collision**. A wrong value is invisible until a player falls through the
floor. Refactor the expression, never the condition.
2. **DO NOT touch the gate `!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel
== 0.0f`.** Every clause is load-bearing and documented in the comment above it — sheet tiles have
no marching cubes, capture tiles need the grid even when uniform, and the verdicts assume the MC
iso is exactly zero.
3. **Thread safety: this runs on WORKERS.** `GenerateTileResult` is called from the async ChunkGen
task *and* the synchronous carve path. Use the `INC_DWORD_STAT` family, which is per-thread-packet
safe. **A plain `static int32` counter, even `++` on an `int32`, is a data race — do not.**
4. **Zero cost when stats are compiled out.** The `INC_DWORD_STAT` macros already vanish when
`STATS == 0`. Do not wrap them in an `if` that survives, and do not compute anything solely to
feed a counter outside the macro.
5. **No new includes in a public header beyond `Stats/Stats.h`**; the plugin follows IWYU and the
include debt was cleared deliberately (`AUDIT §C9` work).
6. **At site B, do not touch `ClassifyTile`'s control flow either — and do not add an early
`return`.** That function is a chain of conservative guards that all **fail to `Mixed`**; every
`return` in it is load-bearing. Add the counter to the existing `return` expression's statement,
nothing else. `ClassifyTile` is `const` and runs on the same workers as site A, so the same
`INC_DWORD_STAT`-not-`static int32` rule applies.
## Acceptance
- Editor, `stat VoxelForge` on screen, fly around: the numbers move.
- **`TilesClassified == TilesSkippedAllSolid + TilesSkippedAllAir + TilesMeshed`** for tiles that
entered the gate. (Tiles that fail the gate are meshed without being classified, so `TilesMeshed`
is legitimately larger than the classified total — say so in a comment rather than "fixing" it.)
- **`TilesOpStackSolid ≤ TilesSkippedAllSolid`** and **`TilesOpStackAir ≤ TilesSkippedAllAir`**,
always. A violation means site B is counting a verdict that site A did not act on.
- With **no strate opted in**, flying underground through a `TunnelNetwork` strate:
- `TilesSkippedAllSolid` is expected to be **non-zero** — that is the pre-existing bedrock/surface
skipping, not a bug, and it is why the lumped counter cannot be the deliverable;
- `TilesOpStackSolid` and `TilesOpStackAir` are **0**. This is the baseline, and it must be
*observed* before the next step even though it is guaranteed by the flag gate.
- Tick `bUseOperatorStack` on **one** `TunnelNetwork` strate, fly the same route:
**`TilesOpStackSolid` becomes non-zero.** ← this is the deliverable, and it is the first
production-side evidence T1.d has ever had.
## Notes for the reviewer (Claude)
- Check the verdict refactor byte-for-byte against the original condition. `!= Mixed` is the whole
contract.
- Check the counters are `DWORD_COUNTER` (per-frame) and not `DWORD_ACCUMULATOR`.
- Confirm no counter is incremented outside the gate in a way that double-counts the carve path,
which calls `GenerateTileResult` synchronously from the game thread.
- Site B: confirm the increment sits **after** the `bCanSolid == bCanAir` bail-out, and that the two
counters follow `bCanSolid` the same way the returned enum does — a swapped pair reads as a
plausible result and proves the wrong thing.
- The automation tests call `ClassifyTile` directly; they will move the site-B counters. Harmless,
but do not let a test-only path become the only thing that moves them.
+129
View File
@@ -0,0 +1,129 @@
# Codex task 002 — is the op-stack column memo thrashing? Count, don't guess.
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Depends on:** `CODEX-TASK-001` — this adds counters to the **same** `stat VoxelForge` group.
Do 001 first; this task assumes `VoxelStats.h` already exists.
---
## Why this exists
The operator-stack density path is measurably slower than the `switch` it replaces, and the cause
has never been attributed. There are three standing suspects. **This task measures the first one,
and does not fix anything.** That order is deliberate: `AUDIT §C10` cost six builds and five refuted
hypotheses by reasoning first, and the last session re-learned it.
### The hypothesis, derived from the code
`FSurfaceColumnSource::GetColumn` (`VoxelDensityOpStack.cpp` ~615) memoises a computed column in a
**direct-mapped, 4096-entry `thread_local` table**, indexed by a hash of the two XY floats:
```cpp
struct FSlot { uint64 Key; float X, Y; FColumn C; }; // 40 bytes
thread_local FSlot Slots[4096] = {}; // 160 KB per worker
const uint32 Idx = ((HX * 0x9E3779B9u) ^ (HY * 0x85EBCA6Bu)) >> 20; // [0,4095]
```
The original path, `GSurfColCache` in `GetDensityAt`, is instead a **direct-indexed box**:
`CI = (IY - Box.BaseY) * Dim + (IX - Box.BaseX)`, with a `Computed[CI]` flag. **No hash, therefore
no collisions, therefore every column is computed exactly once.**
Now the sampling order, which is the premise that makes this bite. `FVoxelMarchingCubesMesher`
pre-samples with **Z as the OUTERMOST loop** (`VoxelMarchingCubesMesher.cpp` ~226):
```cpp
for (int32 gz = GzLo; gz <= GzHi; gz++)
for (int32 gy = -1; gy <= GridDim; gy++)
for (int32 gx = -1; gx <= GridDim; gx++)
Generator->GetDensityAt(...);
```
So the mesher sweeps a **whole XY plane at every Z level**. Every column in the tile is revisited
once per Z plane — roughly 34 times.
The table's own comment sized it for this: *"A chunk is CHUNK_SIZE² columns (1024), so the first
draft's 256 entries could not even hold one chunk and thrashed inside a single tile. 4096 covers
four chunks."* **That reasoning has a gap.** A direct-mapped table does not need to be full to
evict — it needs two live keys to collide. At ~1156 columns per plane in 4096 slots (load factor
0.28), the expected number of columns sharing a slot with another is **~285, about 25 %**. Those
columns evict each other, miss again on the next Z plane, and recompute the **entire height stack**
structural source, cliff (four structural resamples), terrace, layer-line, beach, and the ceiling
stack — every single plane.
Order of magnitude if that is right: ~1156 column computations on the original path versus
~1156 + 285 × 34 ≈ **10 000** on the op path. Roughly **9×** the column work, on the plugin's most
expensive archetype.
**That is a derivation, not a measurement, and it is exactly the kind of confident chain this
project has watched reverse six times.** Hence: count first.
## What to build — two counters, no behaviour change
Add to the existing `stat VoxelForge` group from task 001:
| counter | incremented when |
|---|---|
| `ColumnMemoHit` | `GetColumn` found a live entry (the `if` body did **not** run) |
| `ColumnMemoMiss` | `GetColumn` recomputed (the `if` body ran) |
That is the entire change. Two `INC_DWORD_STAT` calls inside `FSurfaceColumnSource::GetColumn`,
around the existing `if (S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY)`.
## ⚠️ Invariants
1. **Change nothing else in `GetColumn`.** Not the table size, not the hash, not the key comparison.
The point of this task is to produce a number that decides whether the fix is worth writing; a
change bundled in makes the number unattributable. **Do not "obviously improve" the table while
you are in there** — if the fix ships in the same build as the instrument, we learn nothing, and
this project has a written lesson about exactly that.
2. **`GetColumn` is `const` and runs on mesher workers.** `INC_DWORD_STAT`, never a `static int32++`
— same rule as task 001, same reason.
3. **The key comparison is load-bearing and must stay complete.** `S.Key != ColumnKey || S.X != X ||
S.Y != Y` — the full key is compared on every touch precisely so a hash collision can only cost a
recompute and never return **someone else's column**. Do not shorten it to feed a counter.
4. **Zero cost when `STATS == 0`.** No value computed outside the macros.
5. `FCaveCliffMod` and the overhang read this same memo through
`Column->GetColumn(...)` (~line 864). They are legitimate traffic and must be counted, not
excluded — they are part of why a miss is expensive.
## Acceptance — the prediction is now numeric (tightened 2026-08-16 from the real grid dimensions)
The hand-wavy "2030 %" band this section used to carry has been replaced by an arithmetic
prediction, because the inputs are all statically knowable and were read out of the source:
- `CHUNK_SIZE = 32`, `CellsPerAxis = 32`, `GridDim = 33`, and the pre-sample loops run
`g ∈ [-1, GridDim]` per axis ⇒ **35 × 35 = 1225 distinct columns per tile**, over **35 Z planes**
(the mesher's own buffer comment, "35³ floats", confirms the dimension).
- 1225 keys in 4096 slots is a load factor of **0.299**. Expected slots holding exactly one key
`= 4096 · np(1-p)^(n-1) ≈ 908`, so **~317 columns (25.9 %) share a slot with another** and evict
each other on every plane.
- ⇒ op path ≈ `1225 + 34 × 317` ≈ **12 000** column computations per tile.
Original path (`GSurfColCache`, direct-indexed, `Computed[CI]` persists) = **1225**.
**≈ 9.8×.**
### How to read the result
⚠️ **Compare the RATIO OF THE TWO HYPOTHESES, not an absolute percentage.** The overhang and cliff
modifiers call `GetColumn` again at the same XY (~line 864); every extra consumer adds **hits** and
no misses, so it inflates the denominator and drags the miss *rate* down without changing the
verdict. What does not move is the ~10× gap between the two outcomes.
| observation | verdict |
|---|---|
| misses ≈ **810×** the hit-path baseline (single-consumer: ~28 % of lookups) | **CONFIRMED** — the table evicts on collision every Z plane. The fix gets its own task, with this run as its "before". |
| misses ≈ **1 per distinct column** (single-consumer: ~3 %, hit rate ≥ 97 %) | **hypothesis WRONG.** The table behaves like the box, the ~9.8× does not exist, and the perf cost is suspect 2 (19 virtual calls per voxel). |
*A negative result here is a real result.* It retires the most-suspected cause and is worth the build
either way; it must be written into `OPSTACK-PROGRESS.md`, not quietly dropped.
Report **both raw numbers**, never the ratio alone — a ratio cannot distinguish "few lookups" from
"many", and the absolute miss count is what the fix would be reducing.
## Notes for the reviewer (Claude)
- Confirm the miss counter sits inside the `if`, and the hit counter in an `else` — not computed
from a subtraction, which would silently agree with itself.
- Confirm `GetColumn`'s early-out path (if any is added later) cannot skip both counters.
- Confirm nothing else in `VoxelDensityOpStack.cpp` changed. `git diff --stat` should show one file
and a handful of lines.
+109
View File
@@ -0,0 +1,109 @@
# Codex task 003 — three `ExtraReach` formulas use an FBM bound this file already proved wrong
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** ⚠️ **correctness of a box verdict** — the class of bug that deletes collision. Not a perf task.
---
## Why this exists
`VoxelDensityOpStack.cpp` contains a rigorous, written derivation that `|Perlin3D| ≤ 1.5`, exposes it
as `PerlinAbsBound` (line ~2336), and uses it correctly for the tunnel warp dilation (~2452). The
comment there is explicit that the loose "~[-1,1]" figure from the noise header is **not** to be
relied on, and `OPSTACK-HANDOFF.md` records the standard: *a bound in a box verdict must be PROVED,
not observed — over-estimating costs CPU, under-estimating deletes collision.*
**Three `ExtraReach` formulas in the same file silently assume `sup|FBM| ≤ 1.0`.** Each carries the
comment "FBM ∈ [-1,1]", which is exactly the claim the file disproves 1800 lines earlier.
And `VoxelNoise::FBM` **is normalised** — it returns `Total / MaxValue` where `MaxValue = Σ Amp`
(`VoxelNoise.h` ~272). So `sup|FBM| = sup|Perlin3D|` exactly: **1.5, not 1.0.** The octave sum
neither amplifies nor attenuates the bound.
### What that costs, per archetype, at the shipped defaults
`Identity` from these sources means "no primitive within `ExtraReach` of the box", i.e. `Sdf ≥
ExtraReach` throughout. Roughness then does `Sdf += FBM · VOXEL_NOISE_SCALE · Strength`
(`FSdfRoughnessMod::Eval`), so worst case `Sdf' ≥ ExtraReach B·1.25·|Roughness|`. Soundness
requires `Sdf'` to stay at or above the downstream carve/fill threshold.
| archetype | `ExtraReach` at defaults | downstream threshold | needs `B ≤` | verdict at `B = 1.5` |
|---|---|---|---|---|
| **VerticalShafts** (`Rough 3.0`) | `1.25·3 + 2 + 1` = **6.75** | carve blend **2.0** | **1.27** | ⛔ **UNSOUND** (margin 0.875) |
| **Maze** (`Rough 2.0`) | `1.25·2 + 2 + 1` = **5.5** | carve blend **2.0** | **1.40** | ⛔ **UNSOUND** (margin 0.25) |
| **FloatingIslands** (`Rough 4.0`, `K 5.0`) | `1.25·4 + 2·5 + 1` = **16.0** | fill `K` **5.0** (+ `K/6` SmoothMin dip) | **2.03** | ✅ sound — but only because `K` is large. Sound by parameter luck, not by construction. |
Break-even roughness for the two carve archetypes is `|Rough| ≤ 1.6`; they ship at 3.0 and 2.0.
**How alarmed to be, stated honestly.** No strate has `bUseOperatorStack` ticked, so nothing in the
running game is affected today. The brute-force tile scans report 0 violations — but they *sample*,
and they were sampling against a shaft source that proved **zero** tiles until `e002bd4`, so the
shaft path has never been exercised at all. The empirical sup of this Perlin is estimated at
~1.01.1, which is *below* the 1.27 the shafts need — which is why nothing has been seen yet, and
also why the margin is uncomfortably thin. The bug is that the verdict rests on an unproved bound,
which is the thing this codebase has already decided it does not do.
## The fix
1. **Hoist `PerlinAbsBound` to file scope and rename it `VF_PerlinAbsBound`**, so there is **one**
definition rather than a class-static plus three implicit `1.0`s. Keep the existing derivation
comment with it — it is the justification, not decoration.
**Naming, resolved:** the file-scope helpers in this file are all `VF_`-prefixed
(`VF_NearCaveSurface`, `VF_DistPointSegment`, `VF_NoCaveOverBox`), so a file-scope constant takes
the same prefix. That means this is a **rename**, not just a move:
- delete the `static constexpr float PerlinAbsBound = 1.5f;` class-static inside `FRoomGraphSource`
(~2336), moving its whole derivation comment with it;
- **update `FRoomGraphSource`'s own use at ~2452** (`P.CaveWarpStrength * VOXEL_NOISE_SCALE *
PerlinAbsBound`) to the new name. This is the one place where the warp dilation is computed and
it must keep computing the identical value — the rename must not change its arithmetic.
- after the edit, `grep -n "PerlinAbsBound" ` must show **only** `VF_PerlinAbsBound` occurrences.
2. **Multiply the roughness term by it in all three `ExtraReach` formulas** (~4106 VerticalShafts,
~4215 FloatingIslands, ~4247 Maze):
```cpp
// before
FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE
// after
FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound
```
3. **Fix the three comments.** Each says "FBM ∈ [-1,1]". Replace with the real statement: `FBM` is
normalised (`Total / MaxValue`), so `sup|FBM| = sup|Perlin3D| =` the proved `PerlinAbsBound`.
A comment that states a refuted bound is how this happened in the first place.
## ⚠️ Invariants
1. **This must not change density by one bit.** `ExtraReach` is read **only** inside
`EffectOverBox` (verified: every other occurrence is a comment or the `float ExtraReach;` member
declaration — no `Eval`, no `GetCells`). The eight equivalence tests compare `Eval` bit for bit
and must stay green. **If you find yourself editing an `Eval`, stop — you have the wrong site.**
2. **The change direction is strictly conservative**: larger `ExtraReach` ⇒ more `CarveOnly`, fewer
`Identity` ⇒ *fewer* tiles proved uniform. It can only cost CPU, never open a hole. Do not
"balance" it by tightening something else in the same edit.
3. **Anonymous-namespace placement.** Put the hoisted constant **above the labelled end of the
anonymous namespace**, not anchored on the FACTORIES banner — anchoring there puts it outside and
the brace added with it closes nothing. This mistake has been made twice in this file and the
file says so.
4. **`FRoomGraphSource`'s warp dilation changes NAME ONLY.** It already uses the bound correctly and
is the reference implementation for this fix; the value it computes must be bit-identical after
the rename. Do not alter its formula, its `√2` factor, or anything else in that function.
5. Comments are French + English; match the surrounding file.
## Acceptance
- `git diff --stat` shows **one** file: `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`.
- All three `ExtraReach` definitions include the bound; no fourth site exists (`BuildTunnelNetworkStack`
has no `ExtraReach` — it uses `PerlinAbsBound` directly for the warp).
- No `Eval` body changed.
- After the build: the eight equivalence tests stay green (density unchanged), and the box-verdict
lines for **Maze** and **VerticalShafts** may report *fewer* proved tiles than before. **A drop
there is the expected, correct outcome, not a regression** — it is the cost of a sound bound.
Record the before/after in `OPSTACK-PROGRESS.md`.
## Notes for the reviewer (Claude)
- Confirm the constant is genuinely at file scope inside the anonymous namespace and that the
class-static is gone, not shadowed — two definitions that can drift is the failure this fixes.
- Confirm all three call sites got it. Two out of three is worse than none, because it looks done.
- Confirm no `Eval`, `GetCells`, or `GetCellsAt` body appears in the diff.
+101
View File
@@ -0,0 +1,101 @@
# Codex task 004 — two box verdicts assume `MinRadius ≤ MaxRadius`; a third one already doesn't
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** ⚠️ **correctness of a box verdict.** Same class as task 003. Small fix, closes a class.
---
## Why this exists
Three operators roll a primitive radius from a hash between two designer-set params:
```cpp
Out.R = FMath::Lerp(MinRadius, MaxRadius, hash01); // FGridColumnMod ~1129
Out.R = FMath::Lerp(P.ShaftMinRadius, P.ShaftMaxRadius, hash01); // FShaftFieldSource ~1560
Out.Rxy = FMath::Lerp(P.IslandMinRadius, P.IslandMaxRadius, hash01); // FIslandBlobSource ~1850
```
`FMath::Lerp(A, B, t)` with `t ∈ [0,1]` lands anywhere in `[min(A,B), max(A,B)]` — it does **not**
require `A ≤ B`.
Each op's `EffectOverBox` then sweeps a **range of lattice cells** around the query box, padded by
the largest radius a cell could hold, and tests each rolled primitive exactly. The pad decides which
cells are *looked at at all*, so a pad smaller than the true maximum radius means **cells are never
examined**, their primitives are never tested, and the op reports `Identity` for a box that its own
`Eval` will carve or fill.
| op | pad used for the cell sweep | correct? |
|---|---|---|
| `FGridColumnMod` ~1086 | `FMath::Max(MaxRadius, 0.0f) + ColBlend` | ⛔ **exposed** |
| `FShaftFieldSource` ~1436 | `FMath::Max(P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach` | ⛔ **exposed** |
| `FIslandBlobSource` ~1803 | `const float MaxR = FMath::Max(P.IslandMinRadius, P.IslandMaxRadius);` | ✅ **already correct** |
**The third one is the point.** Someone hit this exact concern while writing the island source and
guarded it. The other two shipped without the guard. This task makes the three consistent.
## How exploitable, stated honestly
The shipped defaults are correctly ordered (`2/5`, `2/7`, `5/11`), so **nothing is broken out of the
box.** It needs a mis-ordered asset value — `ColumnMinRadius = 8, ColumnMaxRadius = 4`.
Nothing prevents that. The `UPROPERTY` metas carry `ClampMin = "1.0"`, which is a per-property
floor; Unreal has no declarative way to say "must be ≤ that other property". And `ColumnMinRadius`
is *also* settable per-room through `UVoxelTerrainOpDefinition`, so it is not only the strate asset.
What makes it worth the five lines: when it does happen, the failure is **invisible and maddening**.
`Eval` still draws the fat column perfectly, so every tile that gets meshed looks correct; only the
tiles the classifier *skipped* are missing — no geometry, no collision, in a world that otherwise
looks right.
## The fix — five lines, and one thing you must NOT do
Make each pad use the true envelope:
```cpp
// FGridColumnMod ~1086
const float Reach = FMath::Max3(MinRadius, MaxRadius, 0.0f) + ColBlend;
// FShaftFieldSource ~1436
const float Pad = FMath::Max3(P.ShaftMinRadius, P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach;
```
(Use whatever spelling is idiomatic here — check that `FMath::Max3` is already used in this codebase
before reaching for it; nested `FMath::Max` is fine and matches `FIslandBlobSource`'s existing line.)
### ⛔ DO NOT "fix it properly" by normalising the params
The tempting larger fix — swap `Min`/`Max` at resolution time so `Min ≤ Max` always — is **wrong and
will break the build's tests.** `Eval` computes `Lerp(Min, Max, t)`; swapping the endpoints maps the
same hash `t` to a *different* radius for the same cell. That changes generated geometry and breaks
the eight bit-for-bit equivalence tests against the `switch` path.
**Only the BOUND may become conservative. `Eval` stays byte-identical.** This is the same rule as
task 003 and the same reason.
## ⚠️ Invariants
1. **No `Eval`, `RollColumn`, `RollShaft`, `GetCells`, or `GetCellsAt` body may change.** If your
diff touches one, you have the wrong site — stop and say so.
2. **Do not touch `FIslandBlobSource`.** It is already correct and is the reference for this fix.
3. The change direction is strictly conservative: a wider sweep examines *more* cells, so a verdict
can only move from `Identity` toward `CarveOnly`/`FillOnly`, never the reverse. Do not add any
compensating tightening.
4. Comments are French + English; match the surrounding file. Say **why** the envelope is
`max(Min, Max)` and not `Max` — the next reader must not "simplify" it back.
## Acceptance
- `git diff --stat` shows exactly one file: `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`,
and a handful of lines.
- The three ops now agree on the pattern.
- After the build: **every box-verdict line and every equivalence test is unchanged**, because the
shipped defaults are correctly ordered and the envelope only differs when they are not. **A change
in any of those numbers means the diff did something it should not have.** That is this task's
whole acceptance signal — a *no-op at defaults* is the expected, correct result.
## Notes for the reviewer (Claude)
- Confirm both pads changed and `FIslandBlobSource` did not.
- Confirm no `Lerp` argument order was touched anywhere — that is the failure mode that would look
like a tidy-up and silently change the world.
@@ -0,0 +1,99 @@
# Codex task 005 — three box-verdict tile scans sample less than one lattice period
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** test coverage. **Zero risk to the game — no non-test file may change.**
---
## Why this exists
This project has already found and fixed this exact defect **twice**:
> *"A sampler must cover at least one period of what it samples. The tunnel test drew tile XY from
> ±32 voxels with `RoomSpacing 80` — it measured the spine hub and called it the world. The shaft
> test had the identical bug (±48 against `ShaftSpacing 55`)."* — `OPSTACK-HANDOFF.md`
Both were fixed to `SpanCells = 55`**±440 voxels**, and both now print their own extent in units
of the pattern's period so it cannot silently regress.
**Three box-verdict tile scans were never fixed**, because the fix was applied where the bug was
noticed rather than to the class. All three still use the original `Rng.RandRange(-6, 6) * Extent`
with `Step = 1, Cells = 8``Extent = 8`**half-extent ±48 voxels**:
| test | half-extent | lattice period (default, **unchanged by the fixture**) | coverage |
|---|---|---|---|
| `VoxelForgeOpStackIslandTest.cpp` ~244 | ±48 | `IslandSpacing` **95** | **0.51 periods** ⛔ worse than either bug already fixed |
| `VoxelForgeOpStackSlabTest.cpp` ~289 | ±48 | `ColumnSpacing` **60** | **0.80 periods** ⛔ |
| `VoxelForgeOpStackMazeTest.cpp` ~276 | ±48 | `CellSize` **40** | 1.20 periods ⚠️ marginal |
Verified: none of `EnableIslandFeatures` / the slab tuning / the maze setup overrides the spacing, so
the header defaults are what these tests actually run against.
**Why it matters right now, specifically.** Two commits just changed the box verdicts these very
tests are supposed to guard — `7dbdf51` (the `ExtraReach` bound, which touches **islands**, maze and
shafts) and `eaa44bf` (the radius envelope, which touches **slab columns** and shafts). The tests
that would catch a mistake in those changes currently sample about half a lattice cell.
## The rule the two fixed tests already encode
`Extent = Step * Cells = 8` voxels, so `SpanVoxels = SpanCells * 8`. To get **8 periods** of
half-extent you set:
> **`SpanCells` = the lattice spacing** (`55` for `ShaftSpacing 55` — that is where the shaft test's
> `55` comes from, and it is not a coincidence).
Apply the same:
| test | `SpanCells` | resulting half-extent | periods |
|---|---|---|---|
| Island | `95` | ±760 | 8.0 |
| Slab | `60` | ±480 | 8.0 |
| Maze | `40` | ±320 | 8.0 |
## What to build
For each of the three tests, mirror **exactly** what `VoxelForgeOpStackShaftTest.cpp` (~212) does:
1. Hoist `const int32 SpanCells` and `const int32 SpanVoxels = SpanCells * 8;` **outside the tile
loop** — the report needs them and `Extent` is loop-local. (That scoping slip has already happened
once in this file family; the shaft test's comment records it.)
2. Draw `Rng.RandRange(-SpanCells, SpanCells) * Extent` for X and Y. **Leave the Z draw exactly as
it is** — it is clamped to the strate slot and is not part of this defect.
3. Extend the existing report line to print `SpanVoxels`, the live ratio
`(float)SpanVoxels / FMath::Max(<the spacing param>, 1.0f)`, and the spacing itself — so the
extent is stated in units of the pattern's own period and a future narrowing is visible.
## ⚠️ Invariants
1. **This is NOT "widen until it passes."** The comment already in the tunnel test says it best and
the same reasoning applies here: *every proved tile is still brute-forced voxel by voxel below, so
a wider sampler that produced a FALSE verdict fails exactly as before. We are changing what the
measurement **looks at**, not what it **demands**.* Do not touch the brute-force loop, its
tolerance, or any `AddError`.
2. **Do not adjust an assertion to accommodate a moved number.** Widening will change the proved /
Mixed counts — that is the point. If an existing assertion would now fail, **report it and stop**;
do not retune it. ("Don't assert a number you want to improve" is a written lesson here.)
3. **No file outside `Source/VoxelForge/Private/Tests/` may change.** `git diff --stat` must list
only those three test files.
4. Do not change `Step`, `Cells`, the tile count (`60`), or the RNG seeds — a changed seed makes the
before/after incomparable, and comparability is the whole point of touching this now.
5. Comments are French + English; match the surrounding file.
## Acceptance
- Three test files changed, nothing else.
- Each of the three now prints its extent **and** that extent in periods of its own spacing param.
- After the build, each ratio line reads **≥ 8 periods**.
- The proved counts will move. **That is expected.** What must NOT move: `violations` / `NumUnsound`
stays **0** in all three. If it becomes non-zero, the wider sampler has found a genuine hole that
the narrow one was hiding — which would be this task paying for itself immediately, and must be
reported loudly rather than tuned away.
## Notes for the reviewer (Claude)
- Confirm `SpanCells`/`SpanVoxels` are outside the tile loop in all three.
- Confirm the Z draw is untouched.
- Confirm the ratio is computed **live** from the params struct, not hardcoded — a hardcoded "8.0
periods" in a format string would be a success message that asserts coverage while measuring
nothing, which is a named failure mode in this project.
@@ -0,0 +1,98 @@
# Codex task 006 — the same `Min > Max` under-bound, in `BuildChunkCache` (both density paths)
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** ⚠️ **correctness of a collection bound.** Third instance of this class; the worst of the three.
**Credit:** found by the Sol-High read-only audit (`AUDIT-2026-08-CODEX.md`, VF-05) and **verified
against the code** before being specified.
---
## Why this exists
`CODEX-TASK-004` fixed two cell-sweep pads that assumed the field named `Max*` was numerically the
larger one. **The same defect exists in `VoxelCaveMorphology.cpp`, and it matters more**, for three
reasons:
1. It is **`TunnelNetwork`** — the largest and most-used archetype.
2. `BuildChunkCache` is called by **both** density paths: the original `switch` *and*
`FRoomGraphSource`, which deliberately calls it rather than transcribing it. **This is not an
operator-stack bug — it is in the shipped original code and always has been.**
3. Its failure mode is a **window-invariance break** (`ARCHITECTURE §8.4`), not just a missing room:
whether a room exists depends on which chunk you queried from. In a multiplayer game that means
two peers generate different geometry from the same seed.
### The mechanism
Radii are interpolated:
```cpp
Room.RadiusXY = FMath::Lerp(Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor); // ~262
const float RadA = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorA); // ~467
const float RadB = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorB); // ~468
```
`FMath::Lerp(A, B, t)` with `t ∈ [0,1]` yields anywhere in `[min(A,B), max(A,B)]` — it does **not**
require `A ≤ B`. But every bound derived from those radii reads only the `Max*` field:
| site | line | expression |
|---|---|---|
| `MaxInfluence` | ~127130 | `FMath::Max(Params.MaxRoomRadius, Params.TunnelWarpStrength + Params.TunnelMaxRadius) + Params.SDFBlendRadius` |
| `CollectMargin` | ~152 | `2.0f * MaxTunnelLen + MaxInfluence` (inherits it — **no separate edit needed**) |
| `RoomZBuffer` | ~171 | `Params.MaxRoomRadius * Params.RoomHeightRatio` |
| `EvaluateSDF`'s `Margin` | ~871874 | the same expression as `MaxInfluence`, duplicated |
With `MinRoomRadius > MaxRoomRadius`, rooms larger than `MaxInfluence` are generated, so a room that
can reach a chunk may sit in a cell the collect region never visited. `RoomReachesSearchBox` uses the
*actual* radius and is therefore correct — but it can only test rooms that were collected at all.
## The fix
Derive **bound-only envelopes** and use them at all four sites:
```cpp
const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius);
const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius);
```
- `MaxInfluence``FMath::Max(RoomRadiusEnvelope, Params.TunnelWarpStrength + TunnelRadiusEnvelope) + Params.SDFBlendRadius`
- `RoomZBuffer``RoomRadiusEnvelope * Params.RoomHeightRatio`
- `EvaluateSDF`'s `Margin` → the same corrected expression.
⚠️ `MaxInfluence` and `EvaluateSDF`'s `Margin` are the **same formula written twice**. They must stay
identical. If a shared helper is natural here, use one — two copies of a rule that must agree is a
bug factory, and this file already has the duplication. If you introduce a helper, keep it local to
this translation unit and do not change either call site's semantics.
## ⛔ DO NOT reorder the `Lerp` endpoints
Swapping to `Lerp(min, max, t)` maps the same hash `t` to a **different radius** for the same room,
which changes generated geometry and breaks the eight bit-for-bit equivalence tests. **Only the
bounds may become conservative. The three `Lerp` calls must not be touched at all.**
This is the same rule as tasks 003 and 004, and it is the third time it applies.
## ⚠️ Invariants
1. **No `Lerp` line changes. No room/tunnel placement, hashing, or `bStore` logic changes.**
If your diff touches `Room.RadiusXY`, `RadA`, `RadB`, or `RoomReachesSearchBox`, stop and say so.
2. The change is strictly conservative: a larger envelope collects **more** cells, never fewer.
3. Exactly one file: `Source/VoxelForge/Private/VoxelCaveMorphology.cpp`.
4. Comments are French + English; match the file. Say **why** the envelope is `max(Min, Max)` — the
next reader must not "simplify" it back to `MaxRoomRadius`.
## Acceptance
- One file changed, a handful of lines.
- **With correctly ordered params, `max(Min, Max) == Max`, so every number is bit-identical and this
is a NO-OP. That is the acceptance signal.** The eight equivalence tests, every box-verdict line
and every `violations` count must be **unchanged** after the build. A moved number means the diff
did something it should not have.
- It only changes behaviour for an asset whose range is inverted — which is precisely the case that
was silently producing window-dependent geometry.
## Notes for the reviewer (Claude)
- Confirm all four bound sites use the envelopes, and that `CollectMargin` inherits rather than being
edited separately.
- Confirm `MaxInfluence` and `EvaluateSDF`'s `Margin` still compute the identical expression.
- Confirm no `Lerp` argument order changed anywhere in the file.
+150
View File
@@ -0,0 +1,150 @@
# Codex task 007 — VF-01: never mutate layout/passages while workers read them
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
**Kind:** ⚠️ **crash class (use-after-free).** Highest-severity item found on 2026-08-16.
**Origin:** Sol-High audit VF-01, **independently confirmed by reading** before this spec was written.
---
## The defect
`UVoxelStrateManager::Initialize` does `StrateLayout.Empty()` and `Passages.Empty()` + `Passages.Add()`
— it **frees and reallocates** both arrays. There is **no lock, no barrier, no drain** in that file.
Meanwhile those same arrays are read **on mesher worker threads**:
| reader | access |
|---|---|
| `AnyPassageNearBox` (`VoxelStrateManager.cpp:460`) | range-`for` over `Passages` |
| `EvaluateModifierSDF` | indexes `Passages[...]` |
| `FindSlotIndexForChunkZ` | iterates `StrateLayout` |
all reached from `GetDensityAt` / `ClassifyTile` inside chunk tasks.
`RegenerateAllChunks()` bumps the epoch **after** `Initialize`, so previous-epoch workers are live
*during* the mutation. **The epoch rejects a finished result; it cannot make a read of a freed
allocation safe.**
**Precedent in this very codebase:** `DiffLayer.ChunkMods` is read on mesher workers and written on
the game thread, and all access now holds `ModsLock` — added after a real carve-vs-stream access
violation. `StrateLayout` / `Passages` are the same shape with no guard.
Four `Initialize` call sites:
| line | function | dangerous? |
|---|---|---|
| 145 | `RebuildStrates` | **yes** |
| 309 | `OnObjectModifiedInEditor` | **yes — fires automatically on a strate asset edit while streaming** |
| 417 | `BeginPlay` | **no** — no tasks exist yet. **Leave it alone.** |
| 2091 | `ChangeSeed` | **yes** (also writes `Generator`'s `Seed` / `OriginSpineRadius`) |
## Why THIS fix and not the other two
Rejected deliberately — do not "improve" the design into either of these:
- **An `FRWLock` around the two arrays** (the `ModsLock` shape) would put a **read lock on the
per-voxel hot path** — `EvaluateModifierSDF` and `FindSlotIndexForChunkZ` run ~43k times per tile.
There is an open, unmeasured perf regression under active investigation (`CODEX-TASK-001/002`);
adding hot-path lock traffic now would **contaminate the very measurement those tasks exist to
take.** Correct, but the worst possible timing.
- **An immutable generation snapshot** (Sol's suggestion) is the right long-term architecture and a
real refactor of `UVoxelStrateManager`'s whole API surface. Too large to improvise, and it belongs
in a design conversation.
**The drain has zero hot-path cost**, reuses machinery already proven in `EndPlay`, and its only
cost — a brief stall — lands exclusively on **human-initiated editor actions** (asset edit, rebuild,
seed change). It never occurs during play.
## What to build
### 1. A pause flag distinct from shutdown
Add to `AVoxelWorld`: `std::atomic<bool> bGenerationPaused{false};`
⚠️ **Do NOT reuse `bShuttingDown` for this.** It would work mechanically, but it means "we are tearing
down" and a future reader would be misled about lifetime. Introduce a small helper used at the
existing gate points:
```cpp
FORCEINLINE bool ShouldAbortWork() const
{
return bShuttingDown.load(std::memory_order_relaxed)
|| bGenerationPaused.load(std::memory_order_relaxed);
}
```
Route the **existing** checks through it — the submission gate (`VoxelWorld.cpp:638`) and the
in-task checks (`:1467`, `:1474`). **Do not add new check points**; do not change what those sites do
when the check is true.
### 2. An RAII scoped pause, modelled on `EndPlay`'s drain
`EndPlay` (`:323334`) already implements this exact pattern: raise the gate, then spin until
`ActiveTaskCount` reaches 0. Mirror it.
```
FScopedGenerationPause guard(this);
if (!guard.Acquired()) { /* log error, DO NOT mutate, return */ }
```
- **Ctor:** set `bGenerationPaused = true`, then wait for **both** `AVoxelWorld::ActiveTaskCount == 0`
**and** the decoration tasks to finish. Decoration tasks are counted by the file-static
`GActiveDecoTasks` in `VoxelContentManager.cpp` and already drained by `NotifyShutdown` (`:6580`) —
add a small public drain/wait accessor on `UVoxelContentManager` rather than exposing the counter.
- **Dtor:** always clear `bGenerationPaused`, including on the failure path.
### 3. ⚠️ FAIL SAFE — this is the most important line in the spec
If the deadline expires with tasks still running: **DO NOT MUTATE.** Log an error naming the
function, clear the flag, and return, leaving the world in its previous consistent state. The user
can retry the edit.
**Mutating anyway is what the bug already does.** A timeout that proceeds is not a fix. The three
dangerous call sites must each be structured so the `Initialize` call is *unreachable* unless the
pause was acquired.
Use a generous deadline (≥ 5 s) and log at `Error` when it expires — a silent skip would look like
the edit simply didn't apply.
### 4. Wrap the three call sites
`RebuildStrates`, `OnObjectModifiedInEditor`, `ChangeSeed`. The pause must cover **all** the mutation,
including `ChangeSeed`'s writes to the generator's `Seed` / `OriginSpineRadius`, and it must be
released **before** `RegenerateAllChunks()` so regeneration can submit work. **`BeginPlay` is not
wrapped.**
## ⚠️ Invariants
1. **No density, mesher, or geometry code may change.** This must not move one bit of generated
terrain. If your diff touches `VoxelGenerator.cpp`, `VoxelDensityOpStack.cpp`,
`VoxelCaveMorphology.cpp` or `VoxelMarchingCubesMesher.cpp`, stop — wrong site.
2. **No deadlock.** The pause is taken on the **game thread**. Verify by reading that chunk tasks
never block on the game thread (they read the generator and `Enqueue` to an MPSC queue, which is
non-blocking) — so a drain is bounded. **State in your report that you checked this**, and if you
find any worker path that waits on the game thread, STOP and report it instead of proceeding.
3. **`ProcessQueue` stays `EQueueMode::Mpsc`.** Do not touch it.
4. **Do not change `EndPlay`.** Its 3-second timeout is a separate, deliberate decision
(audit VF-02) and is Jahni's call, not part of this task.
5. **Carry the `Epoch`** through anything you touch; do not reorder the existing epoch bump relative
to `RegenerateAllChunks`.
6. Comments are French + English; match the surrounding file.
## Acceptance
- `stat`/gameplay unchanged; **generated terrain bit-identical** (the equivalence tests and every
box-verdict number must be untouched — this change cannot reach them).
- Editing a strate asset while the world streams: brief stall, then the edit applies. **No crash.**
- The failure path is reachable and honest: if the drain times out, an `Error` log names the function
and the world keeps its previous state.
- `git diff --stat` should list `VoxelWorld.cpp`, `VoxelWorld.h`, and `VoxelContentManager.{h,cpp}`
for the drain accessor. Nothing else.
## Notes for the reviewer (Claude)
- Confirm the three dangerous sites cannot reach `Initialize` when the pause was not acquired, and
that `BeginPlay` is untouched.
- Confirm the dtor clears the flag on **every** path including early return.
- Confirm `ShouldAbortWork` replaced the existing checks rather than adding new ones, and that
`bShuttingDown`'s own semantics are unchanged.
- Confirm the deco drain is included — chunk tasks alone are not the whole reader set.
@@ -0,0 +1,150 @@
# Codex task 008 — (A) fix the measured column-memo thrash, (B) diagnose why T1.d never fires in game
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
**Status:** specified, not started
Two independent changes in different subsystems, deliberately bundled into one build because their
signals cannot contaminate each other: (A) is SurfaceWorld column caching, (B) is a counter in the
cave branch of `ClassifyTile`.
---
# PART A — replace the hashed column memo with a direct-indexed box
## This is now MEASURED, not suspected
`stat VoxelForge` in the running game, SurfaceWorld-dominated flight:
```
Column Memo Hits avg 102,300.84
Column Memo Misses avg 17,683.60 → miss rate 14.7%
```
Predicted **14.0%** if the table thrashes, **1.4%** if it does not. It thrashes. Second confirmation
from a different statistic: 17,683 misses ÷ 2.13 tiles meshed = **~8,300 column recomputes per
tile**, where a healthy cache does ~1,225 — **6.8×**.
**The cause.** `FSurfaceColumnSource::GetColumn` (`VoxelDensityOpStack.cpp` ~615) uses a
**direct-mapped, 4096-entry hashed** table. A direct-mapped table evicts on *collision*, not on
fullness: 1225 columns per tile in 4096 slots is a load factor of 0.30, at which ~317 columns (26%)
share a slot and evict each other — **on every one of the ~35 Z planes**, because the mesher
pre-samples Z-outermost (`VoxelMarchingCubesMesher.cpp` ~226). Each miss recomputes the entire height
stack: structural source, cliff (four structural resamples), terrace, layer-line, beach, ceiling.
## The fix — copy the scheme that already works, one file away
`GSurfColCache` / `FSurfaceColumnBox` in `VoxelGenerator.cpp` (~152, and its use at ~737) is the
original path's solution to the identical problem: a **direct-indexed box**
`CI = (IY - Box.BaseY) * Dim + (IX - Box.BaseX)` with a `Computed[CI]` flag — centred on the first
sample and rebuilt when a query leaves it. **No hash ⇒ no collisions ⇒ every column computed exactly
once.** Read it before writing; you are porting a proven scheme, not inventing one.
Apply the same structure inside `FSurfaceColumnSource`, keeping it `thread_local`.
## ⚠️ The invariant that must not be lost in the port
The current memo's validity check is `S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY`, and
`ColumnKey` is built in `PrepareChunk` from **strate + layout version + seed + `ParamsFingerprint`**.
**`ParamsFingerprint` is load-bearing and its absence was a real shipped bug** — without it, two
stacks of the same strate with different params shared columns, the overhang silently vanished, and
only 69 of 20000 samples showed it. The comment at the site records this. **The new box's validity
key must still contain all four**, or you reopen a fixed bug. (Note `GSurfColCache` itself keys on
`(box XY, StrateKey, Seed, LayoutVersion)` **without** the fingerprint — do **not** copy that part;
it is the weakness the op-stack memo deliberately closed.)
Also keep the full XY comparison semantics: a lookup must never return a column computed for a
different XY. With a direct-indexed box that is structural (the index *is* the XY), but the box
bounds check must be exact.
## Keep the counters
`ColumnMemoHit` / `ColumnMemoMiss` must keep working, incremented on the same meaning (miss = a
column was recomputed). They are the before/after instrument.
## Invariants
1. **Density must not move by one bit.** This changes *caching*, never a computed value. The eight
equivalence tests — especially `SurfaceHeightEquivalence` and its overhang section — must stay
bit-identical. If your diff changes what `TerrainStack.EvalHeight` / `CeilingStack.EvalHeight`
compute, or the overhang gate maths, you have the wrong site.
2. Both consumers keep working: the source (`~858`) and the overhang (`~923`, via
`Column->GetColumn`). The overhang **must** see the same column the source did — that is
"by construction rather than by convention", and the current code says so.
3. Sizing: state in a comment how many columns a tile needs (a 35×35 grid = 1225) and size the box so
one tile fits without eviction, as `FSurfaceColumnBox` does.
4. `thread_local` stays; no shared mutable state across workers.
## Acceptance
- `ColumnMemoMiss` drops roughly **10×**; miss rate goes from **14.7% → ~1.5%**.
- The eight equivalence tests stay green and bit-identical.
- If the miss rate does **not** fall, say so plainly — a fix that does not move its own instrument is
a failed fix, not a partial one.
---
# PART B — name which guard stops T1.d in the running game
## The problem
`Tiles Operator Stack Solid` / `Air` **never appeared** in `stat VoxelForge`, while the harness proves
11 of 40 tiles at production defaults. Rows only render in frames where a counter fires, so site B is
never reached in game. The cave branch of `UVoxelGenerator::ClassifyTile` has **13 `return
EVoxelTileClass::Mixed` paths** and we cannot tell which one fires.
## What to build
Add DWORD counters to the existing `stat VoxelForge` group (`VoxelStats.h` / `.cpp`) that attribute
the bail, grouped by *reason* rather than one per line:
| counter | fires when |
|---|---|
| `CaveBailNotOpStack` | `UsesOperatorStackForChunk` is false (either the initial check or the per-chunk sweep) |
| `CaveBailMixedContent` | `bAnyNonCave` — the tile also touches a gap or SurfaceWorld chunk — or a second cave slot, or out-of-layout |
| `CaveBailParams` | the params `Memcmp` disagreed across the box, the archetype differed, or `NumChunkCoords > 27` |
| `CaveBailStackVerdict` | the stack built fine but `ClassifyBox` returned `Mixed` |
| `CaveBailDisturbance` | the final `bCanSolid == bCanAir` after disturbances |
| `CaveBailNoStack` | `VF_BuildOpStackForChunk` returned false |
Increment **exactly one** per bail, immediately before the `return`. Together with the existing
`TilesOpStackSolid` / `TilesOpStackAir`, one underground flight then names the cause outright.
## Invariants
1. **DO NOT change any control flow, condition, or return value in `ClassifyTile`.** Every `return`
there is a conservative guard that fails to `Mixed`; a wrong verdict leaves a tile with no
geometry and no collision. Add counters beside the existing returns and nothing else.
2. `ClassifyTile` is `const` and runs on **worker threads**`INC_DWORD_STAT` only, never a
`static int32++`. It routes through `FThreadStats::AddMessage` (per-thread packets), which is why
it is safe.
3. Zero cost when `STATS == 0`: compute nothing outside the macros.
4. Do not touch the non-cave parts of `ClassifyTile` (gap / SurfaceWorld / column scan).
## Acceptance
Fly underground in a TunnelNetwork strate: exactly one bail counter should dominate, or
`TilesOpStackSolid` should finally appear. Either outcome is a result.
---
# Shared rules
- **NEVER build, compile or run the editor or the tests.** Stop when the code is written.
- **Do not `git commit`, `git push`, `git checkout`, `git stash`, `git restore`.** Uncommitted work
in the tree must survive.
- Comments are French + English; match the surrounding file.
- Macro spelling: `KINDA_SMALL_NUMBER`, not the `UE_`-prefixed form.
- When inserting anything into `VoxelDensityOpStack.cpp`, put it **above the labelled end of the
anonymous namespace** — anchoring on the FACTORIES banner puts it outside and the brace closes
nothing. This mistake has been made twice in that file.
## Report
1. The diff for Part A and Part B separately.
2. `git diff --stat`.
3. Explicit confirmation that: no height-stack or overhang maths changed; the new column key still
contains strate + layout + seed + `ParamsFingerprint`; `ClassifyTile`'s control flow and return
values are untouched; exactly one bail counter fires per bail path.
4. Likely compile-error spots, specifically.
5. Anything in this spec that contradicts the code — **stop and say so rather than guessing.**
+165
View File
@@ -0,0 +1,165 @@
# VoxelForge investigation — T1.d and SurfaceWorld column memo
Date: 2026-08-16
Base: `experimental` at `4ba53f2`
Method: static source review only. **No build, compile, editor launch, or automation test was run.**
## Executive conclusion
The current explanation “one unticked neighbouring strate makes the exhaustive per-chunk sweep
reject otherwise-safe boundary tiles” is not what the code does.
`UVoxelStrateManager::UsesOperatorStackForChunk` depends only on `ChunkCoord.Z`. Before the full XYZ
sweep, `UVoxelGenerator::ClassifyTile` has already visited every sampled lattice Z, required the
operator-stack predicate for every cave Z it sampled, and required all cave samples to belong to one
layout slot. Within that one slot the flag cannot vary with X or Y. The later XYZ flag check is
therefore redundant with the current layout implementation.
That does **not** make cross-strate boundary tiles safe to classify with one stack. They still fail
the independent one-slot, generator-type, and bit-identical-params guards. Removing only the later
flag check would not unlock those tiles. Requiring the density samples represented by one verdict to
use one implementation is necessary in principle; the current exhaustive re-check is over-strict/
redundant, but it is not the measured T1.d blocker.
The diagnostic is less precise than the log claims: the initial `UsesOperatorStackForChunk` check
runs before the different-slot check, so a coarse/boundary tile that reaches an unticked adjacent
cave slot can increment `Cave Bail Not Op Stack` even though it would subsequently have failed as
mixed content. The measured 80% therefore does not distinguish “the flown slot itself is unticked”
from “a boundary tile encountered an unticked slot first.” The asset state still has to be read in
the editor.
For the column memo, the port is genuinely incomplete. `GSurfColCache` is a six-box spatial LRU;
`FSurfaceColumnSource::GetColumn` currently owns one 81x81 direct-indexed box. A recenter or
`ColumnKey` change clears all 6,561 computed flags in that one box. This is a verified structural
difference, but the observed 1030% location-dependent miss band does not prove its performance
impact. Only the same seed and same route can do that.
## Findings
| Status | Finding | Evidence and consequence |
|---|---|---|
| **Verified by reading** | The late “every chunk opted in” sweep is redundant today. | `VoxelGenerator.cpp`, `UVoxelGenerator::ClassifyTile`; `VoxelStrateManager.cpp`, `UVoxelStrateManager::UsesOperatorStackForChunk`. The predicate ignores X/Y and is constant for a layout slot. The earlier Z loop and `CaveBotChunkZ` guard already establish one opted-in cave slot. |
| **Verified by reading** | Boundary tiles remain unsafe for a one-stack verdict for reasons independent of the flag. | `ClassifyTile` rejects a second cave slot, a different generator type, and any non-bit-identical parameter struct. Gradient/Interleaved transitions can vary params per chunk. Removing the redundant flag re-check alone cannot change the safe result from `Mixed`. |
| **Verified by reading** | `Cave Bail Not Op Stack` is an ambiguous attribution counter. | The first predicate check occurs before `GetStrateChunkZBounds`/different-slot attribution. A boundary tile can be counted as NotOpStack even though mixed content would also reject it. Terrain correctness is unaffected; diagnosis is affected. |
| **Suspicious, needs checking** | Most of the measured 1.42/1.77 NotOpStack bails come from the primary cave asset itself being unticked. | This is the simplest explanation for interior tiles, but `.uasset` state is not readable from this source tree and the counter does not separate primary-slot from boundary-slot failures. Confirm in the editor or use Approach A's initialization log. |
| **Verified by reading** | The op-stack column memo loses an entire 6,561-cell working set on any recenter/key change. | `VoxelDensityOpStack.cpp`, `FSurfaceColumnSource::GetColumn`, has one direct-indexed box. `VoxelGenerator.cpp`, `FSurfaceColumnCache`, has `NumBoxes = 6` and evicts only one LRU box. |
| **Suspicious, needs checking** | The one-box design materially causes the observed 1030% in-game miss rate. | Plausible and location-sensitive, but unmeasured. The retracted different-route comparison cannot support a before/after claim. |
| **Verified by reading** | VF-03's core CP-cache contamination defect is present, and the current source now contradicts the audit reviewer's header. | `VoxelGenerator.cpp`, `UVoxelGenerator::GetDensityAt`, keys the function-static `thread_local CP_*` state by `(ChunkCoord, LayoutVersion)` with no generator/world identity. Each manager's version begins at the same value. `VoxelForgeTestFixture.h`, `FTestWorld::Build`, now explicitly documents the contamination and repeatedly calls `Initialize` to give test worlds process-unique versions. Production has no equivalent owner key. A second world on the same worker can reuse the first world's params, `CP_UseOpStack`, and stack. |
| **Suspicious, needs checking** | Every other TLS cache named in audit VF-03 has the same cross-world exposure. | Several keys visibly omit an owner, but this pass proved the `CP_*` path only. The broader `OC_*`, `BM_*`, passage shortlist, biome, diff, and op-local cache set should be audited as one owner-identity task rather than assumed from the old audit row. Both approaches below leave this open. |
The audit header says VF-03's fixture citation was fabricated. That statement is stale relative to
the checked-out source: the cited explanatory block exists now in `VoxelForgeTestFixture.h` and is
specific about `CP_UseOpStack` contamination. This does not automatically validate every cache
listed in VF-03; it does validate the CP-cache defect above.
## Two implemented approaches
| | Approach A — explicit opt-in + six-box spatial LRU | Approach B — code-enforced cutover + four-way associative memo |
|---|---|---|
| Strategy | Preserve the per-asset A/B contract. Add initialization-time warnings naming every disabled layout slot; the human fixes the `.uasset`. Port the reference six-box LRU to the op source. | Ignore the serialized flag at runtime and route all eight ported archetypes through the stack. Keep the UPROPERTY for asset compatibility. Replace the box with 4,096 exact coordinate entries arranged as 1,024 sets x 4 ways. |
| T1.d effect | No silent behavior change. T1.d fires only after the relevant assets are enabled. Boundary tiles still conservatively fail for slot/params reasons. | `Cave Bail Not Op Stack` should disappear for every valid ported slot without asset edits. `GetDensityAt` and `ClassifyTile` still use the same manager predicate and same stack factory. |
| Cache behavior | Six independent 81x81 boxes. An acquisition miss recenters/clears one LRU victim; five boxes remain warm. Closest match to the proven reference. | Hash routes by exact X/Y plus both halves of `ColumnKey`; exact X/Y/key comparison decides hits. A miss evicts one entry in one set. No bulk clear. Four-way conflicts and capacity eviction remain possible. |
| Runtime/memory cost | Approximately 0.79 MiB TLS per worker for this memo, versus roughly 0.13 MiB for the current single box. Six linear box checks per integer query. | Approximately 160 KiB TLS per worker (compiler padding can change this). Four exact probes per integer query plus hashing/LRU-rank updates. |
| Main risk | High per-worker memory multiplication. The diagnostic currently enumerates SurfaceWorld slots too, although SurfaceWorld's exact-lattice T1.d path does not depend on this flag; narrow that warning before adopting it. | Intentional behavior change for every false-flag asset; the switch can no longer be selected for production A/B. Existing false/true ClassifyTile fixtures now exercise the same runtime path, reducing the distinction between those two end-to-end tests. Associative conflicts may underperform the spatial LRU. |
| What it does not solve | VF-03 owner identity; boundary params/slot conservatism; proof of memo benefit. | VF-03 owner identity; boundary params/slot conservatism; proof of memo benefit. |
### Recommendation
Start from **Approach A**, after narrowing its warning to cave archetypes or rewording the
SurfaceWorld entry. The code does not justify weakening `ClassifyTile`, and the current flag is still
valuable for a same-route A/B. Approach A preserves that measurement lever and ports the known
reference cache design. Its memory cost is the reason not to merge it blindly: measure it against
Approach B on the identical route.
Approach B is the cleaner long-term endpoint only if the project has deliberately decided to retire
the switch as a runtime fallback. It removes configuration drift and is much smaller in TLS, but it
spends the A/B lever and moves every false-flag asset at once. The eight equivalence suites make that
a defensible experiment, not a zero-risk migration.
## Worktrees and files touched
### Approach A
Worktree: `E:\Projet Unreal\VoxelM\Plugins\VF-approach-A`
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`
- `Source/VoxelForge/Private/VoxelStrateManager.cpp`
- `CODEMAP.md`
Likely compile-error/watch spots:
- The function-local `thread_local FColumnCache` containing six large aggregate boxes on MSVC/UE's
TLS implementation.
- Nested local cache types and `FMemory::Memzero` of each victim's `Computed` array.
- The new `UE_LOG` format strings/arguments in `UVoxelStrateManager::Initialize`.
### Approach B
Worktree: `E:\Projet Unreal\VoxelM\Plugins\VF-approach-B`
- `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`
- `Source/VoxelForge/Private/VoxelGenerator.cpp` (comments only)
- `Source/VoxelForge/Private/VoxelStrateManager.cpp`
- `Source/VoxelForge/Public/VoxelDensityOpStack.h` (comments only)
- `Source/VoxelForge/Public/VoxelStrateDefinition.h` (UPROPERTY retained; comments only)
- `Source/VoxelForge/Public/VoxelStrateManager.h` (comments only)
- `Source/VoxelForge/Private/Tests/VoxelForgeClassifyTileTest.cpp` (comments/messages only)
- `Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h` (comments only)
- `CODEMAP.md`
Likely compile-error/watch spots:
- The function-local `thread_local FColumnMemo` 4,096-entry aggregate.
- `VoxelHash::Mix` calls and casts for X/Y plus low/high halves of the 64-bit key.
- The `uint8` four-way LRU ranks and local-entry default initialization.
- UHT should see no serialized layout change: `bUseOperatorStack` was not removed or renamed.
Both worktrees are detached from `experimental`, contain uncommitted changes, and passed
`git diff --check`. Neither was built or tested.
## Human measurement protocol
Do not compare screenshots or stat averages from different flights. For every baseline/candidate:
1. Use the **same world seed, same asset values, same start point, same route, same speed, same LOD/
streaming settings, same capture duration, and the same warm-up policy**.
2. Record at least one repeat of the route; scheduler variation can move worker-local cache reuse even
when the geographic route is identical.
3. Capture `stat VoxelForge` and an Insights trace together. Normalize column misses and worker time by
`Tiles Meshed`; totals alone conflate cheaper tiles with fewer tiles.
4. Change only one worktree/approach at a time. Do not compare Approach A after an asset edit with
Approach B before that edit and call it a cache result.
For T1.d, record:
- `Cave Bail Not Op Stack`, `Cave Bail Mixed Content`, `Cave Bail Params`,
`Cave Bail Stack Verdict`, `Cave Bail Disturbance`, `Cave Bail No Stack`;
- `Tiles Classified`, `Tiles Meshed`, `Tiles Operator Stack Solid/Air`, and total skipped Solid/Air;
- the route segment's active strate and whether each neighboring asset's legacy flag is enabled.
Expected interpretation:
- Approach A: the initialization log must identify any false flag. After the human enables the
relevant cave definitions, NotOpStack should approach zero in single-strate interiors and
`Tiles Operator Stack Solid/Air` should become non-zero. Boundary tiles may move to MixedContent,
Params, or StackVerdict; that is conservative and expected.
- Approach B: NotOpStack should be zero for valid ported cave slots without asset edits. A non-zero
value then points to out-of-layout/invalid-slot logic or a stale build, not the legacy flag.
- In both: `Tiles Meshed < Tiles Classified` is the production prize. A zero `violations` result and
all eight bit-equivalence suites green remain mandatory before trusting it.
For the memo, record:
- `Column Memo Hits` and `Column Memo Misses` as a miss percentage;
- misses per `Tiles Meshed`;
- Insights `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` count and time per meshed tile;
- visible LOD-ring update time/throughput on the identical route;
- process memory if comparing the six-box LRU against the associative table at the same worker count.
A lower miss percentage on a different route is not evidence. A valid claim is: same seed, same
route, same settings, same denominator, with the candidate as the only change.
## Ready-to-build status
Both alternatives are ready for the human's review/build step. No result in this document is a
compile or runtime claim; all implementation conclusions are from source and diff inspection.
+629
View File
@@ -0,0 +1,629 @@
# VoxelForge — the decomposition map
> **What this is:** every one of the 8 archetypes, read line by line, broken into the four roles of
> `OPSTACK-PLAN.md §2.5`, with each existing param traced to the op that will own it. Written
> 2026-07-27 so no later port has to re-derive it.
>
> **Read `OPSTACK-PLAN.md §2.5` first.** The whole point is that an archetype becomes
> `source + combiners + modifiers`, not one opaque op. If a port here collapses into a single
> `FMazeOp`, the refactor has failed its own test.
>
> **Status:** analysis only. No op exists. `Public/VoxelDensityOp.h` (the contract) is written; the
> `switch` in `GetDensityAt` is untouched.
---
## 0. What fell out — read this before the per-archetype sections
Three findings changed how I'd sequence the work. They are the reason this document is worth its
length.
### 0.1 ⚠️ The contract needs an SDF channel, not just a density channel
`IVoxelDensityOp::Eval` returns *density*. But look at what TunnelNetwork actually does:
```
CaveSDF = EvaluateSDFCached(rooms + tunnels) // SDF space
CaveSDF = SmoothMin(CaveSDF, PitSDF, Pit.BlendK) // SDF space
CaveSDF = SmoothMin(CaveSDF, ChimneySDF, Chim.BlendK) // SDF space
→ ONE carve at the end: Density -= CarveFactor · BaseDensity · 2
```
Maze, VerticalShafts and FloatingIslands do the same shape: build an SDF from several primitives,
perturb the SDF with roughness noise, then convert once.
**If each primitive becomes a density op with its own carve, the `SmoothMin` junctions are lost**
a pit would meet its room at a hard seam instead of the organic blend the code deliberately builds
(the comment at the pit block says exactly this: *"SmoothMin at the pit-to-room junction creates the
same organic transition as tunnel-to-room (no hard seam at PitTopZ)"*). Roughness is worse: three of
the four archetypes add noise to the **SDF**, which displaces the surface; adding the same noise to
**density** scales with the local gradient and is a different effect.
So ops need two channels: `float Density` and `float Sdf`, with an explicit `SdfToDensity` op that
converts. Sketch:
```cpp
struct FVoxelOpSample { float Density; float Sdf; }; // Sdf = FLT_MAX ⇒ "no surface nearby"
virtual void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const = 0;
```
**This is the one part of `OPSTACK-PLAN §3` I think is wrong as written, and it matters far beyond
fidelity:** SDF-space `SmoothMin` between two *different* sources is precisely how "a room graph
carved into a mountain" produces an organic junction rather than one field punching a hole in the
other. The single-channel contract can only ever overwrite. **Recommend adopting the two-channel
`Eval` before porting Maze** — it is cheaper now than after four ports.
*(Left as a recommendation, not a change: the header as committed is single-channel, and this is
Jahni's call.)*
### 0.2 ⚠️ Worm tunnels are why TunnelNetwork can never skip a tile — and the fix is a number
`AUDIT §6.2` frames "placed vs fielded" as a question about *future* 3D caves. It is already a live
cost. Worms are a pure 3D-noise threshold carve with no bounds:
```
if (WormStrength > 0 && WormThreshold > 0) { ... Density -= t · WormStrength · NetworkMask; }
```
Unbounded in space ⇒ `EffectOverBox` = `CarveOnly` **everywhere**`AllSolid` is dead for every
tile of every strate with worms enabled. Direction alone cannot recover it.
**But the amplitude is bounded and trivially known:** `t ∈ [0,1]`, `NetworkMask ∈ [0,1]`, so the worm
op can move density toward air by at most `WormStrength`. If the stack so far is provably solid by
more than the sum of every remaining op's max carve, the box is still `AllSolid`.
So the first numeric interval bound worth writing is not the SDF Lipschitz bound the plan reaches
for — it is **a scalar amplitude cap on the fielded-noise carves**. Roughly ten lines, and it is
what unlocks deep-rock skipping for the plugin's most-used archetype. `OPSTACK-PLAN §4` defers all
numeric bounds to Phase 3; on this evidence one of them belongs in Phase 2.
### 0.3 Disturbances already have bounds that `ClassifyTile` throws away
Today: `if (D.ChasmDensity > 0) bCanSolid = false;` — strate-wide, for the whole tile.
But chasms/bridges/ridges are hash-placed on an XY lattice of known spacing and radius, and the
per-voxel code already builds the 3×3 candidate list. A box that no candidate reaches is `Identity`.
This is a **pure win available to SurfaceWorld today**, independent of everything else in this plan:
any surface strate that enables chasms currently loses `AllSolid` on every tile in it.
---
## 1. The shared primitive library
The 8 archetypes are ~6 functions in costumes (`OPSTACK-PLAN §1b`). Decomposed, here is what
actually repeats. **Reuse count is the payoff metric** — anything used once is suspicious.
### Role 1 — FIELD SOURCES
| Op | What it lays down | Used by | `IsXYPure` | `ClassifyBox` / `EffectOverBox` |
|---|---|---|---|---|
| `FConstantRockSource` | `Density = BaseDensity` (solid) | TunnelNetwork, Maze, VerticalShafts, **bedrock gaps** | ✅ trivially | `ClassifyBox`**AllSolid**, always. Free, exact. |
| `FConstantVoidSource` | `Density = BaseDensity` (air) | FloatingIslands | ✅ | `ClassifyBox`**AllAir**, always. |
| `FSlabVoidSource` | floor surface + ceiling surface, `Density = min(zfloor, ceilz)` | FlatPlain, CrystalChamber | ❌ *see §3.1* | `ClassifyBox` by sampling floor/ceiling over the box's XY corners — **exact, cheap, and a new skip win** |
| `FHeightfieldSource` | `max(TerrainZ z, z CeilSurf)` | SurfaceWorld | ✅ (the column) | `ClassifyBox` by sampling columns on the mesher's exact lattice — **this is today's `TestColumn`, moved verbatim** |
| `FRoomGraphSource` | rooms + tunnels (+ pits + chimneys) SDF | TunnelNetwork | ❌ | `Identity` when no room/tunnel bound reaches the box — **`FCachedRoom`/`FCachedTunnel` already carry the bounds** |
| `FLatticeCorridorSource` | 3D lattice capsule corridors | Maze | ❌ | `Identity` when no open edge's capsule bound reaches the box |
| `FShaftFieldSource` | vertical cylinders + horizontal connectors | VerticalShafts | ❌ (cylinders are XY-pure; connectors are not) | `Identity` when no shaft cell reaches the box in XY |
| `FIslandBlobSource` | tapered flat-topped blobs | FloatingIslands | ❌ | `Identity` when no island bound reaches the box |
| `FWormFieldSource` | 3D-noise threshold carve | TunnelNetwork | ❌ | **`CarveOnly` always** — see §0.2. The one unbounded source. |
Note how much `Identity` is available and unused: **six of nine sources can prove themselves absent
from most of the volume**, and today none of them do.
### Role 2 — COMBINERS
`Replace` · `Union`(min) · `Subtract`(max) · `SmoothUnion`/`SmoothSubtract` (reuse
`VoxelSDF::SmoothMin/Max`) · `Add` · `Mask`. Plus, from §0.1, the conversion op:
| Op | Meaning |
|---|---|
| `FSdfCarve(blend)` | `SDF < blend``Density -= smoothstep(...)·BaseDensity·2`. **The exact same six lines appear in TunnelNetwork, Maze and VerticalShafts.** |
| `FSdfFill(blend)` | the `+=` mirror. FloatingIslands. |
That one op deduplicates three copies of the carve formula and one of the fill.
### Role 3 — DETAIL MODIFIERS
Everything here is gated on being near a surface, and everything here already exists.
| Op | Space | Used by | Notes |
|---|---|---|---|
| `FSurfaceRoughnessMod` | **SDF** *or* **density** — two variants, see below | all 4 SDF archetypes | the single biggest reuse in the plugin |
| `FTerraceMod` (cave) | density | TunnelNetwork | SDF-gradient orientation gate (2 extra SDF evals) |
| `FLayerLineMod` | density | TunnelNetwork | sine along Z, cubed |
| `FRibbingMod` | density | TunnelNetwork | sine along Z, squared, `+=` |
| `FCaveOverhangMod` | density | TunnelNetwork | low-Z-frequency fBm, positive lobe only |
| `FCaveCliffMod` | density | TunnelNetwork | noise-modulated vertical gradient |
| `FScallopMod` | density | TunnelNetwork | cellular noise |
| `FArchMod` | density | TunnelNetwork | room-relative |
| `FRoomColumnMod` | density | TunnelNetwork | room-relative, pre-baked in `BuildChunkCache` |
| `FDomeMod` | density | TunnelNetwork | room-relative |
| `FPinchMod` | density | TunnelNetwork | room-relative |
| `FFloorBiasMod` | density | TunnelNetwork | only inside cave air |
| `FPitMod` / `FChimneyMod` | **SDF** | TunnelNetwork | `SmoothMin`'d into the cave SDF — §0.1's motivating case |
| `FGridColumnMod` | density | FlatPlain, CrystalChamber | world-grid cylinders — **different op** from `FRoomColumnMod` |
| `FShaftLedgeMod` | density | VerticalShafts | banded shelves, half-sided |
| `FChasmMod` / `FBridgeMod` / `FRidgeMod` | density (MC) | **all archetypes** (disturbances) | see §0.3 |
**The two roughness variants, because this is the trap:**
- **SDF variant** (Maze, VerticalShafts, FloatingIslands): `Sdf += fBm(x·k, y·k, z·k)·SCALE·Rough`.
Raw, no fade, no clamp, fixed frequency baked into the call site (`0.12`, `0.1`, `0.08`).
- **Density variant** (TunnelNetwork): two octave sets (main + fine×3), optional domain warp,
four noise types, a `min(…, 0)` clamp so roughness can never re-fill definite air, and a
quadratic fade by distance from surface.
They are *not* the same op with different params. Port them as one op with a `Space` enum and let
the SDF variant's fixed frequencies become real params — that alone is a small authoring win, and
`OPSTACK-PLAN §2.6` explicitly permits the re-tune.
### Role 4 — STRUCTURAL POST (fixed order, appended by the compiler, never author-omittable)
| # | Op | Today | Effect over box |
|---|---|---|---|
| 1 | `FOriginSpineOp` | `ApplyOriginSpine` | `CarveOnly`; `Identity` when the XY circle (R + 3) misses the box, or Z is outside the interior |
| 2 | `FBoundarySealOp` | `ApplyBoundarySeal` | **`ClassifyBox` → AllSolid inside its band** (forcing — see `VoxelDensityOp.h`); `Identity` when the box misses both bands |
| 3 | `FPassageCarveOp` | `ApplyPassageCarving` | `CarveOnly`; `Identity` via `AnyPassageNearBox`**already written** |
| 4 | `FDiffLayerOp` | the diff block in `GetDensityAt` | `Both` when mods intersect; `Identity` via `HasAnyModInChunkRange`**already written** |
The order is load-bearing and is the order the code already uses: spine carves the interior only,
the seal then re-solidifies its bands (the spine deliberately never touches them), passages punch
through everything including the seal, and the player wins last.
> ### ⛔ RETIRED 2026-07-28 — frames were never a fifth thing. Porting all three candidates killed it.
>
> The section below argues for a `FRAME` op family from three examples. All three are now ported,
> and none of them turned out to need one:
>
> - **`CaveWarp` wraps exactly ONE operator.** Pits and chimneys explicitly read *unwarped* coords
> while writing the same SDF channel — the thing this document called "the single fiddliest thing
> in the whole decomposition". Inside one operator the difficulty evaporates: the warp is a local
> variable, not an inherited context. A transform whose scope is one op is not a frame.
> - **`VerticalScale` is `Z / Scale`** — a pure function of a scalar and a param, recomputed in one
> line by each op that needs it. A frame would add a channel to avoid a division.
> - **The island warp (§7)** was kept local for the same reason, before the other two were even read.
>
> **Zero frames out of three candidates.** It was not missing infrastructure; it was one idea seen
> three times from a distance. Kept below as the reasoning that was superseded, not as a plan.
### A fifth thing the plan doesn't name: FRAME OPS
Two archetypes transform the *query coordinates* rather than the field:
- `VerticalScale``EffectiveZ = WorldZ / VerticalScale` (TunnelNetwork), stretches everything below it.
- `CaveWarpStrength/Frequency` — domain-warps the coords the SDF is evaluated at, *but not* the
coords roughness/terrain-ops/columns use. The comment is emphatic about why: *"terracing stays
horizontal, columns stay vertical"*.
So a frame op has **scope** — it applies to some ops below it and not others. Modelling that as
"push frame / pop frame" markers in the tape is straightforward; modelling it as a per-op flag is
not, because the same op can appear inside and outside a frame. **Decide this before the tape
format is fixed.** It also has a `EffectOverBox` consequence: any op under a warp frame must inflate
its box by the warp amplitude before answering — the existing code already does exactly this
(`Expansion = CaveWarpStrength + 2`).
---
## 2. TunnelNetwork *(and Underwater — identical rock, a water flag)*
`GetDensityWithParams`, ~1080 lines, the biggest single function in the plugin. **Port LAST** — it
owns `BuildChunkCache`'s two-region window-invariance discipline (§8.4), the most delicate code here.
```
FRAME VerticalScale (Z pre-divide, wraps everything below)
├─ FConstantRockSource Replace BaseDensity
├─ FRAME CaveWarp (SDF queries only — NOT the ops below the frame)
│ ├─ FRoomGraphSource → Sdf rooms + tunnels, cached per chunk
│ ├─ FPitMod → Sdf SmoothMin, unwarped coords ⚠️
│ └─ FChimneyMod → Sdf SmoothMin, unwarped coords ⚠️
├─ FSdfCarve(SDFBlendRadius) Subtract
├─ [gate: bNearCaveSurface = Sdf < SDFBlendRadius·3]
│ ├─ FSurfaceRoughnessMod Add density-space variant
│ ├─ ── per-room op override ── ⚠️ see below
│ ├─ FTerraceMod Add
│ ├─ FLayerLineMod Subtract
│ ├─ FRibbingMod Add
│ ├─ FCaveOverhangMod Add
│ ├─ FCaveCliffMod Add
│ ├─ FScallopMod Subtract
│ ├─ FArchMod Add
│ ├─ FRoomColumnMod Add
│ ├─ FDomeMod Subtract
│ ├─ FPinchMod Add
│ └─ FFloorBiasMod Add
├─ FWormFieldSource Subtract ⚠️ ungated, unbounded — §0.2
└─ [structural post ×4]
```
⚠️ **Pits and chimneys are evaluated at UNWARPED coordinates while rooms are evaluated at warped
ones**, and then `SmoothMin`'d together. The comment justifies it (pit anchors come from unwarped
room centres). Under a frame model this means pits/chimneys must sit *outside* the warp frame while
still writing the same SDF channel. That is expressible, but it is the single fiddliest thing in the
whole decomposition — **budget for it and do not discover it during the port.**
⚠️ **The per-room terrain-op override has no clean home.** Today: `NearestRoomIdx` picks a room,
that room's hash-rolled `UVoxelTerrainOpDefinition` is applied onto a *copy of the whole param
struct*, and the copy shadows `Params` for the rest of the function. In an op stack there is no
"whole param struct" to overwrite. Two options:
- **(a) Scope by room.** Each modifier gains an optional "only inside room N's influence" predicate,
and the room-graph source publishes the nearest-room index per voxel as stack state. Faithful,
and it generalises to "this op only inside this region", which is the `Mask` combiner already in
the plan.
- **(b) Drop per-room ops**, make modifiers strate-wide, and recover variety with `Mask` on a
hash field. Much simpler, visibly different world.
**(a) is right** — per-room variety is a real feature and (b) would flatten it — but it is the piece
that could blow the Phase-1 timebox if attempted early. It is also the *only* consumer of
`NearestRoomIdx`, so it can be deferred: port TunnelNetwork's geometry first with strate-wide ops,
add room scoping after.
**Tile-skipping prize:** currently zero. After the port, with §0.2's amplitude bound and the room
bounds already in `FCachedRoom`, deep bedrock below a tunnel network becomes provably `AllSolid`.
This is the largest single perf item in the whole plan.
---
## 3. FlatPlain and CrystalChamber
**These are one op with two default sets**`OPSTACK-PLAN §4` calls this the first real win, and
reading the code confirms it: `GetSlabDensity` is called for both types with no branch on which.
CrystalChamber is FlatPlain with a bigger `CeilingRoughness`.
```
FSlabVoidSource Replace floor surface + ceiling surface → void field
FGridColumnMod Add world-grid jittered cylinders, infinite height
[structural post ×4]
```
That is the entire archetype. Two of the eight collapse into one, and the ceiling's `abs(noise)`
(formations hang down only, never punch up) is a two-line flag on the source.
> **Observed in-editor 2026-07-27, and it settles the question:** Jahni reports FlatPlain and
> CrystalChamber render **identical** in the live world. They should — they share
> `FSlabGenerationParams`, and nothing in the content sets them apart. **The enum promised a
> difference the data never delivered**, in the shipped world as well as in the test fixture.
> So the merge does not lose a distinction; it *reveals* that there was none. Making CrystalChamber
> look like a crystal chamber is a **params** job — raise `CeilingRoughness` (6 → ~20, what
> `SlabEquivalence`'s tuned pass uses) and drop `CeilingRelativeHeight` a little. That is authoring,
> which is exactly the outcome the whole refactor is aiming at.
### 3.1 ✅ RESOLVED 2026-07-27 — Jahni: the Z term can go. Removed.
**Decision:** the Z term was not intentional character. It is gone from `GetSlabDensity` (both
surfaces), `FSlabVoidSource` is XY-pure, and FlatPlain + CrystalChamber are ported and wired.
**What that bought, and what it cost:**
- `IsXYPure() == true` ⇒ the T1.a column-cache treatment becomes available generically.
- An **exact `ClassifyBox` with no sampling**: `VoxelNoise::FBM`'s contract is `[-1,1]`, so both
surfaces live in Z bands with known bounds — a tile entirely below the floor band is provably
solid, a tile strictly between the bands is provably air. These two archetypes proved **zero**
tiles before. `VoxelForge.OpStack.SlabEquivalence` reports the count.
- **Cost: the world re-tunes once.** Dropping the term samples a different slice of the noise
field, so floor and ceiling shapes change (they do not degrade). Covered by §2.6's explicit
permission to re-tune.
The original finding, kept because it explains why the answer mattered:
`FSlabVoidSource` is **not XY-pure, and probably should be.** Both surfaces sample noise with a
small Z term:
```cpp
FloorNoise: FractalNoise3D(x·FF, y·FF, WorldZ·FF·0.05f) // ← Z
CeilNoise: FractalNoise3D(x·CF, y·CF, WorldZ·CF·0.08f) // ← Z
```
A "floor surface height" that depends on the altitude you sample it from is geometrically odd — the
floor is at a different height depending on which voxel asks. In practice the coefficient is tiny so
it reads as a subtle vertical smear rather than a bug, and it is deterministic, so nothing is broken.
But it blocks the T1.a column-cache treatment and it makes an exact `ClassifyBox` more awkward
(the surface must be sampled per Z rather than per column).
**Question for Jahni: was the `·0.05f` Z term intentional character, or a leftover from copying the
3D-noise call signature?** If it can go, `FSlabVoidSource` becomes XY-pure, gets the column cache
for free, and gets an exact box classification — which means FlatPlain and CrystalChamber start
skipping trivial tiles, which they never have. That is a large win for a one-character change, so
it is worth asking rather than assuming either way.
**Answered: it can go.** See the resolution above.
---
## 4. Maze — **the Phase 1 port**
The plan picks Maze, and the code justifies the pick completely. ~100 lines, and it decomposes
without any of the awkwardness elsewhere.
```
FConstantRockSource Replace BaseDensity
FLatticeCorridorSource → Sdf capsules over open lattice edges
FSurfaceRoughnessMod → Sdf SDF-space variant, frequency 0.12 (hardcoded today)
FSdfCarve(blend = 2.0) Subtract
[structural post ×4]
```
**Why it is the right first port, beyond size:** edge identity is `hash(lower node, axis)`, so two
adjacent chunks *cannot* disagree. No `BuildChunkCache`, no COLLECT/STORE region, no window-invariance
risk at all (`AUDIT §6.4` names this the pattern to prefer). If the source/modifier split does not
fall out here, it will not fall out anywhere, and that is exactly what the stop-trigger is for.
**`EffectOverBox`:** the open-edge set reachable from a box is the same `{-1,0}³` node sweep the
per-voxel code already does, at cell granularity. Capsule bound = `CorridorRadius + roughness
amplitude + blend`. `Identity` when no open edge's capsule reaches the box — which for a sparse
`BranchProbability` is most of the volume. **Maze currently skips zero tiles; this is its first.**
**The proof `OPSTACK-PLAN §4` asks for:** once `FLatticeCorridorSource` exists, drop it into a
`SurfaceWorld` strate under the terrain and confirm you get a maze inside a mountain with no C++.
Note this requires §0.1's SDF channel to look *good* (smooth junctions where corridors meet rock);
it works but reads harsh without it.
---
## 5. SurfaceWorld — biggest payoff, most care
Three XY-pure functions plus a cheap per-voxel combine. The column cache (T1.a) and the exact-lattice
`ClassifyTile` bound must both survive the port — they are the two most valuable pieces of
engineering in the file.
```
FHeightfieldSource Replace ← the whole column pipeline, XY-pure
├─ FStructuralHeightField continents + mountains + detail, under a warp frame
├─ FCliffHeightMod slope-gated steepening (4 structural resamples)
├─ FTerraceHeightMod relief-gated plateaus
├─ FLayerLineHeightMod sine bands
└─ FBeachHeightMod flatten toward the water line
FSkyCapSource Subtract ceiling: warp + signed swell + downward-only hang
FOverhangShelfMod Union ⚠️ per-voxel, NOT XY-pure — the one 3D op here
[structural post ×4]
```
> ### ⚠️ RESOLVED 2026-07-27 — the height ops needed a SECOND OP FAMILY, not a sub-list
>
> This section says the height ops *"operate on Z values in the column, not on density"* and then
> lists them as children of `FHeightfieldSource`. Writing them made the consequence unavoidable:
> **they do not fit `IVoxelDensityOp` at all.** Its signature is `Eval(x, y, z, FVoxelOpSample&)` —
> per voxel, density + SDF. A height op has **no input Z** (it produces one), is XY-pure (once per
> column), and writes neither channel.
>
> The two ways to force it were both bad: a per-voxel third channel for what is a **column**
> property, or collapsing all five into one opaque op — `OPSTACK-PLAN §2.5`'s explicit failure mode.
>
> **So height space got its own contract: `VoxelHeightOp.h`** (`FVoxelHeightSample` with
> `Height` + `Relief`, `IVoxelHeightOp`, `FVoxelHeightStack`). Same lesson as `§0.1`, one step
> further: §0.1 found that density needed a second *channel*; this found that terrain needs a second
> *space*. Verified by `VoxelForge.OpStack.SurfaceHeightEquivalence` before anything was built on
> top of it — deliberately, so a wrong answer would have cost one test rather than a whole port.
>
> **Bonus the type system gives for free:** a height stack cannot contain Z-dependent data, because
> there is no Z in the signature to put there. `AUDIT §6.3` warns that Z-dependent data smuggled into
> `FSurfaceColumn` silently corrupts every chunk in the vertical stack and that `ValidateDeterminism`
> would not catch it. Here the *type* forbids it rather than a convention.
**Critical distinction the port must preserve:** the height ops (`FCliffHeightMod` and friends)
operate on **Z values in the column**, not on density. They are XY-pure and belong in
`PrepareChunk`/the column cache. `FOverhangShelfMod` operates per voxel and re-samples the
structural heightfield at a shifted XY. Mixing those two up puts Z-dependent data in `FSurfaceColumn`,
which `AUDIT §6.3` warns silently corrupts every chunk in the vertical stack — **and
`ValidateDeterminism`, which samples along an X boundary, would not catch it.**
This is the archetype where `IsXYPure()` earns its place in the contract: it turns an implicit
convention that has to be remembered into a declaration the compiler routes on.
**Biome blending:** the heightfield is evaluated for the dominant biome and its nearest neighbour and
the two *heights* are lerped. In stack terms that is the `Mask` combiner with a biome-weight field —
which is exactly the mechanism `OPSTACK-PLAN §4 Phase 3` wants for unifying strates and biomes. So
SurfaceWorld's existing biome blend is the prototype for the whole Phase 3 idea, and porting it is
how that gets validated.
---
## 6. VerticalShafts
```
FConstantRockSource Replace
FShaftFieldSource → Sdf infinite cylinders (XY-only) + hash-gated connectors
FSurfaceRoughnessMod → Sdf SDF variant, frequency 0.1
FSdfCarve(blend = 2.0) Subtract
FShaftLedgeMod Union banded shelves, +X/+Y half only so a climb path remains
[structural post ×4]
```
Nearly identical in shape to Maze — same `source → roughness → carve` spine, different primitive.
That similarity is the evidence the abstraction is real: two archetypes that look unrelated in the
`switch` are the same three ops with a different source.
**Split worth making:** the shafts are XY-pure infinite cylinders; the connectors are not. Two ops
(`FShaftColumnSource` XY-pure + `FShaftConnectorSource`) let the cylinder half get the column-cache
treatment and answer `ClassifyBox` exactly in XY. Keeping them as one op forfeits that.
---
## 7. FloatingIslands
The only archetype whose source is **air**, which is what makes it a good composition test.
```
FConstantVoidSource Replace BaseDensity (open void)
FRAME IslandWarp XY domain warp (lobed outlines, amplitude ~0.35·meanR)
└─ FIslandBlobSource → Sdf tapered flat-top blobs, SmoothMin'd together
FSurfaceRoughnessMod → Sdf SDF variant, frequency 0.08, 4 octaves
FSdfFill(SDFBlendRadius) Union
[structural post ×4]
```
Note the warp here is applied to the **query** (`WX`,`WY` computed once per voxel and shared by all
nearby islands) exactly like TunnelNetwork's cave warp — same frame concept, third instance. Three
uses is enough to make frames a first-class part of the model rather than a special case.
**`FIslandBlobSource` is the cleanest `Identity` opportunity in the plugin:** islands are hash-placed
with a known XY radius and explicit `TopZ`/`BotZ`. A box that no island's AABB reaches is provably
untouched — and since a floating-island strate is *mostly* empty void, that is most tiles. Combined
with `FConstantVoidSource`'s `ClassifyBox → AllAir`, a FloatingIslands strate could go from skipping
zero tiles to skipping the large majority of them.
#### ✅ PORTED 2026-07-28 — three deviations from the sketch above, all deliberate
1. **`FConstantVoidSource` and `FSdfFill` are not new classes.** Each is the class it mirrors, with
the opposite **sign**: `FConstantFieldSource(±Base)` and `FSdfConvertOp(Sign = ±1)`, two factories
each. The table above listed them as separate ops; writing them separately would have duplicated
the classifier and the six-line formula for nothing. Multiplying by ±1 is exact in IEEE-754, so
the three already-green ports are bit-for-bit untouched by the generalisation.
**This is the port's actual result:** reuse **by inversion** rather than by identity — evidence
that the abstract axis (the sign of the internal density) is the right one, not just that two
archetypes happened to look alike.
2. **The warp stays inside the source; no `FRAME` op was built.** Frames are worth building at the
second real user, and two of the three (`TunnelNetwork`'s cave warp, its tunnel warp) are not
ported yet. Designing the abstraction against a single example is what this refactor has avoided
throughout — cf. `IVoxelBiomeField`, which was born from a concrete second need. Revisit with
TunnelNetwork.
3. **`AUDIT §C1`'s last surviving site was in this archetype** and was fixed in both paths in the
same pass (the warp's `(float)S * 0.0007f`; the 2026-07-27 sweep matched `SeedF * K` and missed
the `(float)S` spelling).
**The `Identity` bound is one-sided, and that matters:** `Sdf ≥ WorldZ TopSurf` bounds an island
from **above** only. Below `BotZ` the SDF degenerates to ≈ `DistXY`, so a hairline thread of matter
hangs down each island's axis to the strate floor. Rejecting a box because it sits below an island
would be a hole. Only the top rejects.
---
## 8. Underwater
`GetDensityAt` routes `Underwater` to `GetDensityWithParams` with a comment: *"Underwater shares
tunnel rock (water table is a render-side overlay)."* There is **no density difference at all**.
So `Underwater` is not an archetype, it is TunnelNetwork plus `WaterLevelRelative` consumed by the
water render system. When `ECaveGeneratorType` finally disappears, this one vanishes for free — it
never needed to exist as a generator type.
---
## 9. Q2 — the param audit: every field, and who claims it
`FStrateGenerationParams` via the `VF_STRATE_PARAM_FIELDS` X-macro. **Every field is claimed except
where flagged.**
| Field(s) | Destination op |
|---|---|
| `BaseDensity` | **stack-global** — read by every source and all four structural-post ops. Not any single op's param. |
| `VerticalScale` | `FRAME VerticalScale` |
| `WormFrequency`, `WormHorizontalBias`, `WormThreshold`, `WormStrength`, `WormNetworkRange` | `FWormFieldSource` |
| `RoomSpacing`, `RoomDensity`, `MinRoomRadius`, `MaxRoomRadius`, `RoomHeightRatio`, `RoomShapeVariety`, `RoomFloorCutMin`, `RoomFloorCutMax`, `FloorReliefStrength`, `FloorReliefFrequency` | `FRoomGraphSource` |
| `OriginRoomRadius`, `OriginRoomMaxConnections` | `FRoomGraphSource` — ⚠️ but see §10.3, they couple to the spine |
| `TunnelMinRadius`, `TunnelMaxRadius`, `TunnelDensity`, `MaxTunnelLength`, `TunnelWarpStrength`, `TunnelHorizontalBias`, `bTunnelsFlowTowardOrigin`, `TunnelEndpointZOffset` | `FRoomGraphSource` |
| `SDFBlendRadius` | `FSdfCarve` / `FSdfFill`**shared**, also the `bNearCaveSurface` gate width |
| `CaveWarpStrength`, `CaveWarpFrequency` | `FRAME CaveWarp` |
| `SurfaceRoughness`, `RoughnessFrequency`, `RoughnessNoiseType`, `DomainWarpStrength`, `DomainWarpFrequency` | `FSurfaceRoughnessMod` (density variant) |
| `BoundarySealThickness` | `FBoundarySealOp` — and read by the spine, disturbances, shaft connectors, island spread |
| `StrateTopWorldZ`, `StrateBottomWorldZ` | **`FVoxelOpContext`, not op params.** Runtime-injected, never author-set. |
| `FloorBias` | `FFloorBiasMod` |
| `TerraceStepHeight`, `TerraceHardness`, `TerraceNoiseDisplacement` | `FTerraceMod` |
| `LayerLineSpacing`, `LayerLineDepth` | `FLayerLineMod` |
| `OverhangStrength`, `OverhangDepth`, `OverhangFrequency` | `FCaveOverhangMod` ⚠️ name collision, §9.1 |
| `RibbingSpacing`, `RibbingDepth` | `FRibbingMod` |
| `CliffStrength` | `FCaveCliffMod` ⚠️ name collision, §9.1 |
| `ScallopStrength`, `ScallopFrequency` | `FScallopMod` |
| `ArchDensity`, `ArchMinRadius`, `ArchMaxRadius` | `FArchMod` |
| `ColumnDensity`, `ColumnMinRadius`, `ColumnMaxRadius` | `FRoomColumnMod` ⚠️ name collision, §9.1 |
| `PitDensity`, `PitMinRadius`, `PitMaxRadius`, `PitDepth` | `FPitMod` |
| `ChimneyDensity`, `ChimneyMinRadius`, `ChimneyMaxRadius`, `ChimneyHeight` | `FChimneyMod` |
| `DomeDensity`, `DomeMinRadius`, `DomeMaxRadius`, `DomeHeightRatio` | `FDomeMod` |
| `PinchDensity`, `PinchStrength`, `PinchLength` | `FPinchMod` |
| **`WaterLevelRelative`** | ⚠️ **claimed by no density op.** See §9.2. |
### 9.1 Three names mean different things in different structs
`FStrateGenerationParams` and `FSurfaceGenerationParams` both define `CliffStrength`,
`OverhangStrength`/`OverhangFrequency`, `TerraceHardness` and `ColumnDensity`-family fields — and
they are **genuinely different operations**:
| Name | in `FStrateGenerationParams` (cave) | in `FSurfaceGenerationParams` (surface) |
|---|---|---|
| `CliffStrength` | noise-modulated vertical density gradient near a cave wall | slope-gated steepening of a **height value** |
| `OverhangStrength` | fBm lobe adding rock into a cave | F20 warped-terrain union making a real 3D shelf |
| `TerraceHardness` | staircase edge width on a cave wall | plateau/riser ratio of a **height** quantiser |
| `ColumnDensity` | room-anchored columns | *(slab struct)* world-grid cylinders |
Today the type system keeps them apart. Once ops are data assets in one list, **nothing does**
`DA_Op_Cliff` would be ambiguous. Name them for what they operate on from day one:
`FCaveWallCliffMod` vs `FHeightSlopeCliffMod`, `FCaveShelfMod` vs `FTerrainOverhangMod`. Cheap now,
a rename with authored assets in the field later.
### 9.2 The one unclaimed field: `WaterLevelRelative`
It lives in `FStrateGenerationParams` (and is `Lerp`'d at every strate boundary along with the
density params), but **nothing in the density path reads it.** Its consumers are
`GetWaterLevelWorldZForChunk` (the water render system) and `ComputeSurfaceTerrainZ`'s beach
flattening — which reads the *surface* struct's copy, not this one.
Not dead, but misfiled: it is a *content/render* property riding in a *density* struct, and it is
being interpolated across strate boundaries where a water plane should probably be a hard property
of one strate. **Report, don't delete** — but when ops become assets it should move to the strate
itself rather than to any op.
### 9.3 Fields that are context, not params
`StrateTopWorldZ` / `StrateBottomWorldZ` are runtime-injected by
`UVoxelStrateManager::Get*ParamsForChunk`, never author-set, and every archetype's first line is a
degenerate check on them. They belong in `FVoxelOpContext` (where the header already puts them) and
should be *removed* from the per-op param structs so an author cannot see or set them. That deletes
a whole class of "I set the Z bounds and nothing happened".
---
## 10. Ordering rules the compiler must enforce
### 10.1 Structural post is appended, always, in order
`FOriginSpineOp``FBoundarySealOp``FPassageCarveOp``FDiffLayerOp`. Verified identical in all
six density functions. An author cannot omit, reorder or insert between them.
### 10.2 Disturbances run after the archetype, before the diff layer
`ApplyDisturbances` is called in `GetDensityAt` *after* the archetype function returns (so after that
function's own spine/seal/passages) and *before* the diff layer. So the true global order is:
```
[archetype stack] → spine → seal → passages → disturbances → diff layer
```
Disturbances self-limit to the seal interior (`if (Z <= InnerBot || Z >= InnerTop) return;`), which
is why running them after the seal is safe. **Preserve this or bridges will punch through seals.**
### 10.3 The spine and the origin room are two systems aimed at the same place
`ApplyOriginSpine` carves an unconditional column at XY (0,0) in every strate; `OriginRoomRadius`
makes the room graph put a big room there too; `bOpenSurfaceEntry` opens a shaft from above. Three
mechanisms, one location, and `AUDIT C8` already flags an unchecked invariant between
`OriginRoomRadius` and the COLLECT margin. When these become ops, the coupling becomes visible and
should be either unified or documented — right now it works by everyone independently agreeing that
(0,0) is special.
### 10.4 Recommended port order (unchanged from the plan, now with reasons from the code)
1. **Maze** — cleanest split, no connectivity decision, cheapest mistake. §4.
2. **FlatPlain + CrystalChamber** — two archetypes → one op. Ask §3.1 first.
3. **FloatingIslands** — the biggest `Identity` win, and the first air-source stack.
4. **VerticalShafts** — same spine as Maze, validates the source-swap claim.
5. **SurfaceWorld** — biggest payoff; the column cache and exact-lattice bound must survive.
6. **TunnelNetwork** — last. §8.4, the warp/pit coordinate split, and the per-room op override.
7. **Underwater** — falls out of 6 for free.
---
## 11. Open questions for Jahni
Ranked by how much they change the work.
1. **Two-channel `Eval` (density + SDF)?** §0.1. Changes the contract. Cheapest to decide now.
My recommendation: yes — without it, cross-source `SmoothMin` is impossible and "a maze inside a
mountain" reads as a hole punched in rock rather than a cave that belongs there.
2. **Is `FSlabVoidSource`'s Z-term intentional?** §3.1. One character; unlocks XY-purity, the column
cache and exact tile classification for two archetypes.
3. **Per-room terrain ops: scope-by-room (faithful) or strate-wide + `Mask` (simpler, flatter)?**
§2. Recommend scope-by-room, but *after* the geometry port, not during it.
4. **Amplitude bound on the worm carve in Phase 2 rather than Phase 3?** §0.2. It is the difference
between TunnelNetwork skipping tiles and never skipping tiles.
5. **Rename the colliding cave/surface op names before any asset is authored?** §9.1.
---
*Written 2026-07-27 by Opus 5, unattended, as build-free queue item Q1. Companion to
`OPSTACK-PLAN.md` (the plan) and `OPSTACK-PROGRESS.md` (what is actually built).*
+339
View File
@@ -0,0 +1,339 @@
# Handoff — VoxelForge operator stack, updated 2026-08-16 (four things queued on ONE build)
> Paste the block below into a fresh session. Everything it refers to is on disk and in git.
>
> **State:** 8 of 8 archetypes ported and green. Tile-skipping is measured **in the automation
> harness** (11 of 40 tiles proved `AllSolid` at production defaults, 14641 voxels brute-forced,
> 0 violations) and **still unobserved in the running game**. `AUDIT §C2` is **fully closed** (both
> halves — verified 2026-08-16, don't reopen). `experimental` is pushed.
>
> ## ⛔ FOUR unbuilt things are stacked on `experimental`. Build once, read four numbers.
>
> | # | commit | what to read |
> |---|---|---|
> | 1 | `e002bd4` VerticalShafts connector capsules | `Box verdicts over 60 VerticalShafts tiles` — **0 has been the number for the project's whole life**; `violations` must stay 0 |
> | 2 | `eb317d9` `stat VoxelForge`, 8 counters | baseline `TilesOpStackSolid` = 0 → tick one `TunnelNetwork` strate → **non-zero**. That is the production proof of T1.d, which has never existed |
> | 3 | `7dbdf51` `ExtraReach` × `VF_PerlinAbsBound` | Maze/VerticalShafts may prove **FEWER** tiles. **That is correct, not a regression** |
> | 4 | `eaa44bf` `Max3` radius envelope | **a NO-OP at shipped defaults is the correct result** — any moved number means the diff did more than intended |
>
> In all four: the eight equivalence tests must stay green, and `violations` must stay 0.
>
> **⚠️ 3 and 4 are CORRECTNESS fixes to box verdicts, found by auditing all 28 `EffectOverBox`
> implementations.** Both were the same mistake: *a bound taken from the parameter that reads like
> the maximum instead of the supremum of what `Eval` actually produces* — and both times a correct
> instance of the same reasoning already existed elsewhere in the same file. See
> `OPSTACK-PROGRESS.md` 2026-08-16 (e) and (f); the sound-and-checked ops are listed there so they
> are not re-audited.
---
You're picking up the VoxelForge UE5 voxel plugin on branch `experimental` (already checked out —
do not create another). I'm Jahni. The design and the history are written down so you don't
re-derive them.
## Read first, in this order
1. **`CLAUDE.md`** — project rules. **Rule #1 is absolute: never build, compile, or run the editor.**
I build everything myself. When code is done, stop, say "ready to build", list the likely
compile-error spots, and wait.
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point.
3. **`OPSTACK-PLAN.md`** — the plan. **§2.6.1 is the acceptance bar** and supersedes §2.6.
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is now
*implemented*, not pending; §2 TunnelNetwork and §8 Underwater are history, not instructions.
5. **`AUDIT-2026-07.md`** — **§C2's SDF-cache half is FIXED (2026-07-28)**, its live-edit half
(`OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache`) is still open; §C10 is SOLVED, don't reopen;
§C9's library half is the top open theoretical risk with 0 measured exposure.
6. **`CODEMAP.md`** — navigation. Trust symbol names over line numbers.
## How we work now — Codex writes, you orchestrate
From 2026-07-29 this project runs **in tandem with Codex (Model Luna, xHigh)**. **Codex handles most
of the coding; you orchestrate.** Concretely:
- You read the code and decide *what* to do; you write **precise specs** Codex executes; you **review
what comes back against the real code, not against its description**; you own the docs
(`OPSTACK-PROGRESS.md`, `CODEMAP §3`, this file) and the measurements.
- **Hand Codex the INVARIANT, not just the task.** This codebase's traps are invisible in a diff —
density sign, `Identity` meaning `Sdf ≥ T` (below), cache keys needing params + `LayoutVersion`,
inserting classes above the anonymous-namespace end marker. A spec that omits these gets code that
compiles and deletes collision.
- `CODEX-TASK-*.md` at the plugin root are the specs. Each carries a **Why**, the **exact site**, the
**invariants**, an **acceptance** section, and **notes for the reviewer**. Write the next one the
same way.
- Unchanged: **never build** (Jahni does), and a plausible patch is not a verified one until a
measurement says so.
## Where things stand
All 8 archetypes have an operator-stack twin, per-strate opt-in, each equivalence-tested **bit for
bit** against its original density function. The `switch` and the stack are two complete,
interchangeable implementations.
Everything sits behind `UVoxelStrateDefinition::bUseOperatorStack`; the ported list lives **only** in
`UVoxelStrateManager::UsesOperatorStackForChunk` (all 8). `GetDensityAt` and `ClassifyTile` build the
stack through the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole.
> ### ⚠️⚠️ CORRECTED 2026-08-16 — **THE FLAG IS ON IN THE GAME'S DATA ASSETS.**
>
> This section used to say *"No strate asset has the box ticked — that is my call and I still
> haven't made it."* **That is false and was believed for a whole session.** Jahni: *"the data assets
> in game have the switch on."*
>
> **Everything downstream of that premise flips:**
> - The operator stack is **the production density path**, not a dormant twin. The measured perf
> regression is a regression players feel, not a lab result.
> - Any unsoundness in an op's `EffectOverBox` is a **live** hole, not a latent one. Two were found
> and fixed on 2026-08-16 (`7dbdf51`, `eaa44bf`) and **both of those commit messages say "nothing
> in the running game was affected" — that sentence is WRONG, for this reason.** See
> `OPSTACK-PROGRESS.md` 2026-08-16 (h) for the corrected severity.
> - The T1.d prize is already being collected in-game; `CODEX-TASK-001`'s counters measure how much.
>
> **Never state the flag's state from memory again — it lives in `.uasset` data, which is not
> greppable from here. Ask, or read it in the editor.**
### ✅ T1.d — the tile-skipping prize — is real and measured **in the harness** (not yet in the game)
`FRoomGraphSource::EffectOverBox` answers **spatially**. The result, brute-forced voxel by voxel:
```
[production defaults] 11 of 40 tiles proved AllSolid — 14641 voxels checked, 0 violations
[dense fixture] 0 of 40 — correct, and structurally inevitable
```
One function's verdict is inherited by `FSdfConvertOp`, the twelve detail modifiers (via
`VF_NoCaveOverBox`) **and** `FWormFieldSource` — fourteen operators from one place. That is what the
C1 wiring was built for.
### ⚠️⚠️ THE ONE INVARIANT THAT CAN DELETE COLLISION — read before touching any op
**`FRoomGraphSource::EffectOverBox` returning `Identity` now means `Sdf ≥ T`, NOT `Sdf == FLT_MAX`**,
where `T = max(3·SDFBlendRadius, WormNetworkRange)`. That is sound only because all three consumers
of the SDF channel were read one by one:
| consumer | threshold |
|---|---|
| `FSdfConvertOp::Eval` | `Sdf >= Blend`, and the tunnel stack passes `MakeSdfCarve(P.SDFBlendRadius, …)`**K** |
| the twelve modifiers | `VF_NearCaveSurface`**3K** |
| `FWormFieldSource::Eval` | `CaveSDF >= WormNetworkRange`**WormNetworkRange** |
**Any new consumer of `InOut.Sdf` must have a threshold ≤ `T`, or be added to that `max`.** An op
reading `Sdf < 100` would see false `Identity` verdicts and produce tiles with no geometry **and no
collision**. The warning is written at the site you land on when you add one.
(The `K` slack covers *any* number of primitives because `SmoothMin`'s penalty is exactly zero once
`|AB| ≥ K`, so the running minimum saturates at `K` below the smallest term. Without that
observation the slack would scale with the ~88 tunnels in a cache and the criterion would be dead.)
## First actions — one build to read, two tasks to hand Codex
### (a) Hand Codex `CODEX-TASK-001-tile-skip-stats.md`, then `-002-` — 001 first, they chain
Everything in this refactor has been proved in an automation harness on 40 sampled tiles, and
**nothing has ever been observed in the running game.** Task 001 adds a `stat VoxelForge` group with
`TilesClassified / TilesSkippedAllSolid / TilesSkippedAllAir / TilesMeshed`, **plus
`TilesOpStackSolid / TilesOpStackAir` at a second site**. Task 002 adds `ColumnMemoHit / Miss` to the
same group; it needs 001's header to exist, and both should land in one build.
Its deliverable is a **before/after that constitutes the production proof of T1.d**: after ticking
`bUseOperatorStack` on one `TunnelNetwork` strate and flying the same route, **`TilesOpStackSolid`
must go non-zero**. The spec carries the invariants — most importantly that `bTrivialEmpty` decides
whether a tile has **collision**, and that `GenerateTileResult` runs on **worker threads** so a plain
`static int32++` is a data race.
⚠️ **Corrected 2026-08-16 — the earlier version of this bar was unmeasurable.** It said
`TilesSkippedAllSolid` would read 0 underground with no strate opted in. It will not: `ClassifyTile`
also proves `AllSolid` on its **hand-written** path (a bedrock-gap chunk sets `bCanAir = false`,
`VoxelGenerator.cpp` ~2835), which fires with nothing ticked at all. That is the same "~84 %" caveat
below, which the old bar quoted and then contradicted. The op-stack-only counters are zero **by
construction** — the cave branch returns `Mixed` at `UsesOperatorStackForChunk` — so they are the
ones that prove anything.
Interim answer if Jahni wants it before that lands: **Unreal Insights already shows this.** The trace
scopes `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` exist at the site; a skipped tile is a
`ClassifyTile` with no `GenerateMesh` after it. ⚠️ But ~84 % of tiles were *already* being rejected by
the hand-written SurfaceWorld/bedrock paths long before this work, so surface skips will drown the
cave ones — you must be **underground in an opted-in `TunnelNetwork` strate** for the number to mean
anything.
### (b) Build `e002bd4` (VerticalShafts) and read ONE line
Everything before it is built and green.
> Build, run the `VoxelForge` filter, and read
> **`Box verdicts over 60 VerticalShafts tiles`**.
>
> **0 was the number for the whole project's life.** Its `EffectOverBox` used to return `CarveOnly`
> because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at
> `ShaftSpacing 55 / ShaftDensity 0.6`. It now rebuilds the connectors the way `GetCells` does and
> tests the real capsules, with **Z exact** and XY conservative.
>
> - **Non-zero, and `violations` still 0** ⇒ it worked; record it and move on.
> - **Still 0** ⇒ the warning in that test names what to check **first**: `ExtraReach` inflates both
> remaining tests, so compare it against `ShaftMaxRadius` before touching either test. **Do not
> re-derive from scratch** — that is exactly what cost three rounds on TunnelNetwork.
## Then, in order
1. **PERF — the biggest open item, and now AIMED (2026-08-16). Read this before touching it.**
The op path is measurably slower; one cause was already found and fixed (the column memo
discarded itself every chunk). Three things were worked out since, all still **unmeasured**:
- **The A/B needs no new code.** `VoxelForge_ClassifyTile` and `VoxelForge_GenerateMesh` already
exist, and the world is deterministic, so two Insights traces — flag off, then on, same seed and
route — are a clean before/after.
- **But it is unreadable without task 001.** With the stack on, tiles get *skipped*, so
`GenerateMesh` runs fewer times; a total conflates "cheaper per tile" with "fewer tiles" and
those pull opposite ways. `TilesMeshed` is the denominator. **⇒ 001 is a PREREQUISITE here, not
a parallel item.** Order: 001 → traces → attribution → fix.
- **The suspects don't share an archetype**, so measure one ticked strate at a time.
*SurfaceWorld* = the hashed column memo. *TunnelNetwork* = **19 virtual calls per voxel**
(16 from `BuildTunnelNetworkStack` + 3 from `AppendStructuralPost`), plus the known 12× gate
re-test (stage B5's deliberate trade).
`CODEX-TASK-002` tests the SurfaceWorld suspect and **fixes nothing on purpose** — the derivation
says the direct-mapped 4096-slot table evicts ~25 % of columns *every Z plane* (the mesher
pre-samples **Z-outermost**), for a derived ~9× on column work. **Derived, not measured.**
**Measure before optimising** — the §C10 lesson, re-learned the hard way last session.
2. **The warp squeeze — PARKED with its ceiling measured, my recommendation is leave it.** The
`WARP SHARE` line says over half the remaining blocking is the query-box dilation, not geometry
(production: rooms 0.9 → 0.4, tunnels 2.4 → 1.1 with the dilation zeroed). The only remaining
route is proving `sup|Perlin3D|` down from the proved **1.5** toward its apparent ~1.01.1, worth
~27 % of the dilation. Spot-checking a grid is **not** a proof and a wrong sup is a hole.
**A negative result is already recorded so nobody repeats it:** bounding the warp *locally*
(evaluate at the box centre, shift, dilate by the variation) is **worse** — a rigorous per-axis
Lipschitz bound is `4·1.875 + 1 = 8.5` per unit cell, and `8.5 × 0.206` (the half-box in noise
units) `= 1.75` exceeds the global range bound of 1.5.
3. **`AUDIT §C9` library half** — `sinf`/`cosf` are not IEEE-754 specified, so MSVC's CRT and glibc's
libm can differ. Currently **0 samples within 1e-6 of the isosurface**, i.e. no measured risk. Run
`CrossPlatformDigest` on Linux, compare the SHAPE digest, pin it. The real fix if ever needed is a
deterministic in-house sin/cos.
4. ~~**`AUDIT §C2`'s remaining half**~~**✅ CLOSED, verified 2026-08-16. Do not re-open, and do
not spec a fix for it — I nearly did.** `OC_Chunk`, `BM_Chunk` and `TC_BiomeCache` all carry a
layout-version guard (`OC_Version` / `BM_Version` / `TC_SeenVersion`), `FChunkBiomeCache` has an
explicit `Invalidate()` that all four `thread_local` instances call on a version change, and the
only other two instances in the tree are **function-local**, so they cannot go stale. Recorded in
`AUDIT-2026-07.md §C2`. The live-edit half was fixed at the same time as the determinism half;
only this list was stale.
5. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
unprompted. What makes it possible is already in place: ops depend on capabilities
(`IVoxelBiomeField`), never on `UVoxelGenerator`.
## Debts — status changed, read this before acting on the old text
1. **"Box bounds read STRATE params but a per-room op can raise them" — DORMANT, not urgent.**
Checked rather than paid, and the check reversed the premise: when the source proves `Identity`
the twelve modifiers are `Identity` **soundly** (their `bNearCaveSurface` gate never opens, so no
room op can enable anything), and when it answers `Both` it supplies no `MaxCarveOverBox`, so the
default `FLT_MAX` kills every hypothesis regardless of what the modifiers claim. **It goes live
the day `FRoomGraphSource` gains a `MaxCarveOverBox`** — bounding the converter's `2·BaseDensity`
would make the modifiers' own numbers matter for the first time. Written at the site.
2. **`AUDIT §C2` — FIXED on the `switch` path.** `GetDensityWithParams` now takes **required**
`ParamsFingerprint` + `LayoutVersion`. Required, not defaulted, so a caller that forgets fails to
compile. The CRC is taken **once per chunk** where the params memo already lives (`CP_TunnelFP`) —
a `MemCrc32` per voxel on the hottest path would have been a real regression. Note the audit's own
suggested alternative ("add chunk Z to the key") is both insufficient (`Interleaved` makes `Alpha`
depend on chunk **XY** too) and destructive (chunk XY is deliberately absent so `WorldX ± 1`
gradient probes don't thrash the box — `ARCHITECTURE §8.10`).
## Hard rules that prevent real bugs
- **Density sign:** negative = solid at the mesher. Inside the op stack the convention is INTERNAL
(**positive = solid**), negated once by the caller. The SDF channel uses standard SDF convention.
- **`Identity` from the room source means `Sdf ≥ T`.** See the boxed invariant above. This is the
single most dangerous thing in the current code.
- **Never run both density paths in one world.** **Comparing them is legitimate** — §C10 is closed
since `FPSemantics = Precise`, and all eight equivalence tests compare bit for bit. They are
**port-correctness oracles**, not fidelity checks: §2.6.1 requires *same seed ⇒ same world on every
peer*, not resemblance to the pre-refactor world.
- **Every cache key includes `LayoutVersion` AND the params.** See §C2 and the overhang regression of
2026-07-27, where omitting the params silently deleted the overhang and only 1 sample in 20 000
crossed the isosurface.
- **A bound in a box verdict must be PROVED, not observed.** `|Perlin3D| ≤ 1.5` is derived from
`GradDot`'s two-distinct-axes form and the per-axis weighted bound of 0.5 — *not* from the header's
"~[-1,1]". Over-estimating costs CPU; under-estimating deletes collision.
**⚠️ USE `VF_PerlinAbsBound` — it is file-scope in `VoxelDensityOpStack.cpp` and it is the ONLY
copy. Never write a bare `1.0` for a noise amplitude in a reach.** `VoxelNoise::FBM` **normalises**
(`return Total / MaxValue`), so `sup|FBM| = sup|Perlin3D|` **exactly** — the octave sum neither
amplifies nor attenuates it, and an `FBM`-driven reach needs the same 1.5. This rule was written
*before* three `ExtraReach` formulas were found violating it (2026-08-16, fixed in `7dbdf51`):
VerticalShafts and Maze were unsound at their shipped defaults, FloatingIslands sound only because
its `SDFBlendRadius` happens to be large. **A rule stated in a doc is not a rule enforced in code**
when you add a reach, grep for `VF_PerlinAbsBound` and use it.
- `ProcessQueue` stays `EQueueMode::Mpsc`; `Epoch` carries through every async path; don't "optimize"
the `ARCHITECTURE §8.10` invariants.
- Commit per coherent unit with a real message. **`experimental` is pushed and tracked
(`origin/experimental`, since 2026-07-29) — keep it in sync. NEVER push `main`**, which stays the
known-good fallback at the commit it has always been. ⚠️ A pushed commit here is **not** a
"verified green" marker: the branch carries unbuilt work by design, and only `OPSTACK-PROGRESS.md`
says what was actually built.
- Update `CODEMAP §3`, `ARCHITECTURE §8`, tick `OPSTACK-PLAN`, append to `OPSTACK-PROGRESS.md`.
- **When inserting a class into `VoxelDensityOpStack.cpp` / `VoxelHeightOpStack.cpp`, put it ABOVE
the labelled end of the anonymous namespace.** Anchoring on the FACTORIES banner puts it outside,
and the brace added with it closes nothing. Made that mistake twice; both files say so.
- **Match the codebase's spelling of engine macros.** `KINDA_SMALL_NUMBER`, not
`UE_KINDA_SMALL_NUMBER` — the plugin uses the unprefixed form everywhere.
## Method lessons this refactor actually paid for
Ordered by how much they cost.
- **⭐ Instrument what you ASSUMED, not just what you changed.** This is the expensive one, learned
over four rounds in one session. The warp dilation — `CaveWarpStrength · VOXEL_NOISE_SCALE ·
PerlinAbsBound`, a constant chosen in the first commit — inflated a 10-voxel tile into a 50-voxel
query box, **125× the volume**. Four separate tightenings (the worm, the columns, the sampler, the
tunnel disjunction) were each individually correct and each landed *around* that untouched term.
The tunnel fix, predicted "an order of magnitude", delivered 25 % — **and the instrument said so,
and I credited the tunnels.** *When a fix under-delivers against its predicted size, suspect the
constant you never measured.*
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses. In this
session the attribution line (`AllSolid killed by: …`) was written after *two* wrong guesses and
immediately named a third operator nobody had looked at. **A diagnostic that lists candidate causes
without measuring them is still a guess wearing rigour** — my "either the tiles straddle cave or
the source isn't reaching Identity" warning offered two causes and both were wrong.
- **Verify the premise before reasoning from it.** Six times now a confident chain rested on an
unchecked assumption and the check reversed it. Latest three: `RoomSpacing` was **42** (the fixture
overrides it) while I did three rounds of arithmetic with the header default of 80 — *the number
was printing in the report I kept quoting*; "the plugin bets on `|Perlin3D| ≤ 0.8`" was wrong (the
cache **rebuilds** when the warped query leaves the box, so that expansion is a perf heuristic);
and the per-room-op debt "must be paid first" was wrong (it is dormant). **Include the premises you
are confident enough about not to look up — especially a default, when a fixture exists whose whole
job is overriding defaults.**
- **A sampler must cover at least one period of what it samples.** The tunnel test drew tile XY from
**±32 voxels** with `RoomSpacing 80` and a guaranteed origin room at (0,0) — it measured the spine
hub and called it the world. The shaft test had the identical bug (±48 against `ShaftSpacing 55`).
Both now print their own extent **in units of the pattern's period**.
- **A test fixture tuned for coverage can be antagonistic to the thing you are measuring.**
`EnableTunnelFeatures` densifies (`RoomSpacing` 80→42, `RoomDensity` 0.35→0.85) so the equivalence
check isn't comparing solid rock to solid rock — and at that density the room cull radius *equals*
the lattice spacing, so **no box can ever be proved**. `0 proved` there is the correct answer. The
box verdict is therefore measured on **both** densities, and the dense run must stay at 0.
- **Diagnostics report THIS run; history goes in the log.** The test output had accumulated hardcoded
numbers from previous runs beside live ones ("32 of 34 tiles" printed while the live figure was 21
of 28). Unreadable, and self-inflicted.
- **Read the code, not the comment.** The cliff modifier's comment promises a sampled Z±1 gradient;
the code samples nothing and uses a Z-stretched Perlin it *calls* `VertGrad`. Ported as written —
and written down, so nobody "fixes" it from the comment.
- **A perf change can be a correctness change.** The column-memo optimisation silently deleted the
overhang; the tests caught it the same day. Invisible to inspection, and it produced plausible
terrain.
- **Coverage is a number, not a boolean.** Four related traps, each producing a green run that proved
almost nothing:
- *A test that prints nothing on success is indistinguishable from one that never ran.*
- *A guard that only trips at zero notices absence, it does not measure coverage.* Use fractions.
- *A success message that **asserts** coverage instead of reporting it reads as evidence while
measuring nothing.*
- *A check can be vacuous as well as a counter.* "Nothing leaked" is worthless unless something
happened.
- **Enabling a feature is not evidence it fired — ask the structure, not the output.** Setting
`PitDensity` did nothing (wrong struct). **Prefer the check that can fail for exactly one reason**
— and when a zero has several possible causes, give each one its own number.
- **An oracle that shares the defect under test proves nothing.** The stale-cache check compares each
stack against *itself evaluated alone*. (Since §C2 was fixed, the test call sites now pass a real
params fingerprint, so the original no longer shares the defect either.)
- **One definition, not two kept in sync.** `VF_BuildOpStackForChunk` exists because a tile skipped on
the verdict of a stack that is not the one producing its density is a hole. The same reasoning is
why `GetLastRoomBoxDiagnostic` **reads back** what the operator computed instead of letting the
test re-derive the criterion, and why the two-density tile scan is one lambda called twice.
- **Don't assert a number you want to improve.** Check 4 asserted `0 proved` — honest when written,
and it would have forbidden the entire T1.d gain. What it asserts now is that **no proved tile is
wrong** (brute force, every voxel); the proved count is *reported*.
+548
View File
@@ -0,0 +1,548 @@
# VoxelForge — Density Operator Stack: the plan
> **What this is:** the agreed direction for turning VoxelForge from an *archetype dispatcher* into a
> *composable density pipeline*, so new world ideas become authoring instead of C++. Written
> 2026-07-26 as a handoff for a future context — read this instead of re-deriving it.
>
> **Status (2026-07-28):** **Phases 0.5, 1 and 2 CODE-COMPLETE — 8 of 8 archetypes ported**, each
> bit-identical to its original in an equivalence test, all wired behind `bUseOperatorStack`:
> **Maze · FlatPlain · CrystalChamber · SurfaceWorld (biomes included) · VerticalShafts ·
> FloatingIslands · TunnelNetwork · Underwater.** The archetype `switch` now has a complete
> operator-stack twin, opt-in per strate.
>
> ✅ **BUILT AND GREEN, 2026-07-28 — 14 tests.** TunnelNetwork A+B bit-identical over 6000 samples
> with all twelve group-coverage probes non-zero, all four noise branches covered, 0 gate leaks, and
> C1 proved by 10 Terrace-op rooms containing 1119 samples. One open warning: the `Underwater` check
> landed 0 samples in open cave, so its bit-identity proves little — diagnosed, not guessed, in
> `OPSTACK-PROGRESS.md`'s last entry.
>
> **Not done, and it is the next real prize:** `ClassifyTile` still uses hand-written guards and does
> not consume `ClassifyBox`. That is where measured tile-skipping becomes frames.
>
> Two things came out of Phase 2 that were not in the original design: **height space**
> (`VoxelHeightOp.h`, a second operator family — some things are not another channel but another
> *space*) and **`IVoxelBiomeField`** (ops depend on a capability, never on the generator, which is
> what lets them become assets in Phase 3). Both are described in `OPSTACK-DECOMPOSITION §5`.
>
> **Known open:** generation is measurably slower on the op path (one fix landed — the column memo
> was discarding itself every chunk; virtual dispatch and the hashed lookup remain). Deferred by
> Jahni until the transition is complete. Live state and the next action live in
> [OPSTACK-PROGRESS.md](OPSTACK-PROGRESS.md) — read its last entry first. The per-archetype
> breakdown is in [OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md).
>
> **Read first:** `CODEMAP.md` (navigation) · `ARCHITECTURE.md §8.10` (the perf invariants this must not
> break) · `AUDIT-2026-07.md §6` (the 3D hazards this is designed to kill permanently).
>
> **Jahni's goal, verbatim (2026-07-26):** *"a world generator, of any kind, of any possibility, almost
> — any combination of ideas you could guess, could be happening."*
---
## 0. The one-paragraph version
`UVoxelGenerator::GetDensityAt` is a `switch` over 8 hardcoded `ECaveGeneratorType` values, each owning
a bespoke density function and param struct. That means (a) a new world idea costs ~6 edit sites, and
(b) **ideas cannot combine** — one archetype owns the whole voxel. The fix is to make density a **stack
of small operators**, each of which can `PrepareChunk` / `Eval` / declare **what it can do to a box**.
That last part is the keystone: it makes `ClassifyTile` generic and correct *forever*, instead of a
hand-written guard per feature (the thing that already caused one revert and is the top hazard for the
current 3D work). Staged so the game never stops working, and so the first useful step is small.
**Non-goal, stated deliberately:** this must *serve* the descent-through-strates structure, not dissolve
it. "The world is a vertical stack you dig down through, each layer its own place" is the idea of this
project — the seals, the (0,0) spine and the passages only mean something because of it. The op stack
should make each strate more surprising, not turn the world into undifferentiated composable soup.
---
## 1. Why this specific shape fits this specific codebase
Not a generic "use composition" argument — three concrete reasons:
**(a) It formalises what the code already does by hand.** Every archetype already: hoists chunk-constant
work into a `thread_local` cache (`PrepareChunk`), evaluates cheaply per voxel (`Eval`), and — in
`ClassifyTile` — has a hand-written statement of what it can do to a tile (`Bounds`). The operator model
isn't a new discipline; it's the existing discipline, named.
**(b) The 8 archetypes are ~6 functions wearing costumes.** `FlatPlain` and `CrystalChamber` are literally
the same function with different defaults; `Underwater` is `TunnelNetwork` + a water flag. Decomposed,
they're roughly **15 orthogonal primitives** (floor surface, ceiling surface, hash cylinders, room SDF
graph, tunnel capsules, worm noise, lattice corridors, heightfield stack, island blobs, boundary seal,
spine carve, passage carve, disturbances, surface ops, diff layer). 15 primitives that combine covers
vastly more than 8 that don't.
**(c) It kills the recurring hazard permanently.** T1.d guards are currently written per feature
(`AnyPassageNearBox`, the spine circle test, chasm/bridge flags, `HasAnyModInChunkRange`, phase-2's
`OverhangMargin`). Every new 3D feature needs one, forgetting one is a hole, and one was already
forgotten badly enough to revert T1.d v1 on 2026-06-26. Under this model a new op **cannot ship without
answering the question**, and a test catches it if the answer is wrong.
---
## 2. The keystone insight — start with DIRECTION, not intervals
The full version of `Bounds` returns a numeric interval. **Don't start there.** Almost every existing
operator is *one-directional*: it only ever carves, or only ever fills.
```cpp
enum class EVoxelOpEffect : uint8
{
CarveOnly, // can only move density toward AIR → kills the AllSolid hypothesis
FillOnly, // can only move density toward SOLID → kills the AllAir hypothesis
Both, // unconstrained
Identity // provably no effect on this box (the early-out that makes it fast)
};
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const;
```
**This alone reproduces every hand-written guard in `ClassifyTile` today, generically.** Look at the
current code: "passages ⇒ `bCanSolid = false`" *is* `CarveOnly`. "bridges/ridges ⇒ `bCanAir = false`"
*is* `FillOnly`. "no passage near this box ⇒ skip" *is* `Identity`.
So **Phase 1 ships with no numeric bounds at all** and already gets the whole safety property. Numeric
intervals are a later tightening for gen-cost, not a correctness prerequisite. This is what turns a scary
refactor into a small first step — and it's the thing to remember if this plan ever feels too big.
### Every existing primitive already has an obvious answer
Filled in here so a future context doesn't have to re-derive it:
| Primitive | Effect | Identity test (cheap) | Numeric bound (later) |
|---|---|---|---|
| `ApplyBoundarySeal` | **FillOnly** (`FMath::Max`) | box misses both seal bands | `+[0, BaseDensity]` |
| `ApplyOriginSpine` | **CarveOnly** | circle-vs-box XY, + Z outside interior | `[0, Base*2 + Seal]` |
| `ApplyPassageCarving` | **CarveOnly** | `AnyPassageNearBox`**already written** | `[0, …]` via `AirTarget` |
| Disturbance chasms | **CarveOnly** | `ChasmDensity == 0`, or lattice miss | `+[0, Solid]` toward air |
| Disturbance bridges/ridges | **FillOnly** | density == 0, or lattice miss | `[0, Solid]` |
| F20 overhang | **FillOnly**, banded | outside `(TerrainZ, TerrainZ+Height]`**already written** | union ⇒ `max` only |
| Room / tunnel / column SDF | Both | bounding sphere vs box — **already written** | **Lipschitz-1**: `SDF ∈ [SDF(c) r, SDF(c) + r]`, `r` = box half-diagonal |
| fBm / Ridged term `k·N(...)` | Both | `k == 0` | `±\|k\|``FBM` returns `[-1,1]` by construction (`Total/MaxValue`) |
| Heightfield (`TerrainZ Z`) | Both | — | `[minT Zmax, maxT Zmin]`; **or sample the lattice exactly** (see below) |
| Diff layer | Both | `HasAnyModInChunkRange`**already written** | `±max\|Strength\|` over mods in range |
Two things to notice:
- **Five of these already exist as code.** The work is mostly *moving* guards, not inventing them.
- **`Bounds` is allowed to be exact-by-sampling, not just analytic.** Today's `ClassifyTile` gets the
tightest possible SurfaceWorld verdict by evaluating `ComputeSurfaceColumn` **on the mesher's exact
lattice** — same functions, same floats, so the verdict is exact rather than estimated. That must
survive. The contract is "conservative", not "closed-form": an op may sample to answer.
### Prior art (30 minutes, worth it before Phase 1)
- [Keeter, *Massively Parallel Rendering of Complex Closed-Form Implicit Surfaces*](https://dl.acm.org/doi/10.1145/3386569.3392429) + [fidget](https://github.com/mkeeter/fidget) — interval arithmetic per expression node to prune empty regions. The industrial version of this idea.
- [Barbier et al., *Lipschitz Pruning: Hierarchical Simplification of Primitive-Based SDFs* (CGF 2025)](https://onlinelibrary.wiley.com/doi/10.1111/cgf.70057) — **the better fit for us**: bounds each primitive's range of influence and treats primitives as **black boxes**, where full interval arithmetic needs interval semantics defined for every node. Our ops are SDFs (Lipschitz by construction) and bounded-amplitude fBm. Black-box bounds; don't build a node-level IA engine.
---
## 2.5 ⚠️ The op TAXONOMY — and why this is NOT the old room-ops system
**Jahni's objection, 2026-07-27, and it is the correct one:** *"ops structure — which, let's all be honest,
was what I had before, 'room operations' which would modify stuff, so I sure hope your idea is not that.
There's a world of difference between a grotto strate and an open world strata."*
He is right, and a fresh context **must** internalise this or it will build something useless.
`UVoxelTerrainOpDefinition` today can only **perturb density near a surface that already exists**, inside
a fixed archetype (`Terrace`, `LayerLines`, `Ribbing`, `Cliff`, `Scallop`, `Overhang`, `Arch`, `Column`,
`Pit`, `Chimney`, `Dome`, `Pinch` — all applied near cave walls where `bNearCaveSurface`). It cannot turn a
grotto into an open world, because it never decides **what the field IS**. That decision lives in the
`switch` in `GetDensityAt`, which is exactly the thing we are removing.
So the op stack has **four ROLES**, and the old system only had role 3:
### Role 1 — FIELD SOURCES (the new thing; this is the "world of difference")
Produce a density field *from nothing*. **This is what makes a grotto a grotto and an open world an open
world.** Each of today's archetypes is fundamentally one of these:
| Source | Today's archetype |
|---|---|
| Heightfield ground + sky-cap ceiling | `SurfaceWorld` |
| Room-graph SDF (hash rooms + tunnel capsules) | `TunnelNetwork` |
| Floor/ceiling slab void | `FlatPlain`, `CrystalChamber` |
| 3D lattice corridors | `Maze` |
| Full-height shafts + connectors | `VerticalShafts` |
| Suspended island blobs in open void | `FloatingIslands` |
A source is a first-class op with the same three-method contract. **A "strate archetype" therefore stops
being an enum and becomes `source + combiners + modifiers`.** Two strates differ in their SOURCE first,
their modifiers second.
### Role 2 — COMBINERS (how sources merge; this is what makes ideas compose)
`Replace` · `Union`(min) · `Subtract`(max) · `SmoothUnion`/`SmoothSubtract` (reuse `VoxelSDF::SmoothMin/Max`)
· `Mask` (scale the next op by a field: biome weight, slope gate, relief, depth).
This is the role that buys the ambition. *Floating islands **inside** a grotto. A maze **beneath** an open
world's ground. A room-graph carved **into** a mountain.* None of those are expressible today at any price.
### Role 3 — DETAIL MODIFIERS (the old system, demoted to one role of four)
Roughness, terrace, layer lines, ribbing, scallop, cliff, overhang, domes, pinch. **All of today's
`UVoxelTerrainOpDefinition` types land here, essentially unchanged.** They keep working; they stop being
the whole story.
### Role 4 — STRUCTURAL POST (fixed order, non-negotiable, runs last)
`ApplyOriginSpine``ApplyBoundarySeal``ApplyPassageCarving` → diff layer.
These are **world invariants**, not creative choices: descent must stay possible, seals must hold, passages
must punch through anything, player edits win. They are ops for uniformity but the stack compiler must
always append them, in this order, regardless of authoring. **An author must not be able to omit them.**
### Plus: SCOPING
Any op may be gated by a region predicate — Z band, XY region, biome index, slope range, depth. Scoping is
what lets one strate hold several sources without them fighting, and it's the mechanism that unifies
"strate" and "biome" into one concept in Phase 3.
> **The test for whether this refactor was worth doing:** can you author *"an open-world surface strate
> whose mountains contain a room-graph cave system, with floating islands in the upper void"* **without
> writing C++?** If no, it collapsed back into the old system and something went wrong.
---
## 2.6 The acceptance bar — "very close", NOT byte-identical
**Jahni, 2026-07-27:** *"I want not a 1/1 replica of the current strates with the current system, but a
possible very close result to what I have right now, else it won't really matter much."*
Two different properties get confused here. Keep them apart:
| Property | Required? | Meaning |
|---|---|---|
| **`ValidateDeterminism` = 0** | **YES, ALWAYS** | the same world point sampled twice, from different cache windows/threads, returns the identical float. This is window invariance (§8.4) — self-consistency. Non-negotiable, it's what prevents seams and MP divergence. |
| **Byte-identical to the OLD system's output** | **NO** | matching the pre-refactor world float-for-float. |
**This is a deliberate relaxation of what an earlier draft of this plan demanded, and it matters
strategically:** if bit-identity were required, the cheap path would be to wrap each old density function
as one monolithic op — 8 opaque ops that don't compose, i.e. **the switch with extra steps and zero
gain.** Releasing that constraint is what permits *real* decomposition into the primitives in §2.5.
> **✅ CONFIRMED THE HARD WAY, 2026-07-27.** The Maze port reproduces `GetMazeDensity`'s **SDF bit for
> bit**, and its final density to within 1-2 ULP on ~2% of samples, with **zero isosurface
> crossings** — geometrically identical, not one triangle moved. The exact origin of that last
> rounding was chased through five measured-and-refuted hypotheses and then **parked by decision**;
> the full evidence is in `AUDIT-2026-07.md §C10`. **Read C10 before ever reopening it.**
>
> **The operational bar for every remaining archetype port, encoded in
> `VoxelForge.OpStack.MazeEquivalence`:** hard-fail on any isosurface crossing (that moves geometry);
> tolerate ULP-scale deltas (the accepted floor); warn on anything larger (that is real port drift).
>
> **And the rule that came out of it:** 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 seam. Not a
> client-desync risk (the field is proven bit-pure within a binary); the cross-platform concern is
> `§C9`.
**The bar instead:** for each ported archetype, an authored op stack must reproduce the *character* of the
old one — same scale, same navigability, same feel, recognisably the same kind of place. Judged by Jahni
on a screenshot at a fixed seed, not by a diff. Expect and accept a one-time re-tune, exactly as the T2.a
SIMD noise switch required.
---
### 2.6.1 ⚠️ RELAXED FURTHER, 2026-07-27 — resemblance to the old world is NOT a requirement at all
**Jahni, verbatim:** *"I do not need your work to be identical or near identical to what I had
before, only having it 99.99% at worst reproducible if two people share the same seed, since
everyone rebuilds it on multiplayer."*
**This replaces the "recognisably the same place" bar above.** The requirement is not fidelity to the
past — it is **agreement between peers in the present**. Restated as the only two properties that
now matter:
| Property | Required? | Enforced by |
|---|---|---|
| **Same seed ⇒ same world, on every peer** | **YES — this is the whole bar** | `DensityPurity` within a binary; **`§C9`** across binaries/platforms |
| Resemblance to the pre-refactor world | **NO** | nothing; freely re-tunable |
| Bit-identity with the archetype `switch` | **NO** | nothing; never compare them |
**What this changes, concretely:**
1. **`§C10` is closed, not parked.** It measures old-path vs new-path agreement, and the two paths
will never both exist in a shipped world. The residue cannot affect anything Jahni requires.
2. **The equivalence tests keep their value, but for a different reason.** They are no longer
*fidelity* checks; they are **port-correctness** checks — a transcription slip is still a real
bug, and comparing against the old function is the cheapest way to catch one. Read them that way.
The hard-fail (isosurface crossing) stays; the ULP grading is now diagnostic only.
3. **`§C9` is promoted from a footnote to THE risk.** "Two people share a seed" is exactly the
guarantee `/fp:fast` weakens across toolchains, and a Linux dedicated server generating collision
geometry against Windows clients is the concrete case.
4. **Changes that re-roll the world's noise are no longer expensive.** `§C1` in particular was
deferred *only* because it forces a re-tune. That objection is gone.
---
## 3. The contract
```cpp
// Chunk-constant inputs. Mirrors what the thread_local CP_* block resolves today.
struct FVoxelOpContext
{
FIntVector ChunkCoord;
int32 Step; // LOD step — ops may cheapen themselves (see §7)
uint32 Seed;
uint32 LayoutVersion; // ⚠️ see AUDIT C2 — every cache key MUST include this
float StrateTopWorldZ, StrateBottomWorldZ;
const FBiomeContext* Biome; // null = strate has no biome field
};
class IVoxelDensityOp
{
public:
// Hoist all chunk-constant work here (room lists, biome grids, column caches, lattice bakes).
// Called once per chunk per worker. This is where today's thread_local caches move to.
virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0;
// Per-voxel. InDensity = what the stack produced so far, MC convention (negative = solid).
virtual float Eval(float X, float Y, float Z, float InDensity) const = 0;
// CONSERVATIVE. Phase 1: direction only. Phase 3: add a numeric interval overload.
// Returning Both is always SAFE (costs CPU); returning the wrong one is a HOLE.
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
// Declares whether Eval depends on Z. XY-pure ops get the T1.a column-cache treatment
// generically instead of SurfaceWorld having a bespoke one.
virtual bool IsXYPure() const { return false; }
};
```
**Composition semantics** — keep the vocabulary small, and reuse what exists (`VoxelSDF::SmoothMin` /
`SmoothMax`):
| Mode | Meaning |
|---|---|
| `Replace` | ignore `InDensity` (stack roots: heightfield, base density) |
| `Union` (min) | add solid — bridges, islands, columns |
| `Subtract` (max) | carve air — rooms, tunnels, passages, spine |
| `SmoothUnion/Subtract` | the same with `SmoothMin/Max(k)` — organic junctions |
| `Add` | scalar accumulate — noise/roughness terms |
| `Mask` | scale the *next* op by a field (biome weight, slope gate, relief) |
`Mask` is what buys most of the expressiveness: "this op, but only in high-relief regions / only on
steep slopes / only in this biome" becomes composition rather than a bespoke gate inside each op.
---
## 4. Order of work
### Phase 0 — THIS WEEK. Ship the 3D caves. Refactor nothing.
The current feature (caves inside mountains, volumetric generation) ships as-is with a **hand-written
`ClassifyTile` guard** — see `AUDIT-2026-07.md §6.1` for why it's mandatory (without it the caves are
never meshed: no geometry, no collision, invisible until you fall through them).
**One constraint only:** write the guard as a standalone function in the shape of the future contract —
```cpp
// Not a member of anything yet. Just the right shape.
EVoxelOpEffect CaveSystemEffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx);
```
Same work, zero architectural commitment, and it establishes the pattern.
**Also decide here (see `AUDIT §6.2`): placed, not fielded.** Hash-located cave systems with bounds
(like `FCachedRoom`/`FCachedTunnel`) let the guard prove most of the mountain still solid and keep
T1.d's 44% worker-CPU win. A global 3D noise field makes every deep tile `Mixed` and hands that back.
> ✅ **Gate:** caves render and collide when approached through solid rock, and worker CPU hasn't
> visibly regressed.
---
### Phase 0.5 — The safety net. ~1 day. Do this before Phase 1, not after.
> **✅ WRITTEN 2026-07-27 — ⏳ NOT YET COMPILED OR RUN.** Four tests in `Private/Tests/` (the three
> below, plus a live-edit regression test for `AUDIT C2`). The gate below is NOT met until Jahni
> builds and runs them. See `OPSTACK-PROGRESS.md`.
Three automation tests (`Source/VoxelForge/Private/Tests/`, `IMPLEMENT_SIMPLE_AUTOMATION_TEST`).
There are currently **zero tests**, and nothing machine-checks the dozens of "bit-identical" claims in
the docs.
1. **Density purity** — sample 10k points, shuffle query order, re-sample, assert bit-equality. Catches
every cache-key bug including `AUDIT C2`. Run it across **multiple worker threads**, because
`ValidateDeterminism` runs on the game thread and would miss worker-cache divergence.
2. **`ClassifyTile` soundness** — for random tiles, if the verdict is `AllSolid`/`AllAir`, brute-force
the lattice and assert every sample agrees. **This is the highest-consequence function in the plugin
and it is currently validated only by reasoning.**
3. **`DiffLayer` under contention** — N readers + a writer; assert no crash, monotonic version.
> ✅ **Gate:** all three green on the current code. If #1 or #2 fails, you've found a live bug — fix it
> before building on top.
---
### Phase 1 — The pivot. Port ONE archetype. Timebox it.
**Pick `Maze`.** ~100 lines, no cross-chunk connectivity decision, trivial bound (corridor SDF is
Lipschitz-1 off a lattice), and it's the least-used archetype so a mistake is cheap.
1. ✅ **DONE 2026-07-27 (uncompiled):** `IVoxelDensityOp` + `EVoxelOpEffect` + `FVoxelOpContext` +
the four role tags + the box-verdict fold, in `Public/VoxelDensityOp.h`. **One addition beyond
this spec:** `ClassifyBox` is not source-only — forcing ops (the boundary seal inside its band)
overwrite the input, which pure direction cannot express. Rationale in the header.
**One open question the decomposition raised:** `Eval` probably needs an SDF channel as well as
a density channel — see `OPSTACK-DECOMPOSITION.md §0.1`. Decide before porting Maze.
2. **DECOMPOSE, don't wrap** (§2.5, §2.6). Maze becomes a stack, not one op:
`FLatticeCorridorSource` (role 1 — the capsule field off the 3D lattice) → `Subtract`
`FSurfaceRoughnessMod` (role 3 — the existing `SurfaceRoughness` perturbation) → then the four
structural-post ops appended automatically. **If it comes out as a single `FMazeOp`, the refactor
has failed its own test** — that's the switch with extra steps.
3. `GetDensityAt` gains **one** branch: strate has an op stack ⇒ run it; else fall through to today's
switch. **Both systems coexist**, indefinitely if needed.
4. `ClassifyTile` gains a generic path (`EffectOverBox` folded over the stack) used only by ported strates.
> ✅ **Gate:** `ValidateDeterminism` = 0 delta (§2.6 — always), Phase 0.5 tests green, and the Maze world
> is **recognisably the same place** at a fixed seed — same corridor scale, same connectivity, same feel.
> Judged on a screenshot, not a diff. A one-time re-tune of the params is expected and fine.
>
> ✅ **The real proof, and the one that answers Jahni's objection:** once `FLatticeCorridorSource` exists,
> **drop it into a `SurfaceWorld` strate underneath the terrain** and confirm you get a maze inside a
> mountain with no C++ written. If that works, the architecture is doing the thing it was built for.
>
> 🛑 **Stop-and-reconsider trigger:** if Phase 1 exceeds ~2 days, or the source/modifier split doesn't fall
> out naturally from the existing code, the abstraction is wrong for this domain. **Say so plainly, revert,
> and report** — do NOT push through and port a second archetype to prove a point.
---
### Phase 2 — Port opportunistically. No big bang.
Port each archetype **the next time a feature makes you open it anyway**. The switch shrinks on its own.
Suggested order when there's a free choice — cheapest and least risky first:
`Maze` (P1) → ✅ `FlatPlain`/`CrystalChamber` (one op, two default sets — the first real win: two
archetypes collapse into one; **done**, `BuildSlabStack`, 8 archetypes → 7) → ✅ `SurfaceWorld`
(**done**, incl. biomes — needed a whole second op family, `VoxelHeightOp.h`; biggest payoff, biggest
care: the T1.a column cache and the exact-lattice `ClassifyTile` bound both survived) →
`VerticalShafts` (**done**, 3 ops reused from Maze unchanged) →
`FloatingIslands` (**done**, `BuildFloatingIslandStack` — the stack that runs **backwards**: void
source + fill instead of rock source + carve, the *same* classes with the opposite sign; only the
blob source is new) → ✅ `TunnelNetwork` + `Underwater` (**done**, one builder for both —
`BuildTunnelNetworkStack`, 19 ops).
**8 of 8 ported.** The last two were really one: `Underwater` *is* TunnelNetwork plus
`WaterLevelRelative` (§8, re-verified before relying on it), so the switch lost its last two cases in
a single port.
TunnelNetwork was ~1080 lines and was taken in **three stages, each verifiable on its own** rather
than as ~600 unverified lines on top of ~200 (the `AUDIT §P3` pattern):
* **A** — SDF spine: vertical scale, base rock, cave warp, room graph (+ pits + chimneys), carve,
worms, structural post. Verifiable *while incomplete* because every detail modifier is
amplitude-gated and defaults to zero, so zeroing them sends the ORIGINAL down exactly stage A's path.
* **B** — the twelve detail modifiers of `STEP 4b4h`, one group per commit, each with a coverage
probe that proves the group actually moved something (`B1` roughness, `B2` terrace/lines/ribs,
`B3` overhang/cliff/scallop/arch, `B4` columns/domes/pinch/floor-bias, `B5` the gate itself).
* **C** — the per-room op override (`§2`'s option (a), and it needed no scoping predicate: one op
owns the state, eleven read it), `Underwater`, and the flag flip.
⚠️ **`FRoomGraphSource` CALLS `BuildChunkCache`/`EvaluateSDFCached`; it does not transcribe them.**
That is where §8.4's two-region window-invariance discipline lives, and a copy would fork it — with
the fork "validated" by a test that compares it to the original.
Along the way, `FStrateGenerationParams`' 74 fields decompose into per-op structs, which retires the
`VF_STRATE_PARAM_FIELDS` X-macro drift problem for free.
---
### Phase 3 — The payoff. Ops become data.
1. `UVoxelDensityOpDefinition : UPrimaryDataAsset` — one asset per op, mirroring today's
`UVoxelTerrainOpDefinition` (which is *already* the right authoring shape: an asset + weight +
probability in an ordered list).
2. **A strate becomes "a Z range + an ordered op list"** instead of "an enum + a param bag".
3. **A biome becomes "an XY region predicate + an op list" — the same mechanism.** Today these are two
unrelated systems (`ECaveGeneratorType` dispatch vs the biome field with its `bOverrideTerrain`
special case that only works for `SurfaceWorld`). Unifying them is where the expressiveness comes
from: any op, scoped by any region, in any combination.
4. Numeric interval bounds where profiling shows `Both` is costing real gen time.
5. **Only if needed:** compile the per-chunk stack into a flat opcode tape and interpret with a switch,
killing per-voxel virtual dispatch (~43k samples/tile × N ops). Standard technique. **Don't do this
pre-emptively** — measure first; the noise is likely still dominant.
---
## 5. Invariants this must not break
Non-negotiable. Each has a documented reason and, mostly, a scar.
- **Window invariance (§8.4).** Every op stays a pure function of world coords + seed. Any op with a
*connectivity decision over a neighbourhood* inherits `BuildChunkCache`'s two-region COLLECT/STORE
discipline. **Prefer the `Maze` pattern** (pure hash of `(lower node, axis)` — adjacent chunks cannot
disagree, no cache, no COLLECT region) unless connectivity is a gameplay requirement.
- **T1.a column cache Z-independence.** `FSurfaceColumn` data is keyed `(XY box, StrateKey, Seed)` with
**no ChunkZ** and shared down the whole vertical stack. XY-pure data goes in the column cache;
Z-dependent evaluation happens per voxel. `IsXYPure()` exists to make this explicit instead of implicit.
- **Cache keys include `LayoutVersion`** (`AUDIT C2` — today's `CP_Chunk`/`OC_Chunk`/`BM_Chunk` don't,
and serve stale params after a live edit). `FVoxelOpContext` carries it so a new op can't forget.
- **`ProcessQueue` stays `EQueueMode::Mpsc`**; ops are read-only on workers; `Epoch` carries through
every async path.
- **The two-pass MC loop, margin ring, and `thread_local` grid reuse** (§8.10) are untouched by all of
this — the op stack lives *below* `GetDensityAt`, the mesher never knows.
---
## 6. Risks, and how each one is detected
| Risk | Detection |
|---|---|
| A wrong `EffectOverBox`**a hole** | Phase 0.5 test #2 (brute-force vs verdict) — the load-bearing one |
| A cache key missing an input ⇒ **seams** | Phase 0.5 test #1 (shuffled order, multi-threaded) |
| Per-voxel dispatch cost | Insights `VoxelForge_GenerateMesh` before/after each port; tape compile if it bites |
| Abstraction is wrong for this domain | The Phase 1 stop-trigger — one archetype, timeboxed, revert cheaply |
| Scope creep into a node-graph editor | See §7 |
---
## 7. Explicitly NOT doing
- **No node-graph editor.** An ordered `TArray` of op assets is 90% of the value. A graph UI is a
separate project, years later, and chasing it is how generative systems die.
- **No GPU density.** Same reasons as `fable-idea` Part I: readback latency, CPU collision, and
cross-GPU float determinism is fatal for "replicate the seed, regenerate identically on every peer"
(`ARCHITECTURE §9.1`).
- **No node-level interval arithmetic engine.** Black-box Lipschitz/amplitude bounds per op (§2).
- **No big-bang port.** If more than one archetype is mid-port at any time, stop.
- **No dissolving the strate structure.** See §0.
---
## 8. Independent fixes — do these regardless, they get worse with time
From `AUDIT-2026-07.md §5`. None depend on this plan; all become harder inside it.
1. **Bound `SeedF`** (`AUDIT C1`) — `const float SeedF = (float)(VoxelHash::Mix((uint32)Seed) & 0x3FFF);`
at all 6 definition sites. Large seeds currently collapse noise terms to constants. One world re-tune.
**Do it before tuning 3D caves against a seed you might later randomise.**
2. ✅ **DONE 2026-07-27 (uncompiled)**`GetLayoutVersion()` added to `CP_Chunk` / `OC_Chunk` /
`BM_Chunk`, **plus two the audit missed**: `TC_BiomeCache` in `ClassifyTile`, and the
`GSurfColCache` box key (whose `StrateKey` is `round(StrateBottomWorldZ)`, so a live edit that
changes terrain params without moving the strate served stale columns — the most visible form of
the bug). `FChunkBiomeCache::Invalidate()` added, since a validity BOX says nothing about the
`FBiomeContext` its cells were classified against. (`AUDIT C2`.)
3. **`GetPlayerPosition` no-player flag** (`AUDIT C4`) — `(0,0,0)` is the designed spine landing and
currently stalls all streaming.
4. **Unbounded joins on shutdown** (`AUDIT C5`).
5. ✅ **DONE 2026-07-27**`!*.md` in `.gitignore`; all nine design docs are tracked (commit `3128852`).
6. ✅ **DONE** — the work lives on branch `experimental`; `main` is the known-good fallback.
---
## 9. Resume here
**Next action (2026-07-27): BUILD.** Phase 0.5's four tests and the Phase 1 skeleton header are
committed and unverified. Nothing else should be written until they compile and the tests are green
— writing unverified code on top of unverified code is the exact pattern `AUDIT §P3` documents.
After the build, in order: (1) fix whatever the tests report, (2) answer the five questions in
`OPSTACK-DECOMPOSITION.md §11` — especially the SDF channel, which changes the contract and is
cheapest to decide before any port, (3) port `Maze` per `OPSTACK-DECOMPOSITION.md §4`.
`AUDIT C1` (unbounded `SeedF`) is deliberately still open — see the progress log for why it was held
back rather than forgotten.
When picking this up cold: read §0, §2 (the direction-only insight — that's what makes step 1 small),
and §4. The rest is reference. If the plan feels too big, re-read §2: **Phase 1 needs no numeric bounds
at all**, and the first real win is two archetypes collapsing into one.
---
*Changelog — 2026-07-26: written by Opus 5 after a full-tree audit, at Jahni's request, as a durable
handoff so a future context doesn't re-derive it. Companion to `AUDIT-2026-07.md`.*
+4136
View File
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
# Kickoff prompt — density operator stack refactor
*Paste the block below into a fresh context. Everything above the line is for Jahni, not the new session.*
**Branch:** already created and checked out — `experimental` (from `69fa73e tmp`). `main` is untouched.
**Why this exists:** the refactor is too big for one context. This prompt makes any fresh session able to
start, or resume at a phase boundary, without re-deriving the design.
**Working rhythm it enforces:** one large batch of code → stop → *"ready to build"* + likely compile-error
spots → Jahni builds → he pastes errors/screenshots → fix → next batch. That is deliberate; see §Autonomy.
---
```
You're picking up an agreed refactor of the VoxelForge UE5 voxel plugin, on branch `experimental`
(already checked out — do not create another). I'm Jahni. This was designed with a previous context
and written down so you don't have to re-derive it.
GOAL
Replace the hardcoded archetype `switch` in `UVoxelGenerator::GetDensityAt` with a composable
density OPERATOR STACK, so new world ideas become data-authoring instead of C++. Long-term ambition:
"a world generator of any kind, of any possibility — any combination of ideas could be happening."
READ FIRST, IN THIS ORDER (do not skip, do not skim §2.5)
1. CLAUDE.md — project rules. Rule #1 is absolute.
2. OPSTACK-PLAN.md — THE PLAN. §0 the summary, §2 why step 1 is small, **§2.5 the op taxonomy**,
§2.6 the acceptance bar, §4 the phases, §5 the invariants, §7 non-goals.
3. AUDIT-2026-07.md — §6 (the 3D hazards) and §5 (priority list). §1 has real open bugs.
4. CODEMAP.md — navigation. Trust symbol names over line numbers; the lines are stale.
5. ARCHITECTURE.md §8.10 — the perf invariants. Read before touching any hot path.
THE THREE WAYS YOU WILL FAIL — internalise these before writing code
(1) BUILDING. Never run a build, compile, or the editor. Jahni builds everything himself; he has the
editor open and running it yourself just burns cost and tells you nothing. When a batch of code is
done: STOP, say "ready to build", and list the likely compile-error spots. Then WAIT. Same for
in-editor verification — ask for a screenshot, don't try to produce one.
(2) WRAPPING INSTEAD OF DECOMPOSING. Read OPSTACK-PLAN §2.5. Jahni already had a "room operations"
system — ops that perturb density near an existing surface. His words: "there's a world of
difference between a grotto strate and an open world strata." If you turn each old density
function into one monolithic op, you have rebuilt the switch with extra steps and wasted the
effort. Archetypes must DECOMPOSE into: FIELD SOURCES (role 1 — what makes a grotto a grotto),
COMBINERS (role 2 — how sources merge; this is what makes ideas compose), DETAIL MODIFIERS
(role 3 — his old ops, now one role of four), STRUCTURAL POST (role 4 — spine/seal/passage/diff,
always appended in that order, never author-omittable).
The test: can you author "open-world surface strate whose mountains contain a room-graph cave
system, with floating islands in the upper void" with NO C++? If no, you built the wrong thing.
(3) BREAKING AN INVARIANT. OPSTACK-PLAN §5. In particular: window invariance (§8.4 — every op is a
pure function of world coords + seed); the T1.a column cache is keyed (XY box, StrateKey, Seed)
with NO ChunkZ and is shared down the whole vertical stack, so XY-pure data only; every cache key
must include LayoutVersion (see AUDIT C2 — three existing caches get this wrong today);
ProcessQueue stays EQueueMode::Mpsc; Epoch carries through every async path.
ACCEPTANCE BAR — read OPSTACK-PLAN §2.6 carefully, two properties get confused
- `ValidateDeterminism` = 0 delta: REQUIRED ALWAYS. Same point, different cache window/thread,
identical float. This is self-consistency, not reproduction.
- Byte-identical to the OLD system's output: NOT required. Jahni: "not a 1/1 replica, but a possible
very close result to what I have right now, else it won't really matter much." Recognisably the same
kind of place at a fixed seed, judged on a screenshot. A one-time param re-tune is expected and fine.
This relaxation is WHY you are allowed to decompose properly instead of wrapping.
WHAT TO DO — first batch (Jahni has standing permission for multiple changes per build)
A. Phase 0.5 from the plan — the three automation tests. There are currently ZERO tests in this
plugin and nothing machine-checks the many "bit-identical" claims in the docs.
1. Density purity: sample ~10k points, shuffle query order, re-sample, assert bit-equality. Run it
across MULTIPLE worker threads (the existing `ValidateDeterminism` button is game-thread only and
would miss worker-cache divergence — that's how AUDIT C2 hid).
2. ClassifyTile soundness: for random tiles, if the verdict is AllSolid/AllAir, brute-force the
lattice and assert every sample agrees. This is the highest-consequence function in the plugin
and is currently validated only by reasoning; a false verdict is an invisible, collisionless hole.
3. DiffLayer under contention: N readers + a writer, assert no crash and monotonic version.
B. Phase 1 skeleton — `Public/VoxelDensityOp.h`: `IVoxelDensityOp` (PrepareChunk / Eval /
EffectOverBox / IsXYPure), `EVoxelOpEffect { CarveOnly, FillOnly, Both, Identity }`, the four role
tags, `FVoxelOpContext` (carrying LayoutVersion), and the combiner enum. Header + docs only, no
ports yet. Start with DIRECTION-only effects — no numeric intervals (OPSTACK-PLAN §2 explains why
this alone reproduces every hand-written ClassifyTile guard, and why it makes step 1 small).
C. Then STOP and hand off for a build. Do not start porting Maze in the same batch.
If (A) fails on the current code you have found a live bug — report it, fix it, don't build on top.
FOUR THINGS AGREED WITH JAHNI 2026-07-27 THAT THE PLAN UNDER-STATES — hold these as goals
(i) THE BIGGEST PERF PRIZE IS TILE-SKIPPING FOR CAVE STRATES, and it is currently zero. Read
`ClassifyTile`: any chunk that is neither a bedrock gap nor SurfaceWorld hits
`return EVoxelTileClass::Mixed; // archétype cave […] pas prouvable en v1`. So TunnelNetwork,
Maze, VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber and Underwater capture NONE of
T1.d's win (which was 84% of gens empty, 44% worker CPU). `fable-idea` wanted this from the start
("for cave strates, 'no room/tunnel/passage/spine/seal/diff-layer bounds intersect' — all bounding
data already exists") and it never happened because a bespoke prover per archetype was too much.
`EffectOverBox` IS the generic mechanism. A room-graph source returning `Identity` when no room or
tunnel bound reaches the box makes deep bedrock skippable for the first time. Treat this as an
explicit deliverable of each port, not a side effect.
(ii) COMPILE THE STACK TO A FLAT TAPE, and don't wait for Phase 3 if the profile says otherwise.
Naive per-voxel virtual dispatch is ~6 ops × ~43k samples/tile ≈ 257k indirect calls ≈ ~1 ms/tile
of pure overhead — material against current gen cost. In `PrepareChunk`, compile the stack into a
flat `(opcode, params)` array and run a switch over a small dense opcode set in the inner loop: no
vtables, params cache-hot, predictable branches. Offsetting win, worth stating: ops that are
disabled or out-of-scope are ABSENT from the tape, so the ~15 per-voxel `if (Params.X > 0)` gates
inside `GetDensityWithParams` today become zero cost instead of one always-false branch each.
Take an Insights capture after Phase 1; if dispatch shows up, do the tape then.
(iii) THE PERF STORY IS "≈ NEUTRAL PLUS ONE REAL WIN", NOT "FASTER". Do not oversell it in docs or
reports. The reason to do this refactor is composition. Honest expectation: neutral per-voxel after
the tape, meaningful gain on tiles-never-generated. If a measurement contradicts that, say so.
(iv) SHIP PRESET STACKS so authoring doesn't regress. A simple world today is "pick an enum, fill one
struct"; after, it's "assemble 3-5 assets in the right order", which is more clicks and a new class
of mistake (ordering is now semantic). Provide `DA_Stack_ClassicGrotto`, `DA_Stack_OpenWorld` etc.
as starting points a strate can diverge from. `ECaveGeneratorType` is expected to disappear
eventually — but only after every archetype is ported; it stays as the fallback path until then.
HOW TO REPORT AT EVERY STOP
- What changed, file by file.
- "Ready to build" + the specific spots likely to error (signatures, UHT, includes, template/lambda
capture) so Jahni knows where to look.
- What he should LOOK AT in-editor afterwards, concretely, and what a pass vs fail looks like.
- What the next batch will be.
- Anything you became unsure about. Ask rather than assume — for UE API behaviour especially, ask him
for the docs instead of guessing (DivideAndRoundDown truncating rather than flooring already cost
build cycles once).
DISCIPLINE
- Update CODEMAP §3 rows for any new/renamed symbol; ARCHITECTURE §8 for design changes; tick phases
in OPSTACK-PLAN.md as they land. Comments are French + English — match the surrounding file.
- Never edit Binaries/, Intermediate/, *.generated.h.
- Density sign: NEGATIVE = solid, POSITIVE = air at the mesher. #1 source of confusion.
- Commit per feature with a real message (this branch exists so you can commit freely; `main` is the
known-good fallback). Do not push.
- If the plan turns out to be wrong for this domain, SAY SO and stop. There is an explicit
stop-trigger on Phase 1: if it exceeds ~2 days or the source/modifier split doesn't fall out
naturally from the existing code, revert and report rather than pushing through.
UNATTENDED OPERATION — Jahni may start you and go to sleep. Read this before your first tool call.
YOU CANNOT CHECK YOUR REMAINING BUDGET. No tool reports usage or quota. So do not try, and do not
claim to. Instead assume the harder thing: **this session can end at any moment, without warning,
mid-edit, and nobody will be watching.** Everything below follows from that.
CRASH-SAFE DISCIPLINE (non-negotiable when unattended)
1. `git commit` after every coherent unit — a file, a test, a header. Small and often. You are on
branch `experimental`; `main` is the known-good fallback, so committing costs nothing and a
half-finished commit is infinitely better than an uncommitted half-edit. Pushing `experimental`
is fine and expected (it is tracked as `origin/experimental` since 2026-07-29); **never push
`main`.**
2. Maintain `OPSTACK-PROGRESS.md` at the plugin root. APPEND (never rewrite) a dated entry per
milestone: what you did, what you believe is true, what is UNVERIFIED (i.e. everything not yet
built), and the single next action. Write the entry BEFORE starting the work it describes, so an
abrupt death still leaves an accurate marker. This file is how the next context resumes.
3. Never leave a file mid-transformation across a stopping point. If you're partway through changing
a signature and its call sites, finish all call sites or revert the change. A tree that doesn't
compile *for a reason you documented* is fine; one that doesn't compile for an unknown reason is
the thing that wastes Jahni's morning.
4. Don't batch a risky change with a safe one in the same commit. If a build fails he needs to know
which half did it.
WHEN YOU REACH THE BUILD GATE — this will happen quickly, and it is not a failure
The first batch (A + B) is a few hours at most, and then you physically cannot verify anything. At
that point: STOP writing plugin code. Do NOT invent more C++ to fill the night — writing unverified
code on top of unverified code is the exact failure `AUDIT-2026-07.md §P3` documents, and doing it
unattended would be the worst version of it.
Instead work the BUILD-FREE QUEUE, in this order. All of it is genuinely useful and none of it can
break anything:
Q1. ★ THE DECOMPOSITION MAP — the highest-value unattended task by far. Create
`OPSTACK-DECOMPOSITION.md`: for EACH of the 8 archetypes, read its density function carefully
and write out its proposed op breakdown — which FIELD SOURCE, which COMBINERS, which DETAIL
MODIFIERS, and exactly which existing params migrate to which op asset (field by field, so
nothing is silently dropped). Note per op: `IsXYPure()`, its `EffectOverBox` strategy, and any
shared primitive two archetypes could reuse. This is hours of careful reading, has zero build
risk, and it de-risks and speeds up every later port. Do this before anything else in the queue.
Q2. Audit the `FStrateGenerationParams` 74 fields against Q1 and list any that no op claims — those
are either dead or a decomposition gap. Report, don't delete.
Q3. Tick the stale "PENDING BUILD" markers in `fable-idea.md` / `ARCHITECTURE.md` (confirmed
resolved 2026-07-26 — everything is built and working; see `AUDIT-2026-07.md §0`).
Q4. Fix `.gitignore` to `!*.md` — the design docs are currently untracked (`AUDIT P1`).
Q5. STOP. Write the final `OPSTACK-PROGRESS.md` entry and a clear "good morning" summary: what's
ready to build, exact likely error spots, what to look at in the editor, what a pass looks like.
Then idle. Do not start Phase 2. Do not port a second archetype. Do not refactor anything not
in the plan.
There is no prize for burning the whole night. A small, committed, well-documented, build-ready
increment plus a complete decomposition map is a genuinely good night's work.
```
---
## Autonomy — what a fresh context can and cannot do alone
**Asked:** *"possibly even, you tell me if it's possible, for you to wait for the token to replenish? to
continue it all automatically and on your own."* — and then: *"can you tell it to check for token remaining
before continuing actions? I plan to let it run as I sleep."*
**On checking the budget: no. There is no tool that reports remaining usage or quota**, so an instruction to
"check tokens first" would be unfollowable — a safety net that looks real and isn't. The prompt therefore
instructs the opposite and stronger discipline: **assume the session can die at any moment, unattended**,
and make every stopping point crash-safe (commit-per-unit, an append-only progress log written *before*
the work it describes, never a half-applied edit). That achieves the actual goal — not waking up to a
broken tree — without depending on information the session can't get.
**On resuming automatically across a limit reset: the mechanism exists, but don't use it for this, and
tokens are not the real limit.**
- A self-pacing loop is available (`/loop` with no interval, which schedules its own wakeups). Whether it
resumes cleanly across a usage-limit reset is not something to promise — treat it as unverified.
- **The actual blocker is the build gate, not the budget.** Every meaningful step of a C++ refactor ends
at a compile, and rule #1 is that Jahni compiles. A loop left running would therefore do exactly one
thing: **write more unverified code on top of unverified code** — which is the precise failure pattern
`AUDIT-2026-07.md §P3` was written about (three weeks of stacked "PENDING BUILD"). Automating it would
deepen the problem it diagnosed.
- **What genuinely scales instead: batch size.** A single context can produce a large, coherent,
self-contained batch before stopping. Standing permission for multiple changes per build already exists.
So the throughput lever is "fewer, bigger handoffs", not "unattended looping".
- **What IS worth looping:** work with no build gate — documentation passes, analysis, or the separate
web-based world-rating harness. Not this.
**So the honest shape of "handling it on its own":** a fresh context can own the *design decisions, the
code, the doc updates and the sequencing* end to end, across many sessions, resuming from
`OPSTACK-PLAN.md §9`. It cannot own *verification*. That stays with Jahni, and given that the failure
modes here are invisible holes and silent seams, that's the correct place for it.
### What an overnight run realistically produces
Setting expectations honestly, because the first batch is deliberately small:
| | |
|---|---|
| ~13 h | Phase 0.5 tests + Phase 1 skeleton header. **Then it hits the build gate and cannot verify anything.** |
| remaining night | The BUILD-FREE QUEUE — dominated by **Q1, the decomposition map**: all 8 archetypes read carefully and broken into source / combiners / modifiers, with every param field traced to its destination op. Genuinely hours of work, zero build risk, and it makes every later port faster. |
| morning | A committed build-ready increment, `OPSTACK-PROGRESS.md`, `OPSTACK-DECOMPOSITION.md`, and a "good morning" summary with exact likely error spots. |
**What it must NOT do overnight:** port archetypes, start Phase 2, or write more plugin C++ once the gate
is reached. The prompt says this explicitly, twice, because "fill the night with code" is the tempting
wrong answer and it reproduces the stacked-unverified-work pattern the audit was written about.
---
## Feasibility, honestly
| Phase | Effort | Risk |
|---|---|---|
| 0.5 — three tests | 1 session + 1-2 build rounds | Low. May surface a real bug (that's a win). |
| 1 — skeleton + Maze decomposed | 1-2 sessions + 2-3 build rounds | **Medium — this is the go/no-go.** |
| 2 — port remaining archetypes | 1 session each, opportunistic | Medium; `TunnelNetwork` last, it owns §8.4. |
| 3 — ops as assets, strate = op list | several sessions | Medium-high; big authoring-surface change. |
**The single genuine unknown is Phase 1**, and it's cheap to find out — one archetype, timeboxed, with an
explicit abort. If `Maze` doesn't decompose cleanly into a source + a modifier, the abstraction is wrong
for this domain and two days bought that knowledge.
**The largest hidden cost is not code, it's re-tuning.** Every ported archetype needs its params re-dialled
to look right again (§2.6 accepts this). That's Jahni's time in the editor, not a context's time writing
C++, and it is likely to dominate the schedule.
+145
View File
@@ -0,0 +1,145 @@
# VoxelForge — Review Findings (2026-06-23)
Local-LLM cross-file QUALITY review (Qwen3.6-27B @128K, codemap-guardrailed) + Claude
verification. Scope: performance, redundancy, dead code, over-complexity (not correctness bugs).
**Verdict: codebase is healthy — no perf regressions.** Items below are improvements, by appetite.
> **Reconciled against `fable-idea.md` + the perf-pass history (2026-06-23).** The big density/
> streaming perf wins are already SHIPPED and are *not* listed here (T1.a surface cache, T1.b
> grid normals, T1.c LOD0 collision, CullTiles spiral fix, region foliage, passage shortlist).
> Everything below is **net-new** (redundancy / dead code / complexity — a different axis), except
> the two notes flagged inline. Before acting on a PERF item, glance at `fable-idea.md` Part I.
Legend: ✅ verified against code · ◻️ checklist box.
> **2026-07-04 full-codebase pass (Fable 5)** — applied everything marked `[x]` below, plus NEW items
> found reviewing the un-built two-grid deco + density-volume work:
> • **Deco launch stall fix** — a pending cell already in flight from a previous build was dropped,
> permanently stalling its region build (blank region until strate change). Now deferred + retried.
> • **Capture gating** — level-0 tiles outside the shadow window no longer pay the mesher capture
> (quantize + 32 KB queue payload) nor pollute `CaptureCache` (`IsTileCaptureUseful`, both ends).
> • **Per-tick material pushes change-detected**`UpdateTerrainMaterialParams` (10 vectors + 3
> textures × every terrain MID) and the orb MPC writes now no-op on idle frames; static `FName`s.
> • **Landmark cave march bounded**`FindLandmarkColumn` now honours `ColDepth` (same bedrock
> early-stop as the deco march); it ran the full strate band synchronously on the game thread.
> • **`UploadDirtyTextures` now calls `EnsureTextures`** — GPU upload toggled ON at runtime works.
> Deliberately NOT done: the big behavior-preserving splits below (un-built tree; compile risk).
> **2026-07-04 perf pass 2 (per-voxel hot path, Fable 5)** — ✅ BUILT & WORKING (ticked 2026-07-27). All
> bit-identical (same hashes/math, hoisted per chunk/cell):
> • **DiffLayer snapshot API** (`HasAnyMods`/`GetModsVersion`/`GetChunkModsSnapshot`/static
> `EvaluateMods` + `ModsVersion` atomic) — `GetDensityAt` snapshots a chunk's mods once per
> (chunk, version) via a `thread_local` 64-slot cache: ~27 lock ops per tile task instead of
> ~86k once any carve exists.
> • **Room shape pre-bake**`FCachedRoom::ShapeType/ShapeA/ShapeB/ShapeR` baked in
> `BuildChunkCache`; **`EvaluateSDFCached` signature changed** (`RoomShapeVariety` removed,
> 8 call sites updated).
> • **Strate-index memo** in `GetDensityWithParams`, keyed (chunkZ, `GetLayoutVersion()`) —
> new inline getter on `UVoxelStrateManager`.
> • **Per-cell lattice bakes** (`thread_local`, keyed cell+seed+params): slab columns, maze open
> edges, vertical shafts + cross-connectors, floating-island constants, disturbance
> chasms/bridges/ridges.
> • **Worm N2 short-circuit** — skip the 2nd Perlin when N1 ≥ WormThreshold (lossless).
> **Regression fixed same day:** pass 1's skip-if-identical cache on the orb MPC writes broke the
> mini-sun lighting (MPC world instances reset behind the writer) — REVERTED, `LastOrbMPC`
> removed; never de-duplicate writes to externally resettable state. The MID-side change
> detection stays (MIDs own their values).
> **2026-07-04 batch 3 (Fable 5)** — Jahni green-lit multiple changes per build + visual deltas
> ("nothing is set in stone"). ✅ BUILT & WORKING together with pass 2 (ticked 2026-07-27):
> • **Terracing gradient Z-only** (6→2 SDF samples — see the ticked item above).
> • **Lerp X-macro** + **BakeRoomFeature dedupe** (both ticked above, bit-identical).
> • **T2.b LOD octave drop** — opt-in `UVoxelSettings::LODOctaveDrop` (default 0 = byte-identical);
> `VoxelGenLOD::OctaveBias` thread_local set per tile in `GenerateMesh`, per-voxel fractal sites
> wrapped in `VoxelGenLOD::Eff(N)`, XY-field noise deliberately excluded. ARCHITECTURE §8.10.
> • **T2.c tile component pool**`TileComponentPool` + `Acquire/ReleaseTileComponent`; unload
> parks (RemoveSectionGroup strips geometry+collision, hidden, stays registered), apply pops.
> Bonus: `ApplyMeshToTile` reuses the existing `URealtimeMesh` (`GetRealtimeMeshAs`) — the old
> unconditional `InitializeRealtimeMesh` allocated + orphaned one mesh UObject PER APPLY.
> • **T2.d worker clamp**`GetMaxConcurrentTasks()` caps the asset budget to logical cores 2
> (the BackgroundNormal priority half had already shipped). **Tier 2 is now COMPLETE**
> T2.a was already done (float SSE `VoxelNoise` core, §8.10).
> • **F2 determinism validator**`AVoxelWorld::ValidateDeterminism` CallInEditor button:
> boundary points sampled under two cache-window alignments + a repeat pass; any non-zero
> delta = §8.4 regression. The tool that VERIFIES all the "bit-identical" claims above.
## Performance
- [x] ~~**`VoxelGenerator.cpp` (terrain-op gradient)** — compute once per voxel and reuse.~~
**RE-VERIFIED 2026-07-04: misdiagnosed.** The 6 `EvaluateSDFCached` calls in the terracing block are
six DISTINCT sample positions (±1 on each axis) for one central-difference gradient — computed once
per voxel already, gated to terrace-enabled rooms near surfaces. Nothing is redundantly re-evaluated.
*Optional lossy halving:* **APPLIED 2026-07-04 (Jahni approved visual deltas).** SDFs are
≈unit-gradient, so `Horizontality = clamp(|SDF(Z+1)SDF(Z1)|/2)` — the 4 X/Y samples are gone
(6→2 SDF evals per terrace-voxel); terraces fade slightly differently on SmoothMin'd slopes.
- [x] **`VoxelContentManager.cpp` (BuildCellSpawns)** — slope-gate cosines now precomputed per entry
(`CosMaxSlope`/`CosMinSlope` arrays), bit-identical gating. *(2026-07-04)*
- [x] **`VoxelContentManager.cpp` (LaunchDecoTasks)** — head-index drain + one `RemoveAt(0, Head)`
compaction (was O(N) per pop). Bonus fix: a pending cell still in flight from a previous build is now
DEFERRED, not dropped (dropping stalled its region build forever → permanently blank region). *(2026-07-04)*
- [x] **`VoxelStrateManager.cpp`** — linear `BoundRadius` stored on `FVoxelPassage`; shortlist no longer
Sqrt's per passage. *(2026-07-04)*
## Redundancy (factor out)
- [x] **`VoxelCaveMorphology.cpp`** — Pit/Chimney/Column baking loops → shared `BakeRoomFeature`
hash-placement skeleton (gate/XY/radius chain) + per-type Emit lambda. Bit-identical (same salts,
same hash order). *(2026-07-04, batch 3 — Jahni green-lit batching without intermediate builds)*
- [x] **`VoxelStrateTypes.h`** — `FStrateGenerationParams::Lerp` now expands the
`VF_STRATE_PARAM_FIELDS` X-macro (all 74 fields verified present — no drift had happened yet).
New struct fields MUST be added to that list. Bit-identical. *(2026-07-04, batch 3)*
- [x] **`VoxelWorld.cpp` brush methods** — Carve/Fill sphere now funnel through `ApplyModification`
like Box/Capsule already did (one diff-layer + remesh dispatch). *(2026-07-04)*
- [x] **`VoxelContentManager.cpp`** — duplicated drain/reset loops → `DrainDecoResults()` +
`ResetGridBuildState(FDecoGrid&)`. *(2026-07-04)*
- [ ] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — two overlapping passage-shape enums, both in
active use (11 refs). Consider consolidating to one. *(judgment call, not dead)*
**JUDGED 2026-08-16 — LEAVE THEM. Recommendation: close this item rather than act on it.** They
read as duplicates from the index and are not: `EVoxelPassageType` (`VoxelStrateTypes.h` ~42) is
the **global inter-strate bore shape** the layout generator picks (`SlopedTunnel` / `VerticalShaft`
/ helix…); `EVoxelPassageStyle` (~1741) is **per-strate descent styling** on
`FStratePassageConfig` (`Straight` / `Worm` / `Spiral` / `Cascading`). Different owners, different
value sets, different lifetimes. And both are `UMETA`-tagged, i.e. **serialised into Jahni's
authored strate assets** — merging them silently rewrites saved content. That is a content-risk
change bought for cosmetic tidiness, which is the wrong trade at any time and especially before
the content lock.
## Dead code
- [x] **`UVoxelMarchingCubesMesher::GetDensity()`** — removed, along with `InterpolateEdge`,
`ComputeGradientNormal` and `GradientOffset` (all dead since T1.b). *(2026-07-04)*
- [x] **Vestigial `MidPoint`**`MidPoint`/`bHasMidPoint` fields + the unreachable debug-draw branch
removed. *(2026-07-04)*
- [x] **Codemap-known dead/legacy**`GetLODForChunk`, `LODToStep`, `IsChunkInRange`, `LOD0Distance`,
`LOD1Distance`, `ContentMaxLevel`, `VoxelChunk.h` (whole file — `FVoxelTileKey` is the identity now)
all removed 2026-07-04. `MaxLODLevel` / `DecorationActorRadiusChunks` were already replaced by the
two-grid deco redesign (`StreamTier` / `DecorationNearRadiusChunks`).
**CORRECTION: `GetStrateChunkZBounds` is NOT dead**`BuildDesiredTiles` uses it for the
strate-aware vertical clamp; struck from the dead list.
## Over-complexity (behavior-preserving splits, optional)
> ⛔ **REASSESSED 2026-08-16 — do not pick these up as filler work.** They were written before the
> operator-stack refactor existed, and two of them are now actively counter-productive rather than
> merely optional. Read the reason before ticking anything here.
- [ ] ~~`GetDensityWithParams` (~600-1000 L)~~ → **DON'T.** ⛔ Two independent reasons. (1) The
operator stack is *replacing* this function archetype by archetype — splitting it produces code
that gets deleted, and churns the eight equivalence tests that compare the stack against it **bit
for bit**. (2) It is the hottest path in the plugin and carries the `ARCHITECTURE §8.10`
invariants (`thread_local` box-valid caches, two-pass MC loop, SSE noise). The one time a
"behaviour-preserving" change was made here it silently deleted the overhang and only 1 sample in
20 000 crossed the isosurface — *a perf change can be a correctness change*. Revisit only once the
`switch` path is retired for good.
- [ ] ~~`BuildChunkCache` (~450 L)~~ → **DON'T, same reason.** `FRoomGraphSource` deliberately
**calls** `BuildChunkCache`/`EvaluateSDFCached` instead of transcribing them, precisely so there is
one definition. Restructuring it now forks the thing that was kept unforked on purpose.
- [ ] `GenerateMesh` (~250 L) → `PrecalcDensityGrid`/`MarchCells`/`GenerateSkirts`.
⚠️ Still genuinely optional, but the two-pass loop is an `§8.10` invariant — a split must not
merge the passes, and the Z-outermost pre-sample order is load-bearing for every column cache
downstream (see the column-memo work of 2026-08-16). Low value, non-zero risk.
- [ ] `GetGenerationParams` (~180 L) → extract `ApplyBoundaryTransition(...)`. **The safest of the
six** — pure params math, no caches, and `AUDIT §C2` already forced a close reading of both
Gradient arms. If any of these is ever worth doing, it is this one.
- [ ] `GeneratePassages` (~150 L) → `ComputePlacement`/`BuildControlChain`/`ComputeBounds`
- [ ] `BuildCellSpawns` (~150 L) → `FindSurfaceCrossings`/`PlaceDecorationsAtCrossings`
---
*Full method notes + raw per-pass output: `E:\LocalLLM\reviews\QUALITY_VoxelForge.md` (+ `QUALITY_pass1/2/3`).*
@@ -0,0 +1,595 @@
// VoxelForgeClassifyTileTest.cpp
// Phase 0.5 test #2 — LA SOLIDITÉ DE ClassifyTile / ClassifyTile soundness.
//
// ⚠️ LE TEST LE PLUS IMPORTANT DU PLUGIN / THE HIGHEST-CONSEQUENCE TEST IN THE PLUGIN.
//
// ClassifyTile (T1.d) répond "cette tuile est entièrement solide / entièrement air" AVANT tout
// échantillonnage, et sur un verdict non-Mixed le monde SAUTE GenerateMesh entièrement. Le contrat
// est asymétrique, et le commentaire de la fonction le dit déjà :
//
// un faux Mixed ne coûte que du CPU ;
// un faux AllSolid / AllAir est un TROU — pas de géométrie, PAS DE COLLISION, invisible
// jusqu'à ce qu'un joueur tombe au travers.
//
// ClassifyTile answers "this tile is entirely solid / entirely air" BEFORE any sampling, and on a
// non-Mixed verdict the world SKIPS GenerateMesh completely. The contract is asymmetric:
// a false Mixed only costs CPU; a false AllSolid/AllAir is a HOLE — no geometry, NO COLLISION,
// invisible until a player falls through it.
//
// Cette fonction a DÉJÀ produit cette panne : la v1 de T1.d a été revertée le 2026-06-26 pour une
// borne de plafond pas assez conservative. Jusqu'ici elle n'est validée que par raisonnement.
// Ce test la valide par la force brute : pour chaque tuile jugée non-Mixed, on échantillonne le
// treillis EXACT que le mesher aurait échantillonné (marge ±1 incluse) et on vérifie que chaque
// point est bien du côté annoncé.
//
// This function has ALREADY produced that failure: T1.d v1 was reverted on 2026-06-26 over a
// non-conservative ceiling bound. Until now it was validated by reasoning only. This test
// validates it by brute force: for every tile judged non-Mixed, sample the EXACT lattice the
// mesher would have sampled (±1 margin included) and assert every point is on the claimed side.
//
// CONVENTION (VoxelMarchingCubesMesher.cpp:309, IsoLevel == 0):
// D >= 0 ⇒ côté AIR / air side
// D < 0 ⇒ côté SOLIDE / solid side
// Le classifieur utilise exactement ces inégalités (cf. TestColumn), donc le test aussi.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
// Inclus ici DÉLIBÉRÉMENT : VoxelDensityOp.h n'est encore inclus par aucun .cpp, donc le
// compilateur ne le verrait jamais. Le fold qu'il définit prétend reproduire ClassifyTile — ce
// fichier est l'endroit naturel pour que cette prétention soit à la fois COMPILÉE et TESTÉE.
// Deliberately included here: VoxelDensityOp.h is not yet included by any .cpp, so the compiler
// would never see it. Its fold claims to reproduce ClassifyTile, so this is the natural place for
// that claim to be both compiled and tested.
#include "VoxelDensityOp.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeClassifyTileTest,
"VoxelForge.Determinism.ClassifyTileSoundness",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
/** Tuiles balayées à la recherche d'un verdict non-Mixed (ClassifyTile est bon marché). */
constexpr int32 NumTilesScanned = 600;
/** Tuiles réellement brute-forcées (chacune ~(Cells+3)³ appels à GetDensityAt — cher). */
constexpr int32 MaxTilesVerified = 24;
struct FTileSpec
{
FIntVector Origin = FIntVector::ZeroValue;
int32 Step = 1;
int32 Cells = 16;
};
}
bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
// Quelques carves : la garde diff-layer de ClassifyTile doit elle aussi être couverte, et
// c'est la garde la plus facile à casser en ajoutant une feature (elle est globale, pas
// par-archétype). / A few carves: ClassifyTile's diff-layer guard needs covering too, and it
// is the guard most easily broken by a new feature since it is global rather than per-archetype.
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Sphere;
Mod.Radius = 10.0f;
Mod.Strength = -12.0f;
for (int32 k = 0; k < 4; ++k)
{
Mod.Center = FVector((float)(k * CHUNK_SIZE * 2), 0.0f,
World.MidVoxelZ() + (float)(k * CHUNK_SIZE));
World.DiffLayer->ApplyModification(Mod);
}
}
// ── Balayage : trouver des tuiles où le classifieur ose un verdict. ──
// Les origines suivent la géométrie réelle du clipmap : une tuile couvre Step*Cells voxels et
// est alignée sur son propre pas. / Tile origins follow the real clipmap geometry: a tile
// covers Step*Cells voxels and is aligned to its own extent.
FRandomStream Rng(20260727);
TArray<FTileSpec> ToVerify;
int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0;
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
// La moitié des tuiles vise la strate SurfaceWorld : c'est le SEUL archétype dont ClassifyTile
// sait prouver quoi que ce soit aujourd'hui (avec les gaps de bedrock), donc un tirage uniforme
// sur tout le layout gaspillerait le budget en tuiles Mixed garanties.
// Half the tiles target the SurfaceWorld strate: it is the ONLY archetype ClassifyTile can prove
// anything about today (alongside bedrock gaps), so a uniform draw over the whole layout would
// spend the budget on guaranteed-Mixed tiles.
int32 SurfTopZ = 0, SurfBotZ = 0;
const bool bHaveSurface = World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTopZ, SurfBotZ);
for (int32 t = 0; t < NumTilesScanned; ++t)
{
FTileSpec Spec;
// Step 1/2/4 comme le clipmap ; Cells petit pour que la vérification brute reste tenable.
Spec.Step = 1 << Rng.RandRange(0, 2);
Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16;
const int32 Extent = Spec.Step * Spec.Cells;
const bool bAimSurface = bHaveSurface && (t % 2 == 0);
const int32 LoZ = bAimSurface ? SurfBotZ : BottomVoxelZ;
const int32 HiZ = bAimSurface ? SurfTopZ : TopVoxelZ;
// Division entière PLANCHER : en C++ la troncature va vers zéro, ce qui décalerait la
// borne basse (négative) d'un extent vers le haut. / Integer FLOOR division: C++ truncates
// toward zero, which would shift the negative low bound up by one extent.
auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; };
const int32 LoTile = FloorDiv(LoZ, Extent);
const int32 HiTile = FMath::Max(LoTile, FloorDiv(HiZ, Extent));
Spec.Origin = FIntVector(
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(LoTile, HiTile) * Extent);
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
switch (Verdict)
{
case EVoxelTileClass::Mixed: ++NumMixed; break;
case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
}
}
AddInfo(FString::Printf(
TEXT("ClassifyTile verdicts over %d scanned tiles: Mixed %d, AllSolid %d, AllAir %d ")
TEXT("(brute-forcing %d of them). NOTE: cave archetypes always return Mixed today — see ")
TEXT("VoxelGenerator.cpp \"archétype cave [...] pas prouvable en v1\". A low non-Mixed count ")
TEXT("is expected and is exactly the tile-skipping prize OPSTACK-PLAN wants EffectOverBox ")
TEXT("to unlock."),
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
if (ToVerify.Num() == 0)
{
AddError(TEXT("VACUOUS: not one scanned tile produced an AllSolid/AllAir verdict, so this ")
TEXT("test verified nothing. Either the fixture's layout has no SurfaceWorld/gap ")
TEXT("chunks in the sampled Z range, or T1.d has stopped emitting verdicts entirely ")
TEXT("(which would be a large silent perf regression). Widen the Z range before ")
TEXT("trusting a green run."));
return false;
}
// ── Vérification par force brute, sur le treillis EXACT du mesher. ──
// Les bornes reproduisent ClassifyTile / GenerateMesh : g ∈ [-1, Cells+1] par axe.
int32 NumHoles = 0;
for (const FTileSpec& Spec : ToVerify)
{
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
if (Verdict == EVoxelTileClass::Mixed) { continue; } // verdict instable ⇒ rien à prouver
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
const int32 GridDim = CPA + 1;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
bool bTileBad = false;
for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz)
for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy)
for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx)
{
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
const float Z = (float)(Spec.Origin.Z + gz * Spec.Step);
const float D = Gen->GetDensityAt(X, Y, Z);
// AllSolid ⇒ tout le treillis doit être D < 0
// AllAir ⇒ tout le treillis doit être D >= 0
const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f);
if (!bAgrees)
{
bTileBad = true;
++NumHoles;
AddError(FString::Printf(
TEXT("HOLE: ClassifyTile said %s for tile origin (%d,%d,%d) Step=%d Cells=%d, ")
TEXT("but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s side. This tile ")
TEXT("would be skipped by the mesher: no triangles and NO COLLISION where there ")
TEXT("should be a surface. Find which guard in ClassifyTile failed to fire for ")
TEXT("the feature at that point."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
}
}
TestEqual(TEXT("no tile was classified uniform while containing a surface (a false verdict is a hole)"),
NumHoles, 0);
// ── Stabilité du verdict : ClassifyTile partage GSurfColCache avec GetDensityAt, donc le
// brute-force ci-dessus a réchauffé les caches. Re-classifier doit rendre le MÊME verdict.
// Verdict stability: ClassifyTile shares GSurfColCache with GetDensityAt, so the brute force
// above warmed the caches. Re-classifying must yield the SAME verdict.
for (const FTileSpec& Spec : ToVerify)
{
const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
if (A != B)
{
AddError(FString::Printf(
TEXT("UNSTABLE VERDICT at tile (%d,%d,%d) Step=%d: two consecutive ClassifyTile ")
TEXT("calls disagreed (%d vs %d). The classifier is reading state that GetDensityAt ")
TEXT("mutates — the shared column cache is the prime suspect."),
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B));
}
}
return true;
}
//=============================================================================
// LE CHEMIN PILE D'OPÉRATEURS DE ClassifyTile — MÊME FORCE BRUTE, MONDE OPT-IN
//=============================================================================
// `ClassifyTile` rendait `Mixed` sans appel pour tout archétype de CAVE. Il consulte désormais
// `FVoxelOpStack::ClassifyBox` quand la strate a coché `bUseOperatorStack` — donc **un tout nouveau
// chemin peut faire sauter le maillage d'une tuile**, et son erreur est un TROU : pas de triangles,
// pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers.
//
// Ce test est le même oracle par force brute que `ClassifyTileSoundness`, sur un monde dont TOUTES
// les strates ont coché la case. Il ne vérifie pas le pliage (c'est `BoxVerdictFold`) ni les
// opérateurs (ce sont les huit tests d'équivalence) : il vérifie le **câblage** — que la pile
// interrogée par le classifieur est bien celle qui produit la densité, params, drapeau et
// disturbances compris.
//
// ⚠️ LE COMPTEUR À LIRE EN PREMIER est le nombre de tuiles réellement brute-forcées. Un run vert
// avec zéro verdict non-Mixed ne prouverait RIEN — exactement le piège que ce fichier documente
// depuis sa première version, et la raison pour laquelle l'absence de verdict est une ERREUR ici.
//
// Same brute-force oracle as ClassifyTileSoundness, on a world where every strate has opted in.
// It checks the WIRING, not the fold and not the operators.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackClassifyTileTest,
"VoxelForge.OpStack.ClassifyTileSoundness",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeOpStackClassifyTileTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
// ⚠️ `bUseOperatorStack = true` sur toutes les strates : c'est LE point du test. La fixture
// donne à ce monde une `LayoutVersion` unique dans le processus, sans quoi les caches par chunk
// de `GetDensityAt` — dont `CP_UseOpStack` — pourraient encore porter ceux d'un autre test.
FTestWorld World;
World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
FRandomStream Rng(20260728);
TArray<FTileSpec> ToVerify;
int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0;
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
// Tirage uniforme sur tout le layout, PAS biaisé vers SurfaceWorld comme l'autre test : ici ce
// sont précisément les strates de cave qui intéressent, puisque ce sont elles qui passent par le
// nouveau chemin. SurfaceWorld continue d'être prouvé par le code écrit à la main.
for (int32 t = 0; t < NumTilesScanned; ++t)
{
FTileSpec Spec;
Spec.Step = 1 << Rng.RandRange(0, 2);
Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16;
const int32 Extent = Spec.Step * Spec.Cells;
auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; };
const int32 LoTile = FloorDiv(BottomVoxelZ, Extent);
const int32 HiTile = FMath::Max(LoTile, FloorDiv(TopVoxelZ, Extent));
Spec.Origin = FIntVector(
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(LoTile, HiTile) * Extent);
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
switch (Verdict)
{
case EVoxelTileClass::Mixed: ++NumMixed; break;
case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
}
}
AddInfo(FString::Printf(
TEXT("ClassifyTile ON THE OPERATOR-STACK PATH, %d scanned tiles: Mixed %d, AllSolid %d, ")
TEXT("AllAir %d (brute-forcing %d). Compare with VoxelForge.Determinism.ClassifyTileSoundness, ")
TEXT("which runs the SAME scan on a world that has NOT opted in: every verdict beyond what ")
TEXT("that test reports is a tile the mesher now skips and did not before. That difference ")
TEXT("IS the T1.d prize OPSTACK-PLAN has been aiming at -- and every one of those tiles is a ")
TEXT("hole if the wiring is wrong, which is what the brute force below is for."),
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
if (ToVerify.Num() == 0)
{
AddError(TEXT("VACUOUS: not one tile got a non-Mixed verdict on the operator-stack path, so ")
TEXT("this test verified NOTHING about the new wiring. Either no strate actually ")
TEXT("opted in (check FTestWorld::Build's bUseOperatorStack), or every guard in the ")
TEXT("cave branch of ClassifyTile bailed to Mixed -- the params-identical check and ")
TEXT("the 27-chunk-coord cap are the likeliest. Do NOT read a green run as proof."));
return false;
}
int32 NumHoles = 0;
for (const FTileSpec& Spec : ToVerify)
{
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
if (Verdict == EVoxelTileClass::Mixed) { continue; }
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
const int32 GridDim = CPA + 1;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
bool bTileBad = false;
for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz)
for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy)
for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx)
{
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
const float Z = (float)(Spec.Origin.Z + gz * Spec.Step);
const float D = Gen->GetDensityAt(X, Y, Z);
const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f);
if (!bAgrees)
{
bTileBad = true;
++NumHoles;
AddError(FString::Printf(
TEXT("HOLE ON THE OPERATOR-STACK PATH: ClassifyTile said %s for tile (%d,%d,%d) ")
TEXT("Step=%d Cells=%d, but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s ")
TEXT("side. Check, in order: (1) does GetDensityAt for this chunk actually take ")
TEXT("the stack (CP_UseOpStack), or did the classifier judge a field the mesher ")
TEXT("will not produce; (2) the params-identical check -- a blended transition ")
TEXT("band means one stack cannot represent the whole tile (AUDIT C2); (3) the ")
TEXT("disturbance fold, since disturbances are applied AFTER the stack and are ")
TEXT("not part of it; (4) an operator's EffectOverBox claiming Identity where it ")
TEXT("can act -- the per-room op override can ENABLE a modifier the strate had ")
TEXT("switched off, which makes a box bound too optimistic."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
}
}
TestEqual(TEXT("no tile was classified uniform on the operator-stack path while containing a ")
TEXT("surface (a false verdict is a hole)"), NumHoles, 0);
// Même contrôle de stabilité que sur l'autre chemin : la pile est reconstruite à chaque appel,
// et `FRoomGraphSource` partage un cache `thread_local` avec le chemin densité — deux appels
// successifs doivent malgré tout rendre le même verdict.
for (const FTileSpec& Spec : ToVerify)
{
const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
if (A != B)
{
AddError(FString::Printf(
TEXT("UNSTABLE VERDICT on the operator-stack path at tile (%d,%d,%d) Step=%d: %d vs ")
TEXT("%d. The classifier builds a fresh stack per call, so a difference means an ")
TEXT("operator is reading thread_local state the density path mutates."),
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B));
}
}
return true;
}
//=============================================================================
// LE FOLD DE LA PILE D'OPÉRATEURS / the op-stack fold
//=============================================================================
// `VoxelDensityOp.h` affirme que son fold reproduit le ClassifyTile écrit à la main. C'est de la
// logique pure — pas de monde, pas de bruit, pas de thread — donc elle peut être vérifiée
// exhaustivement, et elle doit l'être : c'est elle qui décidera un jour si une tuile est maillée.
//
// `VoxelDensityOp.h` claims its fold reproduces the hand-written ClassifyTile. That is pure logic —
// no world, no noise, no threads — so it can be checked exhaustively, and it should be: this is what
// will one day decide whether a tile gets meshed at all.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpFoldTest,
"VoxelForge.OpStack.BoxVerdictFold",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeOpFoldTest::RunTest(const FString& Parameters)
{
// Un état neuf ne prouve rien ⇒ Mixed (les deux hypothèses vivantes = égalité = prudence).
{
FVoxelBoxHypotheses H;
TestEqual(TEXT("a fresh state proves nothing"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// Une source qui affirme un côté tue l'autre hypothèse.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
TestEqual(TEXT("a solid source yields AllSolid"), (int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
VF_FoldEffect(H, EVoxelOpEffect::Identity);
TestEqual(TEXT("Identity changes nothing"), (int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
}
// ≡ « AnyPassageNearBox ⇒ bCanSolid = false » : un carve tue AllSolid.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly);
TestEqual(TEXT("a passage over solid rock forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// ≡ « bande de seal ⇒ bCanAir = false » : un fill tue AllAir.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllAir);
VF_FoldEffect(H, EVoxelOpEffect::FillOnly);
TestEqual(TEXT("a fill over open air forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// LE CAS QUI JUSTIFIE ClassifyBox : au-dessus du terrain mais DANS la bande de seal supérieure,
// la source dit « tout air » et le seal FORCE « tout solide ». Aujourd'hui ClassifyTile rend
// AllSolid ici. Un simple FillOnly rendrait Mixed et perdrait la tuile.
// THE CASE THAT JUSTIFIES ClassifyBox — a pure FillOnly would lose this tile.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllAir); // source: above the terrain
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid); // seal: forcing, inside its band
TestEqual(TEXT("a forcing seal recovers AllSolid over an air source"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
// …et un passage qui traverse cette même boîte la reprend, exactement comme aujourd'hui.
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly);
TestEqual(TEXT("a passage still takes the sealed verdict back"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// Le diff layer : Both tue tout, ce qui est le comportement voulu (une édition joueur peut
// creuser OU remplir n'importe où).
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
VF_FoldEffect(H, EVoxelOpEffect::Both);
TestTrue(TEXT("Both kills every hypothesis"), H.IsDead());
TestEqual(TEXT("a player edit in range forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// Une source qui ne sait rien (Mixed) ne peut jamais être ressuscitée par un opérateur
// directionnel — seul un opérateur FORÇANT le peut. C'est la propriété de sûreté.
{
for (const EVoxelOpEffect E : { EVoxelOpEffect::Identity, EVoxelOpEffect::CarveOnly,
EVoxelOpEffect::FillOnly, EVoxelOpEffect::Both })
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::Mixed);
VF_FoldEffect(H, E);
TestEqual(TEXT("a directional op can never resurrect an unprovable box"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
}
//=========================================================================
// LE PLIAGE NUMÉRIQUE — `OPSTACK-DECOMPOSITION §0.2`
//=========================================================================
// La direction seule ne récupère jamais un carve FIELDÉ : il peut creuser partout, donc il rend
// `CarveOnly` partout, et c'est VRAI. Ce que la direction ignore, c'est qu'il ne peut creuser que
// de `WormStrength` au plus. Le pliage porte donc deux nombres : une MARGE posée par l'opérateur
// forçant, et une AMPLITUDE retirée par chaque carve.
//
// ⚠️ LE PREMIER CONTRÔLE CI-DESSOUS EST LE PLUS IMPORTANT : il vérifie que la rétro-compatibilité
// est réelle. Les cinq blocs au-dessus n'ont pas changé d'une ligne et doivent rester verts —
// ils appellent les mêmes fonctions sans marge ni amplitude, donc avec les défauts
// (`Margin = 0`, `MaxCarve = FLT_MAX`), qui reproduisent le comportement purement directionnel.
// 1. Les défauts REPRODUISENT l'ancien pliage — c'est ce qui rend le changement sûr.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid); // marge par défaut = 0
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // amplitude par défaut = FLT_MAX
TestEqual(TEXT("with default margin and amplitude, a carve still kills AllSolid"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 2. Une amplitude INCONNUE tue même une grosse marge. « Je ne sais pas » n'est pas « zéro ».
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1000.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // FLT_MAX
TestEqual(TEXT("an unbounded carve kills AllSolid however solid the rock is"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 3. LE CAS QUI JUSTIFIE TOUT : roc à 1.0, ver à 0.6 ⇒ il reste 0.4 de marge, la boîte est
// prouvablement pleine. C'est exactement `FConstantFieldSource` + `FWormFieldSource`.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f); // BaseDensity
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.6f, 0.0f); // WormStrength
TestEqual(TEXT("rock solid by more than the worm can carve stays provably AllSolid"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
// …et les carves S'ACCUMULENT : un second à 0.5 fait passer la marge sous zéro.
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.5f, 0.0f);
TestEqual(TEXT("carve amplitudes accumulate until the margin runs out"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 4. ÉGALITÉ ⇒ ON PERD LA TUILE, délibérément. Une marge de 1.0 contre un carve de 1.0 peut
// atteindre exactement zéro, et zéro est du côté AIR pour le mesher. Le test `> 0` est
// STRICT, et il doit le rester : se tromper ici ferait un trou, pas une tuile en trop.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 1.0f, 0.0f);
TestEqual(TEXT("a carve exactly equal to the margin loses the tile (strict >, on purpose)"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
// 5. Le miroir côté AIR : une strate d'îles flottantes, surtout vide, avec un remplissage borné.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllAir, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::FillOnly, 0.0f, 0.35f);
TestEqual(TEXT("air deeper than the fill can reach stays provably AllAir"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllAir);
}
// 6. `Both` BORNÉ : la rugosité de paroi peut aller dans les deux sens, mais pas loin. Sur du
// roc forcé, l'hypothèse AIR est déjà morte (le forçage l'a tuée) ; ce qui compte est que
// l'hypothèse SOLIDE survive à un `Both` d'amplitude connue — impossible avant ce changement,
// où `Both` tuait tout inconditionnellement.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f);
VF_FoldEffect(H, EVoxelOpEffect::Both, 0.3f, 0.3f);
TestEqual(TEXT("a bounded Both no longer kills a margin it cannot cross"),
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
}
// 7. LA PROPRIÉTÉ DE SÛRETÉ TIENT TOUJOURS : rien de borné ne ressuscite quoi que ce soit.
// Le pliage numérique ne fait que retarder la mort d'une hypothèse, jamais l'annuler.
{
for (const EVoxelOpEffect E : { EVoxelOpEffect::Identity, EVoxelOpEffect::CarveOnly,
EVoxelOpEffect::FillOnly, EVoxelOpEffect::Both })
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::Mixed, 1000.0f); // marge ignorée sur Mixed
VF_FoldEffect(H, E, 0.0f, 0.0f); // amplitudes NULLES
TestEqual(TEXT("a zero-amplitude op cannot resurrect an unprovable box either"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
}
// 8. Une marge NULLE avec une amplitude NULLE : le carve ne retire rien, mais `0 > 0` est faux,
// donc l'hypothèse meurt quand même. C'est voulu — une marge inconnue reste inconnue, et un
// opérateur qui ne fait rien devrait rendre `Identity`, pas `CarveOnly` d'amplitude 0.
{
FVoxelBoxHypotheses H;
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 0.0f);
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.0f, 0.0f);
TestEqual(TEXT("zero margin dies even to a zero carve -- unknown is not zero"),
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,289 @@
// VoxelForgeCrossPlatformTest.cpp
// LA QUESTION MULTIJOUEUR, RENDUE MESURABLE / THE MULTIPLAYER QUESTION, MADE MEASURABLE
//
// Jahni, 2026-07-27 : *« je voudrais que le jeu soit jouable sur les deux plateformes, Linux et
// Windows, donc un hôte Windows avec un client Linux pourrait arriver, et l'inverse. »*
// Et la barre d'acceptation : *« 99.99% au pire reproductible si deux personnes partagent la même
// seed, puisque tout le monde le reconstruit en multijoueur. »*
//
// ⚠️ LE PROBLÈME (AUDIT §C9) : le MÊME `FPSemanticsMode.Default` d'UBT ne veut pas dire la même
// chose selon la toolchain — `VCToolChain` (Windows/MSVC) le résout en **`/fp:fast`**,
// `ClangToolChain` (Linux/Mac/Windows-Clang) le résout en **précis + `-ffp-contract=off`**. Deux
// builds de la MÊME source sont donc compilés sous des règles flottantes OPPOSÉES. Un hôte Windows
// et un client Linux ne sont pas seulement *autorisés* à diverger : ils sont compilés pour.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// CE TEST NE CORRIGE RIEN — IL MESURE, et c'est ce qui manque
// ─────────────────────────────────────────────────────────────────────────────────────────
// Aucune quantité de raisonnement ne dit à quel point les deux plateformes divergent : il faut le
// LIRE. Ce test produit deux empreintes du même monde, à la même seed, et les affiche. On le lance
// sur Windows, on le lance sur Linux, on compare les deux lignes.
//
// • **EMPREINTE DE FORME** — le SIGNE de la densité seulement (solide / air). C'est la SEULE
// chose que le mesher lit (`D >= IsoLevel ⇒ air`). Si cette empreinte est identique, les deux
// plateformes ont **le même monde** : mêmes cavités, mêmes murs, même navigabilité, même
// collision au voxel près. C'est littéralement le critère « 99.99% reproductible » de Jahni.
//
// • **EMPREINTE DE CHAMP** — tous les bits de tous les floats. Identique ⇒ reproductibilité
// BIT à BIT. Différente alors que la forme est identique ⇒ la divergence est un frémissement
// sous-voxel de la position des sommets, sans conséquence de jeu.
//
// C'est le bon découpage parce qu'il sépare les deux échecs possibles, qui n'ont pas du tout la
// même gravité :
//
// forme == && champ == ⇒ parfait, rien à faire.
// forme == && champ != ⇒ ACCEPTABLE. Les sommets bougent de ~1e-5 voxel. Personne ne le voit,
// rien ne s'y accroche — SAUF si un jour on compare des hashs de
// géométrie entre pairs. À ne pas faire, donc.
// forme != ⇒ **INACCEPTABLE**. Un voxel solide chez l'un est de l'air chez
// l'autre : un joueur traverse un mur que l'autre voit plein.
//
// ⚠️ POURQUOI LA FORME A DE BONNES CHANCES DE TENIR MÊME AUJOURD'HUI — et pourquoi il faut quand
// même la mesurer : toutes les décisions STRUCTURELLES du plugin (quelle arête de treillis est
// ouverte, quelle cellule porte une colonne, où sont les salles et les passages) passent par
// `VoxelHash::*`, c.-à-d. de l'ARITHMÉTIQUE ENTIÈRE, identique sur toute plateforme. Le flottant
// ne décide que la POSITION de la surface. Un signe ne bascule donc que si un échantillon tombe à
// ~1e-5 de l'isosurface — d'où le troisième chiffre affiché, `NearIso`, qui BORNE le risque au
// lieu de le supposer.
//
// The structural decisions all go through integer hashing, so only the surface POSITION is
// float-decided. A sign flips only where a sample sits within ~1e-5 of the isosurface, which is why
// NearIso is reported: it bounds the risk instead of assuming it.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// COMMENT S'EN SERVIR / HOW TO USE THIS
// ─────────────────────────────────────────────────────────────────────────────────────────
// 1. Lancer sur Windows, noter les deux empreintes.
// 2. Lancer sur Linux, comparer.
// 3. Une fois `FPSemantics = Precise` posé sur le module (AUDIT §C9, bloqué par la dette IWYU)
// et les deux plateformes d'accord : **épingler** les valeurs dans `PinnedShapeDigest` /
// `PinnedFieldDigest` ci-dessous. Le test devient alors un garde-fou permanent — toute
// régression de déterminisme échoue bruyamment, sur la plateforme qui a dérivé.
//
// Tant que les constantes valent 0, le test ne peut pas échouer sur les empreintes : il RAPPORTE.
// C'est délibéré — épingler une valeur avant que les plateformes soient d'accord ne ferait que
// graver la divergence dans le test.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelGenerator.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeCrossPlatformTest,
"VoxelForge.Determinism.CrossPlatformDigest",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
//=========================================================================
// ÉPINGLES / PINS — 0 = « pas encore d'accord de référence », le test rapporte sans juger.
//=========================================================================
// À remplir UNIQUEMENT quand Windows et Linux rendent la même valeur. Voir l'en-tête.
constexpr uint64 PinnedShapeDigest = 0;
constexpr uint64 PinnedFieldDigest = 0;
// FNV-1a 64 bits, octet par octet, **entier pur**. Volontairement écrit à la main plutôt que
// pris dans le moteur : une empreinte de déterminisme ne doit dépendre d'aucune implémentation
// qui pourrait, elle, varier. Ici il n'y a que des `^` et des `*` sur uint64.
// Hand-rolled on purpose: a determinism digest must not depend on an implementation that could
// itself vary. Nothing here but XOR and multiply on uint64.
constexpr uint64 FnvOffsetBasis = 0xcbf29ce484222325ull;
constexpr uint64 FnvPrime = 0x00000100000001b3ull;
FORCEINLINE void FnvAccumByte(uint64& H, uint8 B)
{
H ^= (uint64)B;
H *= FnvPrime;
}
FORCEINLINE void FnvAccumU32(uint64& H, uint32 V)
{
FnvAccumByte(H, (uint8)( V & 0xFFu));
FnvAccumByte(H, (uint8)((V >> 8) & 0xFFu));
FnvAccumByte(H, (uint8)((V >> 16) & 0xFFu));
FnvAccumByte(H, (uint8)((V >> 24) & 0xFFu));
}
/** Bits d'un float, avec les NaN NORMALISÉS : un NaN a plusieurs représentations et rien ne
* garantit que deux plateformes produisent la même. On les compte à part. */
FORCEINLINE uint32 FloatBitsNormalised(float V, bool& bOutWasNaN)
{
bOutWasNaN = FMath::IsNaN(V);
if (bOutWasNaN) { return 0x7FC00000u; }
return *reinterpret_cast<const uint32*>(&V);
}
}
bool FVoxelForgeCrossPlatformTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
//=========================================================================
// LA GRILLE — entièrement déterministe, SANS RNG
//=========================================================================
// Pas de `FRandomStream` ici, contrairement aux autres tests : l'ensemble des points doit être
// identique sur les deux plateformes SANS dépendre d'une seule ligne de code partagé. Une
// boucle entière sur des bornes entières ne peut pas diverger.
// No RNG: the point set must be identical across platforms without depending on any shared
// code at all. An integer loop over integer bounds cannot diverge.
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
constexpr int32 XYStep = 8;
constexpr int32 ZStep = 8;
const int32 XYExtent = 3 * CHUNK_SIZE; // couvre la spine (0,0), les passages et le rocher
uint64 ShapeDigest = FnvOffsetBasis;
uint64 FieldDigest = FnvOffsetBasis;
int32 NumSamples = 0, NumSolid = 0, NumNaN = 0;
int32 NumNearWide = 0, NumNearMid = 0, NumNearTight = 0;
// ─────────────────────────────────────────────────────────────────────────
// `NearIso` — À QUELLE DISTANCE DE ZÉRO UN ÉCHANTILLON PEUT-IL CHANGER DE SIGNE ?
// ─────────────────────────────────────────────────────────────────────────
// ⚠️ CORRIGÉ 2026-07-27, ET LA CORRECTION EST LE POINT INTÉRESSANT.
//
// Version d'origine : une seule bande à 1e-4, justifiée par « une différence de MODÈLE
// FLOTTANT ». Depuis `FPSemantics = Precise` (§C9), il n'y a plus de différence de modèle
// flottant : MSVC et Clang compilent tous deux en IEEE-754 sans contraction. J'ai d'abord cru
// que ça rendait cette mesure caduque. **C'est faux, et il a fallu vérifier plutôt que
// supposer.**
//
// Il reste `FMath::Sin` / `FMath::Cos`, présents partout dans le chemin de densité (lignes de
// strates, nervures, placement des salles, rotations). **`sinf`/`cosf` ne sont PAS spécifiés
// par IEEE-754** : le CRT de MSVC et la libm de la glibc ont parfaitement le droit de rendre
// des résultats différents (typiquement ≤ 1 ULP, mais différents). `FPSemantics` a donc fermé
// la moitié COMPILATEUR de §C9 et laissé ouverte la moitié BIBLIOTHÈQUE.
//
// FPSemantics = Precise removed the float-MODEL difference, but sinf/cosf are not IEEE-754
// specified, so MSVC's CRT and glibc's libm may still differ. The compiler half of C9 is closed;
// the library half is not.
//
// D'où trois bandes au lieu d'une : une bande unique à 1e-4 est **100× trop large** pour un
// écart de libm (~1e-6 en absolu sur des densités de magnitude ~10), donc elle sur-estime
// grossièrement le risque et crie au loup. Mesurer trois échelles donne un vrai profil, et
// seule la plus serrée — celle qui correspond réellement à un écart de libm — déclenche
// l'alerte.
// Three bands, not one: 1e-4 over-estimates a libm-scale delta by ~100x. Only the tight band,
// which actually matches a libm difference, raises a warning.
constexpr float NearIsoWide = 1.0e-4f; // profil : large, informatif
constexpr float NearIsoMid = 1.0e-5f; // profil
constexpr float NearIsoTight = 1.0e-6f; // ≈ l'échelle d'un écart libm ⇒ LE chiffre du risque
for (int32 Z = BottomVoxelZ; Z <= TopVoxelZ; Z += ZStep)
{
for (int32 Y = -XYExtent; Y <= XYExtent; Y += XYStep)
{
for (int32 X = -XYExtent; X <= XYExtent; X += XYStep)
{
const float D = Gen->GetDensityAt((float)X, (float)Y, (float)Z);
bool bWasNaN = false;
const uint32 Bits = FloatBitsNormalised(D, bWasNaN);
if (bWasNaN) { ++NumNaN; }
// FORME : un seul bit par échantillon — le côté de l'isosurface, ce que lit le
// mesher. C'est l'empreinte qui doit tenir entre plateformes.
const bool bAir = (D >= 0.0f);
if (!bAir) { ++NumSolid; }
FnvAccumByte(ShapeDigest, bAir ? 1u : 0u);
// CHAMP : tous les bits.
FnvAccumU32(FieldDigest, Bits);
if (!bWasNaN)
{
const float A = FMath::Abs(D);
if (A < NearIsoWide) { ++NumNearWide; }
if (A < NearIsoMid) { ++NumNearMid; }
if (A < NearIsoTight) { ++NumNearTight; }
}
++NumSamples;
}
}
}
//=========================================================================
// LE RAPPORT — c'est le produit de ce test
//=========================================================================
AddInfo(FString::Printf(
TEXT("CROSS-PLATFORM DIGEST (seed %d, %d samples, step %d/%d)\n")
TEXT(" SHAPE digest : 0x%016llX <- must match across Windows/Linux. This is the world.\n")
TEXT(" FIELD digest : 0x%016llX <- bit-for-bit. May differ; see NearIso below.\n")
TEXT(" solid %d / air %d / NaN %d"),
World.Settings->Seed, NumSamples, XYStep, ZStep,
ShapeDigest, FieldDigest, NumSolid, NumSamples - NumSolid, NumNaN));
// Le PROFIL de proximité à l'isosurface, plutôt qu'un seul seuil binaire.
AddInfo(FString::Printf(
TEXT("NearIso profile over %d samples: %d within 1e-4, %d within 1e-5, %d within 1e-6. ")
TEXT("Only the LAST number is the cross-platform risk: FPSemantics = Precise removed the ")
TEXT("float-MODEL difference, so what remains is that sinf/cosf are not IEEE-754 specified ")
TEXT("and MSVC's CRT may differ from glibc's libm by ~1 ULP. On densities of magnitude ~10 ")
TEXT("that is ~1e-6 absolute, which is why the wide band over-states the risk ~100x."),
NumSamples, NumNearWide, NumNearMid, NumNearTight));
if (NumNearTight > 0)
{
AddWarning(FString::Printf(
TEXT("%d of %d samples sit within 1e-6 of the isosurface -- tight enough that a libm ")
TEXT("difference between MSVC and glibc could flip their SIGN, i.e. one voxel solid for ")
TEXT("a Windows host and air for a Linux client. FMath::Sin/Cos are used throughout the ")
TEXT("density path (layer lines, ribs, room placement, rotations), so this is the ")
TEXT("REMAINING half of AUDIT C9 -- the compiler half is fixed, the library half is not. ")
TEXT("If this must be zero, the fix is a deterministic in-house sin/cos in the density ")
TEXT("path (one more world re-tune), not another build flag."),
NumNearTight, NumSamples));
}
else
{
AddInfo(TEXT("No sample sits within 1e-6 of the isosurface, so no sampled voxel is close ")
TEXT("enough for a libm difference to flip its side. Evidence, not proof: it covers ")
TEXT("this grid, not every voxel of a world."));
}
TestEqual(TEXT("no sample produced NaN"), NumNaN, 0);
// Un monde entièrement solide ou entièrement vide rendrait les empreintes vraies mais vides de
// sens. Garde-fou minimal contre un test qui se félicite de ne rien mesurer.
TestTrue(TEXT("the sampled world contains both solid and air (the digest is meaningful)"),
NumSolid > 0 && NumSolid < NumSamples);
//=========================================================================
// LES ÉPINGLES — inertes tant que personne ne les a posées
//=========================================================================
if (PinnedShapeDigest != 0)
{
TestEqual(TEXT("SHAPE digest matches the pinned cross-platform reference"),
ShapeDigest, PinnedShapeDigest);
}
else
{
AddInfo(TEXT("SHAPE digest is not pinned yet. Pin it only once Windows and Linux agree -- ")
TEXT("pinning first would just carve the divergence into the test."));
}
if (PinnedFieldDigest != 0)
{
TestEqual(TEXT("FIELD digest matches the pinned cross-platform reference"),
FieldDigest, PinnedFieldDigest);
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,387 @@
// VoxelForgeDensityPurityTest.cpp
// Phase 0.5 test #1 — LA PURETÉ DE LA DENSITÉ / density purity.
//
// L'INVARIANT / THE INVARIANT (ARCHITECTURE §8.4, "window invariance"):
// GetDensityAt(x,y,z) est une fonction PURE de (coords monde, seed, layout). Le même point
// interrogé depuis une autre tuile, un autre ordre de requêtes ou un autre thread doit rendre
// le float BIT-IDENTIQUE. Pas "proche" — identique : un écart d'1 ULP entre deux fenêtres de
// chunk est une COUTURE visible, et en multijoueur une divergence de monde.
//
// GetDensityAt is a PURE function of (world coords, seed, layout). The same point queried from
// a different tile, in a different order, or on a different thread must return the BIT-IDENTICAL
// float. Not "close" — identical: a 1-ULP disagreement between two chunk windows is a visible
// seam, and in multiplayer a world divergence.
//
// POURQUOI CE TEST EXISTE / WHY THIS TEST EXISTS:
// ~30 caches thread_local à clé manuelle vivent sous GetDensityAt (CP_*, GSurfColCache, les
// slots de diff, le cache SDF). Chacun est correct exactement tant que sa CLÉ contient toutes
// les entrées dont dépend la valeur cachée. Une entrée oubliée ne casse rien tout de suite :
// elle produit une mauvaise valeur seulement quand le cache est chaud pour une AUTRE entrée —
// c'est-à-dire de façon intermittente, dépendante de l'ordre, et invisible en jeu jusqu'à ce
// qu'un joueur trouve la couture. C'est exactement ainsi que AUDIT C2 s'est caché.
//
// ~30 hand-keyed thread_local caches live under GetDensityAt. Each is correct exactly as long as
// its KEY contains every input the cached value depends on. A forgotten input breaks nothing
// immediately: it yields a wrong value only when the cache is warm for a DIFFERENT input — i.e.
// intermittently, order-dependently, invisible in play until a player finds the seam. That is
// precisely how AUDIT C2 stayed hidden.
//
// AVoxelWorld::ValidateDeterminism existe déjà mais tourne sur le GAME THREAD uniquement : il ne
// peut structurellement pas voir une divergence de cache worker. Ce test tourne multi-thread.
// AVoxelWorld::ValidateDeterminism already exists but runs on the GAME THREAD only: it
// structurally cannot see a worker-cache divergence. This test runs multi-threaded.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeDensityPurityTest,
"VoxelForge.Determinism.DensityPurity",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
// Assez de points pour traverser plusieurs chunks/strates et faire tourner tous les caches,
// assez peu pour rester sous la seconde. / Enough points to cross many chunks and strates and
// churn every cache, few enough to stay under a second.
constexpr int32 NumSamples = 10000;
struct FMismatch
{
std::atomic<int32> Count{ 0 };
std::atomic<int32> FirstIndex{ -1 };
void Record(int32 Index)
{
Count.fetch_add(1, std::memory_order_relaxed);
int32 Expected = -1;
FirstIndex.compare_exchange_strong(Expected, Index, std::memory_order_relaxed);
}
};
/** Report the first divergent point with both floats and their raw bits — a mismatch that is
* invisible in decimal (a 1-ULP cache seam) is the exact case this test is for. */
FString DescribeMismatch(const FVector& P, float Ref, float Got)
{
return FString::Printf(
TEXT("at (%.0f, %.0f, %.0f): reference %.9g [0x%08X] vs re-sample %.9g [0x%08X]"),
P.X, P.Y, P.Z,
Ref, *reinterpret_cast<const uint32*>(&Ref),
Got, *reinterpret_cast<const uint32*>(&Got));
}
}
bool FVoxelForgeDensityPurityTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
TArray<FVector> Points;
BuildSamplePoints(World, NumSamples, /*Seed*/ 20260727, Points);
// ── Référence : ordre linéaire, thread de jeu, caches chauds naturellement. ──
TArray<float> Ref;
Ref.SetNumUninitialized(NumSamples);
for (int32 i = 0; i < NumSamples; ++i)
{
Ref[i] = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
// Un monde entièrement NaN/constant passerait tout ce qui suit trivialement. Vérifier qu'on
// mesure bien un vrai champ. / An all-NaN or constant world would pass everything below
// trivially. Check we are measuring a real field. (This is also the canary for AUDIT C1: a
// large seed collapses the noise terms and the field goes constant.)
{
int32 NumFinite = 0, NumDistinct = 0;
TSet<uint32> Seen;
for (const float V : Ref)
{
if (FMath::IsFinite(V)) { ++NumFinite; }
Seen.Add(*reinterpret_cast<const uint32*>(&V));
}
NumDistinct = Seen.Num();
TestEqual(TEXT("every density sample is finite (no NaN/Inf leaking out of the generator)"),
NumFinite, NumSamples);
if (NumDistinct < NumSamples / 100)
{
AddError(FString::Printf(
TEXT("The density field is suspiciously flat: only %d distinct values across %d ")
TEXT("samples. Either the fixture built an empty world, or the noise field has ")
TEXT("collapsed (see AUDIT-2026-07.md C1 — unbounded SeedF). The purity checks ")
TEXT("below would pass trivially on a constant field, so they prove nothing here."),
NumDistinct, NumSamples));
}
}
// ── 1. INDÉPENDANCE À L'ORDRE, même thread. ──
// Un cache dont la clé est incomplète rend une valeur différente selon ce qui l'a précédé.
// An incompletely-keyed cache returns a different value depending on what preceded it.
{
TArray<int32> Order;
BuildShuffledOrder(NumSamples, /*Seed*/ 991, Order);
int32 Mismatches = 0;
FString First;
for (const int32 i : Order)
{
const float Got = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(Got, Ref[i]))
{
if (Mismatches == 0) { First = DescribeMismatch(Points[i], Ref[i], Got); }
++Mismatches;
}
}
if (Mismatches > 0)
{
AddError(FString::Printf(
TEXT("ORDER DEPENDENCE: %d of %d points changed value when queried in a different ")
TEXT("order on the SAME thread. A per-chunk cache is missing an input from its key. ")
TEXT("First: %s"), Mismatches, NumSamples, *First));
}
}
// ── 2. INDÉPENDANCE AU THREAD. ──
// C'est la moitié que ValidateDeterminism (game-thread) ne peut pas voir. Chaque worker
// parcourt SON propre ordre mélangé, donc ses thread_local se réchauffent différemment.
// This is the half game-thread ValidateDeterminism cannot see. Each worker walks its OWN
// shuffled order, so its thread_locals warm up differently.
{
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
FMismatch Bad;
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> Order;
BuildShuffledOrder(NumSamples, /*Seed*/ 4000 + Block, Order);
const UVoxelGenerator* LocalGen = World.Generator.Get();
for (const int32 i : Order)
{
// Chaque bloc parcourt TOUS les points (pas seulement une tranche) : c'est le
// parcours complet dans un ordre différent qui réchauffe les caches thread_local
// différemment, et c'est exactement ce qu'on cherche à faire diverger.
// Every block walks ALL the points, not a slice: it is the full walk in a
// different order that warms the thread_local caches differently, which is
// precisely what we are trying to make diverge.
const float V = LocalGen->GetDensityAt(
(float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Bad.Record(i); }
}
});
const int32 Count = Bad.Count.load();
if (Count > 0)
{
const int32 Idx = Bad.FirstIndex.load();
AddError(FString::Printf(
TEXT("WORKER DIVERGENCE: %d sample evaluations on worker threads disagreed with the ")
TEXT("game-thread reference. This is the failure mode AVoxelWorld::ValidateDeterminism ")
TEXT("cannot detect, and it means a thread_local cache under GetDensityAt is serving a ")
TEXT("value it should not. First: %s"),
Count, *DescribeMismatch(Points[Idx], Ref[Idx],
Gen->GetDensityAt((float)Points[Idx].X, (float)Points[Idx].Y,
(float)Points[Idx].Z))));
}
}
// ── 3. PURETÉ AVEC LA COUCHE DE DIFF ACTIVE. ──
// Les DiffSlots sont un cache direct-mapped à 64 entrées, indexé par les bits bas du chunk.
// Une collision servirait la liste de mods d'un AUTRE chunk : un carve fantôme à distance.
// DiffSlots is a 64-entry direct-mapped cache indexed by the chunk coord's low bits. A
// collision would serve another chunk's mod list: a ghost carve at a distance.
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Sphere;
Mod.Radius = 12.0f;
Mod.Strength = -10.0f;
for (int32 k = 0; k < 8; ++k)
{
Mod.Center = FVector((float)(k * CHUNK_SIZE), (float)(-k * CHUNK_SIZE), World.MidVoxelZ());
World.DiffLayer->ApplyModification(Mod);
}
TestTrue(TEXT("the diff layer registered the test carves"), World.DiffLayer->HasAnyMods());
TArray<float> DiffRef;
DiffRef.SetNumUninitialized(NumSamples);
for (int32 i = 0; i < NumSamples; ++i)
{
DiffRef[i] = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
FMismatch Bad;
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> Order;
BuildShuffledOrder(NumSamples, /*Seed*/ 7000 + Block, Order);
const UVoxelGenerator* LocalGen = World.Generator.Get();
for (const int32 i : Order)
{
const float V = LocalGen->GetDensityAt(
(float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, DiffRef[i])) { Bad.Record(i); }
}
});
const int32 Count = Bad.Count.load();
if (Count > 0)
{
const int32 Idx = Bad.FirstIndex.load();
AddError(FString::Printf(
TEXT("DIFF-LAYER IMPURITY: %d evaluations diverged with player edits present. ")
TEXT("Suspect the direct-mapped DiffSlots cache in GetDensityAt (chunk low-bit ")
TEXT("index + ModsVersion). First mismatch index %d at (%.0f, %.0f, %.0f)."),
Count, Idx, Points[Idx].X, Points[Idx].Y, Points[Idx].Z));
}
// Et le carve doit vraiment avoir changé quelque chose, sinon le sous-test ci-dessus
// n'a rien testé. / And the carve must actually have changed something, else the sub-test
// above tested nothing.
int32 NumChanged = 0;
for (int32 i = 0; i < NumSamples; ++i)
{
if (!BitEqual(DiffRef[i], Ref[i])) { ++NumChanged; }
}
if (NumChanged == 0)
{
AddError(TEXT("No sample changed after applying 8 carves — the diff layer branch of ")
TEXT("GetDensityAt was never exercised, so the purity check above is vacuous. ")
TEXT("Move the carve centres so they overlap the sample cloud."));
}
}
return true;
}
//=============================================================================
// AUDIT C2 — L'INVALIDATION APRÈS ÉDITION À CHAUD / live-edit invalidation
//=============================================================================
// La pureté ci-dessus teste « le même monde répond toujours pareil ». Ce test-ci teste l'inverse,
// et c'est le bug réellement observé : après un RebuildStrates / une édition d'asset dans
// l'éditeur, un worker dont le cache par-chunk est encore chaud pour ce chunk DOIT re-résoudre ses
// params. Sinon il génère avec les ANCIENS — symptôme : « j'ai retouché la strate, régénéré, et une
// zone a gardé l'ancienne forme ».
//
// The purity test above checks "the same world always answers the same". This checks the opposite,
// and it is the bug actually observed: after a layout rebuild, a warm per-chunk cache MUST refetch.
//
// Le test échantillonne D0, mute un param de terrain qui NE déplace PAS la strate (donc StrateKey
// et toutes les autres clés existantes restent identiques), ré-initialise, et exige que la densité
// AIT CHANGÉ au même point sur le MÊME thread. Sans le correctif de version de layout, elle ne
// change pas et ce test échoue.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeLiveEditInvalidationTest,
"VoxelForge.Determinism.LiveEditInvalidation",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeLiveEditInvalidationTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
int32 SurfTopZ = 0, SurfBotZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTopZ, SurfBotZ))
{
AddError(TEXT("The fixture layout has no SurfaceWorld slot, so there is no heightfield to ")
TEXT("live-edit. Check FTestWorld::Build's Archetypes[] against the slot constants."));
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
// Colonne bien à l'écart de (0,0) : la spine et l'entrée de surface y forcent de l'air et
// masqueraient un changement de heightfield.
TArray<FVector> Probes;
for (int32 i = 0; i < 64; ++i)
{
Probes.Add(FVector(200.0f + i * 7.0f, -140.0f + i * 5.0f,
(float)((SurfTopZ + SurfBotZ) / 2)));
}
TArray<float> Before;
Before.Reserve(Probes.Num());
for (const FVector& P : Probes)
{
Before.Add(Gen->GetDensityAt((float)P.X, (float)P.Y, (float)P.Z));
}
// ── L'édition. Choisie pour NE PAS bouger la strate : StrateBottomWorldZ est inchangé, donc
// StrateKey, le seed et les coords de chunk sont tous identiques à avant. La SEULE chose
// qui bouge est la version de layout. / The edit is chosen NOT to move the strate: the only
// thing that changes is the layout version.
UVoxelStrateDefinition* SurfDef = World.Definitions[FTestWorld::SlotSurfaceWorld].Get();
SurfDef->SurfaceParams.ElevationRange *= 2.5f;
SurfDef->SurfaceParams.MountainStrength = FMath::Min(1.0f, SurfDef->SurfaceParams.MountainStrength + 0.4f);
SurfDef->SurfaceParams.ContinentFrequency *= 1.7f;
World.Reinitialize();
int32 NumChanged = 0;
for (int32 i = 0; i < Probes.Num(); ++i)
{
const float After = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
if (!BitEqual(After, Before[i])) { ++NumChanged; }
}
if (NumChanged == 0)
{
AddError(FString::Printf(
TEXT("STALE PARAMS (AUDIT C2): the SurfaceWorld heightfield params were tripled and the ")
TEXT("layout rebuilt, yet all %d probe densities are bit-identical. A per-chunk cache is ")
TEXT("still keyed on ChunkCoord alone and skipped its refetch. Suspects, in order: ")
TEXT("CP_Chunk/CP_Version in GetDensityAt, the GSurfColCache box key, and the ")
TEXT("FChunkBiomeCache validity box (which says nothing about the FBiomeContext its ")
TEXT("cells were classified against)."), Probes.Num()));
}
else
{
AddInfo(FString::Printf(TEXT("%d of %d probes moved after the live edit — caches refetched."),
NumChanged, Probes.Num()));
}
// Et le monde édité doit rester pur : une invalidation qui laisse un cache à moitié chaud
// produirait des valeurs dépendantes de l'ordre. / And the edited world must stay pure.
{
TArray<int32> Order;
BuildShuffledOrder(Probes.Num(), 555, Order);
TArray<float> After;
After.SetNumZeroed(Probes.Num());
for (const int32 i : Order)
{
After[i] = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
}
int32 Impure = 0;
for (const int32 i : Order)
{
const float Again = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
if (!BitEqual(Again, After[i])) { ++Impure; }
}
TestEqual(TEXT("the world is still order-independent after a live edit"), Impure, 0);
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,196 @@
// VoxelForgeDiffLayerTest.cpp
// Phase 0.5 test #3 — LA COUCHE DE DIFF SOUS CONTENTION / DiffLayer under contention.
//
// LE RISQUE / THE RISK:
// UVoxelDiffLayer::ChunkMods est une TMap LUE par les threads de meshing (via GetDensityAt →
// GetChunkModsSnapshot) et ÉCRITE par le thread de jeu (ApplyModification / Clear). TMap n'est
// pas thread-safe : un rehash pendant une lecture est une violation d'accès. Tout est censé
// passer par ModsLock (FRWLock) — et une AV carve-vs-stream a déjà été corrigée exactement là.
//
// UVoxelDiffLayer::ChunkMods is a TMap READ by mesher workers (through GetDensityAt →
// GetChunkModsSnapshot) and WRITTEN by the game thread (ApplyModification / Clear). TMap is not
// thread-safe: a rehash during a read is an access violation. Everything is meant to go through
// ModsLock (FRWLock) — and a carve-vs-stream AV was already fixed in exactly this spot.
//
// CE QUE CE TEST PROUVE / WHAT THIS TEST PROVES:
// 1. Aucun crash quand N lecteurs martèlent la couche pendant que le thread de jeu écrit.
// 2. ModsVersion ne RECULE jamais du point de vue d'un lecteur (c'est la clé sur laquelle les
// caches de snapshot invalident ; une version non monotone rendrait un cache définitivement
// périmé).
// 3. L'état final est exact : chaque carve appliqué est retrouvable.
// Le point (1) est le vrai but, et il ne peut être prouvé que statistiquement — un test vert
// veut dire "pas reproduit ici", pas "impossible". C'est quand même infiniment mieux que rien.
//
// Point (1) is the real target, and it can only ever be shown statistically — a green run means
// "not reproduced here", not "impossible". Still infinitely better than nothing.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/Async.h"
#include "HAL/PlatformMisc.h"
#include "UObject/StrongObjectPtr.h"
#include "UObject/Package.h"
#include "VoxelTypes.h"
#include "VoxelDiffLayer.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeDiffLayerContentionTest,
"VoxelForge.Determinism.DiffLayerContention",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumWrites = 400;
constexpr int32 NumChunksX = 8;
FVoxelModification MakeCarve(int32 Index)
{
FVoxelModification Mod;
Mod.Shape = EVoxelBrushShape::Sphere;
Mod.Radius = 6.0f;
Mod.Strength = -9.0f;
Mod.Center = FVector(
(float)((Index % NumChunksX) * CHUNK_SIZE + 4),
(float)(((Index / NumChunksX) % NumChunksX) * CHUNK_SIZE + 4),
(float)(-((Index / (NumChunksX * NumChunksX)) % 4) * CHUNK_SIZE));
return Mod;
}
}
bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters)
{
TStrongObjectPtr<UVoxelDiffLayer> Diff(
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
Diff->SetBudget(/*MaxMods*/ 0, /*MaxRadius*/ 50.0f, /*MaxVolume*/ 0.0f); // 0 = illimité
const int32 NumReaders = FMath::Max(3, FMath::Min(8, FPlatformMisc::NumberOfCores() - 1));
std::atomic<bool> bStop{ false };
std::atomic<int32> VersionRegressions{ 0 };
std::atomic<int64> ReadOps{ 0 };
// ── Les lecteurs : exactement le mix d'appels que fait le chemin densité d'un worker. ──
// The readers: exactly the call mix a worker's density path makes.
TArray<TFuture<void>> Readers;
Readers.Reserve(NumReaders);
for (int32 R = 0; R < NumReaders; ++R)
{
Readers.Add(Async(EAsyncExecution::Thread, [&, R]()
{
uint32 LastVersion = 0;
int64 LocalOps = 0;
FRandomStream Rng(9000 + R);
while (!bStop.load(std::memory_order_relaxed))
{
const uint32 V = Diff->GetModsVersion();
if (V < LastVersion)
{
VersionRegressions.fetch_add(1, std::memory_order_relaxed);
}
LastVersion = V;
const FIntVector Chunk(Rng.RandRange(0, NumChunksX - 1),
Rng.RandRange(0, NumChunksX - 1),
Rng.RandRange(-3, 0));
// Le fast-reject sans verrou, puis le vrai chemin sous verrou.
if (Diff->HasAnyMods())
{
Diff->HasAnyModInChunkRange(Chunk - FIntVector(1, 1, 1), Chunk + FIntVector(1, 1, 1));
Diff->HasModifications(Chunk);
TArray<FVoxelModification> Snapshot;
Diff->GetChunkModsSnapshot(Chunk, Snapshot);
// Toucher réellement les données copiées : un snapshot qui aliaserait la TMap
// (au lieu de la copier) exploserait ici et pas au moment de la copie.
// Actually touch the copied data: a snapshot that aliased the TMap instead of
// copying it would blow up here rather than at copy time.
const float X = (float)(Chunk.X * CHUNK_SIZE + 3);
const float Y = (float)(Chunk.Y * CHUNK_SIZE + 3);
const float Z = (float)(Chunk.Z * CHUNK_SIZE + 3);
const float Sink = UVoxelDiffLayer::EvaluateMods(Snapshot, X, Y, Z)
+ Diff->GetDensityOffset(Chunk, X, Y, Z);
// Consommer Sink dans une branche que le compilateur ne peut pas prouver morte,
// sinon tout le bloc de lecture est éliminé et le test ne teste rien.
// Consume Sink in a branch the compiler cannot prove dead, otherwise the whole
// read block is optimised away and the test tests nothing.
if (Sink == 1.2345678e30f) { ++LocalOps; }
}
++LocalOps;
}
ReadOps.fetch_add(LocalOps, std::memory_order_relaxed);
}));
}
// ── Phase 1 : écritures pures. L'état final doit être exact. ──
// ⚠️ `GetTotalModificationCount()` ne compte PAS les opérations : il somme les entrées
// STOCKÉES, et un coup de pinceau est rangé dans CHAQUE chunk que son AABB recouvre. 400
// sphères de rayon 6 posées à cheval sur des coins de chunk donnent 3200 entrées, pas 400.
// (Le compteur d'opérations est le membre privé `ModificationCount`, non exposé.)
// C'est d'ailleurs la métrique qui compte pour AUDIT C6 : ce sont les ENTRÉES stockées qui
// grossissent sans borne, pas le nombre de coups de pioche. Le nom du getter induit en erreur.
//
// GetTotalModificationCount() does NOT count operations: it sums STORED entries, and a stroke
// is filed under EVERY chunk its AABB overlaps. It is also the metric that matters for AUDIT C6
// — stored entries are what grow without bound. The getter's name misleads.
int32 ExpectedEntries = 0;
for (int32 i = 0; i < NumWrites; ++i)
{
const TArray<FIntVector> Touched = Diff->ApplyModification(MakeCarve(i));
if (Touched.Num() == 0)
{
AddError(FString::Printf(
TEXT("ApplyModification #%d was rejected. The budget should be unlimited here — ")
TEXT("if this fires, SetBudget(0, ...) no longer means 'no cap'."), i));
break;
}
ExpectedEntries += Touched.Num();
}
// Vérifie que le fan-out RÉELLEMENT stocké correspond à ce qu'ApplyModification a rapporté —
// un désaccord voudrait dire que la liste de chunks rendue à l'appelant (celle qui décide quoi
// re-mailler) ne décrit pas ce qui a été écrit. C'est un bien meilleur test que « == 400 ».
TestEqual(TEXT("stored diff entries match the chunk fan-out ApplyModification reported"),
Diff->GetTotalModificationCount(), ExpectedEntries);
TestTrue(TEXT("the lock-free bHasAnyMods fast-path agrees with the map"), Diff->HasAnyMods());
TestTrue(TEXT("at least one chunk holds mods"), Diff->GetModifiedChunkCount() > 0);
// ── Phase 2 : le chemin réellement dangereux — Clear() pendant que les lecteurs tiennent des
// itérateurs potentiels. On n'affirme plus de compte ici, seulement la survie + la monotonie.
// Phase 2: the genuinely dangerous path — Clear() while readers may hold iterators. No count
// assertions here, only survival + monotonicity.
for (int32 Round = 0; Round < 6; ++Round)
{
for (int32 i = 0; i < 60; ++i) { Diff->ApplyModification(MakeCarve(i + Round * 60)); }
Diff->Clear();
}
bStop.store(true, std::memory_order_relaxed);
for (TFuture<void>& F : Readers) { F.Wait(); }
AddInfo(FString::Printf(TEXT("%d reader threads completed %lld read rounds against %d writes + 6 clears."),
NumReaders, (long long)ReadOps.load(), NumWrites + 360));
TestEqual(TEXT("ModsVersion never went backwards from a reader's point of view"),
VersionRegressions.load(), 0);
if (ReadOps.load() < (int64)NumReaders)
{
AddError(TEXT("The reader threads barely ran, so no contention was actually exercised. ")
TEXT("The writes finished before the threads started — increase NumWrites or add ")
TEXT("a barrier before the writer loop."));
}
// Après Clear(), l'état doit être franchement vide (pas « presque »).
TestFalse(TEXT("Clear() left no mods behind"), Diff->HasAnyMods());
TestEqual(TEXT("Clear() reset the modified-chunk count"), Diff->GetModifiedChunkCount(), 0);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,610 @@
// VoxelForgeHeightStackTest.cpp
// LA QUESTION D'ARCHITECTURE DE LA PHASE 2, POSÉE AVANT D'ÉCRIRE CE QUI EN DÉPEND.
// PHASE 2'S ARCHITECTURAL QUESTION, ASKED BEFORE WRITING WHAT DEPENDS ON THE ANSWER.
//
// SurfaceWorld a forcé une décision que ni Maze ni Slab n'avaient forcée : ses opérateurs de
// terrain (cliff / terrace / layer lines / plage) n'opèrent PAS sur la densité. Ils lisent et
// écrivent **une altitude**. Ils ne rentrent donc pas dans `IVoxelDensityOp`, et les y forcer
// voudrait dire soit un canal par-voxel pour une propriété de COLONNE, soit un seul opérateur
// opaque — ce que `OPSTACK-PLAN §2.5` appelle exactement l'échec du refactor.
//
// D'où une seconde famille, `VoxelHeightOp.h`. **Ce test est ce qui dit si elle était une bonne
// idée** — la même méthode que la Phase 1 a appliquée à la densité : décomposer, puis MESURER
// contre l'original, avant de construire par-dessus.
//
// ⚠️ CE QUE CE TEST COUVRE, ET SURTOUT CE QU'IL NE COUVRE PAS
// ✅ la pile de HAUTEUR du sol, contre `ComputeSurfaceTerrainZ` (2 passes : défauts, puis tous
// les ops F20 allumés — c'est la seconde qui porte le test) ;
// ✅ la pile de HAUTEUR de la voûte + le pont vers l'espace densité (`FSurfaceColumnSource`),
// contre `GetSurfaceDensity` ;
// ❌ **l'OVERHANG** — `GetSurfaceDensity` passe `OverhangAmp = 0`, donc il n'en calcule aucun.
// Sa seule référence est le chemin CACHÉ (`ComputeSurfaceColumn`), qui résout le gate et la
// direction amont par colonne ;
// ❌ **le MÉLANGE DE BIOMES** — ici `ParamsD == ParamsN`, poids 0. C'est le combiner `Mask`, et
// `§5` en fait le prototype de la Phase 3 : ça mérite son étape.
//
// Les deux manques sont l'étape 2b. **Ne pas brancher SurfaceWorld dans un monde à biomes ou à
// overhang avant**, parce que rien ici ne dirait que c'est faux.
//
// COVERED: the ground height stack vs ComputeSurfaceTerrainZ, and the ceiling stack + the bridge
// into density space vs GetSurfaceDensity. NOT COVERED: the overhang (GetSurfaceDensity passes
// OverhangAmp = 0, so only the cached path computes it) and biome blending (weight 0 here). Both
// are step 2b — do not wire SurfaceWorld into a world with biomes or overhangs before then.
//
// LA BARRE : **bit à bit.** Depuis `FPSemantics = Precise` (AUDIT §C9/§C10), Maze et Slab sont
// bit-identiques à leur original ; il n'y a plus de « plancher ULP » à tolérer. Un écart ici est
// donc une vraie trouvaille — un offset de bruit faux, un ordre d'op inversé, un gate oublié.
// Ces fonctions sont des ALTITUDES en voxels, pas des densités : un écart d'un demi-voxel est un
// terrain visiblement différent, pas du bruit d'arrondi.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelHeightOp.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeHeightStackTest,
"VoxelForge.OpStack.SurfaceHeightEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumHeightSamples = 20000;
/** Les params du terrain ne sont intéressants que si les ops sont ALLUMÉS. Ceux de la fixture
* sont les défauts, et `§5` note que les ops F20 sont « all off by default ». Un test qui ne
* ferait tourner que les défauts vérifierait la source structurelle et RIEN des quatre
* modificateurs c'est-à-dire l'essentiel de ce qui est nouveau ici. */
void EnableAllTerrainOps(FSurfaceGenerationParams& P)
{
P.CliffStrength = 0.6f;
P.CliffSampleDist = 2.0f;
P.CliffSlopeThreshold = 0.15f;
P.CliffSharpness = 1.4f;
P.TerraceStrength = 0.7f;
P.TerraceHeight = 9.0f;
P.TerraceHardness = 0.8f;
P.LayerLineDepth = 1.3f;
P.LayerLineSpacing = 7.0f;
// ⚠️ `WaterLevelRelative` DOIT être > 0, sinon `FBeachHeightMod` sort immédiatement et le
// cinquième op n'est jamais exercé — un test vert qui n'a rien testé. Le défaut de la
// struct est 0.0f, donc l'oublier est le piège naturel ici.
// The beach op early-outs unless WaterLevelRelative > 0, so without this the fifth op is
// never exercised at all — a green test that measured nothing.
P.WaterLevelRelative = 0.30f;
P.BeachWidth = 6.0f;
}
}
bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no SurfaceWorld slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotSurfaceWorld."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
// Les points d'échantillonnage : XY seulement, la hauteur ne dépend pas de Z (c'est le point).
TArray<FVector2D> Points;
Points.Reserve(NumHeightSamples);
{
FRandomStream Rng(90210);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
Points.Add(FVector2D(
(float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE),
(float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE)));
}
}
//=========================================================================
// LA BATTERIE, PARAMÉTRÉE PAR JEU DE PARAMS
//=========================================================================
auto RunForParams = [&](const FSurfaceGenerationParams& P, const TCHAR* Label, int32 SeedSalt)
{
FVoxelHeightStack Stack;
VoxelHeightOps::BuildSurfaceHeightStack(Stack, P, World.Settings->Seed);
// Une DÉCOMPOSITION, pas une enveloppe : source + 4 modificateurs.
TestEqual(*FString::Printf(TEXT("%s: the height stack is decomposed into 5 ops"), Label),
Stack.Num(), 5);
//---------------------------------------------------------------------
// 1. ÉQUIVALENCE — contre ComputeSurfaceTerrainZ, en ALTITUDE
//---------------------------------------------------------------------
int32 NumDiff = 0, WorstIdx = -1;
float WorstDelta = 0.0f, WorstOld = 0.0f;
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y;
const float Old = Gen->ComputeSurfaceTerrainZ(X, Y, P);
const float New = Stack.EvalHeight(X, Y);
if (!BitEqual(Old, New))
{
++NumDiff;
const float Delta = FMath::Abs(Old - New);
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; WorstOld = Old; }
}
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("%s: bit-identical across %d samples. The height-space decomposition ")
TEXT("reproduces ComputeSurfaceTerrainZ exactly."), Label, NumHeightSamples));
}
else
{
// Pas de gradation ULP ici, à dessein : ce sont des ALTITUDES. Depuis /fp:precise la
// barre est l'égalité binaire, et un écart de hauteur se voit dans le monde.
AddError(FString::Printf(
TEXT("%s: %d of %d samples differ from ComputeSurfaceTerrainZ (largest |delta| ")
TEXT("%.9g voxels at (%.0f, %.0f), where the reference height is %.4f). These are ")
TEXT("ALTITUDES, not densities -- this is a real port error, not rounding. Check, ")
TEXT("in order: the op ORDER (structural -> cliff -> terrace -> layer lines -> ")
TEXT("beach), the terrace's `* Relief` gate (that is the original's `* M`), the ")
TEXT("cliff resampling the STRUCTURAL field rather than the modified height, and ")
TEXT("the noise offsets (3.1/5.7/0.7, 11/22/1.3, 99/77/0.9, 7.3/2.1/0.5)."),
Label, NumDiff, NumHeightSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstOld));
}
//---------------------------------------------------------------------
// 2. INVARIANCE DE FENÊTRE
//---------------------------------------------------------------------
// Une pile de hauteur alimente le cache de colonne T1.a, qui est PARTAGÉ sur toute la pile
// verticale de chunks. Une impureté ici ne fait pas une couture locale : elle se propage à
// tous les Z d'un coup (AUDIT §6.3).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumHeightSamples);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
Ref[i] = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumHeightSamples, 1200 + Block + SeedSalt, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(*FString::Printf(
TEXT("%s: the height stack is window-invariant across order and threads"), Label),
Impure.load(), 0);
}
//---------------------------------------------------------------------
// 3. LES MAJORANTS DE DÉPLACEMENT SONT-ILS HONNÊTES ?
//---------------------------------------------------------------------
// `MaxDisplacement` servira à borner une colonne pour un `ClassifyBox` de heightfield, la
// même mécanique qui fait prouver 36-40 tuiles sur 60 à la dalle. Un majorant FAUX serait
// un TROU, donc on le teste par force brute AVANT de construire quoi que ce soit dessus.
//
// On mesure le déplacement des trois mods bornables en comparant la pile complète à une
// pile tronquée (source + cliff seuls) : la différence est exactement ce que terrace +
// layer lines + plage ont déplacé.
{
FVoxelHeightStack Base;
const IVoxelHeightOp* Structural = nullptr;
Base.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural));
Base.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural));
FVoxelHeightStack Bounded;
const IVoxelHeightOp* Structural2 = nullptr;
Bounded.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural2));
Bounded.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural2));
Bounded.Add(VoxelHeightOps::MakeTerraceHeightMod(P));
Bounded.Add(VoxelHeightOps::MakeLayerLineHeightMod(P));
Bounded.Add(VoxelHeightOps::MakeBeachHeightMod(P));
const float Claimed = FMath::Max(P.TerraceStrength > 0.0f ? P.TerraceHeight : 0.0f, 0.0f)
+ FMath::Max(P.LayerLineSpacing > 0.0f ? P.LayerLineDepth : 0.0f, 0.0f)
+ FMath::Max(P.WaterLevelRelative > 0.0f ? P.BeachWidth : 0.0f, 0.0f);
float WorstObserved = 0.0f;
int32 NumOverBound = 0;
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y;
const float Moved = FMath::Abs(Bounded.EvalHeight(X, Y) - Base.EvalHeight(X, Y));
WorstObserved = FMath::Max(WorstObserved, Moved);
if (Moved > Claimed) { ++NumOverBound; }
}
TestEqual(*FString::Printf(
TEXT("%s: no sample exceeds the claimed MaxDisplacement (a false bound is a hole)"),
Label),
NumOverBound, 0);
AddInfo(FString::Printf(
TEXT("%s: MaxDisplacement claims %.3f voxels, worst observed %.3f (%.0f%% of the ")
TEXT("claim). A loose bound only costs CPU later; a tight-but-wrong one would be a hole."),
Label, Claimed, WorstObserved,
Claimed > 0.0f ? 100.0f * WorstObserved / Claimed : 0.0f));
}
};
//=========================================================================
// DEUX PASSES — et la seconde est celle qui compte
//=========================================================================
const UVoxelStrateDefinition* SurfaceDef =
World.StrateManager->GetStrateForChunk(FIntVector(0, 0, MidChunkZ));
if (!SurfaceDef)
{
AddError(TEXT("No strate definition resolved for the SurfaceWorld slot's mid chunk."));
return false;
}
// Les bornes Z de runtime sont posées à la main : `GetSlabParamsForChunk` a un équivalent pour
// la dalle, mais le chemin surface passe par `ResolveSurfaceChunkParams`, qui est privé et
// mêle la résolution de biome. La pile de hauteur ne dépend que des params + du seed, donc
// fournir les params directement est à la fois suffisant et plus lisible en cas d'échec.
FSurfaceGenerationParams Defaults = SurfaceDef->SurfaceParams;
Defaults.StrateTopWorldZ = (float)TopVoxelZ;
Defaults.StrateBottomWorldZ = (float)BottomVoxelZ;
RunForParams(Defaults, TEXT("SurfaceWorld(defaults)"), 0);
// ⚠️ LA PASSE LOAD-BEARING. Les ops de terrain F20 sont éteints par défaut, donc la passe
// ci-dessus n'exerce que la source structurelle et laisse les QUATRE modificateurs — c'est-à-
// dire tout ce qui est nouveau dans cette décomposition — non testés. Celle-ci les allume.
FSurfaceGenerationParams AllOps = Defaults;
EnableAllTerrainOps(AllOps);
RunForParams(AllOps, TEXT("SurfaceWorld(all terrain ops on)"), 64);
//=========================================================================
// ÉTAPE 2a — LE PONT VERS L'ESPACE DENSITÉ
//=========================================================================
// `FSurfaceColumnSource` consomme les DEUX piles de hauteur (sol + voûte) et rend une densité.
// La référence est `GetSurfaceDensity`, qui est exactement la variante **sans overhang**
// (il passe `OverhangAmp = 0`) et **sans biomes** (ParamsD == ParamsN, poids 0) — donc la
// comparaison est nette plutôt qu'approximative.
//
// ⚠️ Ce que ce bloc NE teste PAS, et qu'il ne faut pas croire testé : l'overhang et le mélange
// de biomes. Tous deux arrivent à l'étape 2b, avec le chemin CACHÉ pour référence — c'est le
// seul qui les calcule.
{
// ⚠️ `OverhangStrength = 0` EXPLICITEMENT : `GetSurfaceDensity` passe `OverhangAmp = 0`,
// donc il n'en calcule aucun. Comparer une pile qui en produit à une référence qui n'en
// produit pas ferait échouer le test pour la seule raison que la référence est incomplète.
// L'overhang a sa propre passe juste en dessous, avec le bon oracle.
FSurfaceGenerationParams P = AllOps;
P.OverhangStrength = 0.0f;
FVoxelOpStack Stack;
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// 1 source + 1 overhang + 3 structurels. L'op overhang est présent mais inerte ici
// (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
TestEqual(TEXT("the surface density stack is source + overhang + 3 structural"),
Stack.Num(), 5);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
float WorstDelta = 0.0f;
FRandomStream Rng(5150);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
const float Y = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
const float Z = (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ);
// ParamsD == ParamsN, poids 0 ⇒ une seule évaluation, pas de biomes.
const float Old = Gen->GetSurfaceDensity(X, Y, Z, P, P, 0.0f);
const float New = Stack.EvalMC(X, Y, Z);
if (!BitEqual(Old, New))
{
++NumDiff;
const float Delta = FMath::Abs(Old - New);
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; }
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("SurfaceWorld density stack: bit-identical to GetSurfaceDensity across %d ")
TEXT("samples. The height stacks feed the density space correctly."),
NumHeightSamples));
}
else
{
AddError(FString::Printf(
TEXT("SurfaceWorld density stack: %d of %d samples differ (largest |delta| %.9g); ")
TEXT("%d cross the isosurface. Since /fp:precise the bar is bit-identity, so this ")
TEXT("is a real port error. Check, in order: the combine (Density = max(TerrainZ - Z, ")
TEXT("Z - CeilSurf)), the sky-cap transcription (warp offsets 0.71/2.3/3.3 and ")
TEXT("6.1/0.19/4.7, the abs() on roughness, the ridge *0.5+0.5), and the order of ")
TEXT("the structural post ops."),
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree));
}
TestEqual(TEXT("surface: no sample lands on the opposite side of the isosurface"),
NumSideDisagree, 0);
}
//=========================================================================
// ÉTAPE 2b — L'OVERHANG, contre le SEUL oracle qui le calcule
//=========================================================================
// `GetSurfaceDensity` passe `OverhangAmp = 0`. La seule référence est donc le chemin caché :
// `ComputeSurfaceColumn` (qui résout le gate et la direction amont par colonne) suivi de
// `SurfaceDensityFromColumn` (qui applique l'union par voxel). Les deux viennent d'être
// exposées pour ça.
//
// C'est aussi la passe qui vérifie le MÉMO DE COLONNE de `FSurfaceColumnSource` : l'op overhang
// lit la colonne produite par la source, et s'ils divergeaient d'un XY, l'union se ferait au
// mauvais endroit. Un mémo mal clé se verrait ici.
{
FSurfaceGenerationParams P = AllOps;
P.OverhangStrength = 0.8f;
P.OverhangSlopeThreshold = 0.12f;
P.OverhangHeight = 14.0f;
P.OverhangReach = 10.0f;
P.OverhangFrequency = 0.05f;
P.OverhangZScale = 0.6f;
FVoxelOpStack Stack;
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
// Pas de biomes : contexte vide ⇒ ComputeSurfaceColumn retombe sur BaseSurface pour les
// deux côtés, poids 0. C'est exactement ce que la pile fait aujourd'hui.
FBiomeContext EmptyCtx;
TArray<FSurfaceGenerationParams> NoBiomeParams;
FChunkBiomeCache BiomeCache;
int32 NumDiff = 0, NumSideDisagree = 0, NumInWindow = 0;
float WorstDelta = 0.0f;
FRandomStream Rng(1337);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
const float Y = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
float TerrainZ = 0.0f, CeilSurf = 0.0f, Amp = 0.0f, DirX = 0.0f, DirY = 0.0f;
Gen->ComputeSurfaceColumn(X, Y, MidChunkZ, P, EmptyCtx, NoBiomeParams, BiomeCache,
TerrainZ, CeilSurf, Amp, DirX, DirY);
// Échantillonner DANS la fenêtre d'overhang la moitié du temps : un tirage uniforme sur
// toute la strate la raterait presque toujours, et le test serait vert sans avoir
// exercé l'op une seule fois — le même piège que `WaterLevelRelative` plus haut.
float Z;
if ((i & 1) && Amp > 0.0f)
{
Z = TerrainZ + P.OverhangHeight * ((float)(i % 97) / 97.0f);
++NumInWindow;
}
else
{
Z = (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ);
}
const float Old = Gen->SurfaceDensityFromColumn(X, Y, Z, TerrainZ, CeilSurf,
Amp, DirX, DirY, P);
const float New = Stack.EvalMC(X, Y, Z);
if (!BitEqual(Old, New))
{
++NumDiff;
WorstDelta = FMath::Max(WorstDelta, FMath::Abs(Old - New));
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("Overhang: bit-identical to SurfaceDensityFromColumn across %d samples, %d of ")
TEXT("them deliberately inside the overhang window. The per-column memo hands the ")
TEXT("source's column to the overhang op correctly."),
NumHeightSamples, NumInWindow));
}
else
{
AddError(FString::Printf(
TEXT("Overhang: %d of %d samples differ (largest |delta| %.9g); %d cross the ")
TEXT("isosurface; %d samples were inside the window. Check, in order: the column ")
TEXT("memo key (does the overhang op see the SAME column as the source?), the ")
TEXT("window gate (Z > TerrainZ && Z <= TerrainZ + OverhangHeight), Frac and the ")
TEXT("ShiftV > 0.5 threshold, the shelf noise offsets (17.3/23.9/5.1 with the ")
TEXT("OverhangZScale Z term), and that the shift resamples the STRUCTURAL height."),
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree, NumInWindow));
}
TestEqual(TEXT("overhang: no sample lands on the opposite side of the isosurface"),
NumSideDisagree, 0);
if (NumInWindow == 0)
{
AddWarning(TEXT("No sample landed inside the overhang window, so the op was never ")
TEXT("actually exercised. Raise OverhangStrength or lower ")
TEXT("OverhangSlopeThreshold until this is well above zero."));
}
}
//=========================================================================
// LE COMBINER `Mask` — mélange de biomes (§5 : le prototype de la Phase 3)
//=========================================================================
// Testé contre un champ de biomes SYNTHÉTIQUE plutôt que contre le résolveur Voronoï réel, et
// c'est le bon choix ici : le vrai résolveur est déjà couvert par ses propres tests, alors
// qu'un champ synthétique permet de balayer le poids de 0 à 1 de façon CONTINUE et de vérifier
// l'identité `blend(w) == lerp(A, B, w)` sur toute la plage — y compris les deux bouts, où une
// erreur d'inversion (`1-w` au lieu de `w`) se cache le mieux.
//
// Tested against a SYNTHETIC field rather than the real Voronoi resolver: the resolver has its
// own tests, while a synthetic field lets the weight be swept continuously from 0 to 1, which is
// where an inverted lerp hides.
{
// Deux jeux de params franchement différents : si le mélange était un no-op, ou prenait le
// mauvais côté, l'écart serait énorme plutôt que subtil.
FSurfaceGenerationParams A = Defaults;
FSurfaceGenerationParams B = Defaults;
A.ElevationRange = 40.0f; A.MountainStrength = 0.2f;
B.ElevationRange = 12.0f; B.MountainStrength = 0.9f;
B.BaseGroundRelative = FMath::Clamp(A.BaseGroundRelative + 0.15f, 0.0f, 1.0f);
TArray<FSurfaceGenerationParams> PerBiome;
PerBiome.Add(A);
PerBiome.Add(B);
/** Champ synthétique : biome 0 dominant, biome 1 voisin, poids imposé par le test. */
class FFixedWeightField final : public IVoxelBiomeField
{
public:
float W = 0.0f;
FVoxelBiomeWeights SampleAt(float, float) const override
{
FVoxelBiomeWeights Out;
Out.Dominant = 0; Out.Neighbor = 1; Out.NeighborWeight = W;
return Out;
}
};
FFixedWeightField FieldA;
// Les deux piles de référence, non mélangées.
FVoxelHeightStack StackA, StackB;
VoxelHeightOps::BuildSurfaceHeightStack(StackA, A, World.Settings->Seed);
VoxelHeightOps::BuildSurfaceHeightStack(StackB, B, World.Settings->Seed);
FVoxelHeightStack Blended;
Blended.Add(VoxelHeightOps::MakeBiomeBlendHeightSource(PerBiome, World.Settings->Seed, &FieldA));
const float Weights[] = { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f };
int32 NumWrong = 0;
float WorstDelta = 0.0f;
for (const float W : Weights)
{
FieldA.W = W;
for (int32 i = 0; i < 400; ++i)
{
const float X = (float)((i % 20) * 11);
const float Y = (float)((i / 20) * 13);
const float HA = StackA.EvalHeight(X, Y);
const float HB = StackB.EvalHeight(X, Y);
// ⚠️ L'attendu doit reproduire la MÊME expression que l'op, `FMath::Lerp` compris :
// écrire `HA + (HB - HA) * W` à la place testerait l'algèbre, pas le code.
const float Expect = (W > 0.0f) ? FMath::Lerp(HA, HB, W) : HA;
const float Got = Blended.EvalHeight(X, Y);
if (!BitEqual(Expect, Got))
{
++NumWrong;
WorstDelta = FMath::Max(WorstDelta, FMath::Abs(Expect - Got));
}
}
}
TestEqual(TEXT("biome blend: heights lerp between the two biomes' full stacks, bit-exactly"),
NumWrong, 0);
// ⚠️ Rapporter le SUCCÈS, pas seulement l'échec. Un `TestEqual` qui passe n'écrit rien, et
// une vérification silencieuse est indiscernable d'une vérification qui n'a jamais tourné —
// exactement le piège signalé pour `WaterLevelRelative` et la fenêtre d'overhang, dans
// lequel ce bloc-ci était tombé au premier jet. Le compte rend l'exécution visible.
// Report success, not just failure: a silent pass is indistinguishable from a check that
// never ran.
AddInfo(FString::Printf(
TEXT("Biome blend: %d (weight, point) pairs across weights 0/0.25/0.5/0.75/1.0 all match ")
TEXT("Lerp of the two biomes' full height stacks bit-exactly. Weight 0 returns the ")
TEXT("dominant untouched and weight 1 the neighbour, so the lerp is not inverted."),
(int32)UE_ARRAY_COUNT(Weights) * 400));
if (NumWrong > 0)
{
AddError(FString::Printf(
TEXT("Biome blend wrong on %d of 2000 (weight, point) pairs, worst |delta| %.6g. ")
TEXT("Check: is the lerp toward the NEIGHBOUR (weight 0 must give the dominant ")
TEXT("untouched, weight 1 the neighbour), and does each biome's stack compute its ")
TEXT("OWN relief for its OWN terrace gate rather than sharing the dominant's?"),
NumWrong, WorstDelta));
}
// Le plafond SÉLECTIONNE au lieu de mélanger — comportement d'origine, reproduit tel quel.
{
FieldA.W = 1.0f; // le voisin l'emporterait si le plafond mélangeait
FVoxelHeightStack CeilSel;
CeilSel.Add(VoxelHeightOps::MakeBiomeSelectCeilingSource(PerBiome, World.Settings->Seed, &FieldA));
FVoxelHeightStack CeilDominant;
VoxelHeightOps::BuildSurfaceCeilingStack(CeilDominant, A, World.Settings->Seed);
int32 NumCeilWrong = 0;
for (int32 i = 0; i < 200; ++i)
{
const float X = (float)((i % 20) * 11), Y = (float)((i / 20) * 13);
if (!BitEqual(CeilSel.EvalHeight(X, Y), CeilDominant.EvalHeight(X, Y))) { ++NumCeilWrong; }
}
TestEqual(TEXT("biome ceiling SELECTS the dominant (never blends), even at weight 1"),
NumCeilWrong, 0);
AddInfo(TEXT("Biome ceiling: 200 points at neighbour-weight 1.0 still return the ")
TEXT("DOMINANT biome's sky cap, i.e. it selects rather than blends -- the "
"original's behaviour, and the case a \"blend everything\" refactor would "
"silently break."));
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,142 @@
// VoxelForgeLargeSeedTest.cpp
// AUDIT §C1 — le monde doit rester un monde quand la seed est grande.
// AUDIT C1 — the world must still be a world at a large seed.
//
// LE BUG / THE BUG
// Les sites de bruit s'écrivaient `WorldX * Freq + (float)Seed * 97.7f`. Un float a 24 bits de
// mantisse, donc à magnitude `V` l'ULP vaut `V · 2⁻²³` :
//
// Seed = 1 000 → terme 9.8e4 → ULP 0.012 → correct
// Seed = 100 000 → terme 9.8e6 → ULP 1.2 → le bruit se cale sur un treillis
// Seed = 10 000 000 → terme 9.8e8 → ULP 117 → la coordonnée du voxel (~0.02/voxel) est
// ENTIÈREMENT absorbée ⇒ champ CONSTANT
//
// `ChangeSeed(int32)` est `BlueprintCallable` : un `FMath::Rand()` (jusqu'à 2³¹) suffit à produire
// un monde plat. Ça n'a jamais été vu parce que les seeds de test restaient petites — et la fixture
// des autres tests garde délibérément une petite seed, ce qui veut dire qu'**aucun autre test de ce
// dossier ne peut voir ce bug**.
//
// ⚠️ POURQUOI LES TESTS D'ÉQUIVALENCE NE L'AURAIENT JAMAIS ATTRAPÉ
// Ils comparent la pile d'opérateurs au `switch` d'archétype. Les deux lisent la MÊME expression
// fautive, donc les deux s'effondrent EXACTEMENT DE LA MÊME FAÇON à grande seed : bit-identiques,
// verts, et tous les deux plats. Un oracle qui partage le bug de l'implémentation ne le voit pas.
// **Ce test-ci ne compare rien à rien : il vérifie une PROPRIÉTÉ** — le terrain doit varier.
//
// The equivalence tests compare the op stack to the archetype switch. Both read the same faulty
// expression, so at a large seed both collapse identically: bit-identical, green, and both flat. An
// oracle that shares the implementation's bug cannot see it. This test asserts a PROPERTY instead.
//
// LE CORRECTIF, ET POURQUOI L'ÉVIDENT ÉTAIT FAUX
// Borner `SeedF` en gardant le `· 97.7` laisse le terme atteindre 1.6e6 (ULP 0.19 = 9.5× le pas par
// voxel) : moins spectaculaire, toujours cassé, ticket refermé. C'est le MULTIPLICATEUR qu'il faut
// supprimer. `VoxelHash::SeedOffset(Seed, SiteKey)` rend un décalage déjà dans les unités finales,
// borné à [0, 16383], salé par site — donc deux seeds doivent collisionner sur les ~50 sites à la
// fois pour donner le même monde, au lieu d'un seul bucket partagé.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelGenerator.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeLargeSeedTest,
"VoxelForge.Determinism.LargeSeedSurvives",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
/** Les seeds à éprouver. La première est le régime « ça marchait par chance », les suivantes
* sont le champ s'effondrait. La dernière est ce qu'un `FMath::Rand()` produit. */
const int32 SeedsUnderTest[] = { 1337, 100000, 10000000, 2000000000 };
/** Combien de hauteurs distinctes faut-il pour dire « ce n'est pas plat » ? Un champ effondré
* rend UNE valeur (ou deux ou trois par effet de bord d'arrondi). Un terrain sain en rend des
* centaines sur 400 échantillons. Le seuil est bas exprès : on teste « le bruit existe-t-il
* encore », pas « est-il joli ». */
constexpr int32 MinDistinctHeights = 50;
}
bool FVoxelForgeLargeSeedTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
bool bAnyCollapse = false;
for (const int32 Seed : SeedsUnderTest)
{
FTestWorld World;
World.Build(Seed);
if (!World.IsValid())
{
AddError(FString::Printf(TEXT("Seed %d: %s"), Seed, *World.WhyInvalid()));
continue;
}
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no SurfaceWorld slot."));
return false;
}
const UVoxelStrateDefinition* Def =
World.StrateManager->GetStrateForChunk(
FIntVector(0, 0, ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE));
if (!Def) { AddError(TEXT("No SurfaceWorld definition.")); return false; }
FSurfaceGenerationParams P = Def->SurfaceParams;
P.StrateTopWorldZ = (float)TopVoxelZ;
P.StrateBottomWorldZ = (float)BottomVoxelZ;
// Échantillonner le HEIGHTFIELD plutôt que la densité : c'est là que le bruit vit, et une
// hauteur est directement lisible ("le terrain est-il plat ?") là où une densité demande
// d'être interprétée.
TSet<uint32> DistinctBits;
float MinH = FLT_MAX, MaxH = -FLT_MAX;
const UVoxelGenerator* Gen = World.Generator.Get();
for (int32 iy = 0; iy < 20; ++iy)
for (int32 ix = 0; ix < 20; ++ix)
{
// Pas de 7 voxels : assez large pour traverser plusieurs cellules de bruit, assez
// petit pour rester dans une région cohérente.
const float X = (float)(ix * 7);
const float Y = (float)(iy * 7);
const float H = Gen->ComputeSurfaceTerrainZ(X, Y, P);
DistinctBits.Add(*reinterpret_cast<const uint32*>(&H));
MinH = FMath::Min(MinH, H);
MaxH = FMath::Max(MaxH, H);
}
const int32 NumDistinct = DistinctBits.Num();
const float Range = MaxH - MinH;
if (NumDistinct < MinDistinctHeights)
{
bAnyCollapse = true;
AddError(FString::Printf(
TEXT("SEED %d COLLAPSED THE NOISE FIELD: only %d distinct heights across 400 ")
TEXT("samples (range %.4f voxels). This is AUDIT C1 — a seed offset large enough ")
TEXT("that the float ULP swallows the voxel coordinate, so the noise input is ")
TEXT("constant across many voxels and the terrain goes flat. Check that every noise ")
TEXT("site uses VoxelHash::SeedOffset(SeedU, K) and that no `SeedF * K` pattern has ")
TEXT("come back."),
Seed, NumDistinct, Range));
}
else
{
AddInfo(FString::Printf(
TEXT("Seed %d: %d distinct heights across 400 samples, range %.2f voxels. Field alive."),
Seed, NumDistinct, Range));
}
}
TestFalse(TEXT("no seed collapses the noise field (AUDIT C1)"), bAnyCollapse);
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,317 @@
// VoxelForgeOpStackIslandTest.cpp
// FloatingIslands — le portage qui fait tourner la pile À L'ENVERS.
// FloatingIslands — the port that runs the stack BACKWARDS.
//
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
// `VerticalShaftEquivalence` a mesuré la réutilisation À L'IDENTIQUE : trois opérateurs de Maze
// repris sans une ligne de changement. Celui-ci mesure quelque chose de plus fort, et de plus
// risqué pour l'abstraction : **la réutilisation PAR INVERSION**.
//
// Les quatre archétypes déjà portés partent tous de ROC et CREUSENT. FloatingIslands part du VIDE
// et REMPLIT. Si l'axe abstrait choisi (le SIGNE de la densité, convention interne positif = solide)
// est le bon, alors les deux extrémités de la pile doivent être les MÊMES opérateurs au signe près :
//
// FConstantFieldSource(+Base) ←→ FConstantFieldSource(-Base)
// FSdfConvertOp(Sign = -1) ←→ FSdfConvertOp(Sign = +1)
//
// Et c'est le cas : le seul opérateur neuf de ce portage est le blob d'île. Un archétype qui se
// réutilise en s'INVERSANT est une preuve plus forte qu'un archétype qui se réutilise à l'identique
// — le premier dit que l'abstraction a trouvé le bon axe, le second seulement que deux archétypes
// se ressemblaient.
//
// ET LE VERDICT DE BOÎTE : c'est ici que `ClassifyBox` peut rendre **AllAir** pour la première fois
// de tout le plugin. Une strate d'îles flottantes est, par construction, surtout vide ; aucun
// archétype de grotte n'a jamais su prouver « tout air » (`OPSTACK-DECOMPOSITION §7`). Le test
// compte les deux verdicts SÉPARÉMENT, parce qu'un total agrégé masquerait exactement ce gain-là.
//
// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10).
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackIslandTest,
"VoxelForge.OpStack.FloatingIslandEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumIslandSamples = 20000;
/**
* Les défauts génèrent bien des îles, mais un test qui les prend tels quels laisse la question
* « les échantillons sont-ils VRAIMENT tombés dedans ? » à la chance du seed. On force donc une
* densité d'îles haute, et surtout un `TopFlatten < 1` la branche du dôme de bord est le seul
* endroit `TopHalf` et `Edge²` interviennent, et elle est silencieusement morte à 1.0.
* (Même piège que `WaterLevelRelative` et la fenêtre d'overhang : un paramètre au repos est un
* opérateur non testé.)
*/
void EnableIslandFeatures(FFloatingIslandParams& P)
{
P.IslandDensity = 0.75f; // des îles dans presque chaque cellule du 3×3
P.TopFlatten = 0.55f; // < 1 ⇒ la branche du dôme de bord s'exécute
P.SurfaceRoughness = 4.0f; // la rugosité SDF partagée avec Maze et VerticalShafts
P.VerticalJitter = 0.6f; // des îles à des hauteurs différentes
P.ThicknessRatio = 0.7f;
}
}
bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotFloatingIsland, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no FloatingIslands slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotFloatingIsland."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
FFloatingIslandParams P = World.StrateManager->GetFloatingIslandParamsForChunk(
FIntVector(0, 0, MidChunkZ));
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
{
AddError(TEXT("The FloatingIslands strate has degenerate Z bounds, which sends ")
TEXT("GetFloatingIslandDensity down its early-out. The op stack has none by design."));
return false;
}
EnableIslandFeatures(P);
FVoxelOpStack Stack;
VoxelDensityOps::BuildFloatingIslandStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// void + blobs + roughness + fill + 3 structurels.
TestEqual(TEXT("the island stack is decomposed into 7 ops"), Stack.Num(), 7);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumIslandSamples);
{
FRandomStream Rng(60186);
for (int32 i = 0; i < NumIslandSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=========================================================================
// 1. ÉQUIVALENCE
//=========================================================================
// On compte SÉPARÉMENT le solide d'intérieur et le solide de seal : sur cet archétype la
// quasi-totalité du volume est de l'air, donc un « N solides » agrégé serait dominé par les
// deux bandes de seal et ne dirait RIEN sur les îles elles-mêmes.
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
int32 NumInsideIsland = 0, NumOpenVoid = 0;
float WorstDelta = 0.0f;
for (int32 i = 0; i < NumIslandSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetFloatingIslandDensity(X, Y, Z, P);
const float New = Stack.EvalMC(X, Y, Z);
const bool bInterior = (Z > InnerBot && Z < InnerTop);
if (bInterior && Old < 0.0f) { ++NumInsideIsland; } // solide loin des seals ⇒ une île
if (bInterior && Old >= 0.0f) { ++NumOpenVoid; }
if (!BitEqual(Old, New))
{
++NumDiff;
const float D = FMath::Abs(Old - New);
if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; }
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("FloatingIslands: bit-identical across %d samples (%d inside island rock away from ")
TEXT("the seal bands, %d in open void, so the void source, the blobs, the roughness and ")
TEXT("the fill were all exercised). The stack runs BACKWARDS -- void source + fill ")
TEXT("instead of rock source + carve -- using the SAME operators with the opposite ")
TEXT("sign. Only the blob source is new (OPSTACK-PLAN 2.5)."),
NumIslandSamples, NumInsideIsland, NumOpenVoid));
}
else
{
AddError(FString::Printf(
TEXT("FloatingIslands: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ")
TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ")
TEXT("this is a real port error. Check, in order: the C1 warp fix (BOTH paths must now ")
TEXT("use VoxelHash::SeedOffset(S, 0.0007f) -- if only one was changed, EVERY warped ")
TEXT("sample differs), then the SdfConvert SIGN (+1 fills, -1 carves), then the 'Isld' ")
TEXT("salt (0x49736C64), the roughness frequency (0.08 / 4 octaves here, NOT Maze's ")
TEXT("0.12 / 3), the per-island TaperEnd and TopFlatten dome branch, and the ")
TEXT("SmoothMin blend K = max(SDFBlendRadius, 0.01)."),
NumDiff, NumIslandSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSideDisagree));
}
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
if (NumInsideIsland == 0)
{
AddWarning(TEXT("No sample landed inside island rock away from the seal bands, so the blob ")
TEXT("source and the fill were never meaningfully exercised -- the equivalence ")
TEXT("above then only proves that two empty voids agree. Raise IslandDensity or ")
TEXT("IslandMaxRadius."));
}
//=========================================================================
// 2. INVARIANCE DE FENÊTRE
//=========================================================================
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params — et cette clé
// inclut délibérément `BoundarySealThickness`, que l'original omet alors que `SpreadZ` le lit
// (voir la note dans FIslandBlobSource::GetCells).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumIslandSamples);
for (int32 i = 0; i < NumIslandSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumIslandSamples, 3300 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(TEXT("the island stack is window-invariant across order and threads"),
Impure.load(), 0);
}
//=========================================================================
// 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin
//=========================================================================
{
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0;
FRandomStream Rng(24680);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 95;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < 60; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
if (bClaimsSolid) { ++NumProvedSolid; } else { ++NumProvedAir; }
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the blob source's Pad (does it cover the WARP amplitude ")
TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ")
TEXT("then the Z bound -- note there is NO lower bound, a thin thread ")
TEXT("of matter hangs below each island down the axis, so only the ")
TEXT("TOP may be used to reject."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1;
}
}
}
TestEqual(TEXT("every box verdict the island stack emits survives brute force"), NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ")
TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing,
NumProvedSolid, NumProvedAir, NumMixed));
if (NumProvedAir == 0)
{
AddWarning(TEXT("Zero tiles proved AllAir. The stack is still SOUND, but the whole perf ")
TEXT("argument for this archetype rests on that verdict, so it is worth ")
TEXT("knowing it did not fire. Most likely the blob source's Pad is so wide ")
TEXT("that every box finds an island within reach -- the same pessimism ")
TEXT("VerticalShafts has (0 of 60), for the same reason."));
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,344 @@
// VoxelForgeOpStackMazeTest.cpp
// PHASE 1, LE TEST QUI COMPTE — la pile d'opérateurs Maze contre GetMazeDensity.
// PHASE 1'S LOAD-BEARING TEST — the Maze operator stack against GetMazeDensity.
//
// CE QUE LA PHASE 1 DEVAIT PROUVER / WHAT PHASE 1 HAD TO PROVE
// Le déclencheur d'arrêt de `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe
// naturellement du code existant ? »** Réponse mesurée : oui. Maze se décompose en sept opérateurs
// sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de
// l'isosurface.
//
// ═════════════════════════════════════════════════════════════════════════════════════════
// ✅ MISE À JOUR 2026-07-27 : LE PLANCHER ULP N'EXISTE PLUS. C'ÉTAIT `/fp:fast`.
// ═════════════════════════════════════════════════════════════════════════════════════════
// `FPSemantics = Precise` sur le module (AUDIT §C9, posé pour le cross-play Linux/Windows) fait
// passer ce test à **BIT-IDENTIQUE**. La section ci-dessous décrit un état RÉVOLU ; elle est gardée
// parce qu'elle explique pourquoi les cinq expériences d'isolation avaient toutes échoué (sous
// `/fp:fast` le compilateur transforme selon le CONTEXTE — il n'y avait aucune variable à isoler)
// et parce qu'elle dit quoi regarder si la bit-identité régresse un jour.
//
// **Conséquence pratique : ce test est maintenant un instrument BEAUCOUP plus fin.** Le moindre
// écart est désormais une vraie trouvaille, pas du bruit à noter. La machinerie de gradation ULP
// est conservée exprès — c'est elle qui signalerait une régression du modèle flottant.
//
// UPDATE: the ULP floor is GONE — FPSemantics = Precise makes this test bit-identical. The section
// below describes a past state, kept because it explains why five isolation experiments all failed
// (under /fp:fast the compiler transforms by CONTEXT — there was no variable to isolate) and what to
// look at if bit-identity ever regresses.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// ⚠️ LE PLANCHER ULP (HISTORIQUE) — lire ceci avant de « corriger » un écart résiduel
// ─────────────────────────────────────────────────────────────────────────────────────────
// La pile reproduit `GetMazeDensity` à ~1-2 ULP près sur ~2 % des échantillons (ceux qui tombent
// dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le
// dernier arrondi). **Zéro échantillon ne traverse l'isosurface**, donc pas un triangle ne bouge.
//
// L'origine exacte de ce dernier arrondi n'a PAS été identifiée, après six cycles de build et cinq
// hypothèses toutes réfutées par la mesure (aller-retour FVector · fenêtre de rugosité · `/fp:fast`
// entre unités de compilation · contexte d'inlining · constante de compilation vs donnée
// d'exécution). Ce qui EST établi par la mesure :
//
// • le SDF est bit-identique sur 126/126 des écarts — le treillis, les hashs, l'ensemble d'arêtes
// et `VoxelSDF::Capsule` sont donc exacts ;
// • l'écart naît entièrement dans la conversion SDF→densité, au dernier arrondi ;
// • il est DÉTERMINISTE (mêmes échantillons, même delta, même coordonnée à chaque run) ;
// • il ne dépend ni de l'unité de compilation, ni de l'inlining, ni du modèle flottant.
//
// **Décision (Jahni, 2026-07-27) : on l'accepte et on avance.** Aucune décision du projet ne dépend
// de la réponse, et la chasse coûtait plus que l'information. Consigné comme point ouvert dans
// `AUDIT-2026-07.md §C10`.
//
// ⚠️ LA RÈGLE QUI EN DÉCOULE, ELLE, EST IMPORTANTE :
// **ne jamais faire tourner les deux chemins (switch d'archétype et pile d'opérateurs) dans le même
// monde, et ne jamais comparer leurs sorties pour égalité.** Ce n'est PAS un risque de désync entre
// clients — dans un même binaire le champ est prouvé pur (`VoxelForge.Determinism.DensityPurity`,
// bit-identique entre threads et ordres de requête) et tous les pairs exécutent le même chemin. Mais
// une strate à moitié migrée produirait une couture. Le vrai sujet multijoueur est ailleurs :
// `AUDIT §C9` (le défaut FP d'UBT diffère selon la toolchain).
//
// Never run both paths in one world and never compare their outputs for equality. This is NOT a
// client-desync risk — within one binary the field is proven pure and every peer runs the same path —
// but a half-migrated strate would produce a seam.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LA BARRE D'ACCEPTATION, ENCODÉE CI-DESSOUS / THE ACCEPTANCE BAR, ENCODED BELOW
// ─────────────────────────────────────────────────────────────────────────────────────────
// • ÉCHEC DUR : un seul échantillon qui change de côté de l'isosurface (la géométrie bouge).
// • INFO : des écarts à l'échelle de l'ULP (le plancher, attendu).
// • WARN : un écart plus grand — ÇA, c'est une vraie dérive de portage, et il faut chercher.
// Un test qui avertit à chaque portage serait ignoré par le portage qui compte.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackMazeTest,
"VoxelForge.OpStack.MazeEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumMazeSamples = 20000;
/** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */
bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out,
int32& OutTopVoxelZ, int32& OutBottomVoxelZ)
{
using namespace VoxelForgeTest;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotMaze, OutTopVoxelZ, OutBottomVoxelZ)) { return false; }
const int32 MidChunkZ = ((OutTopVoxelZ + OutBottomVoxelZ) / 2) / CHUNK_SIZE;
Out = World.StrateManager->GetMazeParamsForChunk(FIntVector(0, 0, MidChunkZ));
return true;
}
}
bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
FMazeGenerationParams MazeParams;
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!ResolveMazeParams(World, MazeParams, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no Maze slot. Check FTestWorld::Build's Archetypes[] ")
TEXT("against FTestWorld::SlotMaze."));
return false;
}
// GetMazeDensity court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde appartient
// à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate valide.
if (MazeParams.StrateTopWorldZ - MazeParams.StrateBottomWorldZ <= 0.0f)
{
AddError(FString::Printf(
TEXT("The Maze strate has degenerate Z bounds (top %.1f, bottom %.1f), which sends ")
TEXT("GetMazeDensity down its early-out. The op stack has no such early-out by design."),
MazeParams.StrateTopWorldZ, MazeParams.StrateBottomWorldZ));
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
FVoxelOpStack Stack;
VoxelDensityOps::BuildMazeStack(Stack, MazeParams, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les
// tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor (§2.5).
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 3 structural)"),
Stack.Num(), 7);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = MazeParams.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = MazeParams.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumMazeSamples);
{
FRandomStream Rng(31337);
for (int32 i = 0; i < NumMazeSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=========================================================================
// ÉQUIVALENCE — géométrie d'abord, bits ensuite.
//=========================================================================
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
float WorstDelta = 0.0f;
for (int32 i = 0; i < NumMazeSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetMazeDensity(X, Y, Z, MazeParams); // MC : négatif = solide
const float New = Stack.EvalMC(X, Y, Z);
if (!BitEqual(Old, New))
{
++NumDiff;
const float Delta = FMath::Abs(Old - New);
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; }
// `Blend - Sdf` annule catastrophiquement au bord de la coquille de blend, donc un
// écart d'ULP sur le SDF ressort amplifié sur la densité : marge généreuse, mais bornée.
const float UlpNoise = 16.0f * FMath::Max(FMath::Abs(Old), 1.0f) * FLT_EPSILON;
if (Delta > UlpNoise) { ++NumBeyondUlpNoise; }
}
// Le mesher ne lit que le SIGNE (D >= IsoLevel ⇒ air). Un désaccord de CÔTÉ bouge la géométrie.
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(TEXT("Bit-identical across %d samples."), NumMazeSamples));
}
else if (NumBeyondUlpNoise == 0)
{
AddInfo(FString::Printf(
TEXT("%d of %d samples differ, ALL at ULP scale (largest |delta| %.9g at (%.0f, %.0f, ")
TEXT("%.0f)), and 0 cross the isosurface -- not one triangle would move. This is the ")
TEXT("accepted floor; see the header comment and AUDIT-2026-07.md C10. The SDF itself is ")
TEXT("reproduced BIT FOR BIT, so the lattice, the hashes and VoxelSDF::Capsule are exact; ")
TEXT("only the final SDF->density rounding differs. Do not go hunting this again without ")
TEXT("reading C10 first -- five hypotheses have already been measured and refuted."),
NumDiff, NumMazeSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f));
}
else
{
AddWarning(FString::Printf(
TEXT("%d of %d samples differ and %d are TOO LARGE to be the accepted ULP floor (largest ")
TEXT("|delta| %.9g at (%.0f, %.0f, %.0f)); %d cross the isosurface. THIS one is real port ")
TEXT("drift, not the known floor. Check, in order: the roughness apply-window ")
TEXT("(R + SurfaceRoughness + 2), the carve blend (2.0), the noise frequency (0.12) and ")
TEXT("octave count (3), and the order of the structural post ops."),
NumDiff, NumMazeSamples, NumBeyondUlpNoise, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSolidDisagreements));
}
// Le SEUL échec dur : un désaccord de côté d'iso EST une différence de géométrie.
TestEqual(TEXT("no sample lands on the opposite side of the isosurface from the original"),
NumSolidDisagreements, 0);
//=========================================================================
// INVARIANCE DE FENÊTRE — la pile doit tenir les mêmes règles que le générateur.
//=========================================================================
// Le cache par cellule de la source de couloirs est `thread_local` : c'est exactement le genre
// d'endroit où une clé incomplète produit une couture (cf. AUDIT C2).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumMazeSamples);
for (int32 i = 0; i < NumMazeSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumMazeSamples, 500 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(TEXT("the op stack is window-invariant across query order and worker threads"),
Impure.load(), 0);
}
//=========================================================================
// LE VERDICT DE BOÎTE — le vrai prix perf : Maze n'a JAMAIS su sauter une tuile.
//=========================================================================
// ClassifyTile renvoie Mixed pour tout archétype de grotte, donc TunnelNetwork, Maze,
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du
// gain T1.d. Tout nombre > 0 ici est du saut de tuile que Maze n'a jamais eu.
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
FRandomStream Rng(24680);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 40;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < 60; ++t)
{
const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable
const int32 Extent = Step * Cells;
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ")
TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ")
TEXT("the lattice source's ExtraReach (does it cover the roughness ")
TEXT("amplitude AND the carve blend?), then the seal's forcing verdict."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1; // ce verdict est déjà mort, tuile suivante
}
}
}
TestEqual(TEXT("every box verdict the stack emits survives brute force (a false verdict is a hole)"),
NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("CellSize %.0f): %d proved uniform, %d Mixed. Today's ClassifyTile ")
TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ")
TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."),
SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
NumProved, NumMixed));
if (NumProved == 0)
{
AddWarning(TEXT("The stack proved no tile uniform, so it is not yet better than today's ")
TEXT("classifier for Maze. Not a correctness problem, but the perf case for ")
TEXT("the port rests on this number."));
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,295 @@
// VoxelForgeOpStackShaftTest.cpp
// VerticalShafts — le portage qui teste la RÉUTILISATION, pas seulement la fidélité.
// VerticalShafts — the port that tests REUSE, not just fidelity.
//
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
// Les portages précédents demandaient « la décomposition reproduit-elle l'original ? ». Celui-ci
// demande **« les opérateurs se RÉUTILISENT-ils vraiment entre archétypes ? »**, qui est la thèse
// de `OPSTACK-PLAN §2.5` et la seule raison de faire ce refactor plutôt que de nettoyer le `switch`.
//
// Trois des cinq opérateurs de VerticalShafts sont ceux de Maze, **repris sans une ligne de
// changement** : `ConstantRock`, `SdfRoughness`, `SdfCarve`. Dans le `switch`, `GetMazeDensity` et
// `GetVerticalShaftDensity` sont deux fonctions de ~100 lignes qui n'ont rien en commun à l'œil.
// En opérateurs, ce sont les mêmes trois ops avec une source différente et d'autres réglages
// (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`).
//
// **Si ce test passe en bit-à-bit, la réutilisation n'est plus une intention : c'est une mesure.**
//
// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10). Un écart
// est une vraie trouvaille, pas du bruit d'arrondi.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackShaftTest,
"VoxelForge.OpStack.VerticalShaftEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumShaftSamples = 20000;
/** Les ledges et les connecteurs sont éteints ou discrets par défaut. Un test sur les seuls
* défauts vérifierait les cylindres et laisserait les DEUX opérateurs intéressants au repos
* le même piège que `WaterLevelRelative` et la fenêtre d'overhang. */
void EnableShaftFeatures(FVerticalShaftParams& P)
{
P.CrossConnectChance = 0.65f; // des connecteurs, donc des capsules dans le SDF
P.ConnectorRadius = 3.5f;
P.LedgeSpacing = 11.0f; // des étagères, donc l'op forçant s'exécute
P.LedgeDepth = 2.5f;
P.SurfaceRoughness = 3.0f; // la rugosité SDF partagée avec Maze
}
}
bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotVerticalShafts, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no VerticalShafts slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotVerticalShafts."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
FVerticalShaftParams P = World.StrateManager->GetVerticalShaftParamsForChunk(
FIntVector(0, 0, MidChunkZ));
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
{
AddError(TEXT("The VerticalShafts strate has degenerate Z bounds, which sends ")
TEXT("GetVerticalShaftDensity down its early-out. The op stack has none by design."));
return false;
}
EnableShaftFeatures(P);
FVoxelOpStack Stack;
VoxelDensityOps::BuildVerticalShaftStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + shafts + roughness + carve + ledges + 3 structurels.
TestEqual(TEXT("the shaft stack is decomposed into 8 ops"), Stack.Num(), 8);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumShaftSamples);
{
FRandomStream Rng(80486);
for (int32 i = 0; i < NumShaftSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=========================================================================
// 1. ÉQUIVALENCE
//=========================================================================
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumInsideShaft = 0;
float WorstDelta = 0.0f;
for (int32 i = 0; i < NumShaftSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetVerticalShaftDensity(X, Y, Z, P);
const float New = Stack.EvalMC(X, Y, Z);
if (Old >= 0.0f) { ++NumInsideShaft; } // air ⇒ dans un puits/connecteur/étagère
if (!BitEqual(Old, New))
{
++NumDiff;
const float D = FMath::Abs(Old - New);
if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; }
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("VerticalShafts: bit-identical across %d samples (%d of them inside a shaft, so ")
TEXT("the cylinders, connectors, roughness, carve and ledges were all exercised). ")
TEXT("THREE of the five ops here are Maze's, reused unchanged -- operator reuse across ")
TEXT("archetypes is now measured rather than intended (OPSTACK-PLAN 2.5)."),
NumShaftSamples, NumInsideShaft));
}
else
{
AddError(FString::Printf(
TEXT("VerticalShafts: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ")
TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ")
TEXT("this is a real port error. Check, in order: the roughness FREQUENCY (0.1 here, ")
TEXT("NOT Maze's 0.12) and window (rough + 4, not R + rough + 2), the 'Shft' salt ")
TEXT("(0x53686674), the connector pair hash and its Z lerp between sealed bounds, and ")
TEXT("the ledge gate reading the POST-roughness Sdf rather than re-deriving it."),
NumDiff, NumShaftSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSideDisagree));
}
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
if (NumInsideShaft == 0)
{
AddWarning(TEXT("No sample landed inside a shaft, so the source, carve and ledge ops were ")
TEXT("never meaningfully exercised. Raise ShaftDensity or ShaftMaxRadius."));
}
//=========================================================================
// 2. INVARIANCE DE FENÊTRE
//=========================================================================
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params : c'est
// exactement le genre d'endroit où une clé incomplète produit une couture (AUDIT §C2).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumShaftSamples);
for (int32 i = 0; i < NumShaftSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumShaftSamples, 2200 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(TEXT("the shaft stack is window-invariant across order and threads"),
Impure.load(), 0);
}
//=========================================================================
// 3. LE VERDICT DE BOÎTE
//=========================================================================
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
FRandomStream Rng(13579);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 55;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < 60; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
// ⚠️ L'ÉTENDUE XY ÉTAIT ±48 VOXELS, POUR UN `ShaftSpacing` DE 55 : moins d'UNE cellule
// de puits. C'est le même piège que celui qui a coûté trois runs au test TunnelNetwork —
// un échantillonneur qui ne couvre pas une période du motif ne mesure pas le monde, il
// mesure un point du motif. ±440 = 8 périodes.
// The XY extent was ±48 voxels for a ShaftSpacing of 55 — less than one shaft cell, the
// same trap that cost the TunnelNetwork test three runs. ±440 covers 8 periods.
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: the shaft stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the shaft source's ExtraReach (does it cover the roughness ")
TEXT("amplitude AND the carve blend?), then the connector sweep (a ")
TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ")
TEXT("op's FillOnly."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1;
}
}
}
TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ")
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ")
TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ")
TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ")
TEXT("because a false verdict here leaves no geometry and no collision."),
SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing,
NumProved, NumMixed, NumUnsound));
if (NumProved == 0)
{
AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ")
TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ")
TEXT("CAPSULE test are the only two things that can return CarveOnly here, ")
TEXT("and ExtraReach inflates both -- check its value against ShaftMaxRadius ")
TEXT("before touching either test."));
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,425 @@
// VoxelForgeOpStackSlabTest.cpp
// PHASE 2, PREMIER PORTAGE — la pile Slab contre GetSlabDensity, sur LES DEUX archétypes.
// PHASE 2'S FIRST PORT — the Slab operator stack against GetSlabDensity, on BOTH archetypes.
//
// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE
// Trois choses, et la troisième est la raison d'être du portage :
//
// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. ✅ **BIT-IDENTIQUE depuis 2026-07-27**,
// quand `FPSemantics = Precise` (AUDIT §C9/§C10) a supprimé le résidu d'ULP : il venait de
// `/fp:fast`. Un changement de côté d'isosurface reste l'ÉCHEC DUR ; la gradation ULP est
// gardée comme détecteur de régression du modèle flottant, pas comme tolérance attendue.
// 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée contre FlatPlain ET
// CrystalChamber. `GetSlabDensity` ne les distingue par aucun branchement ; si la pile a
// besoin d'en faire un, la fusion est fausse et ce test le dit.
// ⚠️ La fixture ne règle que `GeneratorType`, donc les deux slots portent des params PAR
// DÉFAUT : à eux seuls ils exécutent la même configuration à deux profondeurs. C'est la
// TROISIÈME passe (`CrystalChamber(tuned)`, `CeilingRoughness` 6 → 20) qui fait réellement
// varier ce qui distingue les deux archétypes — et qui sert en même temps de pire cas aux
// bornes d'amplitude de `ClassifyBox`. Voir le bloc en bas de fichier.
// 3. LE VERDICT DE BOÎTE — et c'est ici que §3.1 se paie. `ClassifyTile` prouve ZÉRO tuile pour
// FlatPlain et CrystalChamber aujourd'hui. Depuis que les deux surfaces sont XY-PURES, leurs
// bornes en Z sont connues exactement (contrat [-1,1] de FBM), donc toute tuile entièrement
// sous le sol ou entre les deux bandes se prouve SANS échantillonner.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// ⚠️ CE TEST NE PEUT PAS DÉTECTER LE RETRAIT DU TERME EN Z — et c'est voulu
// ─────────────────────────────────────────────────────────────────────────────────────────
// `GetSlabDensity` a perdu son terme en Z en même temps que ce portage était écrit
// (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni). La pile est comparée à la fonction TELLE
// QU'ELLE EST MAINTENANT, donc ce test dit « le portage est fidèle » et ne dit RIEN sur le
// changement de génération — c'est exactement la séparation voulue :
//
// • ce test vert ⇒ la pile == la fonction de référence. Le portage est un refactor pur.
// • le monde a changé ⇒ imputable au retrait du terme en Z, ET À RIEN D'AUTRE.
//
// Sans cette séparation, un écart visuel serait inattribuable entre « j'ai changé le design » et
// « j'ai raté le portage ». C'est le test qui fait l'attribution, pas l'ordre des builds.
//
// This test compares the stack against the reference function AS IT IS NOW, so green here means the
// port is a pure refactor and ANY visual delta is attributable to the Z-term removal alone.
//
// ⚠️ Et la règle de §C10 tient toujours : ne jamais faire tourner les deux chemins dans le même
// monde, ne jamais comparer leurs sorties pour égalité ailleurs qu'ici.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackSlabTest,
"VoxelForge.OpStack.SlabEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumSlabSamples = 20000;
constexpr int32 NumSlabTiles = 60;
}
bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
//=========================================================================
// LA BATTERIE, PARAMÉTRÉE PAR ARCHÉTYPE
//=========================================================================
// Exécutée à l'identique sur FlatPlain et CrystalChamber. Si les deux passent avec la MÊME
// pile et la MÊME fabrique, la fusion des deux archétypes est démontrée plutôt qu'affirmée.
auto RunBattery = [&](const FSlabGenerationParams& SlabParams,
int32 TopVoxelZ, int32 BottomVoxelZ,
int32 SlotIndex, const TCHAR* SlotName)
{
// `GetSlabDensity` court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde
// appartient à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate
// valide, et `GetDensityAt` retombe sur le `switch` dans ce cas.
if (SlabParams.StrateTopWorldZ - SlabParams.StrateBottomWorldZ <= 0.0f)
{
AddError(FString::Printf(
TEXT("%s has degenerate Z bounds (top %.1f, bottom %.1f), which sends GetSlabDensity ")
TEXT("down its early-out. The op stack has no such early-out by design."),
SlotName, SlabParams.StrateTopWorldZ, SlabParams.StrateBottomWorldZ));
return;
}
FVoxelOpStack Stack;
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 3 structurels.
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 3 structural"), SlotName),
Stack.Num(), 5);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = SlabParams.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = SlabParams.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumSlabSamples);
{
FRandomStream Rng(31337 + SlotIndex);
for (int32 i = 0; i < NumSlabSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=====================================================================
// 1. ÉQUIVALENCE — géométrie d'abord, bits ensuite.
//=====================================================================
// ─────────────────────────────────────────────────────────────────────
// LE BON MÈTRE — corrigé 2026-07-27 après que la passe `tuned` a crié au loup
// ─────────────────────────────────────────────────────────────────────
// Première version : `16 · max(|Old|, 1) · FLT_EPSILON`, c.-à-d. l'ULP mesuré sur la
// DENSITÉ DE SORTIE. C'est le mauvais mètre, et il se trompe exactement là où le test
// regarde le plus : la densité vaut `min(Z - Sol, Plafond - Z)`, donc PRÈS DE L'ISOSURFACE
// la sortie tend vers 0 pendant que les intermédiaires (surfaces, Z monde, amplitudes de
// bruit) valent des CENTAINES. Un arrondi né à l'échelle 400 était jugé contre un mètre
// à l'échelle 1 — 400× trop serré.
//
// Mesuré : la passe `tuned` (rugosités ×2.25 et ×3.33) a vu ses écarts croître ×4.5, et
// son pire écart valait **0.345 ULP de |Z|**. Sous-ULP à l'échelle où l'erreur naît.
// L'erreur est donc proportionnelle à l'AMPLITUDE, ce qui est la signature d'un arrondi
// ordinaire, pas d'une transcription fausse.
//
// Le mètre correct est la magnitude des quantités D'OÙ VIENT l'erreur. Le test reste
// discriminant : une vraie dérive de portage (offset de bruit faux, `abs()` manquant,
// clamp oublié) déplace la surface de plusieurs VOXELS — 4 ordres de grandeur au-dessus
// de ce seuil, pas 4 fois.
//
// The first yardstick measured ULPs on the OUTPUT density, which tends to 0 near the
// isosurface while the intermediates are in the hundreds. Rounding born at scale ~400 was
// judged against a yardstick of scale 1. Real port drift moves the surface by voxels —
// four orders of magnitude above this bound, so the test stays discriminating.
const float SurfaceScale = FMath::Max(FMath::Abs(SlabParams.StrateTopWorldZ),
FMath::Abs(SlabParams.StrateBottomWorldZ));
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
float WorstDelta = 0.0f, WorstOld = 0.0f, WorstUlpsOfScale = 0.0f;
// Le pire cas PARMI LES DÉPASSEMENTS — c'est lui qui dit si un WARN est du bruit ou une dérive.
float WorstOutlierDelta = 0.0f, WorstOutlierOld = 0.0f, WorstOutlierUlps = 0.0f;
for (int32 i = 0; i < NumSlabSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetSlabDensity(X, Y, Z, SlabParams); // MC : négatif = solide
const float New = Stack.EvalMC(X, Y, Z);
if (!BitEqual(Old, New))
{
++NumDiff;
const float Delta = FMath::Abs(Old - New);
// L'échelle à laquelle CET échantillon calcule : la sortie, sa propre altitude, et
// les bornes de la strate. C'est le plus grand des trois qui porte l'arrondi.
const float Scale = FMath::Max3(FMath::Abs(Old), FMath::Abs(Z),
FMath::Max(SurfaceScale, 1.0f));
const float Ulps = Delta / (Scale * FLT_EPSILON);
if (Delta > WorstDelta)
{
WorstDelta = Delta; WorstIdx = i; WorstOld = Old; WorstUlpsOfScale = Ulps;
}
if (Delta > 16.0f * Scale * FLT_EPSILON)
{
++NumBeyondUlpNoise;
if (Delta > WorstOutlierDelta)
{
WorstOutlierDelta = Delta; WorstOutlierOld = Old; WorstOutlierUlps = Ulps;
}
}
}
// Le mesher ne lit que le SIGNE. Un désaccord de CÔTÉ bouge la géométrie.
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(TEXT("%s: bit-identical across %d samples."),
SlotName, NumSlabSamples));
}
else if (NumBeyondUlpNoise == 0)
{
AddInfo(FString::Printf(
TEXT("%s: %d of %d samples differ, ALL at ULP scale (largest |delta| %.9g = %.3f ULP ")
TEXT("of the working scale, where density = %.6g, at (%.0f, %.0f, %.0f)), and 0 cross ")
TEXT("the isosurface. Same accepted floor as Maze -- see AUDIT-2026-07.md C10."),
SlotName, NumDiff, NumSlabSamples, WorstDelta, WorstUlpsOfScale, WorstOld,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f));
}
else
{
// Le message porte maintenant LE DISCRIMINANT, pas seulement l'alarme : la densité au
// point fautif et l'écart exprimé en ULP de l'échelle de travail. Un dépassement à
// quelques ULP avec une densité proche de 0 est un artefact de mètre ; un dépassement
// à des milliers d'ULP est une vraie dérive. La différence se lit, elle ne se devine pas.
AddWarning(FString::Printf(
TEXT("%s: %d of %d samples differ and %d exceed the ULP bound. Worst OUTLIER: ")
TEXT("|delta| %.9g = %.1f ULP of the working scale, where density = %.6g. ")
TEXT("(Worst overall: |delta| %.9g at (%.0f, %.0f, %.0f).) %d cross the isosurface. ")
TEXT("READ THE ULP FIGURE BEFORE INVESTIGATING: a few ULP with a near-zero density is ")
TEXT("cancellation near the isosurface, not drift. Thousands of ULP IS drift -- check, ")
TEXT("in order: the floor/ceiling noise offsets (7.3/11.1 and 17.3+1000/19.7+2000/3000), ")
TEXT("the abs() on the ceiling noise, the ceiling clamp (FloorSurface + 2), the column ")
TEXT("blend (2.0) and the 0.15/0.7 jitter."),
SlotName, NumDiff, NumSlabSamples, NumBeyondUlpNoise,
WorstOutlierDelta, WorstOutlierUlps, WorstOutlierOld,
WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSolidDisagreements));
}
TestEqual(*FString::Printf(
TEXT("%s: no sample lands on the opposite side of the isosurface"), SlotName),
NumSolidDisagreements, 0);
//=====================================================================
// 2. INVARIANCE DE FENÊTRE
//=====================================================================
// Le cache 3×3 des colonnes est `thread_local` et sa clé n'est PAS le chunk mais le jeu de
// params + le seed. Si cette clé est incomplète, la couture apparaît ici.
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumSlabSamples);
for (int32 i = 0; i < NumSlabSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumSlabSamples, 700 + Block + SlotIndex * 32, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(*FString::Printf(
TEXT("%s: the op stack is window-invariant across order and threads"), SlotName),
Impure.load(), 0);
}
//=====================================================================
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
//=====================================================================
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
FRandomStream Rng(24680 + SlotIndex);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 60;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < NumSlabTiles; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved;
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ")
TEXT("ops is not conservative. Suspects, in order: the slab source's ")
TEXT("noise amplitude bounds (does FBM really honour [-1,1]?), the ")
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
TEXT("mod's reach (MaxRadius + blend)."),
SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1;
}
}
}
TestEqual(*FString::Printf(
TEXT("%s: every box verdict survives brute force (a false verdict is a hole)"),
SlotName),
NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed. Today's ")
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
SlotName, NumSlabTiles, SpanVoxels,
(float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing,
NumProved, NumMixed));
if (NumProved == 0)
{
AddWarning(FString::Printf(
TEXT("%s proved no tile uniform. Not a correctness problem, but the entire perf ")
TEXT("case for dropping the Z term rests on this number being well above zero -- ")
TEXT("a slab is mostly solid rock below the floor. Check that the sampled tile Z ")
TEXT("range actually reaches below FloorZ - FloorAmp."), SlotName));
}
}
};
//=========================================================================
// LES TROIS PASSES
//=========================================================================
auto ResolveSlot = [&](int32 SlotIndex, const TCHAR* SlotName,
FSlabGenerationParams& OutParams, int32& OutTop, int32& OutBottom) -> bool
{
if (!World.GetSlotVoxelZRange(SlotIndex, OutTop, OutBottom))
{
AddError(FString::Printf(
TEXT("The fixture layout has no %s slot. Check FTestWorld::Build's Archetypes[] ")
TEXT("against FTestWorld::Slot%s."), SlotName, SlotName));
return false;
}
const int32 MidChunkZ = ((OutTop + OutBottom) / 2) / CHUNK_SIZE;
OutParams = World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
return true;
};
FSlabGenerationParams FlatParams, CrystalParams;
int32 FlatTop = 0, FlatBottom = 0, CrystalTop = 0, CrystalBottom = 0;
if (ResolveSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain"), FlatParams, FlatTop, FlatBottom))
{
RunBattery(FlatParams, FlatTop, FlatBottom, FTestWorld::SlotFlatPlain, TEXT("FlatPlain"));
}
if (ResolveSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"),
CrystalParams, CrystalTop, CrystalBottom))
{
RunBattery(CrystalParams, CrystalTop, CrystalBottom,
FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"));
//=====================================================================
// LA PASSE QUI FAIT VRAIMENT LA DÉMONSTRATION
//=====================================================================
// ⚠️ La fixture ne règle QUE `GeneratorType` : FlatPlain et CrystalChamber y reçoivent des
// `FSlabGenerationParams` PAR DÉFAUT, donc identiques. Les deux passes ci-dessus exécutent
// en réalité la même configuration à deux profondeurs — ce qui est un test utile, mais qui
// ne démontre PAS « un opérateur, deux jeux de défauts » : `CeilingRoughness`, la seule
// chose qui distingue réellement CrystalChamber, n'y varie jamais.
//
// Cette passe-ci fait varier ce qui compte, et elle est aussi le PIRE CAS pour les bornes
// d'amplitude de `ClassifyBox` : un `CeilingRoughness` élevé élargit la bande du plafond et
// rend le clamp `Max(CeilZ - bruit, FloorSurface + 2)` beaucoup plus susceptible de mordre.
// Si un verdict de boîte est faux quelque part, c'est ici qu'il apparaît.
//
// The fixture only sets GeneratorType, so both slots get DEFAULT slab params — the two
// passes above are the same configuration at two depths. This pass varies what actually
// distinguishes CrystalChamber, and is simultaneously the worst case for the ClassifyBox
// amplitude bounds: a large CeilingRoughness widens the ceiling band and makes the
// FloorSurface + 2 clamp far more likely to bind.
FSlabGenerationParams Tuned = CrystalParams;
Tuned.CeilingRoughness = 20.0f; // vs 6.0 par défaut — de vraies stalactites
Tuned.CeilingRoughnessFrequency = 0.09f;
Tuned.FloorRoughness = 9.0f;
Tuned.ColumnDensity = 0.25f; // beaucoup plus de colonnes ⇒ FillOnly plus souvent
Tuned.ColumnMaxRadius = 11.0f;
RunBattery(Tuned, CrystalTop, CrystalBottom, 64, TEXT("CrystalChamber(tuned)"));
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
// VoxelForgeTestFixture.h
// Fixture partagée par les tests d'automatisation VoxelForge (Phase 0.5 de OPSTACK-PLAN.md).
// Shared fixture for the VoxelForge automation tests (OPSTACK-PLAN.md, Phase 0.5).
//
// WHY THIS EXISTS
// ---------------
// The interesting invariants (density purity across worker threads, ClassifyTile soundness)
// only fire on the REAL path — UVoxelGenerator::GetDensityAt — because that is where the
// thread_local per-chunk caches live (CP_*, GSurfColCache, the diff slots, the SDF cache).
// Calling GetSurfaceDensity / GetMazeDensity directly bypasses every one of them and would
// test almost nothing. GetDensityAt in turn needs a live UVoxelStrateManager, whose only
// entry point is Initialize(UVoxelSettings*, int32) reading TSoftObjectPtr pools.
//
// So the fixture builds a whole synthetic world in memory: transient strate definitions →
// a transient UVoxelSettings pointing at them → a real UVoxelStrateManager::Initialize.
//
// ⚠️ KNOWN RISK, stated rather than hidden: the settings hold TSoftObjectPtr, and we point
// them at TRANSIENT objects (/Engine/Transient.<name>). LoadSynchronous() resolves those via
// FindObject, which works for in-memory objects — but it is the one part of this fixture that
// has never been compiled or run. IsValid() below checks the layout actually materialised, and
// every test hard-FAILS with a clear message when it didn't. A silent skip would be worse than
// a failure: it would look like a pass.
//
// Everything is held by TStrongObjectPtr so the GC cannot eat the world mid-test.
#pragma once
#if WITH_DEV_AUTOMATION_TESTS
#include "CoreMinimal.h"
#include "UObject/StrongObjectPtr.h"
#include "UObject/Package.h"
#include "VoxelTypes.h"
#include "VoxelSettings.h"
#include "VoxelStrateTypes.h"
#include "VoxelStrateDefinition.h"
#include "VoxelStrateManager.h"
#include "VoxelDiffLayer.h"
#include "VoxelGenerator.h"
namespace VoxelForgeTest
{
/**
* FTestWorld a complete, headless VoxelForge world: settings + strate layout +
* generator + diff layer. No AActor, no UWorld, no PIE.
*
* The default layout stacks one strate of EVERY archetype (in ECaveGeneratorType order),
* so a single fixture exercises all eight density functions and their per-chunk caches,
* plus the gap-bedrock path when InterStrateGapChunks > 0.
*/
struct FTestWorld
{
TStrongObjectPtr<UVoxelSettings> Settings;
TStrongObjectPtr<UVoxelStrateManager> StrateManager;
TStrongObjectPtr<UVoxelDiffLayer> DiffLayer;
TStrongObjectPtr<UVoxelGenerator> Generator;
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> Definitions;
/** World Z (voxel coords) span actually covered by the layout — handy for picking samples. */
int32 TopChunkZ = 0;
int32 BottomChunkZ = 0;
/**
* Build the world.
*
* The default seed stays SMALL, but the reason has changed. It USED to be a workaround:
* AUDIT §C1 (unbounded `SeedF`) meant a large seed collapsed the noise fields to constants,
* which would have made a purity test pass trivially for the wrong reason.
*
* **§C1 is fixed** (`VoxelHash::SeedOffset` bounded and site-salted). The small default
* now just keeps failure messages comparable across tests. A large seed is no longer
* dangerous and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones
* (up to 2e9) to prove it stays that way.
*/
void Build(int32 InSeed = 1337, int32 InGapChunks = 2, bool bUseOperatorStack = false)
{
Settings = TStrongObjectPtr<UVoxelSettings>(
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
Settings->Seed = InSeed;
Settings->InterStrateGapChunks = InGapChunks;
// Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize()
// mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z
// dépendante du seed et un message d'échec impossible à relire.
// One strate per archetype, PINNED through FixedStrates rather than the pool:
// Initialize() shuffles the pool by seed, which would make the archetype → Z mapping
// seed-dependent and a failure message unreadable. Slot i == Archetypes[i].
static const ECaveGeneratorType Archetypes[] = {
ECaveGeneratorType::TunnelNetwork,
ECaveGeneratorType::FlatPlain,
ECaveGeneratorType::CrystalChamber,
ECaveGeneratorType::Maze,
ECaveGeneratorType::SurfaceWorld,
ECaveGeneratorType::VerticalShafts,
ECaveGeneratorType::FloatingIslands,
ECaveGeneratorType::Underwater,
};
const int32 NumArchetypes = (int32)UE_ARRAY_COUNT(Archetypes);
for (int32 i = 0; i < NumArchetypes; ++i)
{
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
Def->GeneratorType = Archetypes[i];
Def->StrateHeightInChunks = 4;
// L'OPT-IN de la pile d'opérateurs. Faux par défaut : les treize tests existants
// doivent continuer à exercer le `switch`, qui reste le comportement de référence.
// Seul le test de solidité de ClassifyTie côté pile le passe à vrai.
Def->bUseOperatorStack = bUseOperatorStack;
// Hard transitions: param blending across a boundary would make "which archetype
// owns this chunk" ambiguous, and these tests want an unambiguous mapping.
Def->TransitionType = EVoxelStrateTransition::Hard;
Definitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Def));
const TSoftObjectPtr<UVoxelStrateDefinition> SoftDef(Def);
Settings->FixedStrates.Add(i, SoftDef);
Settings->StratePool.Add(SoftDef); // fallback if a fixed entry fails to resolve
}
Settings->TotalStrates = NumArchetypes;
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
//=================================================================
// ⚠️ CHAQUE MONDE DE TEST OBTIENT UNE `LayoutVersion` UNIQUE DANS LE PROCESSUS
//=================================================================
// Ce n'est pas de la cosmétique, c'est une CONTAMINATION CROISÉE réelle entre tests, et
// elle n'était jusqu'ici masquée que par un accident.
//
// `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs
// rendaient tous les deux **1**. Or les caches par chunk de `GetDensityAt` sont clés sur
// `(ChunkCoord, LayoutVersion)` : deux mondes différents, même version, même chunk ⇒ le
// second se voit servir les params — ET le drapeau `CP_UseOpStack` — du premier.
// Personne ne l'a vu parce que `bUseOperatorStack` valait false partout : les deux
// mondes étaient d'accord par défaut. Le premier monde qui coche la case fait tomber
// cette coïncidence, dans les DEUX sens (il contamine, et il est contaminé).
//
// Un compteur de processus donne à chaque monde une version distincte, donc tout cache
// survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe
// (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler
// ne change pas le layout — seulement le compteur.
//
// Each test world gets a process-unique LayoutVersion. Two worlds both reporting 1 made
// GetDensityAt's per-chunk caches serve the previous world's params — and its
// CP_UseOpStack flag — for the same chunk coord. Invisible while every world agreed that
// the flag was false.
static int32 GWorldSerial = 0;
const int32 Bumps = ++GWorldSerial;
for (int32 b = 0; b < Bumps; ++b)
{
StrateManager->Initialize(Settings.Get(), Settings->Seed);
}
DiffLayer = TStrongObjectPtr<UVoxelDiffLayer>(
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
Generator = TStrongObjectPtr<UVoxelGenerator>(
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
Generator->InitializeSettings(Settings.Get());
Generator->SetStrateManager(StrateManager.Get());
Generator->SetDiffLayer(DiffLayer.Get());
CacheZBounds();
}
/** Re-run Initialize (bumps LayoutVersion) — the live-edit path AUDIT C2 is about. */
void Reinitialize()
{
StrateManager->Initialize(Settings.Get(), Settings->Seed);
CacheZBounds();
}
/** False when the soft-pointer resolve failed and no strate layout exists. */
bool IsValid() const
{
return StrateManager.IsValid() && StrateManager->GetNumStrates() > 0;
}
FString WhyInvalid() const
{
return TEXT("FTestWorld could not build a strate layout. Most likely the ")
TEXT("TSoftObjectPtr -> transient UVoxelStrateDefinition resolve failed inside ")
TEXT("UVoxelStrateManager::Initialize (LoadSynchronous on /Engine/Transient.*). ")
TEXT("See the header comment in VoxelForgeTestFixture.h. This is a FIXTURE ")
TEXT("failure, not a generator failure — do not read it as a density bug.");
}
/** Voxel-Z of the middle of the layout — a point guaranteed inside a real strate. */
float MidVoxelZ() const
{
return (float)((TopChunkZ + BottomChunkZ) / 2 * CHUNK_SIZE + CHUNK_SIZE / 2);
}
/** Layout slot index of each archetype — the Archetypes[] order in Build(), pinned via
* FixedStrates so it is stable across seeds. SurfaceWorld matters most: it is the only
* archetype ClassifyTile can currently prove anything about (besides bedrock gaps). */
static constexpr int32 SlotTunnelNetwork = 0;
static constexpr int32 SlotFlatPlain = 1;
static constexpr int32 SlotCrystalChamber = 2;
static constexpr int32 SlotMaze = 3;
static constexpr int32 SlotSurfaceWorld = 4;
static constexpr int32 SlotVerticalShafts = 5;
static constexpr int32 SlotFloatingIsland = 6;
static constexpr int32 SlotUnderwater = 7;
/** Voxel-Z span of one layout slot. False if the layout is shorter than expected. */
bool GetSlotVoxelZRange(int32 SlotIndex, int32& OutTopVoxelZ, int32& OutBottomVoxelZ) const
{
const TArray<FStrateSlot>& Layout = StrateManager->GetLayout();
if (!Layout.IsValidIndex(SlotIndex)) { return false; }
OutTopVoxelZ = Layout[SlotIndex].TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
OutBottomVoxelZ = Layout[SlotIndex].BottomChunkZ * CHUNK_SIZE;
return true;
}
private:
void CacheZBounds()
{
TopChunkZ = 0;
BottomChunkZ = 0;
for (const FStrateSlot& Slot : StrateManager->GetLayout())
{
TopChunkZ = FMath::Max(TopChunkZ, Slot.TopChunkZ);
BottomChunkZ = FMath::Min(BottomChunkZ, Slot.BottomChunkZ);
}
}
};
/**
* A spread of world sample points that deliberately crosses chunk boundaries, strate
* boundaries and bedrock gaps the exact conditions under which a per-chunk cache with a
* missing key input produces a wrong answer. Integer XY on purpose: that is the branch
* GetDensityAt's T1.a column cache actually takes (fractional XY bypasses the cache).
*/
inline void BuildSamplePoints(const FTestWorld& World, int32 Count, int32 Seed,
TArray<FVector>& OutPoints)
{
OutPoints.Reset(Count);
FRandomStream Rng(Seed);
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
for (int32 i = 0; i < Count; ++i)
{
// XY range spans several chunks either side of the origin so the (0,0) spine, the
// passages and plain interior rock all appear in the sample set.
const int32 X = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE);
const int32 Y = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE);
const int32 Z = Rng.RandRange(BottomVoxelZ, TopVoxelZ);
OutPoints.Add(FVector((float)X, (float)Y, (float)Z));
}
}
/** Deterministic shuffle of an index array — the "different query order" half of purity. */
inline void BuildShuffledOrder(int32 Count, int32 Seed, TArray<int32>& OutOrder)
{
OutOrder.Reset(Count);
for (int32 i = 0; i < Count; ++i) { OutOrder.Add(i); }
FRandomStream Rng(Seed);
for (int32 i = Count - 1; i > 0; --i)
{
OutOrder.Swap(i, Rng.RandRange(0, i));
}
}
/** Bit-exact float compare — NOT FMath::IsNearlyEqual. Window invariance is a bit property
* (ARCHITECTURE §8.4): a 1-ULP difference between two chunk windows is a visible seam. */
inline bool BitEqual(float A, float B)
{
return FMath::IsNaN(A) == FMath::IsNaN(B)
&& (FMath::IsNaN(A) || *reinterpret_cast<const uint32*>(&A) == *reinterpret_cast<const uint32*>(&B));
}
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -124,9 +124,13 @@ void VoxelCaveMorphology::BuildChunkCache(
// MaxInfluence = how far a room body / tunnel TUBE reaches PERPENDICULAR to its
// anchor — NOT its length. A room or tunnel whose anchor lies within MaxInfluence
// of a box can touch a voxel inside that box.
// Envelope conservatif / conservative bound: Lerp accepts inverted endpoints,
// so max(Min, Max) covers either radius without changing the authored roll.
const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius);
const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius);
const float MaxInfluence = FMath::Max(
Params.MaxRoomRadius,
Params.TunnelWarpStrength + Params.TunnelMaxRadius
RoomRadiusEnvelope,
Params.TunnelWarpStrength + TunnelRadiusEnvelope
) + Params.SDFBlendRadius;
const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f);
@@ -165,10 +169,10 @@ void VoxelCaveMorphology::BuildChunkCache(
// Vertical range for room CENTER placement.
//=========================================================================
// Buffer = seal thickness + max room half-height.
// This guarantees the tallest possible room (MaxRoomRadius * RoomHeightRatio)
// This guarantees the tallest possible room (RoomRadiusEnvelope * RoomHeightRatio)
// fits entirely within the seal boundary — no room gets its ceiling or floor
// cut flat by the seal. Smaller rooms have proportionally more margin.
const float RoomZBuffer = Params.MaxRoomRadius * Params.RoomHeightRatio;
const float RoomZBuffer = RoomRadiusEnvelope * Params.RoomHeightRatio;
const float StrateMinZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness + RoomZBuffer;
const float StrateMaxZ = Params.StrateTopWorldZ - Params.BoundarySealThickness - RoomZBuffer;
const float StrateRangeZ = StrateMaxZ - StrateMinZ;
@@ -868,9 +872,11 @@ float VoxelCaveMorphology::EvaluateSDF(
const FStrateGenerationParams& Params,
uint32 Seed, int32 StrateIndex)
{
const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius);
const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius);
const float Margin = FMath::Max(
Params.MaxRoomRadius,
Params.TunnelWarpStrength + Params.TunnelMaxRadius
RoomRadiusEnvelope,
Params.TunnelWarpStrength + TunnelRadiusEnvelope
) + Params.SDFBlendRadius;
FChunkSDFCache TempCache;
@@ -68,17 +68,23 @@ void UVoxelContentManager::NotifyShutdown()
// Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs.
const double Deadline = FPlatformTime::Seconds() + 3.0;
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) break;
FPlatformProcess::Yield();
}
WaitForDecorationTasks(Deadline);
DrainDecoResults();
ResetGridBuildState(NearGrid);
ResetGridBuildState(FarGrid);
}
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
{
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) return false;
FPlatformProcess::Yield();
}
return true;
}
void UVoxelContentManager::DrainDecoResults()
{
FDecoCellResult Discard;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,497 @@
// VoxelHeightOpStack.cpp
// Les cinq opérateurs d'espace-hauteur de SurfaceWorld.
// The five height-space operators of SurfaceWorld.
//
// FIDÉLITÉ / FIDELITY
// Chaque corps est une transcription LITTÉRALE du bloc correspondant de
// `SampleSurfaceStructuralZ` / `ComputeSurfaceTerrainZ` — mêmes offsets, mêmes octaves, même ordre
// d'opérations flottantes. Depuis que `FPSemantics = Precise` est posé (AUDIT §C9), l'égalité
// BIT À BIT est atteignable et atteinte pour Maze et Slab : c'est donc la barre ici aussi, et
// `VoxelForge.OpStack.SurfaceHeightEquivalence` la vérifie.
//
// ⚠️ LE DÉTOUR PAR `FVector` EST DÉLIBÉRÉ, comme ailleurs dans ce refactor : `FractalNoise3D` prend
// un `FVector` (donc des DOUBLES en UE5) et re-descend en float. Passer directement des floats
// saute un arrondi. Reproduire le détour, c'est reproduire l'arrondi.
// The FVector round-trip is deliberate: FVector is double in UE5, so the original rounds through a
// double. Going straight through floats skips a rounding step.
#include "VoxelHeightOp.h"
#include "VoxelCaveMorphology.h" // VoxelHash::SeedOffset — AUDIT §C1 (bounded, site-salted)
#include "VoxelNoise.h" // VoxelNoise::FBM / Ridged / Perlin3D
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
namespace
{
//=========================================================================
// HELPERS — les mêmes enveloppes que VoxelGenerator.cpp, transcrites
//=========================================================================
// `FractalNoise3D` et `RidgedNoise3D` sont `static` dans VoxelGenerator.cpp, donc invisibles
// ici. Elles sont recopiées à l'identique plutôt qu'exportées : les exporter changerait leur
// contexte d'inlining, et sous /fp:precise comme sous /fp:fast la règle est la même — on ne
// touche à rien de ce qui entoure une expression flottante qu'on veut reproduire.
FORCEINLINE float HFractalNoise3D(const FVector& Position, int32 Octaves = 4,
float Lacunarity = 2.0f, float Persistence = 0.5f)
{
return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z,
Octaves, Lacunarity, Persistence);
}
FORCEINLINE float HRidgedNoise3D(const FVector& Position, int32 Octaves = 4,
float Lacunarity = 2.0f, float Persistence = 0.5f)
{
return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z,
Octaves, Lacunarity, Persistence);
}
/** Transcription de `UVoxelGenerator::SampleRelief`. Champ [0,1] partagé avec la carte de
* biomes, pour que la géographie et le terrain qu'elle module restent d'accord. */
FORCEINLINE float HSampleRelief(float WorldX, float WorldY, uint32 SeedU,
float Frequency, float Contrast)
{
float R = HFractalNoise3D(FVector(
WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f),
WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f),
VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1]
R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f);
return SmoothStep01(R);
}
//=========================================================================
// SOURCE — CHAMP STRUCTUREL / STRUCTURAL HEIGHT FIELD
//=========================================================================
// Continents + montagnes + détail, sous une frame de domain-warp. Produit les DEUX canaux.
class FStructuralHeightSource final : public IVoxelHeightOp
{
public:
FStructuralHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed)
: P(InP), SeedU((uint32)InSeed) {}
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
{
float M = 1.0f;
InOut.Height = SampleZ(WorldX, WorldY, M); // Replace : racine de pile
InOut.Relief = M;
}
/**
* Le champ nu, exposé parce que `FCliffHeightMod` doit le -ÉCHANTILLONNER en différences
* centrées. C'est une dépendance réelle du code d'origine (`ComputeSurfaceTerrainZ` appelle
* `SampleSurfaceStructuralZ` quatre fois de plus), pas un raccourci : la pente doit venir du
* champ STRUCTUREL, sans rétroaction des ops, sinon le cliff se nourrirait de lui-même.
*/
float SampleZ(float WorldX, float WorldY, float& OutM) const
{
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
const float BottomZ = P.StrateBottomWorldZ;
const float GroundBase = BottomZ + H * P.BaseGroundRelative;
// Domain-warp des coords STRUCTURELLES (continents + montagnes). Le bruit de détail
// reste sur le vrai XY pour que les bosses fines restent nettes et décorrélées.
float QX = WorldX, QY = WorldY;
if (P.HeightWarpStrength > 0.0f)
{
const float WF = P.HeightWarpFrequency;
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f)));
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f)));
QX += wx * VOXEL_NOISE_SCALE * P.HeightWarpStrength;
QY += wy * VOXEL_NOISE_SCALE * P.HeightWarpStrength;
}
const float Relief = HSampleRelief(WorldX, WorldY, SeedU, P.ReliefFrequency, P.ReliefContrast);
const float M = FMath::Lerp(1.0f, Relief, P.ReliefStrength);
float Cont = HFractalNoise3D(FVector(
QX * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f),
QY * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f),
VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1]
float Detail = HFractalNoise3D(FVector(
WorldX * P.DetailFrequency + 11.0f,
WorldY * P.DetailFrequency + 22.0f,
VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1]
float Mountain = 0.0f;
if (P.MountainStrength > 0.0f)
{
float Ridge = HRidgedNoise3D(FVector(
QX * P.MountainFrequency + 99.0f,
QY * P.MountainFrequency + 77.0f,
VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1]
Ridge = Ridge * 0.5f + 0.5f; // [0,1] sommets
Mountain = Ridge * P.MountainStrength * M; // les montagnes ne montent qu'en haut relief
}
// Les plaines gardent une fraction du gonflement continental ; les hautes terres tout.
const float ContScale = FMath::Lerp(0.45f, 1.0f, M);
float Terrain = GroundBase
+ Cont * P.ElevationRange * 0.5f * ContScale
+ Mountain * P.ElevationRange
+ Detail * P.SurfaceRoughness;
OutM = M;
return Terrain;
}
// Une SOURCE pose l'altitude, elle ne la déplace pas : la notion de « déplacement max » ne
// s'applique pas. La borne d'une colonne se calcule à partir de la source elle-même
// (GroundBase ± ElevationRange ± SurfaceRoughness), pas ici — d'où FLT_MAX, honnête.
float MaxDisplacement() const override { return FLT_MAX; }
private:
FSurfaceGenerationParams P;
uint32 SeedU;
};
//=========================================================================
// MOD — FALAISE / CLIFF (raidissement conditionné par la pente)
//=========================================================================
class FCliffHeightMod final : public IVoxelHeightOp
{
public:
FCliffHeightMod(const FSurfaceGenerationParams& InP, const FStructuralHeightSource* InSrc)
: P(InP), Src(InSrc) {}
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
{
if (P.CliffStrength <= 0.0f || Src == nullptr) { return; }
const float D = FMath::Max(P.CliffSampleDist, 0.5f);
float Ms; // relief scratch — on ne veut que les hauteurs
const float Zxp = Src->SampleZ(WorldX + D, WorldY, Ms);
const float Zxm = Src->SampleZ(WorldX - D, WorldY, Ms);
const float Zyp = Src->SampleZ(WorldX, WorldY + D, Ms);
const float Zym = Src->SampleZ(WorldX, WorldY - D, Ms);
const float dZdX = (Zxp - Zxm) / (2.0f * D);
const float dZdY = (Zyp - Zym) / (2.0f * D);
const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY);
const float Thr = FMath::Max(P.CliffSlopeThreshold, 0.05f);
const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
if (SlopeGate > 0.0f)
{
const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym);
const float Gain = P.CliffStrength * SlopeGate * P.CliffSharpness;
InOut.Height += (InOut.Height - Ref) * Gain;
}
}
private:
FSurfaceGenerationParams P;
const FStructuralHeightSource* Src;
};
//=========================================================================
// MOD — TERRASSES / TERRACE (gaté par le relief : le canal Relief sert ICI)
//=========================================================================
class FTerraceHeightMod final : public IVoxelHeightOp
{
public:
explicit FTerraceHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
void Eval(float, float, FVoxelHeightSample& InOut) const override
{
if (P.TerraceStrength <= 0.0f || P.TerraceHeight <= 0.0f) { return; }
const float StepH = P.TerraceHeight;
const float T = InOut.Height / StepH;
const float K = FMath::FloorToFloat(T);
const float Frac = T - K;
const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(P.TerraceHardness, 0.0f, 1.0f));
const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f));
const float Stepped = (K + Fs) * StepH;
// `* InOut.Relief` : c'est le `* M` de l'original — la raison d'être du second canal.
InOut.Height = FMath::Lerp(InOut.Height, Stepped, P.TerraceStrength * InOut.Relief);
}
// Le terrace interpole VERS une hauteur quantifiée : l'écart ne dépasse jamais un palier.
float MaxDisplacement() const override
{
return (P.TerraceStrength > 0.0f) ? FMath::Max(P.TerraceHeight, 0.0f) : 0.0f;
}
private:
FSurfaceGenerationParams P;
};
//=========================================================================
// MOD — LIGNES DE STRATES / LAYER LINES
//=========================================================================
class FLayerLineHeightMod final : public IVoxelHeightOp
{
public:
explicit FLayerLineHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
void Eval(float, float, FVoxelHeightSample& InOut) const override
{
if (P.LayerLineDepth <= 0.0f || P.LayerLineSpacing <= 0.0f) { return; }
const float Phase = InOut.Height * (2.0f * PI / P.LayerLineSpacing);
InOut.Height -= FMath::Sin(Phase) * P.LayerLineDepth;
}
// `sin` ∈ [-1,1] ⇒ borne exacte.
float MaxDisplacement() const override
{
return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f;
}
private:
FSurfaceGenerationParams P;
};
//=========================================================================
// MOD — PLAGE / BEACH (aplatissement vers la ligne d'eau)
//=========================================================================
class FBeachHeightMod final : public IVoxelHeightOp
{
public:
explicit FBeachHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
void Eval(float, float, FVoxelHeightSample& InOut) const override
{
// Le niveau d'eau est GLOBAL à la strate (forcé depuis la strate) pour que le plan
// d'eau reste continu — d'où le calcul depuis les bornes de strate, pas depuis un param
// par biome.
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
const float WaterZ = P.StrateBottomWorldZ + H * P.WaterLevelRelative;
if (P.WaterLevelRelative <= 0.0f || P.BeachWidth <= 0.0f) { return; }
const float DAbs = FMath::Abs(InOut.Height - WaterZ);
if (DAbs < P.BeachWidth)
{
float T = SmoothStep01(DAbs / P.BeachWidth);
InOut.Height = FMath::Lerp(WaterZ, InOut.Height, T);
}
}
// N'agit que dans `BeachWidth` de l'eau, et ne fait qu'y RAPPROCHER.
float MaxDisplacement() const override
{
return (P.WaterLevelRelative > 0.0f) ? FMath::Max(P.BeachWidth, 0.0f) : 0.0f;
}
private:
FSurfaceGenerationParams P;
};
//=========================================================================
// SOURCE — LE CIEL / SKY CAP (c'est une ALTITUDE, donc c'est un op de hauteur)
//=========================================================================
// `ComputeSurfaceCeiling` rend un Z, exactement comme le terrain. `OPSTACK-DECOMPOSITION §5` le
// range en `FSkyCapSource` côté DENSITÉ (« Subtract »), mais c'est le même glissement que pour
// les ops de terrain : ce que la fonction produit est une hauteur, et la soustraction n'arrive
// qu'après, dans le combine. Le mettre ici lui donne gratuitement l'invariance de fenêtre
// testée, la pureté XY garantie par le type, et le cache de colonne.
// The sky cap returns a Z, so it belongs in height space; the subtraction happens later, in the
// density-side combine.
class FSkyCapHeightSource final : public IVoxelHeightOp
{
public:
FSkyCapHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed)
: P(InP), SeedU((uint32)InSeed) {}
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
{
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
float CeilZ = P.StrateBottomWorldZ + H * P.CeilingRelative;
// Domain-warp des coords larges/ridge (miroir du HeightWarp du sol). Les bosses fines
// restent sur le vrai XY pour rester nettes et décorrélées. 0 ⇒ pas de warp.
float QX = WorldX, QY = WorldY;
if (P.CeilingWarpStrength > 0.0f)
{
const float WF = P.CeilingWarpFrequency;
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f)));
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f)));
QX += wx * VOXEL_NOISE_SCALE * P.CeilingWarpStrength;
QY += wy * VOXEL_NOISE_SCALE * P.CeilingWarpStrength;
}
// Gonflement large SIGNÉ : monte/descend toute la voûte.
if (P.CeilingUndulation > 0.0f)
{
const float Swell = HFractalNoise3D(FVector(
QX * P.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f),
QY * P.CeilingUndulationFrequency + 13.0f,
VoxelHash::SeedOffset(SeedU, 0.5f)), 3); // [-1,1]
CeilZ += Swell * VOXEL_NOISE_SCALE * P.CeilingUndulation;
}
// Pendage vers le BAS uniquement : tout est >= 0, donc rien ne perce vers le haut dans
// le seal. Bosses fines + lames ridgées s'additionnent.
float Hang = 0.0f;
if (P.CeilingRoughness > 0.0f)
{
Hang += FMath::Abs(HFractalNoise3D(FVector(
WorldX * P.CeilingRoughnessFrequency + 5.0f,
WorldY * P.CeilingRoughnessFrequency + 6.0f,
VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * P.CeilingRoughness;
}
if (P.CeilingRidgeStrength > 0.0f)
{
float Ridge = HRidgedNoise3D(FVector(
QX * P.CeilingRidgeFrequency + 31.0f,
QY * P.CeilingRidgeFrequency + 47.0f,
VoxelHash::SeedOffset(SeedU, 1.1f)), 4); // [-1,1]
Ridge = Ridge * 0.5f + 0.5f; // [0,1] lignes de crête pendantes
Hang += Ridge * P.CeilingRidgeStrength;
}
InOut.Height = CeilZ - Hang; // Replace : racine de sa propre pile
// Relief laissé intact : le ciel n'en produit pas et personne ne le lui demande.
}
float MaxDisplacement() const override { return FLT_MAX; } // source, pas modificateur
private:
FSurfaceGenerationParams P;
uint32 SeedU;
};
//=========================================================================
// COMBINER `Mask` — MÉLANGE DE BIOMES / BIOME BLEND
//=========================================================================
// Une pile complète par biome ; le champ dit lequel domine ; on interpole les HAUTEURS.
//
// ⚠️ Chaque pile calcule SON PROPRE relief `M` en interne et l'utilise pour son propre gate de
// terrace — exactement comme l'original, où `ComputeSurfaceTerrainZ(X, Y, *PD)` et
// `(…, *PN)` sont deux appels complets et indépendants dont seules les SORTIES sont mêlées.
// Le canal `Relief` qui ressort ici est celui du DOMINANT : il est informatif, personne en aval
// ne s'en sert pour re-gater quoi que ce soit.
//
// Each biome stack computes its own relief internally and gates its own terrace with it, exactly
// as the original makes two independent full calls and blends only the OUTPUTS.
class FBiomeBlendHeightSource final : public IVoxelHeightOp
{
public:
FBiomeBlendHeightSource(const TArray<FSurfaceGenerationParams>& PerBiome, int32 Seed,
const IVoxelBiomeField* InField, bool bCeilingOnly)
: Field(InField)
{
Stacks.Reserve(PerBiome.Num());
for (const FSurfaceGenerationParams& BP : PerBiome)
{
FVoxelHeightStack S;
if (bCeilingOnly) { VoxelHeightOps::BuildSurfaceCeilingStack(S, BP, Seed); }
else { VoxelHeightOps::BuildSurfaceHeightStack(S, BP, Seed); }
Stacks.Add(MoveTemp(S));
}
bBlend = !bCeilingOnly; // le plafond SÉLECTIONNE, il ne mélange pas
}
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
{
if (Stacks.Num() == 0) { return; }
FVoxelBiomeWeights W;
if (Field) { W = Field->SampleAt(WorldX, WorldY); }
const int32 D = Stacks.IsValidIndex(W.Dominant) ? W.Dominant : 0;
InOut = Stacks[D].EvalSample(WorldX, WorldY);
// Le plafond ne se mélange pas (voir la fabrique) ; le sol si, et seulement dans la
// bande de frontière où le poids est non nul.
if (bBlend && W.NeighborWeight > 0.0f && Stacks.IsValidIndex(W.Neighbor))
{
const float HN = Stacks[W.Neighbor].EvalHeight(WorldX, WorldY);
InOut.Height = FMath::Lerp(InOut.Height, HN, W.NeighborWeight);
}
}
float MaxDisplacement() const override { return FLT_MAX; } // source composite
private:
TArray<FVoxelHeightStack> Stacks;
const IVoxelBiomeField* Field;
bool bBlend = true;
};
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
// En dessous commence `namespace VoxelHeightOps` (les fabriques). Y insérer une classe la sort
// de la liaison interne, et l'accolade qu'on ajoute avec elle ne ferme rien → C2059. Erreur
// commise DEUX fois (7cd2bed, puis à nouveau ici) en s'ancrant sur la bannière « FABRIQUES »,
// qui est de l'autre côté de cette accolade.
// END OF THE ANONYMOUS NAMESPACE — new operators go ABOVE this line. Anchoring on the FACTORIES
// banner below puts them outside it, and the brace added with them closes nothing.
//=============================================================================
// FABRIQUES / FACTORIES
//=============================================================================
namespace VoxelHeightOps
{
TUniquePtr<IVoxelHeightOp> MakeBiomeBlendHeightSource(
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
const IVoxelBiomeField* Field)
{
return MakeUnique<FBiomeBlendHeightSource>(PerBiomeParams, Seed, Field, /*bCeilingOnly*/false);
}
TUniquePtr<IVoxelHeightOp> MakeBiomeSelectCeilingSource(
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
const IVoxelBiomeField* Field)
{
return MakeUnique<FBiomeBlendHeightSource>(PerBiomeParams, Seed, Field, /*bCeilingOnly*/true);
}
TUniquePtr<IVoxelHeightOp> MakeSkyCapHeightSource(const FSurfaceGenerationParams& P, int32 Seed)
{
return MakeUnique<FSkyCapHeightSource>(P, Seed);
}
void BuildSurfaceCeilingStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed)
{
// Un seul op aujourd'hui — et c'est une information, pas un manque : le plafond n'a pas
// d'équivalent des quatre modificateurs du sol. Le jour où on veut des terrasses au
// plafond, on ajoute la ligne ; c'est exactement le genre de composition que le refactor
// existe pour rendre possible.
OutStack.Add(MakeSkyCapHeightSource(P, Seed));
}
TUniquePtr<IVoxelHeightOp> MakeStructuralHeightSource(const FSurfaceGenerationParams& P, int32 Seed,
const IVoxelHeightOp** OutSource)
{
TUniquePtr<FStructuralHeightSource> Src = MakeUnique<FStructuralHeightSource>(P, Seed);
if (OutSource) { *OutSource = Src.Get(); }
return Src;
}
TUniquePtr<IVoxelHeightOp> MakeCliffHeightMod(const FSurfaceGenerationParams& P,
const IVoxelHeightOp* StructuralSource)
{
// `static_cast` plutôt que `Cast<>` : ce ne sont pas des UObject, et le contrat de la
// fabrique est qu'on lui rend exactement le pointeur sorti de MakeStructuralHeightSource.
return MakeUnique<FCliffHeightMod>(
P, static_cast<const FStructuralHeightSource*>(StructuralSource));
}
TUniquePtr<IVoxelHeightOp> MakeTerraceHeightMod(const FSurfaceGenerationParams& P)
{
return MakeUnique<FTerraceHeightMod>(P);
}
TUniquePtr<IVoxelHeightOp> MakeLayerLineHeightMod(const FSurfaceGenerationParams& P)
{
return MakeUnique<FLayerLineHeightMod>(P);
}
TUniquePtr<IVoxelHeightOp> MakeBeachHeightMod(const FSurfaceGenerationParams& P)
{
return MakeUnique<FBeachHeightMod>(P);
}
void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed)
{
// L'ORDRE EST CELUI DE `ComputeSurfaceTerrainZ`, et il porte du sens :
// le cliff raidit le champ brut, le terrace quantifie le résultat raidi, les lignes de
// strates se posent dessus, et la plage écrase tout près de l'eau.
const IVoxelHeightOp* Structural = nullptr;
OutStack.Add(MakeStructuralHeightSource(P, Seed, &Structural));
OutStack.Add(MakeCliffHeightMod(P, Structural));
OutStack.Add(MakeTerraceHeightMod(P));
OutStack.Add(MakeLayerLineHeightMod(P));
OutStack.Add(MakeBeachHeightMod(P));
}
}
+20
View File
@@ -0,0 +1,20 @@
// VoxelStats.cpp
// Definitions for the VoxelForge runtime statistics.
// Définitions des statistiques runtime de VoxelForge.
#include "VoxelStats.h"
DEFINE_STAT(STAT_VoxelForgeTilesClassified);
DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllSolid);
DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllAir);
DEFINE_STAT(STAT_VoxelForgeTilesMeshed);
DEFINE_STAT(STAT_VoxelForgeTilesOpStackSolid);
DEFINE_STAT(STAT_VoxelForgeTilesOpStackAir);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStack);
DEFINE_STAT(STAT_VoxelForgeCaveBailMixedContent);
DEFINE_STAT(STAT_VoxelForgeCaveBailParams);
DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict);
DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance);
DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack);
DEFINE_STAT(STAT_VoxelForgeColumnMemoHit);
DEFINE_STAT(STAT_VoxelForgeColumnMemoMiss);
@@ -556,6 +556,60 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto
return StrateLayout[SlotIdx].Definition->GeneratorType;
}
bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const
{
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; }
const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition;
if (!Def->bUseOperatorStack) { return false; }
// LA LISTE DES ARCHÉTYPES PORTÉS — le seul endroit où elle est écrite. Un archétype non porté
// ignore le drapeau et retombe sur le `switch`, pour qu'on puisse cocher la case sur n'importe
// quelle strate sans rien casser en attendant son portage.
// THE PORTED-ARCHETYPE LIST, written down exactly once. An unported archetype ignores the flag
// and falls back to the switch, so the box can be ticked anywhere without breaking anything.
switch (Def->GeneratorType)
{
case ECaveGeneratorType::Maze: return true; // Phase 1
case ECaveGeneratorType::FlatPlain: // Phase 2 — les deux partagent
case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack)
case ECaveGeneratorType::SurfaceWorld:
// ✅ La garde « pas de biomes » est TOMBÉE (étape 2c) : le combiner `Mask` existe, donc une
// strate à biomes mélange bien ses hauteurs comme le chemin d'origine. Les trois archétypes
// du dessus plus celui-ci font 5 des 8 portés.
// The no-biome guard is GONE: the Mask combiner exists, so a biome strate blends its heights
// exactly as the original path does.
return true;
case ECaveGeneratorType::VerticalShafts: return true; // Phase 2 — 3 ops repris de Maze tels quels
case ECaveGeneratorType::FloatingIslands:
// Phase 2 — la pile qui tourne à l'ENVERS : source de VIDE + fill, au lieu de source de ROC
// + carve, avec les MÊMES opérateurs au signe près.
return true;
case ECaveGeneratorType::Underwater:
// ⚠️ AUCUNE PILE À ELLE : `Underwater` EST `TunnelNetwork` plus un drapeau d'eau consommé
// côté rendu. `GetDensityAt` les met dans le même `case`, et `WaterLevelRelative` n'est lu
// que par `GetWaterLevel*` de ce manager — jamais par la densité (vérifié, pas supposé).
case ECaveGeneratorType::TunnelNetwork:
// Phase 2, LE DERNIER, et le plus gros : ~1080 lignes portées en trois étapes (squelette
// SDF → douze modificateurs de détail → override d'op par salle), 19 opérateurs, dont
// `FRoomGraphSource` qui **APPELLE** `BuildChunkCache`/`EvaluateSDFCached` au lieu de les
// transcrire — c'est là que vit la discipline d'invariance de fenêtre d'ARCHITECTURE §8.4,
// et en forker une copie aurait été le pire résultat possible de ce refactor.
//
// **8 SUR 8.** Le `switch` d'archétypes a désormais un jumeau en pile d'opérateurs, opt-in
// par strate, chacun vérifié par un test d'équivalence bit à bit contre sa fonction
// d'origine. Ce qui n'est PAS fait : `ClassifyTile` n'utilise toujours pas `ClassifyBox`.
return true;
default: return false;
}
}
bool UVoxelStrateManager::IsGapChunk(const FIntVector& ChunkCoord) const
{
if (StrateLayout.Num() == 0) return false;
+93 -4
View File
@@ -11,6 +11,15 @@
#include "VoxelTerrainOpDefinition.h"
#include "VoxelContentManager.h"
#include "VoxelDensityVolume.h"
#include "VoxelStats.h"
// IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc
// APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController était
// complet par transitivité seulement : on l'inclut explicitement, c'est exactement la fragilité
// qu'on est en train de retirer.
// GetPlayerPosition dereferences the pawn, so APawn must be COMPLETE — Casts.h only forward-declares
// it. APlayerController was complete transitively only; include it explicitly.
#include "GameFramework/Pawn.h"
#include "GameFramework/PlayerController.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "Materials/MaterialParameterCollection.h"
#include "Kismet/KismetMaterialLibrary.h"
@@ -79,6 +88,45 @@ static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, co
}
}
class FScopedGenerationPause
{
public:
explicit FScopedGenerationPause(AVoxelWorld* InWorld)
: World(InWorld)
{
if (!World) return;
World->bGenerationPaused.store(true, std::memory_order_release);
// The game thread owns this gate; workers only read Generator/Mesher and enqueue results.
// La barrière est prise sur le thread de jeu ; les workers ne font qu'énumérer et Enqueue.
const double Deadline = FPlatformTime::Seconds() + 5.0;
while (World->ActiveTaskCount.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) return;
FPlatformProcess::Yield();
}
if (World->ContentManager && !World->ContentManager->WaitForDecorationTasks(Deadline)) return;
bAcquired = true;
}
~FScopedGenerationPause()
{
if (World)
{
World->bGenerationPaused.store(false, std::memory_order_release);
}
}
bool Acquired() const { return bAcquired; }
private:
AVoxelWorld* World = nullptr;
bool bAcquired = false;
};
//=============================================================================
// LIVE EDIT — regenerate all chunks when params change in the Details panel
//=============================================================================
@@ -130,6 +178,14 @@ void AVoxelWorld::RegenerateAllChunks()
void AVoxelWorld::RebuildStrates()
{
{
FScopedGenerationPause Guard(this);
if (!Guard.Acquired())
{
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] RebuildStrates: generation pause timed out; no mutation applied."));
return;
}
if (StrateManager && Settings)
{
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
@@ -137,6 +193,7 @@ void AVoxelWorld::RebuildStrates()
}
if (AtmosphereManager) AtmosphereManager->Reset();
if (ContentManager) ContentManager->ClearAll();
}
// Reload all chunks against the rebuilt strate data.
RegenerateAllChunks();
@@ -295,6 +352,14 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
// Re-initialize the strate manager so it picks up the changed definition values,
// then regenerate all chunks with the updated params.
{
FScopedGenerationPause Guard(this);
if (!Guard.Acquired())
{
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] OnObjectModifiedInEditor: generation pause timed out; no mutation applied."));
return;
}
if (StrateManager)
{
StrateManager->Initialize(Settings, Settings->Seed);
@@ -303,6 +368,7 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
{
Generator->InitializeSettings(Settings);
}
}
RegenerateAllChunks();
}
@@ -626,7 +692,7 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
// is refilled from the diff via MarkDirtyVoxelBox in RemeshDirtyChunks).
void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile)
{
if (!Generator || !Mesher || bShuttingDown.load(std::memory_order_relaxed)) return;
if (!Generator || !Mesher || ShouldAbortWork()) return;
const FIntVector OriginVoxels = Tile.OriginVoxels();
const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels)
@@ -1455,14 +1521,14 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
} Guard{ActiveTaskCount};
if (bShuttingDown.load(std::memory_order_relaxed)) return;
if (ShouldAbortWork()) return;
FChunkResult Result;
GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result);
if (!bShuttingDown.load(std::memory_order_relaxed))
if (!ShouldAbortWork())
{
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
}
@@ -1494,11 +1560,24 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
if (!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel == 0.0f)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile);
bTrivialEmpty = (Generator->ClassifyTile(OriginVoxels, Step, Cells) != EVoxelTileClass::Mixed);
INC_DWORD_STAT(STAT_VoxelForgeTilesClassified);
const EVoxelTileClass Verdict = Generator->ClassifyTile(OriginVoxels, Step, Cells);
if (Verdict == EVoxelTileClass::AllSolid)
{
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllSolid);
}
else if (Verdict == EVoxelTileClass::AllAir)
{
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllAir);
}
bTrivialEmpty = (Verdict != EVoxelTileClass::Mixed);
}
// F18 — feuille : deux heightfields sol/cap échantillonnés par colonne (pas de marching
// cubes, pas de classifieur — la classe de surface est vraie par construction).
// `TilesMeshed` peut dépasser `TilesClassified` : les tuiles qui ratent cette porte sont
// maillées sans classification. / `TilesMeshed` may exceed `TilesClassified`: tiles that
// fail this gate are meshed without classification.
FVoxelMeshData MeshData;
if (!bTrivialEmpty)
{
@@ -1509,6 +1588,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
: Mesher->GenerateMesh(OriginVoxels, Step, Cells,
bWantCapture ? &Result.CaptureGrid : nullptr,
BandVoxLo, BandVoxHi);
INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed);
}
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
@@ -2049,6 +2129,14 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
const int32 OldSeed = Settings->Seed;
const int32 OldSeason = Settings->CurrentSeason;
{
FScopedGenerationPause Guard(this);
if (!Guard.Acquired())
{
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed: generation pause timed out; no mutation applied."));
return;
}
// 1. Update seed in Settings (the authoritative source)
Settings->Seed = NewSeed;
@@ -2086,6 +2174,7 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
{
AtmosphereManager->Reset();
}
}
// 6. Unload all existing chunks and let Tick reload them with new generation
RegenerateAllChunks();
@@ -12,6 +12,7 @@
#include "CoreMinimal.h"
#include "VoxelAtmosphereManager.generated.h"
class AActor; // IWYU : pointeur / TWeakObjectPtr seulement / pointer-only
class UVoxelStrateManager;
class UVoxelStrateDefinition;
class UVoxelBiomeDefinition;
@@ -22,6 +22,11 @@
#include "VoxelStrateTypes.h" // FStrateDecoration / FStrateAmbientActor
#include "VoxelBiomeDefinition.generated.h"
// IWYU : utilisé en pointeur seulement ⇒ déclaration avant. Fournie gratuitement par le PCH
// partagé jusqu'ici ; `FPSemantics = Precise` nous en sort. / Pointer-only use, so a forward
// declaration is enough. The shared PCH used to provide this for free.
class UMaterialInterface;
/**
* UVoxelBiomeDefinition one biome's identity, placement, terrain modulation and content.
*/
@@ -243,4 +243,16 @@ struct FChunkBiomeCache
&& X >= ValidMinX && X <= ValidMaxX
&& Y >= ValidMinY && Y <= ValidMaxY;
}
/** AUDIT C2 — force a rebuild on the next query. The validity box says nothing about the
* FBiomeContext the cells were classified AGAINST, so when the strate layout is rebuilt
* (RebuildStrates / an editor live edit) the grid is stale even though the box still
* covers the query. Callers that key on GetLayoutVersion() call this on a version change.
* Le box de validité ne dit rien du contexte de biome ayant servi à classer les cellules :
* après un rebuild de layout, la grille est périmée alors que la boîte couvre encore. */
void Invalidate()
{
ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere
ChunkZ = MIN_int32;
}
};
@@ -168,6 +168,43 @@ namespace VoxelHash
return X;
}
/**
* AUDIT §C1 décalage de bruit BORNÉ et salé par site. Remplace le motif `SeedF * K`.
*
* LE BUG QUE ÇA CORRIGE : les sites de bruit s'écrivaient
* `WorldX * Freq + (float)Seed * 97.7f`. Le float a 24 bits de mantisse, donc à magnitude `V`
* l'ULP vaut `V · 2²³`. Avec `Seed = 10` le terme atteint 10, l'ULP vaut **117** la
* coordonnée du voxel (qui avance de ~0.02 par voxel) est **entièrement absorbée** et le champ
* de bruit devient CONSTANT. Terrain plat. `ChangeSeed` est `BlueprintCallable`, donc un
* `FMath::Rand()` suffit à déclencher ça. Ça ne marchait que parce que les seeds restaient petits.
*
* LE CORRECTIF ÉVIDENT EST FAUX. Borner `SeedF` à 16383 en gardant le `· 97.7` laisse le
* terme atteindre 1.6e6, l'ULP vaut 0.19 **9.5× le pas par voxel**. Ça rend le bug moins
* spectaculaire tout en le laissant vivant, et referme le ticket. C'est le multiplicateur qu'il
* faut supprimer, pas le seed qu'il faut réduire.
*
* CE QUE FAIT CETTE FONCTION : le multiplicateur ne SERT plus à décorréler par amplification
* il IDENTIFIE le site, et c'est le hash qui décorrèle. La sortie est déjà dans les unités
* finales, bornée à [0, 16383] : l'ULP y vaut 0.002, soit 10 % d'un pas de voxel.
*
* ET C'EST PLUS SÛR QU'UN SEEDF BORNÉ PARTAGÉ : avec un offset unique par monde, deux seeds qui
* collident donneraient un bruit identique PARTOUT. Salé par site, il faudrait qu'ils
* collident sur les ~50 sites à la fois c'est-à-dire jamais.
*
* The multiplier no longer decorrelates by amplifying it IDENTIFIES the site, and the hash
* decorrelates. Output is already in final units and bounded, so the ULP is 10% of a voxel step.
*
* @param SiteKey la constante littérale d'origine (`7.3f`, `97.7f`, ). Gardée VISIBLE au site
* d'appel pour que la correspondance avec le code d'avant reste vérifiable à l'œil.
*/
FORCEINLINE float SeedOffset(uint32 Seed, float SiteKey)
{
// ×100 puis arrondi : les constantes ont au plus 2 décimales, donc `0.31f` → 31 et
// `3.1f` → 310 restent distincts. Le site est une identité entière, pas un flottant.
const uint32 Site = (uint32)(SiteKey * 100.0f + 0.5f);
return (float)(Mix(Seed ^ (Site * 2654435761u)) & 0x3FFFu); // [0, 16383]
}
// Hash a 2D cell coordinate with a seed → deterministic uint32
FORCEINLINE uint32 Cell(int32 CellX, int32 CellY, uint32 Seed)
{
@@ -205,6 +242,88 @@ namespace VoxelHash
}
}
//=============================================================================
// BRUIT CELLULAIRE / CELLULAR (WORLEY) NOISE — 3D
//=============================================================================
// ⚠️ POURQUOI CE CORPS VIT ICI ET NON DANS VoxelNoise.h.
// Il a besoin de `VoxelHash::Mix` / `ToFloat01`, qui vivent dans CE fichier. Faire dépendre
// VoxelNoise.h (le socle bas niveau, inclus partout) du header de morphologie de grotte serait une
// inversion de dépendance ; dupliquer les 50 lignes serait un FORK d'une fonction pure — exactement
// le motif qui a produit `AUDIT §C1` (un correctif appliqué à une copie sur deux). Il monte donc au
// point le plus bas qui voit déjà le hash, et le générateur comme la pile d'opérateurs l'appellent.
//
// Ce corps était `static float CellularNoise3D(const FVector&)` dans VoxelGenerator.cpp, invisible
// à la pile d'opérateurs. Déplacement LITTÉRAL : mêmes opérations, même ordre, même passage par
// `FVector` (donc par des doubles) — l'égalité binaire du portage TunnelNetwork en dépend.
// `UVoxelGenerator`'s copy is now a one-line forwarder; the body moved verbatim.
//
// Algorithme : distance au point-feature le plus proche dans une grille hachée.
// 1. cellule entière du point 2. voisinage 3×3×3 3. rendre (F2 F1), normalisé ~[-1, 1]
// F2F1 donne des frontières de cellules lisses avec des arêtes entre elles.
namespace VoxelNoise
{
FORCEINLINE float Cellular3D(const FVector& Position)
{
// Integer cell coordinates
int32 CellX = FMath::FloorToInt(Position.X);
int32 CellY = FMath::FloorToInt(Position.Y);
int32 CellZ = FMath::FloorToInt(Position.Z);
// Fractional position within cell
float FracX = Position.X - CellX;
float FracY = Position.Y - CellY;
float FracZ = Position.Z - CellZ;
float F1 = FLT_MAX; // Distance to nearest feature point
float F2 = FLT_MAX; // Distance to 2nd nearest
// Search 3x3x3 neighborhood
for (int32 DZ = -1; DZ <= 1; DZ++)
{
for (int32 DY = -1; DY <= 1; DY++)
{
for (int32 DX = -1; DX <= 1; DX++)
{
int32 NX = CellX + DX;
int32 NY = CellY + DY;
int32 NZ = CellZ + DZ;
// Hash the neighbor cell to get a feature point position [0,1)
// Using three different hash mixes for X, Y, Z offsets
uint32 H = VoxelHash::Mix(
(uint32)(NX + 0x7FFFFFFF)
^ VoxelHash::Mix((uint32)(NY + 0x7FFFFFFF) * 2654435761u)
^ VoxelHash::Mix((uint32)(NZ + 0x7FFFFFFF) * 374761393u)
);
float FPX = (float)DX + VoxelHash::ToFloat01(H) - FracX;
float FPY = (float)DY + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)) - FracY;
float FPZ = (float)DZ + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)) - FracZ;
float DistSq = FPX * FPX + FPY * FPY + FPZ * FPZ;
// Track closest two distances
if (DistSq < F1)
{
F2 = F1;
F1 = DistSq;
}
else if (DistSq < F2)
{
F2 = DistSq;
}
}
}
}
// F2 - F1: smooth cell boundaries with ridges between cells
// Sqrt for actual distance, then normalize to ~[-1, 1]
float Result = FMath::Sqrt(F2) - FMath::Sqrt(F1);
// Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types.
return Result * 2.0f - 1.0f;
}
}
//=============================================================================
// PER-CHUNK SDF CACHE
//=============================================================================
@@ -45,9 +45,11 @@
#include "VoxelTypes.h"
#include "VoxelStrateTypes.h" // FStrateDecoration (resolved per dominant biome)
#include "VoxelBiomeTypes.h" // FBiomeContext (per-column biome resolve on the worker)
#include "Templates/SubclassOf.h" // IWYU : TSubclassOf<AActor> (FRegionActorBucket & co)
#include <atomic>
#include "VoxelContentManager.generated.h"
class AActor; // IWYU : pointeur / TWeakObjectPtr / TSubclassOf seulement
class UVoxelStrateManager;
class UVoxelStrateDefinition;
class UVoxelGenerator;
@@ -136,6 +138,10 @@ public:
* UObject teardown (worker tasks read the Generator). */
void NotifyShutdown();
/** Wait until in-flight decoration march tasks drain before a generation mutation. Deadline is absolute.
* Attend la fin des tâches de décoration avant une mutation de génération ; échéance absolue. */
bool WaitForDecorationTasks(double Deadline);
//--- async-task plumbing (public so the worker lambda can reach them) -----
/** One placement decided off-thread; spawned on the game thread from FDecoCellResult::Entries. */
struct FDecoSpawn
+585
View File
@@ -0,0 +1,585 @@
// VoxelDensityOp.h
// LE CONTRAT de la pile d'opérateurs de densité / THE density operator stack CONTRACT.
// Phase 1 de OPSTACK-PLAN.md. HEADER SEUL — rien n'est encore branché dans GetDensityAt.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// POURQUOI / WHY
// ─────────────────────────────────────────────────────────────────────────────────────────
// UVoxelGenerator::GetDensityAt est aujourd'hui un `switch` sur 8 ECaveGeneratorType, chacun
// possédant sa fonction de densité et son struct de params. Conséquences : une nouvelle idée de
// monde coûte ~6 sites d'édition, et surtout LES IDÉES NE PEUVENT PAS SE COMBINER — un archétype
// possède le voxel entier. On ne peut pas écrire « une strate de surface dont les montagnes
// contiennent un réseau de salles, avec des îles flottantes dans le vide au-dessus », à aucun prix.
//
// GetDensityAt is today a `switch` over 8 ECaveGeneratorType, each owning a bespoke density
// function and param struct. A new world idea costs ~6 edit sites, and — the real problem —
// IDEAS CANNOT COMBINE: one archetype owns the whole voxel.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// ⚠️ CE N'EST PAS L'ANCIEN SYSTÈME DE « ROOM OPERATIONS » / THIS IS NOT THE OLD ROOM-OPS SYSTEM
// ─────────────────────────────────────────────────────────────────────────────────────────
// UVoxelTerrainOpDefinition (Terrace, Ribbing, Cliff, Scallop, Overhang, Arch, Column, Pit…) ne
// sait que PERTURBER une densité près d'une surface qui existe déjà. Il ne décide jamais ce que le
// champ EST — cette décision vit dans le `switch`. Une pile d'opérateurs qui se contenterait de
// cela aurait reconstruit le switch avec des étapes en plus.
//
// D'où QUATRE RÔLES, dont l'ancien système n'occupait que le troisième :
//
// 1. FIELD SOURCE — fabrique un champ À PARTIR DE RIEN. C'est ce qui fait qu'une grotte est
// une grotte et qu'un monde ouvert est un monde ouvert. Chaque archétype
// d'aujourd'hui est fondamentalement l'une de ces sources.
// 2. COMBINER — comment deux champs fusionnent (min/max/smooth/mask). C'EST le rôle qui
// achète la composition ; sans lui il n'y a pas de refactor.
// 3. DETAIL MODIFIER — l'ancien système, rétrogradé à un rôle sur quatre. Inchangé.
// 4. STRUCTURAL POST — spine (0,0) → seal → passages → diff layer. Des INVARIANTS de monde,
// pas des choix créatifs : toujours ajoutés, dans cet ordre, jamais
// omissibles par l'auteur.
//
// The old system only ever had role 3. Roles 1 and 2 are the new thing, and role 4 is what keeps
// the descent structure intact no matter what an author assembles.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LA CLÉ DE VOÛTE : EffectOverBox — direction, pas intervalle / THE KEYSTONE: direction, not intervals
// ─────────────────────────────────────────────────────────────────────────────────────────
// La version « complète » d'une borne rendrait un intervalle numérique. NE PAS COMMENCER LÀ.
// Presque tout opérateur existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir.
// Cela suffit à reproduire GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile :
//
// « passages ⇒ bCanSolid = false » EST CarveOnly
// « ponts/arêtes ⇒ bCanAir = false » EST FillOnly
// « aucun passage près de cette boîte » EST Identity
//
// Donc la Phase 1 n'a besoin d'AUCUNE borne numérique et obtient déjà toute la propriété de
// sûreté. Les intervalles sont un resserrement ultérieur pour le coût de génération, pas un
// prérequis de correction. C'est ce qui rend le premier pas petit.
//
// Phase 1 needs NO numeric bounds and already gets the whole safety property. Intervals are a
// later tightening for gen cost, not a correctness prerequisite.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui
// THE PERF PRIZE: cave strates skip ZERO tiles today
// ─────────────────────────────────────────────────────────────────────────────────────────
// ClassifyTile ne sait prouver que les gaps de bedrock et SurfaceWorld ; tout le reste tombe sur
// `return EVoxelTileClass::Mixed; // archétype cave […] pas prouvable en v1`. TunnelNetwork, Maze,
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent donc RIEN du
// gain T1.d (84 % des générations vides, 44 % de CPU worker). Écrire un prouveur sur mesure par
// archétype a toujours été trop cher — EffectOverBox EST le mécanisme générique qui le rend gratuit :
// une source à graphe de salles qui rend Identity quand aucune borne de salle ni de tunnel n'atteint
// la boîte rend le bedrock profond sautable pour la première fois.
//
// **Traiter cela comme un livrable explicite de chaque portage, pas comme un effet de bord.**
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// ÉTAT / STATUS
// ─────────────────────────────────────────────────────────────────────────────────────────
// Phase 1. Le premier archétype (Maze) EST porté, dans VoxelDensityOpStack.{h,cpp} — mais
// **GetDensityAt et ClassifyTile ne sont PAS touchés** : le `switch` reste le seul chemin qui
// alimente le jeu. La pile est validée par un test qui la compare à GetMazeDensity point par point.
// Le branchement dans GetDensityAt attend un build vert.
//
// Phase 1. Maze IS ported (VoxelDensityOpStack.{h,cpp}) but **GetDensityAt and ClassifyTile are NOT
// touched** — the switch is still the only path feeding the game. The stack is validated by a test
// that compares it to GetMazeDensity point by point. Wiring it in waits for a green build.
//
// NOTE sur les UENUM : ces types sont volontairement du C++ nu (pas d'UHT, pas de .generated.h).
// Ils deviendront UENUM/USTRUCT en Phase 3, quand les opérateurs deviendront des data assets et
// auront besoin d'être édités dans l'éditeur. Les promouvoir plus tôt n'achèterait rien et
// ajouterait une étape UHT à chaque itération.
#pragma once
#include "CoreMinimal.h"
#include "VoxelTypes.h" // CHUNK_SIZE, EVoxelTileClass
struct FBiomeContext;
//=============================================================================
// LES QUATRE RÔLES / THE FOUR ROLES
//=============================================================================
// Le rôle n'est pas décoratif : le compilateur de pile s'en sert pour ORDONNER. Les
// StructuralPost sont toujours ajoutés en dernier, dans l'ordre fixe spine → seal → passage →
// diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit
// rester possible, les seals doivent tenir, les passages doivent percer, les éditions du joueur
// gagnent toujours.
enum class EVoxelOpRole : uint8
{
// Rôle 1 — fabrique un champ à partir de rien. Une pile en a au moins un (sa racine).
FieldSource,
// Rôle 2 — fusionne le champ précédent avec le suivant. Voir EVoxelOpCombine.
Combiner,
// Rôle 3 — perturbe un champ existant près de sa surface (l'ancien UVoxelTerrainOpDefinition).
DetailModifier,
// Rôle 4 — invariants de monde. Ajoutés automatiquement, ordre fixe, non omissibles.
StructuralPost,
};
//=============================================================================
// COMPOSITION / COMBINERS
//=============================================================================
// Vocabulaire délibérément petit, et il réutilise ce qui existe déjà
// (VoxelSDF::SmoothMin / SmoothMax).
//
// ⚠️⚠️ RAPPEL DE SIGNE — LA source n°1 de confusion du plugin, et il y a DEUX conventions en jeu.
// Lire ceci en entier avant d'écrire un opérateur.
//
// • CANAL DENSITÉ, à l'intérieur de la pile : convention INTERNE, **POSITIF = SOLIDE**.
// C'est celle dans laquelle CHAQUE fonction d'archétype est écrite aujourd'hui. La négation
// vers la convention marching-cubes (négatif = solide) se fait UNE FOIS, tout à la fin, par
// l'appelant. Donc ici : « ajouter du solide » = MAX, « creuser de l'air » = MIN.
//
// • CANAL SDF : convention SDF standard, **NÉGATIF = À L'INTÉRIEUR de la primitive**.
// Réunir deux formes = MIN (c'est `SmoothMin`, ce que fait déjà le code pour salle+puits).
// Le sens de « min » est donc l'INVERSE d'un canal à l'autre. Ce n'est pas une incohérence :
// un SDF décrit une FORME, une densité décrit de la MATIÈRE.
//
// DENSITY channel inside the stack: INTERNAL convention, **POSITIVE = SOLID** (what every
// archetype body already uses; the MC negate happens once, at the end, in the caller). So
// "add solid" is max(), "carve air" is min().
// SDF channel: standard SDF, **NEGATIVE = INSIDE the primitive**; unioning shapes is min().
// The meaning of min() is therefore opposite between the two channels — an SDF describes a
// SHAPE, a density describes MATTER.
enum class EVoxelOpCombine : uint8
{
Replace, // ignore l'entrée — racine de pile (heightfield, densité de base)
Union, // max() sur la DENSITÉ — ajoute du solide : ponts, îles, colonnes
Subtract, // min() sur la DENSITÉ — creuse de l'air : salles, tunnels, passages, spine
SmoothUnion, // VoxelSDF::SmoothMin(k) — jonctions organiques
SmoothSubtract, // VoxelSDF::SmoothMax(k)
Add, // accumulation scalaire — termes de bruit / rugosité
Mask, // met à l'échelle l'opérateur SUIVANT par un champ [0,1]
// (poids de biome, porte de pente, relief, profondeur).
// C'est Mask qui achète le plus d'expressivité : « cet opérateur, mais
// seulement dans les régions à fort relief » devient de la composition
// au lieu d'une garde codée en dur dans chaque opérateur.
};
//=============================================================================
// EFFET SUR UNE BOÎTE / EFFECT OVER A BOX
//=============================================================================
// CONSERVATIF PAR CONSTRUCTION. Rendre `Both` est TOUJOURS SÛR (ça ne coûte que du CPU) ;
// rendre le mauvais est un TROU — pas de géométrie, pas de collision, invisible jusqu'à ce
// qu'un joueur tombe au travers. En cas de doute : `Both`.
//
// CONSERVATIVE BY CONSTRUCTION. Returning `Both` is ALWAYS SAFE (it only costs CPU); returning
// the wrong one is a HOLE. When unsure: `Both`.
enum class EVoxelOpEffect : uint8
{
// Prouvablement aucun effet sur cette boîte. C'est le early-out qui rend la pile rapide,
// et c'est ce qui rendra le bedrock profond sautable pour les strates de grotte.
Identity,
// Ne peut que pousser la densité vers l'AIR ⇒ tue l'hypothèse « tout solide ».
CarveOnly,
// Ne peut que pousser la densité vers le SOLIDE ⇒ tue l'hypothèse « tout air ».
FillOnly,
// Non contraint ⇒ tue les deux hypothèses.
Both,
};
//=============================================================================
// CONTEXTE DE CHUNK / CHUNK CONTEXT
//=============================================================================
// Miroir de ce que le bloc thread_local CP_* résout aujourd'hui dans GetDensityAt.
//
// ⚠️ LayoutVersion est ici PAR CONSTRUCTION, pas par politesse. AUDIT C2 : trois caches
// existants (CP_Chunk, OC_Chunk, BM_Chunk) sont clés sur ChunkCoord SEUL, donc après un
// RebuildStrates ou une édition à chaud un worker dont le cache est encore chaud pour ce chunk
// saute le refetch et génère avec les ANCIENS params. En faisant porter LayoutVersion par le
// contexte, un nouvel opérateur ne PEUT PAS oublier de l'inclure dans sa clé.
//
// LayoutVersion is here BY CONSTRUCTION, not by politeness — see AUDIT C2. Carrying it in the
// context means a new op CANNOT forget to put it in its cache key.
struct FVoxelOpContext
{
FIntVector ChunkCoord = FIntVector::ZeroValue;
// Pas d'échantillonnage LOD (1/2/4…). Un opérateur a le droit de se simplifier quand Step
// est grand — c'est le contrat T2.b : le bruit volumétrique par voxel perd des octaves au
// loin, le bruit de champ XY délibérément non (il alimente des caches box-validés partagés).
int32 Step = 1;
uint32 Seed = 0;
// Compteur de génération du layout (UVoxelStrateManager::GetLayoutVersion()).
// DOIT faire partie de toute clé de cache. Voir AUDIT C2.
uint32 LayoutVersion = 0;
// Bornes Z de la strate en coords VOXEL (pas cm).
float StrateTopWorldZ = 0.0f;
float StrateBottomWorldZ = 0.0f;
// null = cette strate n'a pas de champ de biome.
const FBiomeContext* Biome = nullptr;
};
//=============================================================================
// L'ÉTAT QUI TRAVERSE LA PILE / THE STATE THE STACK THREADS THROUGH
//=============================================================================
// DEUX canaux, pas un. Ce n'est pas de la généralité gratuite — c'est ce que le code fait déjà :
//
// CaveSDF = EvaluateSDFCached(salles + tunnels) ← espace SDF
// CaveSDF = SmoothMin(CaveSDF, PitSDF, BlendK) ← espace SDF
// CaveSDF = SmoothMin(CaveSDF, ChimneySDF, BlendK) ← espace SDF
// → UN SEUL carve à la fin : Density -= CarveFactor · BaseDensity · 2
//
// Maze, VerticalShafts et FloatingIslands ont la même forme, et TROIS d'entre eux appliquent la
// rugosité au **SDF** (`MazeSDF += bruit·Rough`), pas à la densité. Sur la densité, le même bruit
// est mis à l'échelle par le gradient local : effet visiblement différent.
//
// Avec un seul canal, un opérateur ne peut qu'ÉCRASER le précédent — les jonctions SmoothMin
// (salle↔puits, et demain « un graphe de salles creusé DANS une montagne ») sont impossibles.
// Un `SmoothMin` entre deux SOURCES différentes est précisément ce qui fait qu'une idée composée
// a l'air d'appartenir au lieu au lieu d'y avoir été percée. Coût : un float.
//
// Two channels, not one — because that is what the code already does, and because SmoothMin between
// two different SOURCES is precisely what makes a composed idea look like it belongs there rather
// than like a hole punched in something else. Cost: one float.
struct FVoxelOpSample
{
// Convention INTERNE : POSITIF = SOLIDE. Négation vers MC une seule fois, par l'appelant.
// INTERNAL convention: POSITIVE = SOLID. Negated to MC once, by the caller.
float Density = 0.0f;
// Convention SDF standard : NÉGATIF = à l'intérieur de la primitive.
// FLT_MAX = « aucune surface à proximité » (l'état initial, et le early-out des sources
// placées quand aucune primitive n'atteint ce voxel).
// FLT_MAX = "no surface nearby" — the initial state, and the early-out placed sources use.
float Sdf = FLT_MAX;
};
//=============================================================================
// L'INTERFACE / THE INTERFACE
//=============================================================================
// Trois méthodes, et elles FORMALISENT CE QUE LE CODE FAIT DÉJÀ À LA MAIN : chaque archétype
// hisse déjà son travail constant-par-chunk dans un cache thread_local (= PrepareChunk), évalue
// à bas coût par voxel (= Eval), et possède déjà dans ClassifyTile une déclaration écrite à la
// main de ce qu'il peut faire à une tuile (= EffectOverBox). Ce n'est pas une nouvelle
// discipline, c'est la discipline existante, nommée.
class IVoxelDensityOp
{
public:
virtual ~IVoxelDensityOp() = default;
virtual EVoxelOpRole GetRole() const = 0;
/**
* Hisser ici TOUT le travail constant sur le chunk : listes de salles, grilles de biome,
* caches de colonnes, cuissons de treillis. Appelé une fois par chunk et par worker.
* C'est ici que déménagent les caches thread_local d'aujourd'hui.
*
* THREADING : appelé sur des workers. L'opérateur ne doit écrire QUE son propre état
* par-chunk ; le Generator / le Mesher / le StrateManager restent en LECTURE SEULE
* (invariant ARCHITECTURE §8.10). Toute clé de cache DOIT inclure Ctx.LayoutVersion.
*/
virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0;
/**
* Par voxel. `InOut` est l'état que la pile a produit jusqu'ici (voir FVoxelOpSample).
* Coordonnées en VOXELS, pas en cm.
*
* INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (coords monde, seed, layout).
* Le même point évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le
* float BIT-IDENTIQUE. Pas « proche » : 1 ULP d'écart entre deux fenêtres est une couture
* visible, et en multijoueur une divergence de monde. Le test
* VoxelForge.Determinism.DensityPurity vérifie cela.
*/
virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0;
/**
* CONSERVATIF. Phase 1 : direction seule. Phase 3 : surcharge avec intervalle numérique.
* Rendre Both est toujours sûr ; rendre le mauvais est un trou.
*
* Le contrat est « conservatif », pas « forme close » : un opérateur A LE DROIT
* D'ÉCHANTILLONNER pour répondre. C'est exactement ce que fait ClassifyTile aujourd'hui pour
* SurfaceWorld il évalue ComputeSurfaceColumn sur le treillis EXACT du mesher, mêmes
* fonctions, mêmes floats, donc verdict exact plutôt qu'estimé. Cela DOIT survivre au portage.
*
* The contract is "conservative", not "closed-form": an op MAY sample to answer.
*
* SIMPLIFICATION DE PHASE 1, à connaître : une source qui n'écrit QUE le canal SDF ne touche
* pas la densité par elle-même c'est l'opérateur de conversion (`FSdfCarve`/`FSdfFill`) qui le
* fait. Répondre honnêtement demanderait de propager un INTERVALLE de SDF à travers la requête
* de boîte, exactement comme `Eval` propage une valeur de SDF. En attendant, **la source répond
* pour la paire** (elle rend `CarveOnly`/`FillOnly` quand une primitive atteint la boîte,
* `Identity` sinon) et la conversion rend `Identity`. Conservatif et correct ; à remplacer par
* une requête de boîte à deux canaux quand les intervalles numériques arriveront (Phase 3).
*
* PHASE 1 SIMPLIFICATION: an SDF-only source answers for itself AND its conversion op; the
* conversion returns Identity. Answering honestly needs an SDF INTERVAL threaded through the box
* query, mirroring how Eval threads an SDF value. Conservative and correct meanwhile.
*/
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
/**
* LE PLIAGE QUI PORTE DES NOMBRES `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de
* perf du plan.
*
* La direction seule ne suffit pas pour les opérateurs FIELDÉS. Un carve à seuil de bruit (les
* vers de TunnelNetwork, la rugosité de paroi) n'a AUCUNE borne spatiale : il rend `CarveOnly`
* sur CHAQUE boîte de CHAQUE strate qui l'active, donc il tue l'hypothèse `AllSolid` partout et
* l'archétype ne saute pas une tuile. Aucun raffinement de `EffectOverBox` ne peut le récupérer,
* parce que la réponse « oui, je peux creuser ici » est VRAIE.
*
* **Mais son AMPLITUDE est bornée, et souvent triviale** : pour un ver, `t [0,1]` et
* `Mask [0,1]`, donc il ne peut déplacer la densité vers l'air que de `WormStrength` au plus.
* Si le roc est solide d'une marge SUPÉRIEURE à la somme de tous les carves restants, la boîte
* est prouvablement pleine quel que soit le bruit.
*
* D' deux nombres, en unités de DENSITÉ (convention interne, positif = solide) :
* `MaxCarveOverBox` de combien AU PLUS cet opérateur peut baisser la densité sur la boîte,
* `MaxFillOverBox` de combien AU PLUS il peut la monter.
*
* **`FLT_MAX` = « je ne sais pas », et c'est le DÉFAUT.** Un opérateur qui ne redéfinit rien se
* comporte donc EXACTEMENT comme avant ce changement : le pliage retire `FLT_MAX` à la marge,
* elle passe sous zéro, l'hypothèse meurt. Les treize tests d'équivalence et
* `VoxelForge.OpStack.BoxVerdictFold` ne bougent pas d'un verdict.
*
* SENS DE L'ERREUR : SUR-estimer une amplitude coûte du CPU (une tuile maillée pour rien) ;
* SOUS-estimer produit un TROU. Comme partout ailleurs dans ce fichier, en cas de doute rendre
* `FLT_MAX`. Ce n'est pas une borne « raisonnable », c'est une borne PROUVÉE ou rien.
*
* The fold carries NUMBERS, not just directions. A fielded noise carve has no spatial bound but
* its AMPLITUDE is bounded, so "the rock is solid by more than the sum of every remaining carve"
* becomes provable. FLT_MAX means "unknown" and is the default, so every existing op is
* unchanged. Over-estimating costs CPU; under-estimating is a hole.
*/
virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
return FLT_MAX;
}
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
return FLT_MAX;
}
/**
* Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien
* la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ?
*
* C'est l'autre moitié du pliage numérique. `MaxCarveOverBox` dit ce qu'on peut RETIRER ; ceci
* dit ce qu'il y avait à retirer. Sans les deux, la soustraction n'a pas de premier terme.
*
* Exemple, et c'est LE cas qui compte : `FConstantFieldSource` pose `Density = BaseDensity`
* partout. Sa marge est donc exactement `BaseDensity`. Un ver à `WormStrength = 0.6` sur un roc
* à `BaseDensity = 1.0` laisse 0.4 de marge la boîte reste prouvablement pleine.
*
* **0 = « je ne sais pas », et c'est le DÉFAUT** : la marge tombe à zéro, le premier carve la
* fait passer sous zéro, l'hypothèse meurt le comportement d'avant, à l'identique.
*/
virtual float ForcedMarginOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
return 0.0f;
}
/**
* OPÉRATEURS FORÇANTS. Certains opérateurs ne « déplacent » pas la densité d'entrée : ils
* l'ÉCRASENT. La question « dans quelle direction peux-tu bouger ce champ ? » n'a alors pas de
* sens ; la bonne question est « sais-tu prouver que toute cette boîte est d'un seul côté,
* QUELLE QUE SOIT l'entrée ? ».
*
* Deux familles répondent autre chose que Mixed :
* les SOURCES (rôle 1) elles posent le champ, donc elles le savent par construction ;
* les op STRUCTURELS forçants typiquement ApplyBoundarySeal, qui à l'intérieur de sa
* bande fait `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est
* solide garanti quoi qu'il y ait eu avant. C'est exactement ce que ClassifyTile encode
* aujourd'hui avec « bande de seal bCanAir = false » et un simple FillOnly ne suffirait
* PAS à le reproduire (voir VF_FoldOp plus bas).
*
* Par défaut Mixed = « je ne sais pas », toujours sûr. Une source à primitives placées (graphe
* de salles, îles, puits) répond en testant ses bornes ; une source heightfield répond en
* échantillonnant ses colonnes sur le treillis exact, exactement comme aujourd'hui.
*
* FORCING OPS. Some ops do not *move* the input density, they *overwrite* it. Default Mixed =
* "I don't know", always safe. The boundary seal is the non-source example, and it is the
* reason this method exists at all rather than being folded into EffectOverBox.
*/
virtual EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
return EVoxelTileClass::Mixed;
}
/**
* Déclare si Eval dépend de Z. Les opérateurs purs en XY reçoivent le traitement du cache de
* colonnes T1.a de façon GÉNÉRIQUE, au lieu que SurfaceWorld en ait un sur mesure.
*
* Le cache de colonnes est clé sur (boîte XY, StrateKey, Seed) SANS ChunkZ et il est
* partagé sur TOUTE la pile verticale de chunks. Mettre une donnée dépendante de Z dans un
* opérateur qui se déclare XY-pur corrompt silencieusement chaque chunk de la colonne, et
* ValidateDeterminism qui échantillonne le long d'une frontière en X ne le verrait pas.
*/
virtual bool IsXYPure() const { return false; }
/**
* DIAGNOSTIC UNIQUEMENT le nom que les rapports de test impriment pour cet opérateur.
*
* POURQUOI CETTE MÉTHODE EXISTE, ET CE QU'ELLE A COÛTÉ DE NE PAS AVOIR. Le premier build de
* l'`EffectOverBox` spatial est revenu **vert avec 0 tuile prouvée sur 40**, et la seule chose
* que le rapport pouvait dire était « ou bien les tuiles traversent toutes une grotte, ou bien
* la source n'atteint pas sa branche `Identity` ». Deux causes, zéro nombre pour les
* départager exactement le piège que ce projet a déjà payé plusieurs fois. La vraie cause
* était un TROISIÈME opérateur (le ver, qui rendait `CarveOnly` partout). Avec un nom par
* opérateur, `ClassifyBoxAttributed` répond « c'est celui- » au lieu de laisser deviner.
*
* N'entre dans AUCUNE clé de cache, dans aucun hash, dans aucune décision de génération : le
* changer ne peut pas changer le monde. Le défaut est volontairement laconique un opérateur
* sans nom se repère à son index, ce qui suffit à savoir regarder.
*
* Diagnostic only: the first build of the spatial EffectOverBox came back green with 0 tiles
* proved, and the report could not name which operator was killing the hypothesis. It was a
* third one nobody was looking at. Never part of a cache key or any generation decision.
*/
virtual const TCHAR* DebugName() const { return TEXT("(unnamed op)"); }
};
//=============================================================================
// LE PLIAGE : comment la pile devient un verdict de tuile
// THE FOLD: how a stack becomes a tile verdict
//=============================================================================
// C'est l'algorithme générique qui remplace le ClassifyTile écrit à la main, et il REPRODUIT
// EXACTEMENT le comportement d'aujourd'hui — vérifié ligne à ligne contre VoxelGenerator.cpp :
//
// source gap bedrock → ClassifyBox = AllSolid
// source SurfaceWorld → ClassifyBox échantillonne les colonnes sur le treillis exact
// ApplyPassageCarving → CarveOnly (tue AllSolid) ≡ « AnyPassageNearBox ⇒ bCanSolid=false »
// ApplyOriginSpine → CarveOnly (tue AllSolid) ≡ le test cercle/boîte XY
// ApplyBoundarySeal → ClassifyBox = AllSolid DANS sa bande (opérateur forçant),
// FillOnly ailleurs ≡ « bande de seal ⇒ bCanAir=false »
// disturbances chasms → CarveOnly ≡ « ChasmDensity > 0 ⇒ bCanSolid=false »
// disturbances ponts/arêtes → FillOnly ≡ « Bridge/RidgeDensity > 0 ⇒ bCanAir=false »
// diff layer → Both si des mods touchent la boîte, sinon Identity
// ≡ « HasAnyModInChunkRange ⇒ Mixed »
//
// et le verdict final « exactement une hypothèse survit, sinon Mixed » est littéralement le
// `if (bCanSolid == bCanAir) return Mixed;` de la fin de ClassifyTile.
//
// This fold reproduces today's hand-written ClassifyTile exactly — verified line by line against
// VoxelGenerator.cpp. That correspondence is the evidence that the abstraction fits this codebase
// rather than being imposed on it.
/** Les deux hypothèses que ClassifyTile poursuit, sous forme d'état pliable. */
struct FVoxelBoxHypotheses
{
bool bCanBeAllSolid = true;
bool bCanBeAllAir = true;
/**
* LES DEUX NOMBRES DU PLIAGE (`OPSTACK-DECOMPOSITION §0.2`).
* `SolidMargin` = de combien la densité est encore garantie AU-DESSUS de zéro partout dans la
* boîte, SOUS l'hypothèse « tout solide ». Un opérateur forçant la pose ; chaque carve en retire
* son amplitude maximale ; quand elle n'est plus strictement positive, l'hypothèse meurt.
* `AirMargin` est son miroir.
*
* **Elles valent 0 sur un état neuf, et c'est ce qui rend le changement rétro-compatible :**
* sans opérateur forçant qui déclare une marge, le premier carve fait `0 FLT_MAX < 0` et tue
* l'hypothèse le comportement exact d'avant le pliage numérique.
*/
float SolidMargin = 0.0f;
float AirMargin = 0.0f;
bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; }
/** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */
EVoxelTileClass Resolve() const
{
if (bCanBeAllSolid == bCanBeAllAir) { return EVoxelTileClass::Mixed; }
return bCanBeAllSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
}
};
/** Poser l'état depuis un verdict FORÇANT (source, ou seal dans sa bande) : l'opérateur écrase
* l'entrée, donc il écrase aussi tout ce que la pile avait conclu avant lui. Un verdict « tout
* air » affirme du même coup « pas tout solide », et réciproquement. */
/** @param Margin de combien la densité est garantie du bon côté de zéro dans toute la boîte.
* 0 (le défaut) = « je ne sais pas » comportement d'avant le pliage numérique. */
FORCEINLINE void VF_ForceHypotheses(FVoxelBoxHypotheses& H, EVoxelTileClass ForcedVerdict,
float Margin = 0.0f)
{
switch (ForcedVerdict)
{
case EVoxelTileClass::AllSolid: H.bCanBeAllSolid = true; H.bCanBeAllAir = false;
H.SolidMargin = Margin; H.AirMargin = 0.0f; break;
case EVoxelTileClass::AllAir: H.bCanBeAllSolid = false; H.bCanBeAllAir = true;
H.SolidMargin = 0.0f; H.AirMargin = Margin; break;
case EVoxelTileClass::Mixed:
default: H.bCanBeAllSolid = false; H.bCanBeAllAir = false;
H.SolidMargin = 0.0f; H.AirMargin = 0.0f; break;
}
}
/**
* Plier l'effet d'un opérateur dans l'état. **Monotone : ne fait que tuer**, jamais ressusciter
* c'est la propriété de sûreté, et le pliage numérique ne l'affaiblit pas : une marge ne peut que
* DESCENDRE, jamais remonter, en dehors d'un opérateur forçant.
*
* `MaxCarve` / `MaxFill` valent `FLT_MAX` par défaut = « amplitude inconnue ». La soustraction
* fait alors passer la marge très en dessous de zéro et l'hypothèse meurt, exactement comme la
* version purement directionnelle de ce pliage. Aucun opérateur existant ne change de verdict.
* (Arithmétique volontairement laissée en float sans garde : `0 FLT_MAX` vaut `FLT_MAX`,
* `FLT_MAX FLT_MAX` sature à `inf`, et `inf > 0` est faux. Pas de NaN possible, les deux
* termes étant de même signe.)
*/
FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect,
float MaxCarve = FLT_MAX, float MaxFill = FLT_MAX)
{
switch (Effect)
{
case EVoxelOpEffect::Identity:
break;
case EVoxelOpEffect::CarveOnly:
H.SolidMargin -= MaxCarve;
if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; }
break;
case EVoxelOpEffect::FillOnly:
H.AirMargin -= MaxFill;
if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; }
break;
case EVoxelOpEffect::Both:
default:
H.SolidMargin -= MaxCarve;
if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; }
H.AirMargin -= MaxFill;
if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; }
break;
}
}
/**
* Plier UN opérateur. L'ORDRE COMPTE ICI, et c'est délibéré : un opérateur forçant écrase ce que
* la pile avait conclu AVANT lui, tandis que les opérateurs qui suivent continuent de s'appliquer.
*
* Exemple à garder en tête, parce qu'il est le piège : sur une boîte entièrement dans la bande de
* seal, la source dit peut-être « tout air » (on est au-dessus du terrain), puis le seal FORCE
* « tout solide » verdict AllSolid, comme aujourd'hui. Si le seal ne savait dire que FillOnly,
* on obtiendrait « les deux hypothèses mortes Mixed » : pas un trou, mais la perte pure et
* simple d'une des tuiles triviales que T1.d sait sauter. C'est pour cela que ClassifyBox existe.
*
* Réciproquement, un passage qui traverse cette même boîte rend CarveOnly APRÈS le seal et retue
* l'hypothèse solide Mixed. Identique au code actuel, la garde passage et la garde seal se
* neutralisent en `bCanSolid == bCanAir`.
*
* ORDER MATTERS HERE, deliberately: a forcing op overwrites what the stack concluded before it,
* while ops after it still apply. This is what lets the seal recover an AllSolid verdict that a
* pure FillOnly would have thrown away, while still letting a passage take it back.
*/
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
const FBox& VoxelBox, const FVoxelOpContext& Ctx)
{
const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx);
if (Forced != EVoxelTileClass::Mixed)
{
VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx));
return;
}
VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx),
Op.MaxCarveOverBox(VoxelBox, Ctx), Op.MaxFillOverBox(VoxelBox, Ctx));
}
@@ -0,0 +1,391 @@
// VoxelDensityOpStack.h
// La PILE : un conteneur ordonné d'opérateurs, plus les fabriques d'opérateurs concrets.
// The STACK: an ordered container of operators, plus the concrete-operator factories.
//
// ⚠️ CECI ALIMENTE LE JEU, MAIS SEULEMENT SUR OPT-IN (depuis OPSTACK-PLAN §4, Phase 1, point 3).
// `UVoxelGenerator::GetDensityAt` construit la pile par chunk et l'évalue à la place du `switch`
// UNIQUEMENT quand `UVoxelStrateManager::UsesOperatorStackForChunk` rend true — c.-à-d. quand la
// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés :
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands (6 sur 8)**.
// Toute autre strate passe encore par le `switch`, inchangé.
// `ClassifyTile` n'est PAS branché : il utilise toujours ses gardes écrites à la main, pas
// `ClassifyBox`. C'est la Phase 2.
//
// THIS FEEDS THE GAME, BUT ONLY BEHIND AN OPT-IN. GetDensityAt builds the stack per chunk and
// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate ticked
// bUseOperatorStack AND its archetype ported — 6 of 8). ClassifyTile is NOT wired: it still uses its
// hand-written guards rather than ClassifyBox. That is Phase 2.
//
// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde.
// ⚠️ EN REVANCHE, LES COMPARER EST DEVENU LÉGITIME — cette ligne disait l'inverse et elle est
// périmée. `AUDIT §C10` (le résidu ~1 ULP) est CLOS depuis `FPSemantics = Precise` : les cinq tests
// d'équivalence comparent bit à bit et sont verts. Ils ne sont plus des contrôles de FIDÉLITÉ (la
// barre `§2.6.1` n'exige aucune ressemblance avec l'ancien monde) mais des oracles de
// CORRECTION DE PORTAGE — une faute de transcription reste un vrai bug, et l'ancienne fonction est
// le moyen le moins cher de l'attraper.
//
// POURQUOI CETTE FORME / WHY THIS SHAPE
// La question à laquelle la Phase 1 doit répondre n'est pas « est-ce que ça marche ? » mais
// **« est-ce que la séparation source / modifier tombe naturellement du code existant ? »**
// (OPSTACK-PLAN §4, le déclencheur d'arrêt). En portant Maze hors du chemin chaud et en le
// comparant à l'original, cette question reçoit une réponse MESURÉE plutôt qu'une opinion.
#pragma once
#include "CoreMinimal.h"
#include "VoxelDensityOp.h"
#include "VoxelStrateTypes.h" // FMazeGenerationParams
#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one
class UVoxelStrateManager;
/**
* FVoxelOpStack une liste ordonnée d'opérateurs + le pliage de verdict de boîte.
*
* PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et
* l'ordre spine seal passage est garanti par cette fonction, pas par l'auteur. Un auteur ne
* peut pas les omettre ni les réordonner ce sont des invariants de monde (la descente doit rester
* possible, les seals doivent tenir, les passages doivent percer).
*
* PROPRIÉTÉ (threading) : la pile est LUE par les workers. Les opérateurs concrets qui ont besoin
* d'un cache par cellule/chunk le tiennent en `thread_local` à l'intérieur de leur `Eval`, comme le
* fait déjà chaque fonction d'archétype. En Phase 3, quand les opérateurs deviendront des assets
* partagés, il faudra un objet d'état PAR WORKER noté ici pour que ça ne surprenne personne.
*
* THREADING: the stack is READ by workers. Concrete ops that need a per-cell/per-chunk cache keep it
* thread_local inside Eval, exactly as every archetype function already does. Phase 3 (ops as shared
* assets) will need a per-worker state object flagged here so it is not a surprise.
*/
class FVoxelOpStack
{
public:
// DÉPLAÇABLE, PAS COPIABLE — et c'est la bonne sémantique, pas un contournement de compilateur :
// une pile POSSÈDE ses opérateurs de façon unique. La copier voudrait dire cloner des opérateurs
// polymorphes, ce qui n'a pas de sens ici (une pile n'existe qu'une fois par strate).
//
// ⚠️ NOTE COMPILATEUR : ne PAS remettre `VOXELFORGE_API` sur la classe. Sous MSVC, dllexport sur
// une classe force l'instanciation de TOUS ses membres implicites, y compris l'opérateur
// d'affectation par copie — impossible à générer pour un `TArray<TUniquePtr<...>>`, d'où
// l'erreur C2280 « fonction supprimée ». L'export va sur la seule méthode hors-ligne.
//
// MOVE-ONLY, and that is the correct semantics rather than a compiler workaround: a stack
// uniquely OWNS its operators. Do NOT put VOXELFORGE_API back on the class — under MSVC,
// dllexport forces instantiation of every implicit member including copy-assignment, which
// cannot be generated for a TArray<TUniquePtr<...>> (error C2280). Export the out-of-line
// method instead.
FVoxelOpStack() = default;
FVoxelOpStack(FVoxelOpStack&&) = default;
FVoxelOpStack& operator=(FVoxelOpStack&&) = default;
FVoxelOpStack(const FVoxelOpStack&) = delete;
FVoxelOpStack& operator=(const FVoxelOpStack&) = delete;
void Add(TUniquePtr<IVoxelDensityOp> Op) { Ops.Add(MoveTemp(Op)); }
int32 Num() const { return Ops.Num(); }
/** Hoist chunk-constant work for every op. Une fois par chunk et par worker.
* Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un
* mensonge utile qui finirait par masquer une course. */
void PrepareChunk(const FVoxelOpContext& Ctx)
{
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops) { Op->PrepareChunk(Ctx); }
}
/**
* Évalue la pile complète en un point. Rend la densité en convention INTERNE
* (positif = solide) l'appelant négate UNE FOIS pour le marching cubes.
*
* Returns INTERNAL-convention density (positive = solid). The caller negates once for MC.
*/
float EvalInternal(float WorldX, float WorldY, float WorldZ) const
{
return EvalSample(WorldX, WorldY, WorldZ).Density;
}
/** L'état COMPLET (densité + SDF) après toute la pile. Diagnostic : quand une comparaison
* avec l'ancien chemin diverge, c'est le canal SDF qui dit si l'écart naît avant ou après
* la conversion. / The full state after the stack the SDF channel is what says whether a
* divergence is born before or after the carve. */
FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const
{
FVoxelOpSample S;
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops) { Op->Eval(WorldX, WorldY, WorldZ, S); }
return S;
}
/** Le même, négaté pour le mesher (négatif = solide). */
float EvalMC(float WorldX, float WorldY, float WorldZ) const
{
return -EvalInternal(WorldX, WorldY, WorldZ);
}
/**
* Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile.
* Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique en particulier pourquoi un
* opérateur FORÇANT (le seal dans sa bande) écrase ce que la pile avait conclu avant lui.
*/
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
FVoxelBoxHypotheses H;
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops)
{
VF_FoldOp(H, *Op, VoxelBox, Ctx);
if (H.IsDead()) { return EVoxelTileClass::Mixed; } // early-out : plus rien à prouver
}
return H.Resolve();
}
/**
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
*
* IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` même boucle, même early-out,
* même ordre. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
* l'autre change avec lui.
*
* `OutSolidKiller` / `OutAirKiller` reçoivent l'INDEX du premier opérateur qui fait passer
* l'hypothèse correspondante de vraie à fausse, ou `INDEX_NONE` si elle a survécu. Le nom
* lisible s'obtient par `GetOpDebugName(index)`.
*
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
* ClassifyBox a diagnostic that takes a slightly different path sends you hunting in the
* wrong place.
*/
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
int32& OutSolidKiller, int32& OutAirKiller) const
{
OutSolidKiller = INDEX_NONE;
OutAirKiller = INDEX_NONE;
FVoxelBoxHypotheses H;
for (int32 i = 0; i < Ops.Num(); ++i)
{
const bool bSolidBefore = H.bCanBeAllSolid;
const bool bAirBefore = H.bCanBeAllAir;
VF_FoldOp(H, *Ops[i], VoxelBox, Ctx);
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
if (H.IsDead()) { return EVoxelTileClass::Mixed; }
}
return H.Resolve();
}
/** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */
const TCHAR* GetOpDebugName(int32 Index) const
{
return Ops.IsValidIndex(Index) ? Ops[Index]->DebugName() : TEXT("(none)");
}
/**
* RÔLE 4 ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
* spine (0,0) seal de frontière carve de passage.
*
* La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la
* négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront
* portées et que la question de convention MC-vs-interne sera tranchée pour de bon
* (OPSTACK-DECOMPOSITION §10.2). Tant que la pile n'alimente pas le jeu, c'est sans effet.
*
* The diff layer is NOT here: it lives in GetDensityAt, AFTER the MC negate, with disturbances.
* It joins the stack when disturbances are ported. Harmless while the stack feeds nothing.
*
* @param StrateManager peut être nullptr pas de carve de passage (comme le fallback actuel).
*/
VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
float SealThickness, float BaseDensity, float SpineRadius,
const UVoxelStrateManager* StrateManager);
private:
TArray<TUniquePtr<IVoxelDensityOp>> Ops;
};
//=============================================================================
// FABRIQUES / FACTORIES
//=============================================================================
namespace VoxelDensityOps
{
/** Rôle 1 — `Density = BaseDensity` partout. `ClassifyBox` → AllSolid, exact et gratuit.
* Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity);
/** Rôle 1 — le MÊME opérateur au signe près : `Density = -BaseDensity`, un grand vide ouvert.
* `ClassifyBox` **AllAir**, ce qu'aucune source n'avait encore su rendre c'est ce qui rend
* une strate d'îles flottantes (surtout vide) sautable aucune île n'arrive. Racine de
* FloatingIslands. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity);
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
* Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux
* chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
* COLLECT, zéro risque de couture (AUDIT §6.4 le motif à préférer). */
/* `ExtraReach` = tout ce qui peut eLARGIR la portée du couloir en aval (amplitude de rugosité
* rayon de blend du carve). La source répond pour la paire source+conversion dans
* `EffectOverBox` (voir la note « SIMPLIFICATION DE PHASE 1 » dans VoxelDensityOp.h), donc elle
* doit connaître cette marge, sinon sa réponse `Identity` serait un MENSONGE c'est-à-dire un
* trou. / The source answers for the source+conversion pair, so it must know the downstream
* margin: an Identity that is wrong is a hole. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
int32 Seed, float ExtraReach);
/** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands).
* `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est
* un gain d'authoring gratuit, et §2.6 autorise explicitement le re-tune. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfRoughnessMod(float Strength, float Frequency,
int32 BaseOctaves, float ApplyWithin);
/** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur.
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts.
* @param MinDivisor plancher du diviseur `Blend·2`. **TunnelNetwork passe 1.0** (son original
* écrit `FMath::Max(SDFBlendRadius·2, 1)`) ; Maze/Shafts laissent 0,
* `Max(x,0) == x` exactement. Les deux formules divergent si `Blend·2 < 1`,
* donc ce paramètre est une vraie différence, pas une précaution. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity,
float MinDivisor = 0.0f);
/** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur.
* C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1
* étant exacte en IEEE-754, le chemin carve reste bit pour bit ce qu'il était. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity);
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
* bornées par le contrat [-1,1] de FBM. Sert FlatPlain **et** CrystalChamber. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed);
/** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒
* `FillOnly` quand une colonne atteint la boîte, `Identity` (le cas courant) sinon. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed);
/** Rôle 1 — le pont entre les deux espaces : consomme les piles de HAUTEUR (sol + voûte,
* `VoxelHeightOp.h`) et en fait une densité. `IsXYPure()` est **false** les hauteurs sont
* pures en XY, la densité est une distance à celles-ci et ne peut pas l'être. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSurfaceColumnSource(const FSurfaceGenerationParams& P,
int32 Seed);
/**
* SurfaceWorld, COMPLET : colonne (sol + voûte) densité, overhang 3D, post structurel, et le
* mélange de biomes quand `PerBiomeParams` est non vide.
*
* @param PerBiomeParams vide pas de biomes, chemin d'origine strictement inchangé. Non vide
* une pile de hauteur COMPLÈTE par biome, sol mélangé / voûte
* sélectionnée, amplitude d'overhang interpolée (§5, combiner `Mask`).
* @param BiomeField **transféré** à la pile, qui le possède. Doit répondre pour les mêmes
* indices que `PerBiomeParams`. `nullptr` avec des params non vides
* biome 0 partout (dégradation sûre, pas un crash).
*/
VOXELFORGE_API void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager,
const TArray<FSurfaceGenerationParams>& PerBiomeParams =
TArray<FSurfaceGenerationParams>(),
TUniquePtr<IVoxelBiomeField> BiomeField = nullptr);
/**
* FlatPlain ET CrystalChamber la même pile, **sans branchement sur le type** :
* SlabVoidSource GridColumnMod [structural post ×3]
*
* C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes
* disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans
* `GetSlabDensity` un jeu de valeurs par défaut, pas du code.
*/
VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* VerticalShafts 8 ops, et **TROIS viennent de Maze sans une ligne de changement** :
* ConstantRock ShaftField SdfRoughness SdfCarve ShaftLedge [structural post ×3]
*
* C'est la démonstration que `§2.5` promettait : dans le `switch`, Maze et VerticalShafts sont
* deux fonctions de ~100 lignes sans rien de commun à l'œil ; en opérateurs, ce sont les mêmes
* trois ops avec une source différente et d'autres réglages (fréquence 0.1 au lieu de 0.12,
* fenêtre `rough + 4` au lieu de `R + rough + 2`).
*/
VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* TunnelNetwork **COMPLET, 19 ops** :
* ConstantRock RoomGraph(warp + pits + cheminées) SdfCarve CaveRoughness(4b)
* Terrace LayerLines Ribbing Overhang Cliff Scallop Arch RoomColumn(4d)
* Dome(4g) Pinch(4h) FloorBias Worms [structural ×3]
*
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
* `LocalParams()` les params de la strate avec l'op de la salle la plus proche appliqué et
* ONZE des douze modificateurs y lisent leurs champs. La rugosité (4b) lit les params de la
* STRATE, parce que dans l'original elle précède la déclaration du shadow.
*
* `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit
* pas : c'est que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE
* §8.4`), et en faire une copie serait le pire résultat possible pour un refactor dont le but est
* d'avoir UNE définition de chaque idée.
*/
VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack,
const FStrateGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* DIAGNOSTIC la ventilation par CLASSE DE PRIMITIVE du dernier `FRoomGraphSource::EffectOverBox`
* évalué sur ce thread. **Tests uniquement. N'entre dans aucune décision de génération.**
*
* POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
* pour rejouer le critère il appelle `BuildChunkCache` ailleurs. Le rejouer serait une
* DEUXIÈME définition du critère, qui dériverait de la vraie et mentirait exactement le jour
* on la croirait. C'est la même raison qui a fait exister `VF_BuildOpStackForChunk`. On expose
* donc ce que l'opérateur a réellement calculé.
*
* `Hit*` = combien de primitives de cette classe atteignent la boîte (0 partout `Identity`).
* `Num*` = combien le cache en contenait, ce qui distingue « aucune n'atteint » de « il n'y en
* avait aucune » deux zéros de sens opposé.
*
* Reads back what the operator actually computed, rather than letting the test re-derive the
* criterion: a second copy would drift and would lie on the day it was believed.
*/
struct FRoomBoxDiagnostic
{
int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0;
int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0;
/** Les mêmes comptes si la dilatation de warp valait ZÉRO, et de combien de voxels la boîte
* est effectivement dilatée. `Hit* - Hit*NoWarp` = la part du blocage due à MA boîte plutôt
* qu'à la géométrie. Cette mesure manquait, et son absence a coûté trois builds de
* resserrement autour du mauvais terme. */
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
float WarpDilation = 0.0f;
};
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
/**
* FloatingIslands 7 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid IslandBlob SdfRoughness SdfFill [structural post ×3]
*
* Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et
* REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf `FConstantFieldSource` et
* `FSdfConvertOp` sont les mêmes classes au signe près, et `FSdfRoughnessMod` est repris sans
* une ligne de changement (4 archétype). Seul le blob d'île est nouveau.
*
* The stack that runs backwards: void source + fill instead of rock source + carve, using the
* SAME operators with the opposite sign.
*/
VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* La pile Maze complète, décomposée PAS un `FMazeOp` monolithique :
* ConstantRockSource LatticeCorridorSource SdfRoughnessMod SdfCarve [structural post]
*
* C'est le test de la Phase 1 : si Maze ne se décompose pas ainsi, l'abstraction est mauvaise
* pour ce domaine (OPSTACK-PLAN §4, déclencheur d'arrêt).
*/
VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
}
@@ -0,0 +1,130 @@
// VoxelDensityPrimitives.h
// Les trois post-traitements STRUCTURELS partagés par chaque archétype.
// The three STRUCTURAL post-processes every archetype shares.
//
// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS
// Ces trois fonctions étaient `static` dans VoxelGenerator.cpp et appelées à l'identique par les six
// fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici : UNE copie,
// partagée par le générateur et par les opérateurs. Dupliquer serait garantir qu'elles divergent —
// et ce sont des INVARIANTS de monde (la descente doit rester possible, les seals doivent tenir, les
// passages doivent percer), pas des choix créatifs.
//
// They were `static` in VoxelGenerator.cpp and called identically by all six density functions. The
// operator stack needs the same ones, so they move here: ONE copy, shared. Duplicating would
// guarantee divergence, and these are world INVARIANTS, not creative choices.
//
// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin.
// Ces trois fonctions travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la
// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`.
// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC
// convention happens ONCE, at the caller's return.
//
// Aucun changement de comportement en les déplaçant : corps identiques, FORCEINLINE au lieu de
// static, mêmes appelants. / No behavioural change: identical bodies, FORCEINLINE instead of static.
#pragma once
#include "CoreMinimal.h"
#include "VoxelTypes.h" // SmoothStep01
//=============================================================================
// SEAL DE FRONTIÈRE / BOUNDARY SEAL
//=============================================================================
// Seal solide aux bords haut et bas de la strate. Fade smoothstep sur `Thickness` voxels depuis
// chaque bord. N'AJOUTE que de la densité (FMath::Max), jamais n'en enlève → le joueur ne peut
// jamais percer le seal "par accident", seulement via les passages.
//
// ⚠️ C'est un opérateur FORÇANT, pas seulement un FillOnly : à l'intérieur de la bande, avec
// SealFactor > 0 et BaseDensity > 0, le résultat est solide GARANTI quelle qu'ait été l'entrée.
// C'est exactement ce qu'encode `IVoxelDensityOp::ClassifyBox` (voir VoxelDensityOp.h), et la
// raison pour laquelle cette méthode existe.
FORCEINLINE void VF_ApplyBoundarySeal(float& Density, float WorldZ,
float StrateTopZ, float StrateBottomZ,
float Thickness, float BaseDensity)
{
if (Thickness <= 0.0f) return;
const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond
const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol
if (DistTop >= 0.0f && DistTop < Thickness)
{
float SealFactor = 1.0f - (DistTop / Thickness);
SealFactor = SmoothStep01(SealFactor);
Density = FMath::Max(Density, SealFactor * BaseDensity);
}
if (DistBot >= 0.0f && DistBot < Thickness)
{
float SealFactor = 1.0f - (DistBot / Thickness);
SealFactor = SmoothStep01(SealFactor);
Density = FMath::Max(Density, SealFactor * BaseDensity);
}
}
//=============================================================================
// CARVE DE PASSAGE / PASSAGE CARVING
//=============================================================================
// Creuse un passage inter-strates. Évalué APRÈS le seal pour que les passages puissent percer à
// travers le bouchon solide. Le rayon de blend hard-codé à 4.0f correspond à l'ancienne valeur —
// à exposer via UVoxelSettings si on veut pouvoir le tweaker.
FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF,
float BaseDensity, float SealThickness)
{
constexpr float PASSAGE_BLEND_RADIUS = 4.0f;
if (ModSDF >= PASSAGE_BLEND_RADIUS) return;
float CarveFactor = FMath::Clamp(
(PASSAGE_BLEND_RADIUS - ModSDF) / (PASSAGE_BLEND_RADIUS * 2.0f),
0.0f, 1.0f);
CarveFactor = SmoothStep01(CarveFactor);
// FORCE the density toward guaranteed AIR so the passage punches through ANYTHING in
// its path (seals, columns, surface roughness, terrain ops). A plain subtraction can
// be out-paced by stacked density additions, leaving solid plugs mid-tunnel — which is
// why the shaft "didn't go all the way through". Lerp toward a strongly negative target
// and take the min so we only ever make it MORE air (never refill an existing cave).
const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f);
Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor));
}
//=============================================================================
// SPINE DE DESCENTE (0,0) / (0,0) DESCENT SPINE
//=============================================================================
// Creuse une colonne verticale garantie ouverte au XY monde (0,0) dans l'INTÉRIEUR de la strate
// (entre les seals haut et bas). Les seals sont laissés intacts pour que le joueur doive encore
// creuser à travers pour descendre — ceci ne fait qu'un espace d'atterrissage propre, indépendant
// de l'archétype, aligné à travers toutes les strates.
FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ,
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
{
if (Radius <= 0.0f) return;
// Stay within the interior — never touch the seal bands.
const float InnerTop = StrateTopZ - SealThickness;
const float InnerBot = StrateBottomZ + SealThickness;
if (WorldZ <= InnerBot || WorldZ >= InnerTop) return;
const float DistXY = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY);
const float SDF = DistXY - Radius; // < 0 inside the column
const float Blend = 3.0f;
if (SDF < Blend)
{
float Carve = FMath::Clamp((Blend - SDF) / (Blend * 2.0f), 0.0f, 1.0f);
Carve = SmoothStep01(Carve);
Density -= Carve * (BaseDensity * 2.0f + SealThickness);
}
}
//=============================================================================
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
//=============================================================================
// Les constantes de blend ci-dessus (3.0 pour la spine, 4.0 pour les passages) sont dupliquées à la
// main dans ClassifyTile aujourd'hui. Les nommer ici pour qu'un futur test de bornes ne puisse pas
// les désynchroniser. / The blend constants above are hand-duplicated inside ClassifyTile today.
// Naming them here so a future bounds test cannot let the two drift apart.
namespace VoxelDensityReach
{
constexpr float SpineBlend = 3.0f;
constexpr float PassageBlend = 4.0f;
}
@@ -37,9 +37,17 @@
#include "CoreMinimal.h"
#include "Containers/Queue.h"
#include "VoxelTypes.h"
// ⚠️ IWYU, ET CELUI-CI EST PIÉGEUX : `ENABLE_DRAW_DEBUG` est utilisé en `#if` plus bas. Un macro
// NON DÉFINI vaut 0 dans un `#if` — donc sans cet include le bloc de debug disparaît EN SILENCE au
// lieu de provoquer une erreur de compilation. Il venait du PCH partagé ; `FPSemantics = Precise`
// (AUDIT §C9) nous en prive. Défini par DrawDebugHelpers.h (vérifié dans UE 5.7).
// An UNDEFINED macro evaluates to 0 in an #if, so without this include the debug block vanishes
// SILENTLY instead of failing the build. Defined by DrawDebugHelpers.h (verified in UE 5.7).
#include "DrawDebugHelpers.h"
#include <atomic>
#include "VoxelDensityVolume.generated.h"
class AActor; // IWYU : pointeur / TWeakObjectPtr seulement / pointer-only
class UVoxelGenerator;
class UVoxelSettings;
class UVolumeTexture;
+68 -37
View File
@@ -50,23 +50,9 @@ namespace VoxelGenLOD
FORCEINLINE int32 Eff(int32 Octaves) { return FMath::Max(1, Octaves - OctaveBias); }
}
//=============================================================================
// TRIVIAL-TILE CLASSIFICATION (T1.d)
//=============================================================================
// Verdict de ClassifyTile pour une tuile AVANT le pré-échantillonnage 33³+ :
// AllSolid / AllAir garantissent que CHAQUE point du treillis du mesher (marge
// ±1 incluse) est du même côté de l'iso ⇒ maillage vide, GenerateMesh est
// sautée. Mixed = "je ne peux pas le prouver" ⇒ génération normale. Un faux
// Mixed coûte juste du CPU ; un faux AllSolid/AllAir ferait un TROU — les
// verdicts ne sont donc émis que sur des bornes exactes (colonnes surface
// échantillonnées au MÊME treillis que le mesher) + gardes conservatives sur
// tout ce qui peut creuser/remplir (spine, passages, disturbances, diff layer).
enum class EVoxelTileClass : uint8
{
Mixed, // peut contenir une surface → mesher normalement
AllSolid, // chaque échantillon prouvé solide → maillage vide
AllAir, // chaque échantillon prouvé air → maillage vide
};
// NOTE: EVoxelTileClass (le verdict T1.d) a déménagé dans VoxelTypes.h — inclus ci-dessus —
// pour que VoxelDensityOp.h puisse le partager sans dépendre d'un header UCLASS.
// EVoxelTileClass (the T1.d verdict) moved to VoxelTypes.h, included above.
/**
* UVoxelGenerator
@@ -132,9 +118,35 @@ public:
* Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise).
* Utilisée en interne par GetDensityAt quand la strate est de ce type.
* Exposée pour permettre des tests isolés avec des params custom.
*
* `ParamsFingerprint` ET `LayoutVersion` SONT OBLIGATOIRES, ET C'EST LE CORRECTIF
* D'`AUDIT §C2` (2026-07-28). Le cache SDF interne est clé sur (boîte XY, strate, seed) et
* PAS sur les params. Or `GetGenerationParams` BLENDE les params à l'intérieur d'une même
* strate `Alpha` dépend du chunk Z en mode `Gradient` (le DÉFAUT, avec
* `TransitionBlendChunks = 2`) et du chunk XY en plus en mode `Interleaved`. Deux chunks de la
* même strate, même seed, donc même clé, mais des params DIFFÉRENTS : le worker évalue le
* deuxième chunk qu'il construit contre les salles du premier. Et comme *quel* chunk vient en
* premier dépend de l'ordre des workers, **deux pairs divergent depuis la même seed** ce que
* `OPSTACK-PLAN §2.6.1` interdit explicitement.
*
* Pourquoi une empreinte PASSÉE plutôt qu'un `MemCrc32` calculé ici : ce serait ~300 octets de
* CRC PAR VOXEL sur le chemin le plus chaud du plugin. L'appelant la calcule UNE fois par
* chunk, le mémo de params vit déjà (`CP_*`), donc le coût par voxel est exactement deux
* comparaisons d'entiers. Pas de valeur par défaut : un appelant qui oublie doit ne pas
* compiler, pas hériter silencieusement du trou (la discipline de `FVoxelOpContext`).
*
* POUR LES TESTS : passez `FCrc::MemCrc32(&Params, sizeof(Params))`. Un oracle qui partage
* le défaut qu'il teste ne prouve rien c'est précisément ce que la note de
* `VoxelForgeOpStackTunnelTest.cpp` (contrôle 3) décrivait comme le trou de l'original.
*
* The SDF cache key had neither the params nor anything that determines them, while the params
* are blended per chunk INSIDE a strate so a worker could evaluate one chunk against another
* chunk's rooms, and which came first depends on worker order. Passing a once-per-chunk
* fingerprint keeps the fix off the per-voxel path. No default: forgetting it must not compile.
*/
float GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
const FStrateGenerationParams& Params) const;
const FStrateGenerationParams& Params,
uint32 ParamsFingerprint, uint32 LayoutVersion) const;
/**
* Densité pour une strate Slab (FlatPlain / CrystalChamber).
@@ -191,6 +203,37 @@ public:
*/
float SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const;
/**
* La chaîne de hauteur complète de SurfaceWorld : structural cliff terrace layer lines
* plage. Rend une ALTITUDE monde en voxels, pas une densité.
*
* PUBLIQUE pour la même raison que `GetSlabDensity` / `GetMazeDensity` : permettre un test
* isolé. C'est la référence de `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui compare la
* pile d'opérateurs de hauteur (`VoxelHeightOp.h`) à cette fonction point par point.
* Public so the height-op stack can be measured against it same reason as GetSlabDensity.
*/
float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
/**
* La colonne de surface : terrain Z, plafond, et le gate d'OVERHANG résolu par colonne
* (amplitude + direction amont). PUBLIQUES toutes deux pour la même raison que ci-dessus :
* c'est le seul chemin qui calcule l'overhang `GetSurfaceDensity` passe `OverhangAmp = 0`
* donc c'est la seule référence possible pour `FOverhangShelfMod`.
* Public because this is the ONLY path that computes the overhang (GetSurfaceDensity passes 0),
* so it is the only possible reference for the ported op.
*/
void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache,
float& OutTerrainZ, float& OutCeilSurf,
float& OutOverhangAmp, float& OutDirX, float& OutDirY) const;
/** Le combine par voxel : colonne → densité, overhang compris, puis le post structurel. */
float SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
float TerrainZ, float CeilSurf,
float OverhangAmp, float DirX, float DirY,
const FSurfaceGenerationParams& S) const;
/**
* Moisture field at a world XY [0,1]. The second climate axis for biome placement.
*/
@@ -281,9 +324,9 @@ private:
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
/** The SurfaceWorld heightfield: world XY → terrain surface Z (voxel coords). Pure
* per-XY; the part that's evaluated per biome and blended in GetSurfaceDensity. */
float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
// ComputeSurfaceTerrainZ a été DÉPLACÉE en `public` (voir plus haut) pour que
// VoxelForge.OpStack.SurfaceHeightEquivalence puisse s'y comparer. Une seule déclaration.
// Moved to public above so the height-stack test can compare against it. One declaration only.
/** F20 — the RAW structural heightfield (continents + mountains + detail), BEFORE any
* terrain op (cliff/terrace/layer-lines/beach). Ops in ComputeSurfaceTerrainZ build on
@@ -302,22 +345,10 @@ private:
FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx,
TArray<FSurfaceGenerationParams>& OutBiomeParams) const;
/** Biome-blended terrain Z + sky-cap ceiling Z for one column (the XY-only surface field). Shared
* by the density column cache (T1.a) and the oracle. F20 phase 2 overhang, resolved per column:
* `OutOverhangAmp` = strength·slope-gate (0 = off), `(OutDirX,OutDirY)` = unit UPHILL gradient dir. */
void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache,
float& OutTerrainZ, float& OutCeilSurf,
float& OutOverhangAmp, float& OutDirX, float& OutDirY) const;
/** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the cheap per-voxel
* Z-combine + F20 overhang shelf (warped-terrain union, uphill dir) + origin spine + seal + passages.
* The XY-only work (terrain/ceiling/overhang amp+dir) is done once per column and cached (T1.a). */
float SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
float TerrainZ, float CeilSurf,
float OverhangAmp, float DirX, float DirY,
const FSurfaceGenerationParams& Structural) const;
// ComputeSurfaceColumn et SurfaceDensityFromColumn ont été DÉPLACÉES en `public` (voir plus
// haut) : c'est le seul chemin qui calcule l'overhang, donc la seule référence possible pour
// VoxelForge.OpStack.SurfaceHeightEquivalence. Une seule déclaration chacune.
// Moved to public above — the only path that computes the overhang, hence the only oracle.
/** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */
void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ,
+273
View File
@@ -0,0 +1,273 @@
// VoxelHeightOp.h
// L'ESPACE DES HAUTEURS — une seconde famille d'opérateurs, et pourquoi elle DOIT exister.
// HEIGHT SPACE — a second operator family, and why it has to exist.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LE CONSTAT QUI FORCE CE FICHIER
// ─────────────────────────────────────────────────────────────────────────────────────────
// `OPSTACK-DECOMPOSITION §5` décompose SurfaceWorld ainsi :
//
// FHeightfieldSource ← toute la chaîne de colonne, XY-pure
// ├─ FStructuralHeightField
// ├─ FCliffHeightMod
// ├─ FTerraceHeightMod
// ├─ FLayerLineHeightMod
// └─ FBeachHeightMod
//
// et note, sans en tirer la conséquence : *« les ops de hauteur opèrent sur des valeurs Z dans la
// colonne, pas sur la densité »*. En lisant `ComputeSurfaceTerrainZ`, c'est littéralement vrai :
// c'est une suite de blocs qui lisent et écrivent **un seul float `Terrain`**, une altitude.
//
// **Ils ne rentrent donc PAS dans `IVoxelDensityOp`.** Sa signature est
// `Eval(x, y, z, FVoxelOpSample&)` — par voxel, deux canaux densité/SDF. Un op de hauteur n'a pas
// de Z d'entrée (il en PRODUIT un), ne veut pas être appelé par voxel (il est XY-pur, une fois par
// colonne), et n'écrit ni densité ni SDF. Les forcer dans le contrat densité demanderait soit un
// troisième canal par voxel — alors que la hauteur est une propriété de COLONNE, pas de voxel —,
// soit de replier les cinq en un seul op opaque, ce que `§2.5` appelle précisément l'échec du
// refactor.
//
// **Donc : une seconde famille, dans son propre espace.** C'est la même leçon que `§0.1` (il fallait
// un canal SDF en plus de la densité), un cran plus loin : certaines choses ne sont pas un canal de
// plus, elles sont un ESPACE de plus.
//
// The height ops read and write a single float ALTITUDE. They have no input Z (they produce one),
// are XY-pure (once per column, not per voxel), and write neither density nor SDF. Forcing them into
// IVoxelDensityOp would need either a per-voxel third channel for what is a COLUMN property, or
// collapsing all five into one opaque op — which §2.5 calls the failure mode. Hence a second family.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// CE QUE ÇA ACHÈTE / WHAT IT BUYS
// ─────────────────────────────────────────────────────────────────────────────────────────
// • **Le cache de colonne T1.a tombe naturellement.** Une pile de hauteur est XY-pure PAR
// CONSTRUCTION — il n'y a pas de Z à mettre dedans par erreur. `AUDIT §6.3` avertit qu'une donnée
// dépendante de Z glissée dans `FSurfaceColumn` corrompt silencieusement toute la pile verticale
// de chunks, et que `ValidateDeterminism` ne le verrait pas. Ici c'est le TYPE qui l'interdit.
// • **La composition d'idées de terrain devient de l'authoring**, comme pour la densité.
// • Les mêmes ops resserviront à VerticalShafts (ledges) et FloatingIslands.
//
// ⚠️ CE FICHIER NE TOUCHE PAS AU JEU. Il est bâti et exercé par
// `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui le compare à `ComputeSurfaceTerrainZ` point par
// point. Le branchement dans le chemin densité est l'étape SUIVANTE (§5 : `FHeightfieldSource`,
// `FSkyCapSource`, `FOverhangShelfMod`), délibérément séparée pour que la question d'architecture
// — *« l'espace des hauteurs se décompose-t-il vraiment ? »* — reçoive une réponse MESURÉE avant
// qu'on écrive l'adaptateur qui en dépend.
#pragma once
#include "CoreMinimal.h"
#include "Templates/UniquePtr.h"
#include "VoxelStrateTypes.h" // FSurfaceGenerationParams
/**
* L'état qui traverse une pile de hauteur. DEUX canaux, exactement comme `FVoxelOpSample` et
* pour la même raison : le code le fait déjà.
*
* `Relief` (le `M` de `SampleSurfaceStructuralZ`) est PRODUIT par la source structurelle et CONSOMMÉ
* par le gate du terrace (`TerraceStrength * M`). Sans ce second canal, le terrace devrait
* -échantillonner le champ de relief plus lent, et surtout une occasion de diverger de la valeur
* que la source a réellement utilisée.
*
* Two channels, for the same reason as FVoxelOpSample: Relief (the `M` of the structural field) is
* produced by the source and consumed by the terrace gate. Threading it beats resampling it.
*/
struct FVoxelHeightSample
{
/** Altitude monde en VOXELS (pas cm). */
float Height = 0.0f;
/** « Montagnosité » [0,1]. 1 = uniforme (ReliefStrength = 0). */
float Relief = 1.0f;
};
/**
* Le résultat d'une requête de champ de biome en un XY : qui domine, qui est le voisin, et à quel
* poids on va vers lui dans la bande de frontière.
*/
struct FVoxelBiomeWeights
{
int32 Dominant = 0;
int32 Neighbor = -1;
float NeighborWeight = 0.0f; // 0 ⇒ pas de mélange, le dominant seul
};
/**
* LE CHAMP DE BIOMES, VU COMME UNE INTERFACE et c'est délibérément une interface, pas un pointeur
* vers `UVoxelGenerator`.
*
* Le résolveur de biome réel est une Voronoï warpée avec un cache par chunk, qui vit sur le
* générateur. Un opérateur ne doit PAS en dépendre : la Phase 3 veut que les opérateurs deviennent
* des DONNÉES (des assets), et un op qui tient un `UVoxelGenerator*` ne peut pas le devenir. En
* passant par cette interface, l'adaptateur qui connaît le générateur reste du côté du générateur,
* et l'opérateur ne connaît qu'« un truc qui répond (dominant, voisin, poids) en XY ».
*
* Deliberately an interface rather than a UVoxelGenerator*: Phase 3 wants ops to become data, and an
* op holding a generator pointer never can. The adapter that knows the generator stays on the
* generator's side; the op only knows "something that answers (dominant, neighbour, weight) at XY".
*/
class IVoxelBiomeField
{
public:
virtual ~IVoxelBiomeField() = default;
/** PURE en XY, et bit-identique quel que soit le thread ou l'ordre — même contrat que le reste
* de l'espace-hauteur, puisque le résultat alimente le cache de colonne T1.a. */
virtual FVoxelBiomeWeights SampleAt(float WorldX, float WorldY) const = 0;
};
/**
* Un opérateur d'espace-hauteur. Trois différences avec `IVoxelDensityOp`, toutes voulues :
* pas de Z d'entrée la pile en PRODUIT un ;
* XY-pur par construction, donc pas de `IsXYPure()` à déclarer ni à oublier ;
* pas de `PrepareChunk` ces ops sont déjà appelés une fois par colonne, ce qui EST la
* granularité que `PrepareChunk` sert à obtenir côté densité.
*/
class IVoxelHeightOp
{
public:
virtual ~IVoxelHeightOp() = default;
/**
* INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (X, Y, seed, params). Le même XY
* évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le float
* BIT-IDENTIQUE le cache de colonne T1.a est partagé sur toute la pile verticale de chunks,
* donc une impureté ici se propage à tous les Z d'un coup.
*/
virtual void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const = 0;
/**
* Majorant CONSERVATIF du déplacement vertical que cet op peut ajouter, en voxels.
* Sert à borner la colonne pour un futur `ClassifyBox` exact du heightfield la même logique
* que les bandes de `FSlabVoidSource`, qui prouvent 36-40 tuiles sur 60.
* Rendre trop grand coûte du CPU ; rendre trop petit serait un TROU. `FLT_MAX` = « je ne sais
* pas », toujours sûr, et c'est le défaut.
*/
virtual float MaxDisplacement() const { return FLT_MAX; }
};
/**
* Pile de hauteur : source modificateurs, dans l'ordre. Déplaçable, pas copiable, exactement
* comme `FVoxelOpStack` et pour la même raison (elle POSSÈDE ses opérateurs).
*/
class FVoxelHeightStack
{
public:
FVoxelHeightStack() = default;
FVoxelHeightStack(FVoxelHeightStack&&) = default;
FVoxelHeightStack& operator=(FVoxelHeightStack&&) = default;
FVoxelHeightStack(const FVoxelHeightStack&) = delete;
FVoxelHeightStack& operator=(const FVoxelHeightStack&) = delete;
void Add(TUniquePtr<IVoxelHeightOp> Op) { Ops.Add(MoveTemp(Op)); }
int32 Num() const { return Ops.Num(); }
/** L'altitude après toute la pile. */
float EvalHeight(float WorldX, float WorldY) const
{
return EvalSample(WorldX, WorldY).Height;
}
/** L'état complet (altitude + relief). */
FVoxelHeightSample EvalSample(float WorldX, float WorldY) const
{
FVoxelHeightSample S;
for (const TUniquePtr<IVoxelHeightOp>& Op : Ops) { Op->Eval(WorldX, WorldY, S); }
return S;
}
/** Somme des majorants. `FLT_MAX` dès qu'un seul op ne sait pas répondre. */
float MaxTotalDisplacement() const
{
float Total = 0.0f;
for (const TUniquePtr<IVoxelHeightOp>& Op : Ops)
{
const float D = Op->MaxDisplacement();
if (D >= FLT_MAX) { return FLT_MAX; }
Total += D;
}
return Total;
}
private:
TArray<TUniquePtr<IVoxelHeightOp>> Ops;
};
//=============================================================================
// FABRIQUES / FACTORIES
//=============================================================================
namespace VoxelHeightOps
{
/**
* La source structurelle : continents + montagnes + détail, sous une frame de warp.
* Produit `Height` ET `Relief`. Transcription littérale de `SampleSurfaceStructuralZ`.
*
* Rend un pointeur NON-POSSÉDANT via `OutSource` : `FCliffHeightMod` doit pouvoir
* -ÉCHANTILLONNER ce champ (4 fois, en différences centrées) et doit le faire sur la MÊME
* fonction, pas sur une copie qui pourrait dériver. La pile garde la propriété ; la source vit
* donc aussi longtemps que le modificateur qui la référence, parce que le constructeur de pile
* les ajoute ensemble et que la pile ne réordonne jamais.
*/
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeStructuralHeightSource(
const FSurfaceGenerationParams& P, int32 Seed, const IVoxelHeightOp** OutSource);
/** Raidissement conditionné par la pente. Le seul op qui coûte des échantillons en plus
* (4 resamples structurels), et seulement quand il est activé. */
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeCliffHeightMod(
const FSurfaceGenerationParams& P, const IVoxelHeightOp* StructuralSource);
/** Plateaux quantifiés, gatés par le relief (`TerraceStrength * M`) — d'où le canal Relief. */
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeTerraceHeightMod(const FSurfaceGenerationParams& P);
/** Bandes sédimentaires : `Height -= sin(Height · 2π / Spacing) · Depth`. */
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeLayerLineHeightMod(const FSurfaceGenerationParams& P);
/** Aplatissement vers la ligne d'eau dans `BeachWidth`. */
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeBeachHeightMod(const FSurfaceGenerationParams& P);
/**
* La pile de hauteur complète de SurfaceWorld, dans l'ordre de `ComputeSurfaceTerrainZ` :
* structural cliff terrace layer lines beach
*
* L'ordre n'est PAS négociable : le terrace quantifie une hauteur que le cliff a déjà raidie,
* les layer lines se posent sur le résultat, et la plage écrase tout près de l'eau. C'est
* l'ordre du code d'origine, et le test échouerait bruyamment sur toute permutation.
*/
VOXELFORGE_API void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack,
const FSurfaceGenerationParams& P, int32 Seed);
/** La voûte : warp + gonflement signé + pendage vers le bas uniquement. C'est une ALTITUDE,
* donc un op de hauteur la soustraction n'arrive qu'au combine côté densité. */
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeSkyCapHeightSource(const FSurfaceGenerationParams& P,
int32 Seed);
/** La pile de plafond de SurfaceWorld. Un seul op aujourd'hui, et c'est une information : le
* plafond n'a pas d'équivalent des quatre modificateurs du sol. */
VOXELFORGE_API void BuildSurfaceCeilingStack(FVoxelHeightStack& OutStack,
const FSurfaceGenerationParams& P, int32 Seed);
/**
* LE COMBINER `Mask` mélange de biomes, et `OPSTACK-DECOMPOSITION §5` en fait le prototype
* de la Phase 3 entière : « unifier strates et biomes » EST ce mécanisme, généralisé.
*
* Une pile de hauteur COMPLÈTE par biome, plus un champ qui dit lequel domine en (X,Y). Ce sont
* les **HAUTEURS** qui sont interpolées, pas les params c'est ce que fait déjà le code
* d'origine, et c'est ce qui rend les frontières continues quelle que soit la différence de
* params entre deux biomes (interpoler des params ferait passer un terrace de « fort » à
* « faible » à travers des états intermédiaires qui n'ont de sens pour personne).
*
* Each biome gets a COMPLETE height stack; the HEIGHTS are lerped, not the params which is
* what keeps borders continuous across any param difference.
*
* @param Field non possédé, doit survivre à la pile. `nullptr` biome 0 partout.
*/
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeBiomeBlendHeightSource(
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
const IVoxelBiomeField* Field);
/** Idem pour le plafond — mais le plafond N'EST PAS mélangé : le code d'origine prend celui du
* biome DOMINANT seul. Reproduit tel quel, pas « amélioré » : une voûte interpolée changerait
* la silhouette du monde et ce portage n'est pas l'endroit pour décider ça. */
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeBiomeSelectCeilingSource(
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
const IVoxelBiomeField* Field);
}
+3
View File
@@ -11,6 +11,9 @@
#include "VoxelStrateDefinition.h"
#include "VoxelSettings.generated.h"
// IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use.
class UMaterialInterface;
UCLASS(BlueprintType)
class UVoxelSettings : public UPrimaryDataAsset
{
+24
View File
@@ -0,0 +1,24 @@
// VoxelStats.h
// Per-frame runtime counters for tile classification and meshing.
// Compteurs runtime par frame pour la classification et le meshing des tuiles.
#pragma once
#include "Stats/Stats.h"
DECLARE_STATS_GROUP(TEXT("VoxelForge"), STATGROUP_VoxelForge, STATCAT_Advanced);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Classified"), STAT_VoxelForgeTilesClassified, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Skipped All Solid"), STAT_VoxelForgeTilesSkippedAllSolid, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Skipped All Air"), STAT_VoxelForgeTilesSkippedAllAir, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Meshed"), STAT_VoxelForgeTilesMeshed, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Solid"), STAT_VoxelForgeTilesOpStackSolid, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Air"), STAT_VoxelForgeTilesOpStackAir, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack"), STAT_VoxelForgeCaveBailNotOpStack, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Mixed Content"), STAT_VoxelForgeCaveBailMixedContent, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Params"), STAT_VoxelForgeCaveBailParams, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Stack Verdict"), STAT_VoxelForgeCaveBailStackVerdict, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Disturbance"), STAT_VoxelForgeCaveBailDisturbance, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail No Stack"), STAT_VoxelForgeCaveBailNoStack, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Hits"), STAT_VoxelForgeColumnMemoHit, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Misses"), STAT_VoxelForgeColumnMemoMiss, STATGROUP_VoxelForge, VOXELFORGE_API);
@@ -18,9 +18,16 @@
#include "GameplayTagContainer.h"
#include "VoxelStrateTypes.h"
#include "VoxelBiomeTypes.h"
#include "Templates/SubclassOf.h" // IWYU : TSubclassOf<AActor> (Atmosphere/Ceiling/FloorLayerActor)
#include "VoxelStrateDefinition.generated.h"
class UVoxelBiomeDefinition;
// IWYU : tous en pointeur ou en paramètre de TSubclassOf ⇒ déclarations avant suffisantes.
// Le PCH partagé les fournissait ; `FPSemantics = Precise` (AUDIT §C9) nous en prive.
// All pointer-only or TSubclassOf parameters, so forward declarations suffice.
class UMaterialInterface;
class USoundBase;
class AActor;
/**
* UVoxelStrateDefinition The content bag for a strate type.
@@ -106,6 +113,29 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation")
ECaveGeneratorType GeneratorType = ECaveGeneratorType::TunnelNetwork;
/**
* OPERATOR STACK (experimental) generate this strate through the composable density operator
* stack instead of the hardcoded archetype switch. Same world, different machinery.
*
* This is the A/B switch for `OPSTACK-PLAN §2.6`'s acceptance bar: flip it, regenerate, and
* judge on a screenshot that it is recognisably the same place. Both systems coexist
* indefinitely the switch is not going away until every archetype is ported.
*
* ONLY `Maze` IS PORTED SO FAR. On any other GeneratorType this flag is ignored and the
* switch runs as before, so setting it is harmless but does nothing yet.
*
* Do NOT flip this on a strate mid-session and expect the old and new geometry to agree to
* the bit they differ by ~1-2 ULP with ZERO isosurface crossings, so the shape is identical
* but the floats are not (`AUDIT-2026-07.md §C10`). Regenerate the world after changing it
* rather than letting old and new tiles sit side by side.
*
* Pile d'opérateurs (expérimental) : génère cette strate via la pile composable au lieu du
* `switch` d'archétype. Seul `Maze` est porté ; ailleurs le drapeau est ignoré.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation",
meta = (DisplayName = "Use Operator Stack (experimental)"))
bool bUseOperatorStack = false;
//=========================================================================
// TUNNEL NETWORK PARAMS (shown only for TunnelNetwork generator type)
//=========================================================================
@@ -201,6 +201,16 @@ public:
*/
ECaveGeneratorType GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const;
/**
* True when this chunk's strate opts into the density OPERATOR STACK instead of the hardcoded
* archetype switch (`UVoxelStrateDefinition::bUseOperatorStack`).
*
* Returns false for archetypes that have no port yet, so the flag can be set on any strate
* without changing its output until that archetype lands. Only `Maze` is ported today this
* predicate is where that list grows, and it is deliberately the ONLY place it is written down.
*/
bool UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const;
/**
* True if this chunk is in the solid-bedrock GAP between two strates (inside the
* overall stack's Z range but not in any strate slot). Chunks above the top strate
@@ -14,9 +14,11 @@
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "Templates/SubclassOf.h" // IWYU : TSubclassOf<AActor> (FPlacementProfile & co)
#include "VoxelStrateTypes.generated.h"
class UVoxelBiomeDefinition; // FPlacementProfile::RequiredBiome (optional per-entry biome filter)
class AActor; // IWYU : paramètre de TSubclassOf seulement / TSubclassOf param only
//=============================================================================
// ENUMS
+23
View File
@@ -28,6 +28,29 @@ constexpr int32 CHUNK_VOLUME = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE; // 32
constexpr float VOXEL_SIZE = 25.0f;
//=============================================================================
// TRIVIAL-TILE CLASSIFICATION (T1.d)
//=============================================================================
// Verdict de ClassifyTile pour une tuile AVANT le pré-échantillonnage 33³+ :
// AllSolid / AllAir garantissent que CHAQUE point du treillis du mesher (marge
// ±1 incluse) est du même côté de l'iso ⇒ maillage vide, GenerateMesh est
// sautée. Mixed = "je ne peux pas le prouver" ⇒ génération normale. Un faux
// Mixed coûte juste du CPU ; un faux AllSolid/AllAir ferait un TROU — les
// verdicts ne sont donc émis que sur des bornes exactes (colonnes surface
// échantillonnées au MÊME treillis que le mesher) + gardes conservatives sur
// tout ce qui peut creuser/remplir (spine, passages, disturbances, diff layer).
//
// Vit ici plutôt que dans VoxelGenerator.h pour que VoxelDensityOp.h (le contrat
// de la pile d'opérateurs) puisse s'en servir sans tirer un header UCLASS.
// Lives here rather than in VoxelGenerator.h so VoxelDensityOp.h (the operator-stack
// contract) can use it without pulling in a UCLASS header.
enum class EVoxelTileClass : uint8
{
Mixed, // peut contenir une surface → mesher normalement
AllSolid, // chaque échantillon prouvé solide → maillage vide
AllAir, // chaque échantillon prouvé air → maillage vide
};
//=============================================================================
// DENSITY → R8 QUANTIZATION (density clipmap / mini-sun shadows)
//=============================================================================
+13
View File
@@ -25,6 +25,7 @@ class UMaterialParameterCollection;
class UVolumeTexture;
class UMaterialInterface;
class UMaterialInstanceDynamic;
class FScopedGenerationPause;
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
/**
@@ -373,6 +374,8 @@ public:
UVolumeTexture* GetDensityVolumeTexture(int32 Level = 0) const;
private:
friend class FScopedGenerationPause;
/** Get/create the shared MID wrapping a base terrain material (binds volume textures + shadow params).
* Returns Base unchanged-wrapped, or nullptr if Base is null. */
UMaterialInstanceDynamic* GetOrCreateTerrainMID(UMaterialInterface* Base);
@@ -677,9 +680,19 @@ public:
// Set to true during EndPlay — async tasks check this before accessing UObjects
std::atomic<bool> bShuttingDown{false};
// Set during editor-driven generation mutations; distinct from teardown/shutdown semantics.
// Active pendant les mutations de génération lancées par l'éditeur, sans signifier la destruction.
std::atomic<bool> bGenerationPaused{false};
// Number of async tasks currently running — EndPlay waits for this to reach 0
std::atomic<int32> ActiveTaskCount{0};
FORCEINLINE bool ShouldAbortWork() const
{
return bShuttingDown.load(std::memory_order_relaxed)
|| bGenerationPaused.load(std::memory_order_relaxed);
}
// Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
FIntVector CurrentCenterChunk = FIntVector::ZeroValue;
+49
View File
@@ -11,6 +11,55 @@ public class VoxelForge : ModuleRules
// UseExplicitOrSharedPCHs is the modern recommended setting
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
// ============================================================================
// FLOAT MODEL — pinned to Precise so Windows and Linux compute the SAME WORLD.
// ============================================================================
// Jahni, 2026-07-27: the game must be playable on both Linux and Windows, either side
// hosting. The MP design replicates the SEED and has every peer regenerate the terrain, so
// a Windows host and a Linux client must agree on the density field.
//
// They did not, by construction. UBT resolves FPSemanticsMode.Default differently per
// toolchain (verified in UE 5.7 source, not assumed):
// VCToolChain.cs:1264 Default/Imprecise -> "/fp:fast" (Windows/MSVC)
// ClangToolChain.cs:712 Default/Precise -> "-ffp-contract=off" (Linux/Mac/Clang)
// So the same source was compiled under OPPOSITE float rules depending on who built it.
//
// Precise resolves to "/fp:precise" on MSVC and "-ffp-contract=off" on Clang — both
// IEEE-754 compliant with no FMA contraction, so the two toolchains agree BY CONSTRUCTION
// rather than by luck. That is the fix for AUDIT-2026-07.md C9.
//
// COST: /fp:precise forbids the reassociation and contraction /fp:fast allowed, on a
// noise-heavy hot path. Expect a measurable perf regression and check it against
// ARCHITECTURE 8.10 — determinism across platforms is worth paying for, but the price
// should be known, not assumed.
//
// VERIFY: run VoxelForge.Determinism.CrossPlatformDigest on both platforms and compare the
// SHAPE digest (sign of density = the world) and the FIELD digest (bit-for-bit). Pin the
// values in that test once they agree, and it guards this forever after.
FPSemantics = FPSemanticsMode.Precise;
// ============================================================================
// ⚠️ HISTORY — why this took a second attempt (kept: it explains the includes below)
// ============================================================================
// Setting FPSemantics (or any property that alters this module's compile environment)
// makes VoxelForge ineligible for the ENGINE'S SHARED PCH — UBT can only share a
// precompiled header between modules whose compile environments match. The first attempt
// (2026-07-27) was therefore reverted: it failed with ~30 "undefined type" errors that
// were not FP-related at all — UMaterialInterface, USoundBase, TSubclassOf<AActor>,
// ENABLE_DRAW_DEBUG — i.e. includes this plugin had always taken from the shared PCH for
// free. That is a latent IWYU debt, not an FP problem.
//
// ⚠️ SO IF YOU SEE "undefined type" ERRORS HERE, THEY ARE IWYU, NOT FLOAT SETTINGS.
// The fix is to add the missing include or forward declaration to the header that needs
// it — never to revert FPSemantics, which is now load-bearing for cross-platform play.
// Headers fixed on 2026-07-27: VoxelBiomeDefinition, VoxelSettings, VoxelStrateDefinition,
// VoxelStrateTypes, VoxelContentManager, VoxelAtmosphereManager, VoxelDensityVolume.
// Plus VoxelWorld.cpp (GameFramework/Pawn.h) — the .cpp files needed auditing too, not just
// the public headers. That was the whole residual tail: one site, found in one build.
// Expect a residual tail: the shared PCH hid these for years and only a build enumerates
// them all. VoxelDensityVolume's was the nasty one — ENABLE_DRAW_DEBUG is used in an #if,
// and an undefined macro there is silently 0 rather than an error.
// Modules we depend on:
// - Core: Basic types (TArray, FString, etc.)
// - CoreUObject: UObject system (UCLASS, UPROPERTY, etc.)
+267
View File
@@ -0,0 +1,267 @@
# fable-idea.md — performance & feature ideas
*Fable 5, max-effort pass — 2026-06-09. Grounded in a read of the actual code (mesher, apply path, task launch, content manager), not generic advice. Companion to CODEMAP.md §8 — nothing here is implemented; it's a menu.*
*Note: the strict-whitelist `.gitignore` will ignore this file. Add `!fable-idea.md` if you want it tracked.*
---
## Part I — Performance
### 0. Measure before anything (half a session, directs everything else)
Add `TRACE_CPUPROFILER_EVENT_SCOPE` around the four stages of a chunk (density grid sample / MC loop / normals / RMC stream build+upload) plus a `stat VoxelForge` group: chunks pending, applies this frame, avg gen ms, verts/chunk. One Unreal Insights capture then tells us if we're density-bound (my bet) or upload-bound, and every item below gets a before/after number. Cheap insurance against optimizing the wrong thing.
### The verified cost model (what I found reading the code)
A LOD0 chunk today costs roughly:
| Stage | Cost | Source |
|---|---|---|
| Density grid | 33³ = 35,937 `GetDensityAt` | `GenerateMesh` pre-sample (already optimal shape) |
| **Vertex normals** | **+6 `GetDensityAt` per unique vertex** (~1236k more for 26k verts) | `ComputeGradientNormal` — central differences, *per vertex* |
| Heightfield redundancy | SurfaceWorld's XY-only terrain stack (~16 Perlin evals: warp 2 + relief 2 + continents 4 + detail 4 + mountains 4) recomputed **for all 33 Z samples of a column** | `GetSurfaceDensity` is pure per-call |
| Noise core | Scalar, **double-precision** `FMath::PerlinNoise3D(FVector)`, 4-octave loops | `FractalNoise3D`/`RidgedNoise3D` |
| Collision | **Cooked for every chunk at every LOD** | `UpdateSectionConfig(SectionKey, Config, /*bShouldCreateCollision=*/true)` |
| Apply | Unbounded `while (ProcessQueue.Dequeue(...))` drain — all finished chunks upload in one frame; `Enqueue(Result)` copies the whole MeshData; RMC StreamSet built on the **game thread** | `VoxelWorld.cpp:410`, `:602`, `ApplyMeshToChunk` |
| Components | `NewObject`+`Register` per load, `DestroyComponent` per unload (no pooling) | `LoadChunk`/`UnloadChunk` |
So: normals can cost as much as the entire density grid; a SurfaceWorld chunk does ~33× redundant heightfield work; and every distant LOD2 chunk pays Chaos tri-mesh cooking the player can never touch.
### Tier 1 — high win / low risk / small-medium effort (do these first)
> **STATUS 2026-07-05: Tier 1 COMPLETE.** T1.a ✅ (surface-column cache, now file-scope `GSurfColCache`);
> T1.b ✅ (grid-based normals — the old per-vertex `ComputeGradientNormal` trio is gone from the mesher);
> T1.c ✅ (`bShouldCreateCollision` = level 0 only, VoxelWorld.cpp ApplyMeshToTile); T1.d ✅ (v2
> `ClassifyTile`, trace-verified 44 % worker CPU); T1.e ✅ (`MaxMeshAppliesPerFrame`, default 4);
> T1.f ✅ (`BuildTileStreamSet` runs in the gen task; apply only uploads).
> **Per-tile generation cost is now in diminishing-returns territory — remaining perf lives in
> Tier 3, Transvoxel, and the streaming/crossing work (see voxelforge-lod-transition-cost memory).**
**T1.a — Per-column XY cache for heightfield work.** Split SurfaceWorld density into `f_XY` (everything up to `Terrain`, + WaterZ) and a trivial Z-combine. In the chunk task, compute a thread-local `(GridDim+margin)²` column grid once, then `GetDensityAt` reads it. Heightfield evals drop ~33× (36k → ~1.2k). Same pattern later for any archetype with an XY-only sub-field (biome map will be one). Keep it keyed like the existing per-chunk param cache; purity in world coords is preserved so it stays bit-identical and window-invariant.
**T1.b — Normals from the density grid, not 6 fresh samples per vertex.** Sample the grid with a 1-point margin ring — `(GridDim+2)³` = 42.9k at LOD0, +19% — then compute vertex normals by central differences *on the grid* (trilinear-interpolate the 8 cell-corner gradients at the vertex position). Net: ~4872k density calls → ~43k, normals become batchable, and chunk-border shading stays continuous because the margin uses the same pure world-coord samples a neighbor would. Trade-off: gradient resolution becomes `Step` instead of `GradientOffset` — slightly softer normals, smoother (good) at distance. Verify visually at LOD0.
**T1.c — Collision only where it matters.** `bShouldCreateCollision = (LOD == 0)`. Distant chunks are unreachable by definition (if the player got there, they'd be LOD0 — and the LOD reconciliation loop §8.10 guarantees a hot-swap on approach). Kills Chaos cooking + collision memory for the large majority of loaded chunks. Also check RMC's async-collision setting is on. **Likely the best win-per-line-changed in the whole document.**
**T1.d — Chunk classification: skip trivially solid/air chunks before sampling.** ✅ DONE 2026-07-05
(v2 — `UVoxelGenerator::ClassifyTile`, see ARCHITECTURE §8.10; a 2026-06-26 v1 with a global ceiling
bound was reverted for roof holes. Trigger: trace showed 84 % of GenerateMesh calls produced empty
tiles.) In a tall multi-strate world most chunks in the desired set are full bedrock or full sky. Conservative per-chunk test before the 33³ sample: for heightfield strates, min/max terrain over the footprint (free from T1.a's column grid ± noise amplitude bound) vs the chunk's Z range; for cave strates, "no room/tunnel/passage/spine/seal/diff-layer bounds intersect" (all bounding data already exists). Fully-solid/air ⇒ empty MeshData, no component, done. Cuts whole chunks, not percentages — compounds with everything else.
**T1.e — Bound the apply side.** The submit loop is budgeted; the drain loop isn't. Cap mesh applies per frame (~24, generous for carves), keep draining *results* into a pending-apply list sorted by player distance. Also `ProcessQueue.Enqueue(MoveTemp(Result))` — currently the whole vertex/index payload is copied. Smooths the burst hitch (the "upload spikes" already observed).
**T1.f — Build the RMC StreamSet inside the worker task.** `FRealtimeMeshStreamSet` is plain data — the per-vertex Builder loop in `ApplyMeshToChunk` can run in the chunk task; the game thread then only does `CreateSectionGroup(MoveTemp(Streams))` + config. Removes a few ms of game-thread work per applied chunk; pairs with T1.e.
### Tier 2 — multiplicative, more effort
> **STATUS 2026-07-04:** T2.a ✅ DONE (float SSE `VoxelNoise.h` core — see ARCHITECTURE §8.10);
> T2.b ✅ DONE (opt-in `LODOctaveDrop`, default 0); T2.c ✅ DONE (`TileComponentPool`);
> T2.d ✅ DONE (BackgroundNormal priority had already shipped; core-clamp via
> `GetMaxConcurrentTasks`); T2.e ✅ DONE (per-chunk passage shortlist). **Tier 2 complete.**
**T2.a — Float + SIMD noise core (the big multiplier).** Everything funnels into scalar double-precision `FMath::PerlinNoise3D`. Two routes: an **ISPC kernel** (UBT compiles `.ispc` natively — zero third-party deps; used by Chaos/Niagara) or **FastNoise2** (MIT, runtime SIMD dispatch). Evaluate fractal/ridged noise over the whole flat grid / column grid in one batch call per field. Realistic 410× on the noise-bound part, on top of T1.
⚠️ Two correctness notes: (1) world changes for existing seeds — do this *before* content lock-in, bump a generator-version constant; (2) 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]) in the float core or noise quantizes.
**T2.b — LOD-aware octave count.** At Step=4, octaves with wavelength < the cell size are pure aliasing cost. `EffectiveOctaves = Octaves - LODBias(Step)` per field (keep the *low* octaves identical so the coarse shape matches). 3050% off distant chunks; iso-surface shifts by sub-cell amounts that Transvoxel/skirts have to stitch anyway. Cheap, do together with T2.a.
**T2.c — Component pooling + no components for empty chunks.** Recycle `URealtimeMeshComponent`s through a free list on unload instead of `DestroyComponent`/`NewObject`/`Register` churn (T1.d already stops creating them for empty chunks). Reduces GC pressure and register/unregister hitches during fast travel.
**T2.d — Task priority + worker count.** `UE::Tasks::Launch(..., LowLevelTasks::ETaskPriority::BackgroundNormal)` so a 16-task gen burst can't starve game/render workers; consider `MaxConcurrentTasks = Clamp(NumberOfCores - 2, 2, 16)` instead of a flat 16 on smaller CPUs.
**T2.e — Per-chunk passage gather.** `EvaluateModifierSDF` sphere-tests *every* passage per voxel. Gather the passages whose bounds intersect the current chunk once into the thread-local chunk cache; the per-voxel loop walks that short (usually empty) list. Matters as passage counts grow with deeper worlds.
### Tier 3 — when carving becomes the moment-to-moment verb
**T3.a — LRU base-density grid cache for carve re-mesh.** Keep the LOD0 density grid for the ~3264 most recently carved/near-player chunks (~144 KB each ⇒ < 10 MB). A carve then re-meshes as *cached grid + diff + MC* — no noise at all. Dig feedback becomes effectively instant, which is exactly where the game's feel lives.
**T3.b — Streaming feel:** frustum-weighted priority bonus in the submit sort (load what the player looks at first), and the pre-load/unload hysteresis ring already discussed (kill leading-edge pop and boundary churn).
### Explicitly NOT now (and why)
- **GPU density/meshing** — 100× throughput on paper, but a rewrite: readback latency, CPU collision still needed, float determinism across GPUs. North star only if T1+T2 ever hit a wall; they won't for this scope.
- **Octree/adaptive within-chunk structures** — T1.d gets the win at chunk granularity for ~5% of the complexity.
- **CHUNK_SIZE change, Nanite, greedy meshing** — lever already documented (§8.10) / no runtime procedural Nanite / MC isn't blocky.
**Expected compound for a SurfaceWorld chunk: T1.a × T1.b × T2.a ≈ order-of-magnitude on generation; T1.c/d/e attack frame-time and chunk count independently.**
---
## Part II — Features
### A. Tooling first — force multipliers for everything after
**F1 — 2D world-preview editor tool.** ★ my top pick. An editor utility that samples `f_XY` (terrain height, relief M, water mask — later the biome map) over an N×N window into a `UTexture2D`, with the Surface|Macro knobs live. Tuning ReliefStrength/biome layout becomes seconds instead of regen-and-fly. Directly addresses the standing "hard to dial blind" pain (Worm passages, today's Stage 0 knobs). Extend with a top-view passage-path overlay (the data exists in `bDebugDrawPassages`).
**F2 — Determinism validator button.** ✅ DONE 2026-07-04 (`AVoxelWorld::ValidateDeterminism`,
Live Edit category). CallInEditor: sample a band of densities from two different chunk-window alignments, diff, report max delta. Turns the scariest invariant (§8.4 — window invariance) into a one-click regression test *before* biome code starts landing.
**F3 — `stat VoxelForge` + Insights scopes.** Same as Perf §0 — listed here because it's also the tool that tells us when a feature regressed something.
### B. The "this becomes a game" features
**F4 — Save/load.** The diff layer is the player's entire footprint and it currently dies with the session. Serialize: seed + settings hash + generator version + per-chunk modification lists (they're compact structs already). Versioning matters: stamp saves with a gen-version so a noise change (T2.a!) can refuse/migrate old saves instead of silently shifting terrain under bases.
**F5 — Biome system. ✅ DONE (warped-Voronoi + climate XY field, per-chunk resolve —
`ResolveBiomeSampleAt` / `FBiomeContext`, VoxelGenerator.cpp §biomes; deco borders follow the
Voronoi field).** Original sketch (kept for the cave-biome extension): Deterministic XY biome map = warped Voronoi/cellular cells (seeded, window-invariant by construction, same family as the relief map — and relief M should be an *input*: mountain biomes live where M is high). Resolution rules to protect §8.10: resolve **per chunk** (dominant biome + ≤2 neighbors + blend weights, stored in the thread-local chunk cache); per **voxel** blend only a handful of scalars (height offset, roughness, terrace, water tint index). Per biome: a *content profile* — decoration set, atmosphere/audio override, material palette index, water level offset. Archetype transitions stay Hard; biomes vary *within* SurfaceWorld first, cave-biomes (crystal/fungal/ice) reuse the identical pattern later.
**F6 — Material identity: vertex-data masks + triplanar palette material.** Geometry variety without *surface* variety still reads samey. At mesh time, pack per-vertex: slope (from the T1.b normal), relative height, biome/material index (from F5) into vertex color channels. One master material: triplanar rock/grass/sand/snow layers selected & blended by those masks + a macro-variation texture. This is the single biggest *visual* multiplier available and it's mostly material-graph work.
**F7 — POI / set-piece system.** Noise terrain everywhere = beautiful nowhere. Deterministic destinations: chunk-hash-placed stamps (composed SDF carves/fills + a decoration prefab + optional ambient actor), e.g. buried shrines, crystal gardens at passage mouths, ruins on mesas. Placement uses the same two-region COLLECT discipline as rooms (§8.4). Destinations are what turn wandering into stories ("found a shrine at 400 m").
**F8 — Ore veins / diggable resources.** The game's verb is digging; give digging a reward loop. A secondary material-id field (cheap 3D noise threshold, per-strate/biome tables with depth curves) evaluated **only at mesh vertices** (≈ free) → vertex color → material shows veins; on carve, query the field at the brush center → grant resource. No per-voxel density cost, fully deterministic.
**F9 — Audio/ambience manager.** The exact architectural twin of the atmosphere manager (player strate/biome → assets): ambient loop crossfade, cave reverb submix, dig impacts by surface type, a stinger + title card on first strate entry. Sound is half of cave atmosphere and this is days, not weeks.
**F17 — Generator surface-class tag (ceiling/ground/cave material the *right* way). ✅ DONE 2026-07-05** (per-vertex semantic class in the mesher — down-facing verts query a memoized `GetSurfaceHeightAt`, nearer CeilSurf ⇒ sky-cap — per-tri majority → two contiguous polygroup runs → RMC section per group, slot 1 = `CeilingMaterial`, per-section shadow. Trigger: the whole-tile normal vote painted mixed coarse tiles with one material. Cave-roof discrimination hook is in place: down-facing below TerrainZ ⇒ ground/rock. Remaining polish idea: fully sideways cap-fold tris (all 3 verts |N.Z|≤0.1) default to ground.) Original design note: ★ do this when caves land. Today `ApplyMeshToTile` picks one material per tile from a ceiling test — first a height-oracle sample (midpoint), now a worker-side **normal vote** over the tile's mesh normals (down-facing ⇒ `bIsCeiling``CeilingMaterial` + no shadow; gated to SurfaceWorld by one `GetSurfaceHeightAt` probe). That's a **stopgap that only works because down-facing == sky-cap *while no caves exist*.** The moment a mountain-biome cave uses the same density/mesh system, its roof is also down-facing and would wrongly get the sky-cap material — orientation can't tell a cave ceiling from a surface ceiling. **The discriminator is semantic, not geometric, and only the generator knows it:** a sky-cap surface is the `ComputeSurfaceCeiling` (`CeilSurf`) boundary; a cave ceiling is a 3D-noise **carve** below `TerrainZ`. Cheap test the generator already has the inputs for — at a down-facing surface vertex, compare world Z to the column's `TerrainZ`/`CeilSurf` (both from the surface-column cache the mesher already holds): near `CeilSurf` ⇒ sky-cap, below `TerrainZ` ⇒ cave. **Plan:** stamp a discrete *surface class* (ground / sky-cap / cave-ceiling / cave-wall…) per vertex/triangle **at mesh time** in the mesher → carry it as the **polygroup** (already enabled, `Builder.EnablePolyGroups()`, every tri currently group 0) → `ApplyMeshToTile` maps polygroup → material slot (slot 0 terrain, slot 1 sky-cap, slot 2 cave-rock, biome-specific via the F5 palette mask) and sets per-section shadow. Discrete "which material" → polygroup/slot; continuous masks (biome blend, slope — F6) stay in `Colors`. This **subsumes** the current ground/ceiling split (it falls out as a special case), fixes the coarse mixed-tile horizon artifact exactly (per-triangle, not per-tile dominant-wins), and is the only version that survives caves. Pairs naturally with F5/F6/F8 (all want generator-stamped per-vertex material identity). Cost: a per-tri classify in the mesher (cheap, has the cache) + multi-slot setup in the apply path (RMC supports it; confirm the v5 per-section `UpdateSectionConfig` / slot-per-polygroup calls + empty-polygroup = no draw). Until then: the normal vote is fine — it's commented as "no caves yet → down == cap."
**F18 — Far-field per-surface SHEETS (the render-distance ring, cheap). ✅ BUILT & WORKING 2026-07-06**
(marker ticked 2026-07-27). With `RenderDistanceChunks` the
outermost ring can reach many km — as MC tiles that's 600-1000 primitives paying per-frame visibility/VSM
forever, and each far tile runs full 3D marching cubes just to rediscover two heightfields. In an open
strate the far field IS two heightfields the generator already computes per column (`GetSurfaceHeightAt`:
TerrainZ + CeilSurf). So: the extended ring streams SHEET tiles instead (level `MaxClipLevel +
FarSheetSpanLevels`, so one sheet covers 4-16 MC-tile footprints) and the mesher builds each as two
regular displaced grids — ground sheet (polygroup 0) + sky-cap sheet (polygroup 1), tags true **by
construction** (no vote, no classify probes), same materials/UVs/biome color masks as MC, normals from
the height gradient, perimeter skirts per bucket. Gen ~3-6× cheaper per area than band-cut MC; primitives
~10-16× fewer. Caveats accepted: SurfaceWorld only (non-open strates produce empty sheets — their far
ring was invisible rock anyway; FloatingIslands has no far ring beyond MaxClipLevel), carved features
(passages/spine/chasms) don't show at sheet distance, sheets need the strate band armed (in the
inter-strate gap the far ring blanks until you land). Streaming: sheet ring in `BuildDesiredTiles`
(covered-check vs the MaxClipLevel box), `IsTileInClipRange` shares the same outer shell,
`LoadTile` routes `Level > MaxClipLevel` to `GenerateSheetMesh`. **Build-1 fix (XY hole):** a
partially-covered sheet rendered its *whole* footprint, overlaying the near LOD0-1 terrain with its
coarse sampling. So the MC-covered box around the player (level-MaxClipLevel box, shrunk 1 tile for a
seam-overlap ring) is cut from sheets at cell granularity (`AVoxelWorld::SheetHole*Vox``GenerateSheetMesh`
hole args; hole moves on a MaxClipLevel-tile crossing → overlapping sheets re-queue via `BandRemeshQueue`;
hole-edge cells emit no skirt).
**F19 — AI navigation & agents (function-based). PARKED (no NPCs yet); FOUNDATION BUILT 2026-07-07.**
Mobs need two things the world didn't give them: (1) to *exist* away from the local player, (2) to *route*.
- **(1) DONE — multi-anchor collision streaming + render-skip** (ARCHITECTURE §9.3/§9.4, built 2026-07-07):
`AVoxelWorld::RegisterStreamingAnchor(actor, CollisionOnly, thin box)` keeps a small box of level-0
collision tiles loaded around any actor; `CollisionOnly` tiles cook collision but are hidden (no draw/VSM)
unless the player clipmap also wants them. So an NPC/remote-player has ground to stand on / be hit on,
cheaply, anywhere. Same system serves MP (stream around every player) — [[voxelforge-multiplayer]] §9.
- **(2) TO BUILD — routing, function-based (NOT Recast).** The world is a cheap deterministic function, so
nav = a query, not a baked navmesh: coarse A* / flow-field over a grid sampling `GetSurfaceHeightAt`
(slope + water gated, + the diff layer so AI sees carves) → **funnel/string-pull** → **Catmull-Rom spline**
→ a **steering follow-component** for smooth, non-robotic, cheap locomotion (path once, re-path on a timer;
budget requests like the streaming loop). Needs **zero loaded geometry**, deterministic (same seed = same
path), and **digging costs it nothing** (no per-carve re-cook). Bridgeable to `AIController`/`CharacterMovement`.
- **Analytic surface-follow (the free tier):** a pure *surface walker* needs no collision AND no navmesh —
pin it to `GetVoxelSurfaceHeightAt` each tick. Reserve real collision (the anchor) for physics / caves /
carved terrain / being hit by player traces. Both coexist, decided per NPC type.
- **Recast rejected:** would need cooked collision everywhere AI roams, re-cooking on every dig — and it's
unusable on a future headless dedicated server (no meshes), where function nav is not just cheaper but
mandatory. AI is server-authoritative (§9.6). ★ Build when NPCs actually land.
**F20 — Biome-selected surface terrain ops (terrace / cliff / layer-lines / overhang / spike / hole). SPEC 2026-07-07.**
Terrain ops are cave-only today (per-room in `GetDensityWithParams`; `GetSurfaceDensity` applies NONE — that's
the "ops don't work on the surface" report). This brings them to the SURFACE, as a **biome** property,
**conditioned on local terrain** so they read geological instead of random. (Slots in ahead of the later
cave-system redo, which will add biome support cave-side reusing this same op→biome model.)
*Data:* add `TArray<FStrateTerrainOpEntry> SurfaceOps` to `UVoxelBiomeDefinition`, each entry gated by a
condition (Min/MaxSlopeAngle like `FStrateDecoration` already has, + optional relief/height band via the F7
`FTerrainCondition` set — add a `Slope` type). Resolved through the biome field (dominant biome per column —
already cached). Empty ⇒ early-out ⇒ **zero cost**, so the feature is free in every biome that doesn't use it.
*The cheapness architecture (the whole point — think per-COLUMN first, per-voxel only when forced):*
- **Two op classes.** HEIGHTFIELD ops modify the cached column → **~free**: **Terrace** (quantize TerrainZ into
steps), **Cliff** (sharpen the height transition where slope is high), **LayerLines/Ribbing** (a `sin(Z)`
groove in the near-surface band — no noise). VOLUMETRIC ops need genuine 3D near the surface → real but
bounded: **Overhang**, **Spike**, **Hole**.
- **Slope/relief conditioning is free AND is what makes them look right.** Slope = the surface height gradient
(2 extra cached-column samples, or reuse the mesh normal / F6 slope channel). Overhang strength ∝ slope ⇒
overhangs grow out of EXISTING cliffs, never poke out of flat ground (the "would it look weird" answer: no,
it's cliff-conditioned, not random). Terrace on moderate slopes; spikes where relief M is high (mountains).
Conditions cost ~0 (fields already computed) and double as the "not random" guarantee.
- **Volumetric work is banded + tile-skipped.** Overhang = low-freq 3D displacement only in a ±few-voxel band
around TerrainZ, only where slope-gated + the biome has it. Non-straddling tiles never pay (T1.d ClassifyTile).
Coarse/far tiles stay pure heightfield (LOD-cull the 3D ops — they're near-field detail; the sheet ring ignores
them entirely).
- **Spikes/holes = hash-placed SDF via a per-tile shortlist** (cone/capsule UP for spikes, shaft DOWN for holes;
placed on a hash lattice like landmarks, collected once per tile like rooms/passages; per-voxel tests a 0-3
shortlist). Biome+condition-gated so empty biomes collect nothing.
- **GOTCHA — the dominant cost, spikes/holes only:** a spike rises INTO otherwise-all-air tiles and a hole carves
INTO otherwise-all-solid tiles → they DEFEAT T1.d's trivial-skip for every tile they pass through (those must
now mesh). So ClassifyTile needs a spike/hole shortlist guard (cheap AABB, like `AnyPassageNearBox`), and a tall
spike "wakes up" the whole vertical stack of air tiles it crosses = more meshed tiles + collision. Overhang/
terrace do NOT do this (they stay in the already-meshed surface band). ⇒ keep spikes SHORT + SPARSE; they're the
priciest of the set in tiles-meshed + triangle/collision terms, not just density evals.
*Cost:* heightfield ops ≈ free. Overhang ≈ 1.5-2× noise on slope-gated surface tiles, ~0 elsewhere. Spike/hole ≈
that PLUS the woken tiles (the real cost — budget by count/height). All biome-gated ⇒ world-average cost ≈ 0;
you pay only near the player, in the biomes that opt in.
*Determinism/borders:* op strength blends by biome weight (like the surface height output-blend), conditioned ops
fade with slope ⇒ seamless at biome borders; placement hashes are pure `(seed, coord)` ⇒ window-invariant (§8.4).
*Build phasing:* **(1)** heightfield ops (terrace/cliff/layerlines) — cheap, high payoff, low risk, ships the
"ops finally work on the surface" win; **(2)** overhang (3D band, slope-conditioned); **(3)** spike/hole (placed +
shortlist + ClassifyTile guard) — most cost, do last.
**PHASE 1 — ✅ BUILT & WORKING** (built 2026-07-08; marker ticked 2026-07-27 — confirmed working by Jahni 2026-07-26, see `AUDIT-2026-07.md §0`). Heightfield ops shipped: **Cliff** (slope-gated STEEPENING —
push height from the local mean where steep ⇒ sheer walls; the slope-conditioned one, hugs steep terrain;
v1 band-snap was too subtle, reformulated to steepening after Jahni's "doesn't change much"), **Terrace** (relief-gated plateau quantize, now with
`TerraceHardness` soft-round↔crisp-mesa), **LayerLines** (sedimentary sine shelves, slope-expressed). *Design
deviation from the spec above, deliberate:* instead of a `SurfaceOps` array of `FStrateTerrainOpEntry` +
`FTerrainCondition` gating on `UVoxelBiomeDefinition`, phase-1 ops are **direct fields on
`FSurfaceGenerationParams`** (the `Surface|Ops` category). Rationale: that struct is ALREADY the per-biome
surface-shape carrier (`B->SurfaceParams` when `bOverrideTerrain`) and is already biome-resolved +
border-blended by `ResolveSurfaceChunkParams`/`ComputeSurfaceColumn` (the height output-lerp) — so biome
selection AND seamless border blending come for FREE with zero new resolution path, and the conditioning
(relief `M`, analytic slope) is intrinsic to each op. Cost: a biome must set `bOverrideTerrain` to carry its own
ops (fine — biome-differentiated terrain already implies that), and a strate can also carry ops with no biomes at
all (more flexible than biome-only). All fields default OFF ⇒ current world byte-identical. Applied in the single
height oracle `ComputeSurfaceTerrainZ` (new `SampleSurfaceStructuralZ` helper = pre-op raw height, re-sampled at
an XY offset for Cliff's slope) so MC/sheets/ClassifyTile/deco/BP-bridge all agree, no T1.d interference. Revisit
the array+condition model for **phase 2 (overhangs)** where per-entry slope-gating earns its keep.
**PHASE 2 — ✅ BUILT & WORKING** (built 2026-07-08; marker ticked 2026-07-27 — confirmed working by Jahni 2026-07-26, see `AUDIT-2026-07.md §0`). Overhang (first VOLUMETRIC op) as
`FSurfaceGenerationParams` fields (`OverhangStrength/Reach/Height/Frequency/ZScale/SlopeThreshold`, default
off). **Design NOTE — v1 additive-noise-band was WRONG (Jahni: "does nothing" + sketch of a real cliff lip):
band-additive noise can only bump the surface where it already is, never make rock jut OUT over a void.**
Rewritten to a **warped-terrain UNION**: for air voxels in `(TerrainZ, TerrainZ+OverhangHeight]` above a
steep slope, re-sample the heightfield UPHILL by a reach that GROWS with height (tiny low ⇒ air over the
void; full high ⇒ borrows the far cliff rock) and `max()` it in ⇒ a shelf attached to the cliff, tapering
out over the void with air beneath (matches the sketch). Per-column `OverhangAmp`(=strength·slope-gate) +
unit uphill `(DirX,DirY)` resolved in `ComputeSurfaceColumn` — gradient sampled at the REACH scale so a
point over the void can SEE the cliff — cached on `FSurfaceColumn`. **ClassifyTile guard:**
`FSurfSlot::OverhangMargin`=max `OverhangHeight`; a column Z in `(TerrainZ, TerrainZ+margin]` ⇒ Mixed
(UPWARD only — the union only adds rock). Thin cliff-edge band woken, NOT far tiles. Genuine 3D per-voxel
structural re-eval (gated hard to steep overhang columns → localized to cliff edges; flagged as a real but
bounded cost). KNOWN v1 LIMITS (flagged): applies at ALL LODs (may alias far — gate to fine later); shelf
sits at ~`OverhangHeight` above the ground below it, not necessarily at the cliff TOP (raise Height for
taller); terrain-overriding biomes use strate params for the warped sample (minor seam). NEXT = **phase 3
spike/hole** (hash-placed SDF + per-tile shortlist + the real T1.d wake-guard — keep SHORT+SPARSE).
### C. Experience polish (cheap, high feel-per-effort)
- **F10 — Swimmable water:** physics volume + underwater post-process tied to the existing water-chunk regions (the plane is visual-only today). Buoyancy later.
- **F11 — Depth & place HUD:** depth meter, strate name title cards (the atmosphere manager already detects strate change), simple explored-chunks map.
- **F12 — Day/night + weather on strate 0 only** — surface gets a sky lifecycle; underground untouched (free scoping).
- **F13 — Carve UX:** runtime brush ghost preview, tool tiers (radius/speed), material-aware dig speed (bedrock slow), rockfall-dust juice on carve.
- **F14 — The (0,0) spine as gameplay:** buildable lift/teleport anchors per strate — descent is the game, but re-ascent shouldn't be the chore. BP prototype on the existing carve/actor APIs.
- **F15 — Ambient life:** Niagara bats/fireflies/fish schools per biome profile (no AI, pure atmosphere). Real mobs = **F19** (nav strategy now decided: function-based).
- **F16 — Decorations as HISM: ✅ DONE** (region-granular HISMs, two-grid Near/Far streaming, Static mobility). Original note: scatter currently `SpawnActor`s every prop — actors tick, register, and pile up fast. Pure props should be `UHierarchicalInstancedStaticMeshComponent` instances (per mesh type, per chunk); keep actors only for lit/interactable things. This is also a perf item wearing a feature hat.
### D. Deliberately deferred
Multiplayer — **NO LONGER just "deferred": it's the confirmed direction (listen-server first), design + first foundation IN.** Full model in ARCHITECTURE §9 ([[voxelforge-multiplayer]]): determinism = replicate seed+layout+diff events, never geometry; server-authoritative diff; multi-anchor streaming + §9.4 render-skip BUILT 2026-07-07. Remaining netcode (§9.7): seed replication at join, `Server_RequestModification` RPC, late-join diff snapshot. Diff records stay compact/replicatable-shaped (they already are). — GPU generation, mod/scripting API, Nanite: all real, none load-bearing for the current vision.
---
## Suggested order (if it were mine to pick)
1. **Perf 0 + T1.c + T1.a + T1.b** — one focused session: measurement, the one-line collision win, the two big density cuts.
2. **T1.e + T1.f + F16** — smooth the game thread (apply budget, worker-side streams, HISM props).
3. **F1 preview tool + F2 validator** — before biome work starts, build the instruments.
4. **F5 biomes + F6 materials** — the look of the game. (F9 audio rides along cheaply.)
5. **F4 save/load** — the moment it feels like a game, players will want to keep one.
6. **F7 POIs + F8 ores** — destinations and rewards.
7. **T2.a SIMD noise** — after content direction settles (it changes seeds), before world-size ambitions grow.
8. **Transvoxel** (already chosen) whenever LOD cracks become the loudest remaining flaw.