The plateau was the tell. Roughness changed the component COUNT (it
manufactures noise pockets) but the giant component sat at ~0.22 in every
variant, and widening connectors past the proven roughness bound barely
moved it. That is a random graph below its percolation threshold: 0.6
sites, 0.35 bonds, and a 1.6-cell pair cutoff that drops many neighbour
pairs once jitter spreads them 1.7 (orthogonal) to 2.4 (diagonal) cells
apart.
So the lever was topology. Every shaft now emits a deterministic connector
to the nearest shaft STRICTLY closer to the origin, so chains drain inward
and terminate at the spine. The probabilistic connectors stay as texture
and loops -- the tree guarantees you can get out, the noise keeps it from
reading as a diagram.
Deliberately NOT tuned: raising CrossConnectChance/ShaftDensity/the cutoff
toward the percolation threshold buys a world connected at one seed and
shattered at the next, which is exactly the 1-in-16 behaviour we had. A
phase transition is not a knob.
seed 1337 components 30 -> 17, largest share .220 -> .975
16 seeds spine is the largest component in 16/16 (was 8/16)
largest share .58 - .9999
Seam safety, because a parent computed from a 3x3 window would differ
between chunks and the connector would exist in one and not the other:
collect over 7x7, emit only for the inner 3x3, whose 5x5 candidate windows
are then fully contained. 154 seam probes per path, 0 mismatches.
Box verdicts re-checked against the new air: 27 proved, 33 mixed,
0 violations. Stack mirror bit-identical over 20,000 samples.
Perf: 1.634 us forced rebuild, 0.100 us hot call -- the 49 rolls happen
only on cache rebuild, never per voxel.
⚠️ ACCEPTANCE STILL FAILS: arrival->departure 4/16 (was 1/16). Reported,
not tuned. With 97% of the air in one component, two mouths that both land
IN the field would essentially always connect -- so the remaining failure
is placement, not topology. Next.
Build clean, 20 succeeded / 0 warnings / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
Jahni chose: make the (0,0) column join the shafts it passes, so
VerticalShafts behaves like the other seven archetypes. Built, and it
works as far as it goes -- the spine is now the LARGEST air component in
13 of 16 seeds (share up to 0.81, was 0.096 and never largest).
⚠️ IT DOES NOT MEET THE ACCEPTANCE CRITERION. arrival->departure is still
1/16. Reported as a failure rather than dressed up, because the criterion
was the measured law, not "the code was written". The test now prints
ACCEPTANCE arrival->departure: FAIL (1/16 seeds pass) so it cannot be
quietly forgotten.
Why it is insufficient, and it is structural: the spine only joins its own
3x3 cell neighbourhood, while passage mouths land 60-200 voxels from
(0,0). The departure network is unreachable from the spine in all 16
seeds. Joining the hub to its neighbours does not help if the rest of the
graph never reaches the hub.
Part A diagnosis (seed 1337, margin 0, step 2, radius 192):
baseline 30 components, largest .220
roughness 0 17 components, largest .226
connector radius 8 29 components, largest .229
H1 is real but partial: roughness fragments the space (13 of the 30
components are noise pockets), yet widening connectors past the proven
roughness bound barely moves it. So roughness is NOT what severs the
network -- the largest component sits at ~0.22 in every variant, which is
the signature of a graph below its percolation threshold. The lever is
topology, not radius. H2 (the hardcoded 1.6 pair cutoff) went untested.
Implementation notes: the spine is added ONLY as a connector endpoint, not
as a carving shaft -- VF_ApplyOriginSpine remains sole owner -- and is
excluded from ledge placement. Spine connectors use radius 6.625, above
the proven roughness supremum of 5.625 (sup|FBM| = 1.5, not 1.0).
SpineRadius is threaded through BuildVerticalShaftStack, not hardcoded.
Op-stack mirror bit-identical over 20,000 samples, 0 box-verdict
violations. LayoutOrderIndependence green.
Build clean, 19 succeeded / 0 warnings / 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
VoxelGenerator.cpp(2796): C2248 -- FindSlotIndexForChunkZ is protected.
I claimed it was public "verified" in the previous commit. The grep printed the
declaration line, not the access specifier above it. Same shape as the partial
read that produced a false accusation earlier today.
GetStrateChunkZBounds is public (VoxelStrateManager.h:177), returns false for
exactly the no-slot case, and ClassifyTile already calls it three times for the
same question. Wrapped in a VF_ChunkZHasSlot lambda so the condition reads as
the predicate it is. No accessor promoted, no API widened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured in game: Cave Bail Not Op Stack No Layout = 1.58 of 1.90 classified
tiles (83%), with SoleSlot and BoundaryTile never firing. The assets were
correctly ticked all along.
Cause: GetGeneratorTypeForChunk returns TunnelNetwork for any chunk outside the
strate stack, commenting "the fallback density path will produce solid rock
anyway". That comment is FALSE. GetGenerationParams for SlotIdx < 0 returns
BaseDensity = -1, RoomDensity = 0, WormStrength = 0 -- a CONSTANT AIR field.
IsGapChunk's "open air, NOT a gap" was the correct description.
So every tile touching the open air above the world looked like a cave
archetype, entered the cave branch, found no layout slot, and bailed. T1.d was
never failing -- it was unreachable, behind a routing mistake in the archetype
lookup that had nothing to do with the operator stack, the box verdicts, or the
flags.
Fix: ClassifyTile gains a fourth Z category for no-slot chunks. It sets
bAnyNonCave (never enters the cave branch) and bCanSolid = false (AllAir
survives), and bails to Mixed if a disturbance could add rock there -- chasms
only carve, so they cannot threaten an air verdict.
A tile entirely above the stack now resolves AllAir and is skipped: the
majority of a surface flight, a class T1.d has never been able to prove.
Classifier only -- no density value changes. Equivalences must stay
bit-identical and violations must stay 0.
Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Function-static thread_local CP state previously trusted only chunk coordinates and each manager's locally-reused layout version, allowing a worker to carry params, biome context, CP_UseOpStack, and the built stack into another world. Allocate each generator a monotonic owner ID and add one uint64 comparison to the hot key. This intentionally fixes only the proved CP_* path; other TLS caches remain outside this change.
The old Not Op Stack counter fired before slot attribution, so interior disabled strates and boundary encounters produced the same number. Split the instrumentation into sole-slot, boundary-tile, unresolved-layout, and late-recheck counters while preserving every ClassifyTile condition, return point, and verdict.
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>
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>
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>
⚠️ 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>