Commit Graph

70 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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