Compare commits

..

10 Commits

Author SHA1 Message Date
Fr0zka 41ba3c34a9 docs(opstack): rewrite the handoff for the T1.d-delivered state
The old handoff's "first action" (confirm the Underwater coverage number) and its
"one task everything is waiting on" (make FRoomGraphSource::EffectOverBox answer
spatially) are both done, so most of it was actively misleading.

What it says now:

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

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

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

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

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

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

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

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

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

Unbuilt.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Unbuilt.

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

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

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

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

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

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

Unbuilt.

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

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

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

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

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

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

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

Unbuilt.

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

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

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

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

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

Unbuilt.

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

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

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

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

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

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

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

Unbuilt.

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

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

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

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

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

Unbuilt.

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

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

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

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

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

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

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

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:48:18 +02:00
11 changed files with 2126 additions and 235 deletions
+27 -6
View File
@@ -221,12 +221,33 @@ evaluates the second chunk against **the room list baked from the first chunk's
(so "which archetype owns this chunk" stays unambiguous), which switches the blend off entirely.
The one configuration the tests never build is the default one.
**Fix (unimplemented, deliberately — this is the `switch` path, not the port):** fold the params into
the SDF cache key exactly as `FRoomGraphSource` already does — `FCrc::MemCrc32` over the params
struct, plus `LayoutVersion`. `FStrateGenerationParams` is pure POD, so a memory CRC cannot produce a
false *match*; at worst padding causes a needless rebuild. Err on CPU, never on a wrong room.
Alternatively add chunk Z to the key, which is coarser (it rebuilds on every Z step even outside a
blend band) but needs no CRC.
#### ✅ FIXED 2026-07-28 (the SDF-cache half) — pending build
The params now travel into the key, and the shape of the fix is worth recording because the obvious
version of it was the wrong one.
`GetDensityWithParams` takes **two new required arguments**, `ParamsFingerprint` and `LayoutVersion`,
and both go into the `bNeedRebuild` test next to the existing `(box, strate, seed)`.
- **Required, not defaulted.** A caller that forgets must fail to compile rather than silently
inherit the hole — the same discipline that put `LayoutVersion` inside `FVoxelOpContext`.
- **The CRC is computed once per chunk, not per voxel.** `FCrc::MemCrc32` over ~300 bytes on the
hottest path in the plugin would have been a real regression; instead it is taken where the params
memo already lives (`CP_TunnelFP`, refreshed in the same block that refetches `CP_Tunnel`), so the
per-voxel cost is two integer compares.
- **Why not "add chunk Z to the key" (the alternative this section used to offer):** it is not
actually cheaper *and* it is not sufficient. `Interleaved` makes `Alpha` depend on chunk **XY**
too, and chunk XY is *not* pinned by the existing key — the box deliberately outlives the chunk so
that `WorldX ± 1` gradient probes don't thrash it (`ARCHITECTURE §8.10`). Pinning chunk XY to fix
the params would have destroyed that invariant. The fingerprint fixes the cause and leaves the box
reuse intact: what forces a rebuild now is a *real* params change, once per chunk in a transition
band, which is the number of rebuilds this cache should always have done.
The three test call sites pass `VF_FP(P)` so the **oracle no longer shares the defect under test**
see the rewritten note at check 3 of `VoxelForgeOpStackTunnelTest.cpp`.
**Still open in this section:** the `OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache` sites listed above.
Those are the live-edit staleness half, not the determinism half, and they are untouched.
The operator-stack port does **not** inherit this: `FRoomGraphSource` folds a `FCrc::MemCrc32`
fingerprint of the params (plus `LayoutVersion`) into its key, so differing params force a rebuild.
+4 -4
View File
@@ -154,8 +154,8 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. |
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion** that the original lacks — see the suspected staleness note in AUDIT §C2. `EffectOverBox` `Both` for now (a real answer means building the cache for the queried box; only pays once `ClassifyTile` consumes `ClassifyBox`). |
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). `EffectOverBox` **`CarveOnly` everywhere** — no spatial bound, so it kills `AllSolid` on every tile of every strate with worms on. `MaxCarveAmplitude()` holds the bound from DECOMPOSITION §0.2 that would recover it, waiting for a fold that carries numbers. |
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)`**any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|AB\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. |
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. |
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). |
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
| `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. |
@@ -262,7 +262,7 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. |
| `InitializeSettings` | 211 | Copies seed from settings. |
| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. |
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. |
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. |
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
@@ -433,7 +433,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. Known-pessimistic: proves **0 of 60** tiles (its `EffectOverBox` rejects on a `Spacing*1.6` halo instead of real connector capsules — lost CPU, never a hole). |
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. |
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. |
+146 -129
View File
@@ -1,10 +1,10 @@
# Handoff — VoxelForge operator stack, 2026-07-28 (Phase 2 complete and green)
# Handoff — VoxelForge operator stack, 2026-07-29 (T1.d delivered and measured)
> Paste the block below into a fresh session. Everything it refers to is on disk and in git.
>
> **State:** Phase 2 is **DONE — 8 of 8 archetypes ported, built, and green** (14 tests, two
> consecutive green builds). `ClassifyTile` consumes `ClassifyBox`. **Everything in git is compiled
> and tested.** One question to confirm in the first run, then one clear next task.
> **State:** 8 of 8 archetypes ported and green. **Tile-skipping now actually works and is measured:
> 11 of 40 tiles proved `AllSolid` at production defaults, 14641 voxels brute-forced, 0 violations.**
> `AUDIT §C2` is fixed. One commit is written but **not yet built** — see "First action".
---
@@ -17,142 +17,128 @@ re-derive them.
1. **`CLAUDE.md`** — project rules. **Rule #1 is absolute: never build, compile, or run the editor.**
I build everything myself. When code is done, stop, say "ready to build", list the likely
compile-error spots, and wait.
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point. The last
entry is the green build with every measured number in it.
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point.
3. **`OPSTACK-PLAN.md`** — the plan. **§2.6.1 is the acceptance bar** and supersedes §2.6.
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is the
live one; §2 TunnelNetwork and §8 Underwater are now history, not instructions.
5. **`AUDIT-2026-07.md`** — **§C2 has a CONFIRMED sub-item as of 2026-07-28, read it**; §C10 is
SOLVED, don't reopen; §C9's library half is the top open theoretical risk with 0 measured
exposure.
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is now
*implemented*, not pending; §2 TunnelNetwork and §8 Underwater are history, not instructions.
5. **`AUDIT-2026-07.md`** — **§C2's SDF-cache half is FIXED (2026-07-28)**, its live-edit half
(`OC_Chunk` / `BM_Chunk` / `FChunkBiomeCache`) is still open; §C10 is SOLVED, don't reopen;
§C9's library half is the top open theoretical risk with 0 measured exposure.
6. **`CODEMAP.md`** — navigation. Trust symbol names over line numbers.
## Where things stand — the transition is COMPLETE and VERIFIED
## Where things stand
All 8 archetypes have an operator-stack twin, per-strate opt-in, each equivalence-tested **bit for
bit** against its original density function. The `switch` and the stack are now two complete,
bit** against its original density function. The `switch` and the stack are two complete,
interchangeable implementations.
| Archetype | State |
|---|---|
| `Maze` | ✅ ported, bit-identical, wired |
| `FlatPlain` + `CrystalChamber` | ✅ **one op for both**, bit-identical, wired |
| `SurfaceWorld` | ✅ ported incl. biome blending, bit-identical, wired |
| `VerticalShafts` | ✅ ported, bit-identical, wired |
| `FloatingIslands` | ✅ ported, bit-identical, wired — the stack that runs **backwards** |
| `TunnelNetwork` | ✅ **19 ops**, bit-identical incl. all 12 detail modifiers + per-room override |
| `Underwater` | ✅ same builder, second `case` — confirm its coverage number once, see below |
Everything sits behind `UVoxelStrateDefinition::bUseOperatorStack`; the ported list lives **only** in
`UVoxelStrateManager::UsesOperatorStackForChunk` (now all 8). **No strate asset has the box ticked**
that is my call and I haven't made it. But the flag is no longer a no-op anywhere: ticking it now
really switches that strate onto the stack, for density *and* for tile classification.
`UVoxelStrateManager::UsesOperatorStackForChunk` (all 8). **No strate asset has the box ticked**
that is my call and I still haven't made it. `GetDensityAt` and `ClassifyTile` build the stack
through the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole, not a bug.
`ClassifyTile` **consumes `ClassifyBox`** for cave archetypes (SurfaceWorld and bedrock gaps keep
their hand-written exact-lattice proofs). `GetDensityAt` and `ClassifyTile` build the stack through
the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole, not a bug.
### ✅ T1.d — the tile-skipping prize — is real, and it is measured
## The one number to confirm, and it takes one run
The first green build reported this, and it is the one result worth understanding before trusting
anything about `Underwater`:
`FRoomGraphSource::EffectOverBox` answers **spatially**. The result, brute-forced voxel by voxel:
```
Underwater (stage C2): bit-identical across 2000 samples — 0 of them in open cave (0.0%)
[production defaults] 11 of 40 tiles proved AllSolid — 14641 voxels checked, 0 violations
[dense fixture] 0 of 40 — correct, and structurally inevitable
```
**A green bit-identity over 2000 samples of solid rock is not evidence** — it is exactly what two
agreeing voids look like. Same failure as stage A's 1.1 % run, in a different slot, caught by a
counter written for it.
One function's verdict is inherited by `FSdfConvertOp`, the twelve detail modifiers (via
`VF_NoCaveOverBox`) **and** `FWormFieldSource` — fourteen operators from one place. That is what the
C1 wiring was built for.
A real bug surfaced while diagnosing it: the sampled chunk-Z range used `Z / CHUNK_SIZE`, and C++
integer division **truncates toward zero**. TunnelNetwork is at the top of the layout in positive Z
where truncation == floor, so it could not show there; Underwater is at the **bottom, in negative
Z**, where it shifts the upper chunk bound a notch high and the `Clamp` piles samples into the top
seal band. Fixed (`FloorDivChunk`), sampling widened 8 → 24 clusters, **and not trusted**: check 5b
gives each of the three possible causes (the bake / the sampled Z range / the XY spread) its own
number and prints how to read them.
### ⚠️⚠️ THE ONE INVARIANT THAT CAN DELETE COLLISION — read before touching any op
**That fix is built — the second build was green too. What I did not see is the number.** So:
**`FRoomGraphSource::EffectOverBox` returning `Identity` now means `Sdf ≥ T`, NOT `Sdf == FLT_MAX`**,
where `T = max(3·SDFBlendRadius, WormNetworkRange)`. That is sound only because all three consumers
of the SDF channel were read one by one:
> **First action: run the `VoxelForge` filter and read the `Underwater diagnosis` line, plus the
> cave-coverage percentage on the line above it.**
| consumer | threshold |
|---|---|
| `FSdfConvertOp::Eval` | `Sdf >= Blend`, and the tunnel stack passes `MakeSdfCarve(P.SDFBlendRadius, …)`**K** |
| the twelve modifiers | `VF_NearCaveSurface`**3K** |
| `FWormFieldSource::Eval` | `CaveSDF >= WormNetworkRange`**WormNetworkRange** |
**Any new consumer of `InOut.Sdf` must have a threshold ≤ `T`, or be added to that `max`.** An op
reading `Sdf < 100` would see false `Identity` verdicts and produce tiles with no geometry **and no
collision**. The warning is written at the site you land on when you add one.
(The `K` slack covers *any* number of primitives because `SmoothMin`'s penalty is exactly zero once
`|AB| ≥ K`, so the running minimum saturates at `K` below the smallest term. Without that
observation the slack would scale with the ~88 tunnels in a cache and the criterion would be dead.)
## First action: build, then read ONE line
**The last commit (`e002bd4`, VerticalShafts) is written and NOT built.** Everything before it is
built and green.
> Build, run the `VoxelForge` filter, and read
> **`Box verdicts over 60 VerticalShafts tiles`**.
>
> - **Non-zero cave coverage** ⇒ the truncation *was* the cause, `Underwater` is genuinely covered,
> and this whole section is closed. Say so in `OPSTACK-PROGRESS.md` and move on to the next
> section — do not go looking for a bug that no longer exists.
> - **Still 0.0 %** ⇒ the diagnosis line names which of the three causes it is, and the fix follows
> from that rather than from a guess.
> **0 was the number for the whole project's life.** Its `EffectOverBox` used to return `CarveOnly`
> because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at
> `ShaftSpacing 55 / ShaftDensity 0.6`. It now rebuilds the connectors the way `GetCells` does and
> tests the real capsules, with **Z exact** and XY conservative.
>
> Either way it is one run, and the answer is printed. Do not infer it from the fact that the suite
> is green: a bit-identity over solid rock is green for the wrong reason, which is the entire point
> of that counter existing.
> - **Non-zero, and `violations` still 0** ⇒ it worked; record it and move on.
> - **Still 0** ⇒ the warning in that test names what to check **first**: `ExtraReach` inflates both
> remaining tests, so compare it against `ShaftMaxRadius` before touching either test. **Do not
> re-derive from scratch** — that is exactly what cost three rounds on TunnelNetwork.
## Then the one task everything is waiting on
## Then, in order
**Make `FRoomGraphSource::EffectOverBox` answer spatially.**
TunnelNetwork proves **0 of 40** tiles today, and the test asserts that. The chain dies at the room
source, which returns `Both` with unknown amplitude before anything downstream is reached. Its room
and tunnel bounds (`FCachedRoom::CullRadiusSq`, `FCachedTunnel::BoundRadiusSq`) are **already in the
SDF cache**; what it costs is building that cache for the *queried box*, on the querying thread.
That cost is now clearly worth paying, and every other piece is already built to receive it:
- `ClassifyTile` consumes `ClassifyBox` in production, so a proved tile skips `GenerateMesh`
30 000+ density evaluations saved against one `BuildChunkCache`;
- the fold carries **numbers** (`MaxCarveOverBox` / `MaxFillOverBox` / `ForcedMarginOverBox`), so a
bounded worm no longer kills `AllSolid` on rock that is solid by more than it can carve;
- the twelve detail modifiers already **inherit** the room source's verdict via `VF_NoCaveOverBox`
the day the source says `Identity` for a box, all twelve follow, in one place rather than thirteen.
**Keep the brute-force check.** `VoxelForge.OpStack.ClassifyTileSoundness` verifies verdicts against
`GetDensityAt` on a world where every strate opted in. A false verdict is an invisible hole: no
geometry, **no collision**, until a player falls through it.
## ⚠️ Debts that must be paid BEFORE that lands, not after
Both were introduced knowingly and are written at the exact site a reader would land on.
1. **Box bounds read STRATE params, but a per-room op can raise them.** `EffectOverBox` and the new
amplitude bounds are computed from strate params, because a box spans many rooms. But `ApplyTo`
writes the op's value **even where the strate's was 0**, so a room op can enable a modifier the
strate had switched off, or give it a bigger amplitude. A box verdict on a strate with a
terrain-op pool can therefore be **too optimistic** — the dangerous direction. Harmless while the
room source answers `Both` (nothing is provable anyway); **not harmless the moment it doesn't.**
Noted at `FLayerLineMod::EffectOverBox` and `FRoomGraphSource::LocalParams()`.
2. **`AUDIT §C2` is confirmed and unfixed on the `switch` path.** `GetGenerationParams` blends params
*within* a strate (`Alpha` depends on chunk Z for `Gradient`, and on chunk XY too for
`Interleaved`), and `Gradient` + `TransitionBlendChunks = 2` are the **defaults**. The original's
SDF cache key has neither params nor chunk Z, so a worker evaluates the second chunk it builds
against the first chunk's rooms — and *which* chunk came first depends on worker order, so two
peers can diverge from the same seed. The op stack does **not** inherit it (params CRC in the
key), and `ClassifyTile`'s new path guards against it explicitly (params must be bit-identical
across every chunk coord the box touches). The fix on the `switch` path is a params CRC in its
key — a live-generation change that wants a build in front of it.
## After that, in order
1. **PERF — unparked.** The op path is measurably slower. One cause found and fixed (the column memo
discarded itself every chunk). Remaining suspects in order: the hashed column lookup vs
`GSurfColCache`'s direct-indexed box, then per-voxel virtual dispatch. Also measured and stated:
the gate is now tested twelve times per voxel instead of once (stage B5's deliberate trade).
**Measure before optimising** — that is the §C10 lesson.
2. **`VerticalShafts` proves 0 of 60 tiles.** Pessimistic, not wrong: `EffectOverBox` returns
`CarveOnly` whenever any shaft is within a `Spacing*1.6` halo instead of testing real connector
capsules. Lost CPU, never a hole.
1. **PERF — still unparked, and now the biggest open item.** The op path is measurably slower. One
cause found and fixed (the column memo discarded itself every chunk). Remaining suspects in order:
the hashed column lookup vs `GSurfColCache`'s direct-indexed box, then per-voxel virtual dispatch.
Also measured and stated: the gate is tested twelve times per voxel instead of once (stage B5's
deliberate trade). **Measure before optimising** — that is the §C10 lesson, and this session
re-learned it the hard way.
2. **The warp squeeze — PARKED with its ceiling measured, my recommendation is leave it.** The
`WARP SHARE` line says over half the remaining blocking is the query-box dilation, not geometry
(production: rooms 0.9 → 0.4, tunnels 2.4 → 1.1 with the dilation zeroed). The only remaining
route is proving `sup|Perlin3D|` down from the proved **1.5** toward its apparent ~1.01.1, worth
~27 % of the dilation. Spot-checking a grid is **not** a proof and a wrong sup is a hole.
**A negative result is already recorded so nobody repeats it:** bounding the warp *locally*
(evaluate at the box centre, shift, dilate by the variation) is **worse** — a rigorous per-axis
Lipschitz bound is `4·1.875 + 1 = 8.5` per unit cell, and `8.5 × 0.206` (the half-box in noise
units) `= 1.75` exceeds the global range bound of 1.5.
3. **`AUDIT §C9` library half** — `sinf`/`cosf` are not IEEE-754 specified, so MSVC's CRT and glibc's
libm can differ. Currently **0 samples within 1e-6 of the isosurface**, i.e. no measured risk. Run
`CrossPlatformDigest` on Linux, compare the SHAPE digest, pin it. The real fix if ever needed is a
deterministic in-house sin/cos.
4. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
4. **`AUDIT §C2`'s remaining half** — `OC_Chunk`, `BM_Chunk`, `FChunkBiomeCache` are still keyed
without the layout version. That is the live-edit staleness class ("I tweaked the asset and one
patch kept the old shape"), not the determinism class, which is fixed.
5. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
unprompted. What makes it possible is already in place: ops depend on capabilities
(`IVoxelBiomeField`), never on `UVoxelGenerator`.
## Debts — status changed, read this before acting on the old text
1. **"Box bounds read STRATE params but a per-room op can raise them" — DORMANT, not urgent.**
Checked rather than paid, and the check reversed the premise: when the source proves `Identity`
the twelve modifiers are `Identity` **soundly** (their `bNearCaveSurface` gate never opens, so no
room op can enable anything), and when it answers `Both` it supplies no `MaxCarveOverBox`, so the
default `FLT_MAX` kills every hypothesis regardless of what the modifiers claim. **It goes live
the day `FRoomGraphSource` gains a `MaxCarveOverBox`** — bounding the converter's `2·BaseDensity`
would make the modifiers' own numbers matter for the first time. Written at the site.
2. **`AUDIT §C2` — FIXED on the `switch` path.** `GetDensityWithParams` now takes **required**
`ParamsFingerprint` + `LayoutVersion`. Required, not defaulted, so a caller that forgets fails to
compile. The CRC is taken **once per chunk** where the params memo already lives (`CP_TunnelFP`) —
a `MemCrc32` per voxel on the hottest path would have been a real regression. Note the audit's own
suggested alternative ("add chunk Z to the key") is both insufficient (`Interleaved` makes `Alpha`
depend on chunk **XY** too) and destructive (chunk XY is deliberately absent so `WorldX ± 1`
gradient probes don't thrash the box — `ARCHITECTURE §8.10`).
## Hard rules that prevent real bugs
- **Density sign:** negative = solid at the mesher. Inside the op stack the convention is INTERNAL
(**positive = solid**), negated once by the caller. The SDF channel uses standard SDF convention.
- **`Identity` from the room source means `Sdf ≥ T`.** See the boxed invariant above. This is the
single most dangerous thing in the current code.
- **Never run both density paths in one world.** **Comparing them is legitimate** — §C10 is closed
since `FPSemantics = Precise`, and all eight equivalence tests compare bit for bit. They are
**port-correctness oracles**, not fidelity checks: §2.6.1 requires *same seed ⇒ same world on every
@@ -160,49 +146,80 @@ Both were introduced knowingly and are written at the exact site a reader would
- **Every cache key includes `LayoutVersion` AND the params.** See §C2 and the overhang regression of
2026-07-27, where omitting the params silently deleted the overhang and only 1 sample in 20 000
crossed the isosurface.
- **A bound in a box verdict must be PROVED, not observed.** `|Perlin3D| ≤ 1.5` is derived from
`GradDot`'s two-distinct-axes form and the per-axis weighted bound of 0.5 — *not* from the header's
"~[-1,1]". Over-estimating costs CPU; under-estimating deletes collision.
- `ProcessQueue` stays `EQueueMode::Mpsc`; `Epoch` carries through every async path; don't "optimize"
the `ARCHITECTURE §8.10` invariants.
- Commit per coherent unit with a real message. **Never push.** `main` is the known-good fallback.
- Update `CODEMAP §3`, `ARCHITECTURE §8`, tick `OPSTACK-PLAN`, append to `OPSTACK-PROGRESS.md`.
- **When inserting a class into `VoxelDensityOpStack.cpp` / `VoxelHeightOpStack.cpp`, put it ABOVE
the labelled end of the anonymous namespace.** Anchoring on the FACTORIES banner puts it outside,
and the brace added with it closes nothing. Made that mistake twice; both files say so at the
exact line.
and the brace added with it closes nothing. Made that mistake twice; both files say so.
- **Match the codebase's spelling of engine macros.** `KINDA_SMALL_NUMBER`, not
`UE_KINDA_SMALL_NUMBER` — the plugin uses the unprefixed form everywhere.
## Method lessons this refactor actually paid for
Ordered by how much they cost.
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses, then was
solved for free by a build setting changed for an unrelated reason. Park a question whose
consequences are measured and benign.
- **Verify the premise before reasoning from it.** Five times now a confident chain rested on an
unchecked assumption and the check reversed it: C1's *documented* fix was wrong; "C9's risk is gone
after FPSemantics" was wrong; "C1 is closed, 0 sites left behind" was wrong (the sweep matched a
*spelling*); "PitDensity enables pits" was wrong; "there are 13 detail modifiers" was wrong (twelve,
and only eleven read the per-room copy). **A grep over a spelling is evidence about the spelling.**
- **Instrument what you ASSUMED, not just what you changed.** This is the expensive one, learned
over four rounds in one session. The warp dilation — `CaveWarpStrength · VOXEL_NOISE_SCALE ·
PerlinAbsBound`, a constant chosen in the first commit — inflated a 10-voxel tile into a 50-voxel
query box, **125× the volume**. Four separate tightenings (the worm, the columns, the sampler, the
tunnel disjunction) were each individually correct and each landed *around* that untouched term.
The tunnel fix, predicted "an order of magnitude", delivered 25 % — **and the instrument said so,
and I credited the tunnels.** *When a fix under-delivers against its predicted size, suspect the
constant you never measured.*
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses. In this
session the attribution line (`AllSolid killed by: …`) was written after *two* wrong guesses and
immediately named a third operator nobody had looked at. **A diagnostic that lists candidate causes
without measuring them is still a guess wearing rigour** — my "either the tiles straddle cave or
the source isn't reaching Identity" warning offered two causes and both were wrong.
- **Verify the premise before reasoning from it.** Six times now a confident chain rested on an
unchecked assumption and the check reversed it. Latest three: `RoomSpacing` was **42** (the fixture
overrides it) while I did three rounds of arithmetic with the header default of 80 — *the number
was printing in the report I kept quoting*; "the plugin bets on `|Perlin3D| ≤ 0.8`" was wrong (the
cache **rebuilds** when the warped query leaves the box, so that expansion is a perf heuristic);
and the per-room-op debt "must be paid first" was wrong (it is dormant). **Include the premises you
are confident enough about not to look up — especially a default, when a fixture exists whose whole
job is overriding defaults.**
- **A sampler must cover at least one period of what it samples.** The tunnel test drew tile XY from
**±32 voxels** with `RoomSpacing 80` and a guaranteed origin room at (0,0) — it measured the spine
hub and called it the world. The shaft test had the identical bug (±48 against `ShaftSpacing 55`).
Both now print their own extent **in units of the pattern's period**.
- **A test fixture tuned for coverage can be antagonistic to the thing you are measuring.**
`EnableTunnelFeatures` densifies (`RoomSpacing` 80→42, `RoomDensity` 0.35→0.85) so the equivalence
check isn't comparing solid rock to solid rock — and at that density the room cull radius *equals*
the lattice spacing, so **no box can ever be proved**. `0 proved` there is the correct answer. The
box verdict is therefore measured on **both** densities, and the dense run must stay at 0.
- **Diagnostics report THIS run; history goes in the log.** The test output had accumulated hardcoded
numbers from previous runs beside live ones ("32 of 34 tiles" printed while the live figure was 21
of 28). Unreadable, and self-inflicted.
- **Read the code, not the comment.** The cliff modifier's comment promises a sampled Z±1 gradient;
the code samples nothing and uses a Z-stretched Perlin it *calls* `VertGrad`. Ported as written —
and written down, so nobody "fixes" it from the comment.
- **A perf change can be a correctness change.** The column-memo optimisation silently deleted the
overhang; the tests caught it the same day. Invisible to inspection, and it produced plausible
terrain.
- **Coverage is a number, not a boolean.** Four related traps, each of which produced a green run
that proved almost nothing:
- **Coverage is a number, not a boolean.** Four related traps, each producing a green run that proved
almost nothing:
- *A test that prints nothing on success is indistinguishable from one that never ran.*
- *A guard that only trips at zero notices absence, it does not measure coverage.* Use fractions.
- *A success message that **asserts** coverage instead of reporting it reads as evidence while
measuring nothing.*
- *A check can be vacuous as well as a counter.* "Nothing leaked" is worthless unless something
happened — so the gate check also reports how many samples move when the modifiers are zeroed.
happened.
- **Enabling a feature is not evidence it fired — ask the structure, not the output.** Setting
`PitDensity` did nothing (wrong struct). Diffing two stacks with/without the op pool would have
*lied* (the pool is not in the SDF cache key, so both share the `thread_local` cache). What worked:
call `BuildChunkCache` and look at `Pits.Num()`. **Prefer the check that can fail for exactly one
reason** — and when a zero has three possible causes, give each one its own number.
`PitDensity` did nothing (wrong struct). **Prefer the check that can fail for exactly one reason**
— and when a zero has several possible causes, give each one its own number.
- **An oracle that shares the defect under test proves nothing.** The stale-cache check compares each
stack against *itself evaluated alone*, never against the original — which keys its SDF cache
without the params and would fail it.
stack against *itself evaluated alone*. (Since §C2 was fixed, the test call sites now pass a real
params fingerprint, so the original no longer shares the defect either.)
- **One definition, not two kept in sync.** `VF_BuildOpStackForChunk` exists because a tile skipped on
the verdict of a stack that is not the one producing its density is a hole. A "keep these in sync"
comment would not have been enough.
the verdict of a stack that is not the one producing its density is a hole. The same reasoning is
why `GetLastRoomBoxDiagnostic` **reads back** what the operator computed instead of letting the
test re-derive the criterion, and why the two-density tile scan is one lambda called twice.
- **Don't assert a number you want to improve.** Check 4 asserted `0 proved` — honest when written,
and it would have forbidden the entire T1.d gain. What it asserts now is that **no proved tile is
wrong** (brute force, every voxel); the proved count is *reported*.
+799
View File
@@ -2467,3 +2467,802 @@ building that cache for the queried box, and that is now clearly worth it — a
30 000+ density evaluations, `ClassifyTile` consumes `ClassifyBox` in production, the numeric
amplitude fold is in place, and the twelve modifiers already inherit the source's verdict. Every
piece is built to receive it and nothing else moves until it lands.
## 2026-07-28 — the Underwater 0% is EXPLAINED, and `EffectOverBox` answers spatially. **UNBUILT.**
Two things, in the order the handoff asked for them. The first cost one file read; the second is the
piece everything has been waiting on since stage A.
### 1. The one number — read, not inferred
`Saved/Automation/Automation2026.07.28-14.30.37.csv`, the run Jahni had already made:
```
Underwater (C2) ..... bit-identical, 6000 samples in 24 chunks — 94 in open cave (1.6%) was 0.0%
Underwater diagnosis strate index 7, voxel Z [-1440, -1313], seal-free interior (-1436, -1316);
5600 of 6000 samples inside that interior;
bake: 54 rooms, 28 pits, 17 chimneys, 25 columns over 6 search boxes
```
**The truncation WAS the cause.** All three counters of check 5b came back the way the "it's the Z
range" hypothesis predicts: the bake is alive (54 rooms — never the problem), and the interior count
is now 93 % where the whole point of the bug was that `Z / CHUNK_SIZE` truncating toward zero in
negative Z shoved samples into the top seal band. `FloorDivChunk` moved them back. **Section closed.**
One honest caveat, because the number deserves to be read rather than celebrated: **1.6 % is not
16.7 %.** TunnelNetwork's cave coverage carries a 10 % floor; Underwater's guard trips only at
**zero** (`if (UWInCave == 0)`), which is the exact shape the project's own lesson warns about — *a
guard that only trips at zero notices absence, it does not measure coverage.* 94 samples genuinely
exercise the carve, so the bit-identity now means something; it means about a tenth as much as
TunnelNetwork's. Per the diagnosis line's own reading key (rooms > 0, interior high) the residual
thinness is the **XY spread**. Not chased — the criterion for closing was non-zero, and it is a
counter to tighten when someone is next in this file, not a bug.
### 2. `FRoomGraphSource::EffectOverBox` answers spatially — the debt is paid
The chain no longer dies at the room source. The criterion is the **per-voxel cull lifted from point
to box**, which is the one formulation that 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 can survive its cull *anywhere in the box*, `Sdf` stays `FLT_MAX`
> across the whole box — the source is the identity, and so is everything behind it.
"Everything behind it" is not a figure of speech: `FSdfConvertOp` already returns `Identity` ("la
source a répondu pour la paire") and the twelve detail modifiers already inherit through
`VF_NoCaveOverBox`. **One function learned to answer and fourteen operators became provable.** That
is what the C1 wiring was for, and it is the first time the "one place, not thirteen" bet has paid.
What it does, in order: reject boxes spanning two strates or two op pools (`Both`); memoise the
verdict (all twelve modifiers ask the *same* question about the *same* box — without the memo a tile
would cost thirteen `BuildChunkCache` instead of one); build the cache for the queried box into a
**second per-worker cache**, never `FState::Cache`, so classification cannot disturb a live
generation; then test rooms and tunnels as spheres against the warp-dilated box, pits and chimneys
against the **undilated** box (they are queried in real coordinates — dilating them would be merely
cautious, *not* dilating them would be wrong), and columns as infinite cylinders.
Three things chosen deliberately, all erring toward CPU:
- **`|Perlin3D| <= 2`, not `<= 1`.** The header says "~[-1,1] (typically [-0.7,0.7])" — that `~` is an
observation, and a box verdict resting on an observation is the hole this file spends its life
avoiding. What is *provable* from reading `GradDot`: it returns `ru + rv` with both in `[-1,1]`, and
a trilinear lerp never leaves the hull of its inputs. So the warp dilation uses 2, roughly 3x the
real displacement. Costs a wider search box; cannot cost a verdict.
- **The op pool is passed to `BuildChunkCache`, not `nullptr`.** Tempting to skip it "since room
geometry doesn't depend on it" — and false. The bake reads `OpParams` to place **pits and
chimneys**. Passing `nullptr` would under-bound the cache and could return `Identity` over a real
pit. That is precisely a hole, and it is the same trap as `ColumnDensity = 0` not disabling columns.
- **The search box is wider than `Eval`'s**, which yields a *superset* of primitives — so "nothing
reaches the box here" implies "nothing reaches it there".
### 3. `AUDIT §C2` fixed on the `switch` path — and the obvious fix was the wrong one
`GetDensityWithParams` now takes **required** `ParamsFingerprint` + `LayoutVersion`. Details and the
reasoning are in `AUDIT §C2`; the part worth repeating here is why the alternative that section used
to recommend ("just add chunk Z to the key") is both insufficient and destructive: `Interleaved`
makes `Alpha` depend on chunk **XY** as well, and chunk XY is deliberately *not* in the key — the box
outlives the chunk so `WorldX ± 1` gradient probes don't thrash it (`§8.10`). Pinning XY to fix params
would have traded a determinism bug for a perf regression. 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 now pass `VF_FP(P)`, so the oracle stops sharing the defect it tests.
### 4. The other debt, `FLayerLineMod` / `LocalParams` — **dormant, and the premise was slightly off**
The handoff said this had to be paid before `EffectOverBox` landed, because a per-room op can raise a
modifier's amplitude above what the strate params claim. Checked before paying it, and the check
reverses the conclusion — the fifth time this refactor that has happened:
- when the source proves `Identity`, all twelve modifiers return `Identity` **soundly**: their
per-voxel gate is `bNearCaveSurface`, which is false everywhere in the box no matter what any room
op says. The room op cannot enable a gate that never opens.
- when the source answers `Both`, it supplies **no** `MaxCarveOverBox`, so the default `FLT_MAX`
removes the whole margin and nothing is provable regardless of what the modifiers claim.
So there is no reachable path today where an over-optimistic modifier bound changes a verdict. **The
debt goes live the day `FRoomGraphSource` gains a `MaxCarveOverBox`** — bounding the converter's
`2·BaseDensity` would make the modifiers' own numbers matter for the first time. That is now written
at the site rather than in a handoff.
### Ready to build. Likely compile-error spots, worst first
1. `GetDensityWithParams` signature — 2 production call sites + 3 in `VoxelForgeOpStackTunnelTest.cpp`
are updated; **any other caller I missed will fail to compile, which is the intent.**
2. `FCrc::MemCrc32` in `VoxelGenerator.cpp` and the test — reachable transitively in
`VoxelDensityOpStack.cpp` today, so it should resolve, but `#include "Misc/Crc.h"` is the fix.
3. `FBoxState` / `BoxState()` / `PerlinAbsBound` are new members of `FRoomGraphSource`, inserted
INSIDE the class (not near the FACTORIES banner — the mistake this file warns about twice).
4. `FBox::operator==` on `B.KeyBox == VoxelBox`.
5. The rewritten check 4 uses `EVoxelTileClass::AllSolid` / `AllAir` and a triple `float` loop.
### What to read in the results
- **`Box verdicts over 40 TunnelNetwork tiles`** — this line no longer asserts `0 proved`. It asserts
that **no proved tile is wrong under brute force**, and reports the count. A non-zero proved count
is the T1.d prize arriving; a zero count now emits a *warning* saying the check verified nothing,
because a soundness check with no verdicts to contradict is vacuous.
- Everything else should be **unchanged and green**. The §C2 fix changes generated terrain only
inside transition bands on the `switch` path (where it was previously order-dependent, i.e. not
well-defined), so an equivalence test that moves is a real signal, not expected noise.
## 2026-07-28 — GREEN, and `0 proved of 40`. The blocker was the **worm**, not the room source.
The build was green and every number in the previous entry held. One line did not:
```
Box verdicts over 40 TunnelNetwork tiles: 0 proved (0 AllSolid, 0 AllAir), 40 Mixed
-- brute-forced over 0 voxels, 0 violations.
WARNING: No TunnelNetwork tile was proved ... this check verified nothing.
```
**The warning I wrote for exactly this case fired, and then it was not good enough.** It offered two
candidate causes — "the tiles genuinely straddle cave" or "the source isn't reaching its `Identity`
branch" — and **both were wrong**. The real cause was a third operator that neither candidate
mentioned. That is the failure worth recording, more than the bug itself.
### The cause, found by reading two default values
`FWormFieldSource::EffectOverBox` answered `CarveOnly` unconditionally, with a *provably correct*
amplitude bound of `WormStrength`. And in `VoxelStrateTypes.h`:
```
float BaseDensity = 8.0f;
float WormStrength = 10.0f; // "Must exceed BaseDensity to create air." <- the field's own comment
```
So `SolidMargin = 8 10 = 2 < 0`, on **every tile of every strate with worms on**, before the fold
ever reached anything the room source had proved. The worm's bound is not loose by accident — the
defaults *require* it to exceed `BaseDensity`, or worms could never carve. A numerically correct
bound that is structurally always fatal.
### The fix was already written in the worm's own `Eval`, three lines up
```cpp
if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX)
{ NetworkMask = 0.0f; }
...
if (NetworkMask <= 0.0f) { return; }
```
**The worm IS spatially bounded** — not by a bound of its own, but by the room source's, exactly like
the twelve detail modifiers. Where `FRoomGraphSource` proves `Identity`, `Sdf` stays `FLT_MAX`
(verified: `FVoxelOpSample::Sdf = FLT_MAX` is the initialiser), so `NetworkMask` is 0 at every voxel
and `Eval` returns before touching `Density`. The worm is the *identity* there, not "a bounded
carve". It simply never asked the question.
So it now inherits the verdict — thirteen inheritors instead of twelve. Two details:
- **Deliberately NOT `VF_NoCaveOverBox`.** That helper returns `true` when `Rooms == nullptr`, which
is right for the twelve modifiers (they only ever exist in a stack where the room source is the
sole SDF writer) and **wrong** for the worm, which a future assembly could place behind a different
SDF writer — `FLatticeCorridorSource` writes that channel too. No room source ⇒ we don't know ⇒
`CarveOnly`. Not knowing must cost CPU, never a hole.
- `MaxCarveOverBox` now calls `EffectOverBox` rather than re-testing the condition, so the two cannot
drift. The room source's verdict memo makes the second call free.
### The lesson, and the instrument that came out of it
**A diagnostic that lists candidate causes without measuring them is still a guess** — it just looks
like rigour. My warning named two causes and had a number for neither, so a green run with a real
defect in it produced a message that sent the reader to the wrong two places.
`FVoxelOpStack::ClassifyBoxAttributed` now exists: the same fold, verdict-identical to `ClassifyBox`
(same loop, same early-out — a diagnostic that takes a different path than the thing it explains is
worse than none), reporting the **index of the first operator that kills each hypothesis**.
`IVoxelDensityOp::DebugName()` gives them readable names; it touches no cache key and no generation
decision, so it cannot change the world. Check 4 prints:
```
AllSolid killed by: <op> x<count>, <op> x<count>, ...
```
Always printed, not only on failure — when tiles *are* proved, that line is what says why the rest
are not. And the `0 proved` warning now says **"do not re-derive the cause, read the attribution
line"**, because the next person's guess would be as good as mine was.
### Ready to build. Likely compile-error spots
1. `FWormFieldSource` gained a third ctor arg (defaulted) and a `Rooms` member; its single
construction site in `BuildTunnelNetworkStack` passes `RoomPtr`, which is already in scope there.
2. `IVoxelDensityOp::DebugName()` is a new virtual with a default — six overrides added
(`ConstantFieldSource`, `RoomGraphSource`, `SdfConvertOp`, `OriginSpineOp`, `BoundarySealOp`,
`PassageCarveOp`, `WormFieldSource`). Everything else inherits `"(unnamed op)"` and reports by index.
3. `ClassifyBoxAttributed` / `GetOpDebugName` are new inline methods on `FVoxelOpStack` (header).
4. The test uses `TMap<FString,int32>::ValueSort` and `FindOrAdd` on a `const TCHAR*` key.
### What to read, in order
1. **`AllSolid killed by:`** — the new line. If it is empty ("AllSolid survived every tile") the
prize has landed. If it names `RoomGraphSource`, the tiles genuinely straddle cave and the sampler
is what to look at. If it names anything else, that operator's box answer is more pessimistic than
its `Eval`, and it is now named rather than guessed at.
2. **`Box verdicts over 40`** — proved count is a measurement; the assertion is only that no proved
tile is *wrong*.
3. Everything else should be unchanged. The worm change cannot alter density: `EffectOverBox` and
`MaxCarveOverBox` are box-verdict methods, and `Eval` is untouched.
## 2026-07-28 — the worm fix WORKED. `AllSolid killed by: RoomGraphSource x40` — and that is not an answer.
The attribution line did its job on its first run:
```
AllSolid killed by: RoomGraphSource x40
```
`WormFieldSource` is gone from that list, so the previous entry's fix landed exactly as reasoned. The
blocker moved one operator upstream, to the room source itself.
**And my own warning is now the thing to distrust.** It said: *"If it names RoomGraphSource, the tiles
genuinely straddle cave."* That 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 (rooms, tunnels, pits, chimneys) whose bounds differ enormously in quality, and "the tiles straddle
cave" is only one of the things a `Both` from it can mean. So: no third guess. Two changes, one of
them proved, and an instrument for the rest.
### Proved, not guessed: the columns test is gone
The first version 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` because it shared an XY
circle with a column. That was the loosest test in the function — and it was **redundant**, not
conservative:
```cpp
void FRoomColumnMod::Eval(...) const
{
if (!VF_NearCaveSurface(InOut.Sdf, P.SDFBlendRadius)) { return; } // <- the only consumer
```
Columns are read by exactly one operator, and it gates on `Sdf` being near a cave surface. In a box
no room, tunnel, pit or chimney reaches, `Sdf` stays `FLT_MAX` at every voxel, so **no column can
execute regardless of where it sits in XY**. Removing the test tightens the verdict without touching
its correctness. That is a proof, not a relaxation.
### The instrument: which primitive class actually reaches the box
`EffectOverBox` no longer early-outs on the first hit. It **counts all four classes**, because
stopping at the first gives the right verdict and no information — which is precisely why
`RoomGraphSource x40` told us nothing actionable. The cost is nil at the scale that matters: we have
just run `BuildChunkCache`, which dwarfs a walk over ~100 structs, and the verdict is memoised so the
walk happens once per box rather than thirteen times.
`VoxelDensityOps::GetLastRoomBoxDiagnostic()` exposes it. **Deliberately a read-back of what the
operator computed, not a re-derivation in the test** — the test has everything needed to replay the
criterion, and replaying it would create a second definition that drifts from the real one and lies
on the day it is believed. Same reason `VF_BuildOpStackForChunk` exists.
The report now prints, per killed tile, how many rooms and tunnels of those in the cache actually
reach the box. **The hypothesis it is built to kill or confirm:** a tunnel is culled per voxel by its
**bounding sphere**, and for a long thin capsule that sphere is an enormous over-estimate, while a
room's cull sphere is a fair fit for a roughly spherical room. If tunnels ≫ rooms, the box test is
losing to capsule bounding spheres rather than to real cave, and the fix is a segment-vs-box distance
— nothing to do with the sampler or with cave density.
### What was deliberately NOT done, and why
Tightening tunnels to a real 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
("no primitive survives its cull"). Making it sound needs the stronger criterion — *no primitive can
bring `Sdf` below `max(Blend, SDFBlendRadius·3, WormNetworkRange)`* — which in turn needs a bound on
how far `SmoothMin` of N primitives can dip below `min`. That is real §0.2 design work, and doing it
blind, in the same build, before knowing whether tunnels are even the problem, is the §C10 mistake
verbatim. **Measure, then tighten what the numbers name.**
### Ready to build. Likely compile-error spots
1. `VoxelDensityOps::FRoomBoxDiagnostic` + `GetLastRoomBoxDiagnostic()` — new declaration in the
header, defined in the .cpp *after* the anonymous namespace closes (it reads
`FRoomGraphSource::BoxState()`, whose type lives in that namespace; legal, since the type does not
appear in the function's signature).
2. `FBoxState` gained eight `int32` counters.
3. The test accumulates into new locals and calls `VoxelDensityOps::GetLastRoomBoxDiagnostic()`.
### What to read
1. **`...and when RoomGraphSource is the killer`** — the new second line. `tunnels 40 / rooms 3` says
capsule bounding spheres; `rooms 40` says the tiles really are near rooms and the sampler is what
to look at; pits or chimneys leading would be a surprise worth stopping on.
2. Whether removing the columns test alone moved `proved` off zero. If it did, that number is the
first real T1.d saving in the plugin.
## 2026-07-28 — the breakdown refuted MY hypothesis, and found the sampler was the bug
```
rooms 40, tunnels 40, pits 14, chimneys 0
Averages per killed tile: 4.9 of 7.2 rooms reach, 69.2 of 80.4 tunnels reach
```
I predicted "tunnels ≫ rooms ⇒ capsule bounding spheres". **Wrong, or rather insufficient:** tunnels
*are* wildly over-counted (69 of 80), but **rooms hit all 40 tiles too**, so fixing tunnels alone
would have moved the number by exactly zero. Fourth hypothesis this refactor has reversed on contact
with a measurement. The instrument paid for itself on its first run.
### The real finding: the tile sampler never left the (0,0) spine
```cpp
const int32 Extent = Step * Cells; // 1 * 8 = 8
Rng.RandRange(-4, 4) * Extent // XY ∈ [-32, +32] voxels
```
Against the defaults:
| | |
|---|---|
| `RoomSpacing` | **80** — ±32 does not cover half of one room cell |
| `OriginRoomRadius` | **20**, guaranteed at (0,0); cull radius `max(20·1.5, 8) + 3·4` = **42** |
| the (0,0) spine | descends exactly there |
So all 40 tiles sat inside the origin room's cull sphere, in the most cave-riddled cubic
metre of the entire world. `4.9 of 7.2 rooms reach` was not measuring the world's cave density — it
was measuring the spine hub. **Every conclusion about "is deep rock provable" drawn from that sample
was answering a different question.**
Widened to ±320 voxels (4 × `RoomSpacing`), and the report now **prints its own sampling extent** plus
how many tiles landed clear of the spine — because a sampler whose extent you cannot quote is one
nobody is watching. The brute-force soundness assertion is untouched: this changes what the
measurement *looks at*, never what it *demands*.
### Why rooms can't be tightened and tunnels can — the arithmetic, before writing any code
With `SDFBlendRadius K = 4`, `WormNetworkRange = 24`, mods gating at `3K = 12`, the strongest useful
threshold is `T = max(Blend, 3K, WormNetworkRange) = 24`.
`SmoothMin(A,B,K) = min(A,B) H³K/6`, `H = max(K|AB|,0)/K`. Two consequences worth writing down:
the penalty is **exactly zero** once `|AB| ≥ K`, and the running minimum therefore **saturates at
`K` below the true minimum** — so `Sdf ≥ min_i(SDF_i) K` for **any** number of primitives. That is
the bound that makes an "`Sdf ≥ T`" criterion possible at all.
Now compare the two possible criteria per primitive:
- **rooms** — cull rejects at `dist > Rmax + 3K` (= 57 worst case). The `Sdf ≥ T+K` criterion rejects
only at `dist ≥ Rmax + T + K` (= 73). Since `T + K = 28 > 3K = 12`, **the cull is strictly the
better test for rooms.** Nothing to gain; leave them alone.
- **tunnels** — the cull is the capsule's **bounding sphere**, and with `MaxTunnelLength = 200` that
sphere has radius up to ~107 for a tube of radius 7. The `Sdf ≥ T+K` criterion uses the real
distance to the segment, so it rejects at `dist(box, segment) ≥ 7 + 28 = 35`. **Order of magnitude
tighter**, and sound because `TaperedCapsule` is a genuine distance function
(`Dist(P, ClosestOnSegment) Lerp(Ra,Rb,T)`), verified rather than assumed.
So the eventual fix is a **per-primitive disjunction** — a primitive cannot matter if it fails its
cull *or* its own SDF stays ≥ `T + K` over the box — applied **only to tunnels**, where the SDF is
exact. Mixing is sound: primitives failing the cull contribute nothing, the rest are all ≥ `T+K`, so
the fold is ≥ `T`, and every consumer (converter at `Blend`, twelve mods at `3K`, worm at
`WormNetworkRange`) is identity at `Sdf ≥ T`.
### NOT done in this build, deliberately
The tunnel disjunction changes what `Identity` *means* here — from "`Sdf` stays `FLT_MAX`" to
"`Sdf ≥ T`" — and that is only sound if **every** consumer's threshold is ≤ `T`. Three premises still
need reading rather than assuming: `VF_NearCaveSurface`'s exact constant, the `Blend` actually passed
to `FSdfConvertOp` by `BuildTunnelNetworkStack`, and whether any modifier re-probes the SDF *outside*
the box (`FCaveTerraceMod` samples Z±1) before its gate. Any one of those wrong is a hole with no
collision behind it, and this session has already produced four confident chains that reversed on
checking. **Fix the measurement first; it costs nothing and it is wrong today.**
### The prediction, stated so the next run can refute it
With the widened sampler: **rooms should stop hitting every tile** (cull spheres of mean radius ~42
on an 80-lattice cover roughly 60 % of space, so ~40 % of tiles should be clear of all rooms), and
**tunnels should now be the binding constraint on nearly all of them.** If instead tunnels stay at
~100 % *and* rooms drop, the tunnel disjunction above is the whole remaining job. If rooms also stay
at 100 %, my model of the room lattice is wrong and that is the thing to look at next — not the
tunnels.
### Ready to build. Compile-error spots
`SpanCells` / `SpanVoxelsReported` / `NumTilesAwayFromSpine` are new locals hoisted **outside** the
tile loop (the report line needs them); `P.OriginRoomRadius` and `P.RoomSpacing` are read in check 4.
## 2026-07-28 — the sampler fix worked, the prediction failed, and the fixture is the reason
The widened sampler did exactly what it was meant to: **39 of 40 tiles now land clear of the (0,0)
spine.** And the verdict did not move.
```
[before] rooms 40, tunnels 40 | 4.9 of 7.2 rooms, 69.2 of 80.4 tunnels (XY ±32)
[after ] rooms 40, tunnels 40 | 6.3 of 8.3 rooms, 78.0 of 87.7 tunnels (XY ±320, 39/40 off-spine)
```
My stated prediction was *"rooms should stop hitting every tile."* **They did not.** By my own
pre-registered rule that means the room-lattice model was wrong — and it was, for a reason printed in
the report I had been reading past for three runs:
> `= 7.6 x RoomSpacing 42`
**`RoomSpacing` is 42 here, not the 80 I computed with.** I read the `UPROPERTY` default out of the
header instead of the fixture, which overrides it. Fifth premise this refactor that reversed on being
checked, and the plainest one: *the number was on screen.*
### Why the fixture cannot ever prove a tile — arithmetic, not opinion
`EnableTunnelFeatures` densifies **on purpose**, and says so: `RoomSpacing` 80 → 42,
`RoomDensity` 0.35 → 0.85, because at the defaults the first run had **1.1 %** of samples in open
cave and the equivalence was comparing solid rock to solid rock. That densification is what makes
check 1 mean anything.
It is also exactly antagonistic to provability:
```
room cull radius = max(R·1.5, R·HeightRatio) + 3·SDFBlendRadius
= 1.5R + 12 for R ∈ [10,30] ⇒ 27 … 57, mean ≈ 42
lattice spacing = 42, occupancy 0.85
```
**The mean cull radius equals the lattice spacing.** Sphere volume over cell volume gives ~3.6×
redundant coverage — *no box in that world can be outside all room cull spheres.* `6.3 of 8.3 rooms
reach` is not a symptom of a loose test; it is the world being saturated. No sampler change and no
tightening of tunnels can move it, which is why widening the sampler correctly changed nothing.
**So `0 proved` on this fixture is the right answer**, and it is informative: the tile-skipping prize
shrinks to nothing as caves saturate. What it is *not* is an answer about production.
### Both worlds, one criterion
Check 4 is now a lambda run twice — **parameterised, not copy-pasted**, because two copies of the
criterion would drift and the second one would lie:
- **`[dense fixture]`** — as before. Expected to keep reporting ~0, and that is now written down as
the correct result rather than read as a failure.
- **`[production defaults]`** — the same 40 tiles against `RoomSpacing 80 / RoomDensity 0.35`, the
actual `UVoxelStrateDefinition` defaults, which is the world the question "how many tiles can we
skip" is really about. At that spacing, cull spheres of mean radius 42 on an 80-lattice at 35 %
occupancy cover roughly a fifth of space.
Both are brute-forced voxel by voxel. This is **not** "widen it until it passes": the dense run stays
in the report and must stay ~0, and a false verdict in either run still fails the assertion. The two
stacks deliberately share `FRoomGraphSource`'s `thread_local` caches — the params fingerprint in the
key is what keeps them apart, so this run and check 3 now watch each other.
### The lesson, and it is not the one I expected to write
Three runs in a row I reasoned from `RoomSpacing = 80` while the test printed 42 at me. The
instrument was right, the arithmetic done on it was right, and the *input to the arithmetic* was
taken from the wrong file. **"Verify the premise" has to include the premises you are confident
enough about not to look up** — especially a default, when a fixture exists whose entire job is to
override defaults.
### Ready to build. Compile-error spots
1. Check 4 is now `auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
const FVoxelOpContext& TCtx, const TCHAR* Label)`, called twice. Verified mechanically: zero bare
`Stack` / `P.` / `Ctx` references survive inside the lambda body, braces and parens balance.
2. Five `FString::Printf` sites gained a leading `%s` **and** the matching `Label` argument — the
mismatch was caught and fixed before commit; worth re-checking if the log looks scrambled.
3. `SparseStack` is a second `FVoxelOpStack` built by `BuildTunnelNetworkStack` and `PrepareChunk`ed.
### What to read
**`[production defaults] Box verdicts`** — the only new number that matters. Non-zero is the first
real T1.d saving in the plugin. If it is *also* zero, then the per-class line under it says whether
rooms still saturate at 80 spacing (my model is wrong again, look there) or whether tunnels are
finally alone (the segment-vs-box disjunction from the previous entry is then the whole remaining
job, and its three unverified premises are listed there).
## 2026-07-28 — ✅ **THE PRIZE LANDED.** 6 of 40 tiles proved, 7986 voxels brute-forced, 0 violations.
```
[production defaults] Box verdicts over 40 tiles: 6 proved (6 AllSolid, 0 AllAir), 34 Mixed
-- brute-forced over 7986 voxels, 0 violations
[dense fixture] 0 proved, 40 Mixed <- correct, and predicted
```
**This is the first real T1.d saving in the plugin.** 15 % of tiles skip `GenerateMesh` entirely —
one `BuildChunkCache` traded against 30 000+ density evaluations each — and not one of those verdicts
is asserted: all 7986 voxels were re-evaluated through the stack and every single one came back on
the claimed side. The two-world split also did its job: the dense fixture reported 0, exactly as the
arithmetic said it must, so the number above is a measurement of *production*, not of a test that was
widened until it agreed.
Five runs to get here, and the honest summary of them is that **every single one of my hypotheses was
wrong and every single instrument was right**:
| run | my hypothesis | what the instrument said |
|---|---|---|
| 1 | "the tiles straddle cave" / "Identity unreachable" | **the worm**, unbounded `CarveOnly`, `8 < 10` by default |
| 2 | "tunnels ≫ rooms ⇒ bounding spheres" | rooms hit 40/40 too — fixing tunnels alone changes nothing |
| 3 | "widen the sampler and rooms will drop" | they did not — `RoomSpacing` was **42**, not the 80 I computed with |
| 4 | — | the fixture is *deliberately* saturated; `0 proved` is its correct answer |
| 5 | "production defaults will prove tiles" | **6 of 40**, and now tunnels really are alone (32 vs 21) |
### The tunnel disjunction — the deferred piece, now justified by its own number
The breakdown finally isolated it: of the 34 unproved tiles, **32 were blocked by a tunnel and 21 by
a room**, so ≥13 were blocked by tunnels *alone*. That is what made the deferred work worth doing, and
it is why it was deferred until now rather than guessed at three runs ago.
A primitive now fails to matter under a **disjunction**:
> it misses its cull entirely **OR** its own SDF stays ≥ `T + K` over the whole box.
Each branch wins on a different class, and the arithmetic says which:
- **rooms** — cull rejects at `Rmax + 3K`, threshold only at `Rmax + T + K`. Cull is strictly tighter,
so rooms keep the cull alone. Not an omission; a calculation.
- **tunnels** — the cull is the capsule's *bounding sphere*: radius ~107 for a 200-long tube of
radius 7. The threshold uses the true distance to the segment. Order of magnitude.
**The bound is exact, not cautious.** `TaperedCapsule` was *read* (`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, and that is the one real debt this creates
It used to mean "`Sdf` stays `FLT_MAX`". It now means "`Sdf ≥ T`", with
`T = max(3·SDFBlendRadius, WormNetworkRange)`. That is only sound because all three consumers of the
SDF channel were **read one by one**, not assumed:
| consumer | threshold | verified at |
|---|---|---|
| `FSdfConvertOp::Eval` | `Sdf >= Blend`, and `BuildTunnelNetworkStack` passes `MakeSdfCarve(P.SDFBlendRadius, …)` ⇒ **K** | call site |
| the twelve modifiers | `VF_NearCaveSurface` ⇒ **3K** | its body |
| `FWormFieldSource::Eval` | `CaveSDF >= WormNetworkRange` ⇒ **WormNetworkRange** | its body |
Plus the one that could have bitten: `FCaveTerraceMod` re-probes the SDF at **Z±1, outside the box** —
but its `VF_NearCaveSurface` gate is line 13 and the probes are lines 2829. Gated first, so a gate
that is false everywhere never emits a probe. Checked, not assumed.
**Any new consumer of the `Sdf` channel must have a threshold ≤ `T` or be added to that `max`.** An
operator reading `Sdf < 100` would see false `Identity` verdicts, i.e. tiles with no geometry **and no
collision**. That warning is written at the site, in the function you land in when you add one.
### Why ` K` is enough for any number of primitives
`SmoothMin(A,B,K) = min(A,B) H³K/6` with `H = max(K|AB|,0)/K`. The penalty is **exactly zero**
once `|AB| ≥ K`, so the running minimum saturates at `K` below the smallest term — it cannot descend
further, because at that distance `H = 0` and subsequent folds return it unchanged. Hence
`Sdf ≥ min_i(SDF_i) K` for **any** N, not ` N·K/6`. Without that observation the slack would scale
with the ~88 tunnels in the cache and the criterion would be worthless.
### Ready to build. Compile-error spots
1. `VF_DistPointSegment` — new helper in the anonymous namespace, next to `VF_NearCaveSurface`
(i.e. well above the end-of-namespace marker).
2. `K` / `T` / `TunnelClear` / `QCenter` / `BoxHalfDiag` are new locals in `EffectOverBox`; the tunnel
loop variable is `Tn` **specifically so it does not shadow `T`**. Braces and parens verified balanced.
3. `(QMax - QMin).Size()` returns a double under UE5's `FVector` — cast to float.
### What to read
`[production defaults] Box verdicts` — **6 is the number to beat.** The disjunction should raise it;
≥13 tiles were tunnel-only blocked, so somewhere near 19 of 40 is the expectation. And
`[dense fixture]` **must stay at 0** — if the dense world starts proving tiles, the disjunction is
wrong somewhere and the brute force is the thing that will say so.
## 2026-07-29 — Jahni: "are you in a loop?" **Yes.** The dominant term was a constant I set on day one and never measured.
11 of 40 proved, 0 violations — real, but Jahni called it: *still wrong, re-read the original and how
you're doing it.* He was right, and the re-read found it in one line.
### The loop
`PerlinAbsBound = 2.0`, chosen in the very first commit of the spatial `EffectOverBox` and never
revisited. The warp dilation is `CaveWarpStrength · VOXEL_NOISE_SCALE · PerlinAbsBound`, and
**`CaveWarpStrength = 8.0` by default** (the fixture does not override it):
```
dilation = 8 × 1.25 × 2.0 = 20 voxels, applied ±, on every axis
tile = 10 voxels → query box 50 voxels per side → 125× the tile's volume
BoxHalfDiag = 43.3
```
So the tunnel test I described as "an order of magnitude tighter" actually required
`DistToAxis ≥ 28 + 43.3 + 7 = 78`, against a bounding-sphere cull that rejects at ~107. **27 %
tighter, not 10×** — and the run reported exactly that: tunnels reaching went 78.0 → 58.5, a 25 %
cut. The instrument told me the truth and I credited it to the tunnels.
Four rounds of work — the worm, the columns, the sampler, the tunnel disjunction — every one of them
tightening a term while **the term nobody measured stayed 125× too big**. Each round was individually
correct and the loop was still real: *I kept instrumenting the thing I had just changed, and never
instrumented the thing I had assumed.*
### What re-reading the original actually showed
`BuildChunkCache` is called — in `GetDensityWithParams`, in `FRoomGraphSource::Eval`, everywhere —
with `Expansion = CaveWarpStrength + 2.0f`. That is the shipped, working, years-old code, and it is
only correct if `|Perlin3D| · VOXEL_NOISE_SCALE ≤ CaveWarpStrength`, i.e. **`|Perlin3D| ≤ 0.8`**.
**The whole plugin has always bet on 0.8. I chose 2.0 — 2.5× more conservative than the assumption
the cache's own correctness already rests on — and then optimised around my own choice for three
builds.** That is the answer to "are you in a loop", and it is not a subtle one.
### The corrected bound, derived rather than guessed
Read out of `GradDot`: `u` and `v` are always **two distinct** components of the corner offset —
checked on all four hash branches, not assumed. Then per axis, splitting the eight corners by `i`:
```
Σ_c w_c·|dx_c| = (1su)·fx + su·(1fx) su = Fade(fx)
≤ 0.5 (max at fx = 0.5; 0.302 at fx = 0.25 and 0.75)
|Perlin3D| ≤ Σ_c w_c(|a_c|+|b_c|) ≤ S_x + S_y + S_z ≤ 1.5
```
**1.5, provable, no case analysis on the hashes.** (The true max is lower still — only two of three
axes appear per corner — and the classical `√3/2 ≈ 0.87` depends on the gradient set, so it is not
leaned on.) Dilation drops 20 → 15 voxels. That is a 25 % cut in the dominant term, honestly
obtained, and it is *not* the end of the story.
### The instrument that should have existed from the first commit
`EffectOverBox` now also computes every room/tunnel test **with the warp dilation set to zero**, and
reports both. `HitRooms HitRoomsNoWarp` is exactly the blocking caused by my own box rather than by
geometry. The line reads:
```
WARP SHARE: the query box is dilated by ±N voxels per axis; with that dilation set to ZERO
the same tests would keep only X rooms and Y tunnels.
```
If that gap dominates, the next move is the warp bound — **not** the primitives. That sentence is in
the output so the next person (me, next run) cannot repeat this.
### The other thing that was wrong: the diagnostics had become narrative
The report was printing **hardcoded numbers from previous runs** next to live ones — "the first build
reported 0 proved of 40", "32 of 34 tiles vs 21 for rooms" — while the live figures said 21 rooms of
28 tiles. A reader cannot tell which is which, and I wrote every line of it. Diagnostics now report
**this run only**; the history lives here, in the log, where it belongs.
### Ready to build. Compile-error spots
1. `PerlinAbsBound` 2.0 → 1.5 (one constant, big blast radius on the numbers, none on compilation).
2. `FBoxState` gained `HitRoomsNoWarp` / `HitTunnelsNoWarp` / `WarpDilation`; mirrored on
`VoxelDensityOps::FRoomBoxDiagnostic` and copied in `GetLastRoomBoxDiagnostic`.
3. `NWMin` / `NWMax` / `NoWarpHalfDiag` are new locals in `EffectOverBox`, declared before use
(verified mechanically, along with brace/paren balance).
4. The per-class report line went from 10 to 13 format specifiers and 13 arguments — counted, and the
previous round's arg-list mismatch is exactly why it was counted.
### What to read, and in this order
1. **`WARP SHARE`** — the number that has been invisible all along. `rooms 1.1 → 0.4` would mean most
of the remaining block is my dilation and the warp bound is the whole job; `1.1 → 1.0` would mean
the geometry really is that dense and the dilation was never the point after the 1.5 fix.
2. `[production defaults] Box verdicts` — **11 is the number to beat.**
3. `[dense fixture]` must still be 0.
## 2026-07-29 — WARP SHARE measured: **over half the blocking is my box.** And two corrections.
The instrument that should have existed from commit one, on its first run:
```
[production defaults] 0.9 of 1.1 rooms reach, 2.4 of 3.3 tunnels reach
WARP SHARE: dilated ±15.0 voxels/axis; with dilation ZERO
the same tests keep only 0.4 rooms and 1.1 tunnels
[dense fixture] 5.0 rooms / 46.3 tunnels → 2.1 / 22.4 with zero dilation
```
**Rooms 0.9 → 0.4, tunnels 2.4 → 1.1.** Better than half of everything still blocking a verdict is
caused by the query-box dilation rather than by cave geometry. Hypothesis confirmed, and the number
is now permanent output.
### ✋ CORRECTION — the justification I gave last entry was wrong
I wrote that `BuildChunkCache`'s `Expansion = CaveWarpStrength + 2` means *"the whole plugin has
always bet on `|Perlin3D| ≤ 0.8`"*, and used that to argue 2.0 was needlessly conservative. **That is
false, and I should have read six lines further:**
```cpp
const bool bNeedRebuild = … || WarpedX < CachedSMinX || WarpedX > CachedSMaxX || …;
```
The cache **rebuilds when the warped query leaves the box.** So that expansion is a
*rebuild-frequency heuristic*, not a correctness bound — nothing anywhere bets on 0.8, and the cache
is self-correcting no matter how far the warp reaches. The 2.0 → 1.5 change stands on its own
derivation and needs no support from that claim. Sixth premise this refactor that reversed on being
checked, and this one I asserted **in the same entry where I diagnosed the habit.**
### ✋ NEGATIVE RESULT — "shift the box by the local warp" does **not** work here
The obvious next move is: the warp is smooth and low-frequency (`WF = 0.015`, wavelength ~67 voxels)
and the tile is only 10 voxels, so evaluate the warp at the box centre, **shift** the box by it, and
dilate only by the *variation* across the box. Worked through before writing it, and it loses:
A rigorous per-axis Lipschitz bound for this `Perlin3D`:
```
∂V/∂fx = [Σ_c ∂W_c/∂su · G_c]·Fade'(fx) + Σ_c W_c·∂G_c/∂fx
|Fade'| ≤ 15/8 = 1.875
|Σ ∂W/∂su · G| ≤ 4 (difference of two convex combinations of values in [2,2])
|∂G/∂fx| ≤ 1 (u and v are distinct axes, so at most one of them is x)
⇒ |∂V/∂fx| ≤ 4·1.875 + 1 = 8.5 per unit noise cell
```
Half-box in noise units: `WF·5 = 0.075` for x and y, `WF·5/1.35 = 0.056` for z ⇒ 0.206 total.
So the local variation bound is `8.5 × 0.206 = 1.75`, against a **global** range bound of **1.5**.
**The local bound is worse than the global one.** The crude Lipschitz constant times the box's extent
in noise space already exceeds the whole range of the function. Recorded so nobody spends a build
discovering it — the idea is sound in principle and simply does not pay at `WF = 0.015` with a
Lipschitz constant this loose.
### Where that leaves the warp term
The measured ceiling for *all* remaining warp work is "about half the current blocking", i.e.
somewhere around 11 → 16-20 tiles of 40. The only route left is tightening the sup of `|Perlin3D|`
from the proved 1.5 toward its true value:
```
|Perlin| ≤ S_x + S_y + S_z Σ_c w_c·min(|dx_c|,|dy_c|,|dz_c|)
spot values: 1.000 at (0.5,0.5,0.5) · 1.003 at (0.9,0.5,0.5) · 0.614 at (0.9,0.9,0.5)
```
so the true sup looks like **≈ 1.01.1**, worth ~27 % off the dilation. But "spot-checked on a grid"
is not a proof, and a sup proved wrong here is a tile with no geometry and **no collision**. That is
analysis work with real hole-risk for a partial share of a bounded prize — a judgement call about
appetite, not a technical unknown, so it goes to Jahni rather than getting done on my own initiative.
### State: T1.d is delivered and verified
**11 of 40 tiles (27.5 %) proved AllSolid at production defaults, 14641 voxels brute-forced,
0 violations.** The dense fixture correctly proves nothing. Every verdict is checked voxel by voxel,
so the risk of the whole feature is bounded by a test that runs on every build.
## 2026-07-29 — T1.d banked at 11/40. VerticalShafts: the connector branch never tested a connector.
Jahni's call: bank the tunnel result and take VerticalShafts rather than squeeze the Perlin sup.
Right call — the shaft fix is a clearly-scoped defect with no hole-risk maths, on an archetype that
was getting nothing.
### The defect, and it is stated in its own comment
`FShaftFieldSource::EffectOverBox` ends with a "connectors" branch that never looks at a connector:
```cpp
const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad);
for (cells in ConnBox)
if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent
```
It returns `CarveOnly` because a shaft **exists** somewhere in the neighbourhood. Against the
defaults — `ShaftSpacing = 55`, `ShaftDensity = 0.6`, `Pad ≈ 11 + ExtraReach` — that expanded box
spans roughly 4×4 cells and therefore ~10 shafts. **The condition is true essentially everywhere**,
which is exactly the reported `0 proved of 60`. Conservative, never wrong, and completely sterile —
the same shape as the worm's unconditional `CarveOnly`, one archetype over.
### The fix: rebuild the connectors the way `GetCells` does, and test the real capsule
Two things had to be right, and both were checked in the source rather than assumed:
- **The enumeration is a superset.** `Eval` reads connectors from the 3×3 neighbourhood of *its
query's* cell. So any pair visible from a point in the box has both shafts inside the 3×3 of some
cell the box touches ⇒ both lie in `[box cells] ± 1`, which is precisely the range swept. Pairs
that no query ever sees may be produced — extra `CarveOnly`, never a hole.
- **The pair order matches, so the hash matches.** `VoxelHash::Pair(A…, B…)` is fed in insertion
order, and `GetCells` inserts over `(dy, dx)` — row-major. The sweep here is `(cy, cx)`, the same
order, and row-major order restricted to a sub-grid preserves the relative order of any two cells.
So the same pair gets the same hash **without assuming `Pair()` is symmetric** — which was never
verified and now does not need to be.
The capsule test splits the axes because a connector is a **horizontal** capsule at height `Zc`:
Z is exact (`RMinZ > Zc + Reach || RMaxZ < Zc - Reach` ⇒ skip), XY uses point-to-segment from the box
centre minus the XY half-diagonal. Far tighter than a 3-D half-diagonal, which is what the tunnel
version had to settle for.
### And the same sampler trap, caught this time before the build
The shaft test drew tile XY from `RandRange(-6,6)*8` = **±48 voxels, against `ShaftSpacing = 55`** —
less than one period of the pattern. Identical in kind to the ±32-vs-80 bug that cost the tunnel test
three runs. Widened to ±440 (8 periods), and the report now prints its own extent in units of
`ShaftSpacing`, so it cannot go unnoticed again.
**The safety net was already there and is untouched:** this test brute-forces the full lattice of
every proved tile against `EvalMC`, both hypotheses, and `AddError`s on the first violation. So the
fix is checked by construction — if the superset argument or the hash-order argument is wrong, the
existing assertion fails rather than a player falling through the floor.
### Ready to build. Compile-error spots
1. `VF_DistPointSegment2D` — new helper next to `VF_NearCaveSurface` in the anonymous namespace.
2. Both new helpers now use `KINDA_SMALL_NUMBER`, matching the ~6 existing uses in the plugin; they
were briefly written with the `UE_`-prefixed spelling, which nothing else here uses.
3. The shaft test hoists `SpanCells` / `SpanVoxels` **outside** the tile loop — the report needs
them, and `Extent` is loop-local. (That exact scoping slip happened in the tunnel test two rounds
ago; caught here before the build rather than after.)
4. Format string is 6 specifiers / 6 arguments — counted.
### What to read
`Box verdicts over 60 VerticalShafts tiles` — **0 is the number to beat**, and `%d violations` must
stay 0. If it is still 0 proved, the warning now says what to check *first*: `ExtraReach` inflates
both remaining tests, so its value against `ShaftMaxRadius` is the thing to look at before touching
either test — not a re-derivation from scratch.
@@ -207,14 +207,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
FRandomStream Rng(13579);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 55;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < 60; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
// ⚠️ L'ÉTENDUE XY ÉTAIT ±48 VOXELS, POUR UN `ShaftSpacing` DE 55 : moins d'UNE cellule
// de puits. C'est le même piège que celui qui a coûté trois runs au test TunnelNetwork —
// un échantillonneur qui ne couvre pas une période du motif ne mesure pas le monde, il
// mesure un point du motif. ±440 = 8 périodes.
// The XY extent was ±48 voxels for a ShaftSpacing of 55 — less than one shaft cell, the
// same trap that cost the TunnelNetwork test three runs. ±440 covers 8 periods.
const FIntVector Origin(
Rng.RandRange(-6, 6) * Extent,
Rng.RandRange(-6, 6) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
@@ -259,10 +269,24 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 VerticalShafts tiles: %d proved uniform, %d Mixed. Today's ")
TEXT("ClassifyTile proves ZERO of these -- every cave archetype falls through to \"pas ")
TEXT("prouvable en v1\"."),
NumProved, NumMixed));
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ")
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ")
TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ")
TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ")
TEXT("because a false verdict here leaves no geometry and no collision."),
SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing,
NumProved, NumMixed, NumUnsound));
if (NumProved == 0)
{
AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ")
TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ")
TEXT("CAPSULE test are the only two things that can return CarveOnly here, ")
TEXT("and ExtraReach inflates both -- check its value against ShaftMaxRadius ")
TEXT("before touching either test."));
}
}
return true;
@@ -70,6 +70,23 @@ namespace
constexpr int32 PointsPerChunk = 250;
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
/**
* L'empreinte de params que `GetDensityWithParams` exige depuis le correctif d'`AUDIT §C2`.
*
* CE N'EST PAS DU REMPLISSAGE D'ARGUMENT. Avant ce correctif, l'original clé son cache SDF
* sans les params, et le contrôle 3 plus bas explique en détail pourquoi il fallait alors
* comparer chaque pile à ELLE-MÊME plutôt qu'à l'original : l'oracle partageait le défaut
* testé. En passant la même empreinte que la production, l'oracle ne le partage plus.
*
* `LayoutVersion = 0` partout dans ce test : le monde de test ne rebâtit jamais son layout en
* cours de route, donc la version est constante ce qui compte ici, c'est que l'empreinte
* DIFFÈRE entre deux jeux de params, et c'est exactement ce que la CRC donne.
*/
FORCEINLINE uint32 VF_FP(const FStrateGenerationParams& InP)
{
return FCrc::MemCrc32(&InP, sizeof(InP));
}
//=========================================================================
// ⚠️ `DisableStageBModifiers` A DISPARU, ET SA DISPARITION EST LE RÉSULTAT DE L'ÉTAPE B
//=========================================================================
@@ -503,7 +520,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetDensityWithParams(X, Y, Z, P);
const float Old = Gen->GetDensityWithParams(X, Y, Z, P, VF_FP(P), 0);
const float New = Stack.EvalMC(X, Y, Z);
FullVals[i] = New;
@@ -650,7 +667,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
for (int32 i = 0; i < RoughSweepPoints; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV), VStack.EvalMC(X, Y, Z)))
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV, VF_FP(PV), 0),
VStack.EvalMC(X, Y, Z)))
{
++VDiff;
}
@@ -819,22 +837,28 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
// empreinte CRC des params. Ce bloc le vérifie en ALTERNANT A, B, A, B au même point, le motif
// qui fait mentir une clé incomplète.
//
// ⚠️⚠️ ON NE COMPARE **PAS** À L'ORIGINAL ICI, ET C'EST LE POINT LE PLUS IMPORTANT DE CE TEST.
// `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed) — **sans les params**. En
// alternance il rendrait donc, pour B, les salles de A : l'original ÉCHOUERAIT ce contrôle. Le
// comparer à lui ici ne mesurerait pas mon opérateur, ça mesurerait son bug. On compare donc
// chaque pile à ELLE-MÊME évaluée seule — un oracle qui ne partage pas le défaut testé.
// ⚠️⚠️ HISTORIQUE, ET LE DÉNOUEMENT EST DANS LE PARAGRAPHE SUIVANT — À LIRE EN ENTIER.
// Ce bloc a été écrit quand `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed),
// **sans les params** : en alternance il rendait, pour B, les salles de A, donc l'original
// ÉCHOUAIT ce contrôle. Le comparer à lui ici n'aurait pas mesuré l'opérateur, ça aurait mesuré
// son bug — d'où le choix de comparer chaque pile à ELLE-MÊME évaluée seule.
//
// ⚠️ ET CE N'EST PEUT-ÊTRE PAS QU'UN ARTEFACT DE TEST — à vérifier, pas à croire. En production
// `GetGenerationParams` MÉLANGE les params entre strates voisines (transitions Gradient), donc
// deux chunks de Z différents dans la même strate peuvent avoir des params différents, avec la
// même boîte XY, le même index de strate et le même seed ⇒ aucune reconstruction. Si c'est
// exact, un worker qui descend une bande de transition sert les salles du chunk précédent.
// Noté dans `AUDIT §C2` comme SUSPECTÉ, avec le test qui le confirmerait — pas comme prouvé.
// ✅ **CE N'ÉTAIT PAS QU'UN ARTEFACT DE TEST, ET C'EST MAINTENANT CORRIGÉ** (2026-07-28). Le
// soupçon écrit ici s'est confirmé : `GetGenerationParams` blende les params À L'INTÉRIEUR
// d'une strate (`Alpha` = f(chunk Z) en `Gradient`, le défaut), donc deux chunks de Z différents
// partageaient boîte XY, index de strate et seed ⇒ aucune reconstruction ⇒ le deuxième chunk
// évalué contre les salles du premier. Et comme l'ordre des workers décide lequel est « le
// premier », **deux pairs divergeaient depuis la même seed**, ce que §2.6.1 interdit.
// `GetDensityWithParams` prend désormais une empreinte de params et une `LayoutVersion`
// OBLIGATOIRES (calculées une fois par chunk côté production, `VF_FP` ici).
//
// We compare each stack to ITSELF evaluated alone, not to the original: the original keys its
// SDF cache without the params and would fail this check, so comparing against it would measure
// its bug rather than this operator.
// ⚠️ ON GARDE POURTANT L'ORACLE « CHAQUE PILE CONTRE ELLE-MÊME », et ce n'est pas de la
// paresse : il teste la clé de la PILE, qui est une clé distincte de celle de l'original. Les
// faire dépendre l'une de l'autre remettrait exactement le couplage qu'on vient de défaire.
//
// The suspicion recorded here was CONFIRMED and is now fixed: the params fingerprint and layout
// version are required arguments. The self-comparison oracle stays, because it tests the STACK's
// key, which is a different key from the original's.
{
FStrateGenerationParams P2 = P;
P2.RoomSpacing = P.RoomSpacing * 0.6f; // une autre disposition de salles
@@ -1059,39 +1083,276 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
}
//=========================================================================
// 4. LE VERDICT DE BOÎTE — attendu NUL, et c'est le point
// 4. LE VERDICT DE BOÎTE — plus attendu nul, et CHAQUE VERDICT EST BRUTE-FORCÉ
//=========================================================================
// ⚠️ CE BLOC A CHANGÉ DE NATURE LE 2026-07-28, ET IL FAUT SAVOIR POURQUOI.
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que `FRoomGraphSource::EffectOverBox`
// rendait `Both` inconditionnellement : « zéro » était alors une description honnête de l'état
// du portage. Depuis que la source répond SPATIALEMENT, asserter zéro reviendrait à interdire
// le gain qu'on vient de construire — et pire, ça transformerait le test en gardien du bug.
//
// Ce qui le remplace n'est PAS « on enlève l'assertion » : c'est l'assertion qui compte
// vraiment, la SOUNDNESS. Un verdict faux ne se voit pas — pas de géométrie, **pas de
// collision** — jusqu'à ce qu'un joueur traverse le sol. Donc chaque tuile déclarée prouvée est
// ré-évaluée voxel par voxel, et le test échoue si UN seul échantillon contredit le verdict.
// Le nombre de tuiles prouvées, lui, est REPORTÉ, pas asserté : c'est une mesure, pas un
// contrat (la leçon « coverage is a number, not a boolean »).
//
// Was: assert zero proved. That was honest while the source answered Both unconditionally; it
// would now forbid the very gain this change makes. What replaces it is the assertion that
// actually matters — every proved tile is brute-forced voxel by voxel, because a false verdict
// means no geometry and NO COLLISION until a player falls through it.
// ⚠️ UNE SEULE DÉFINITION DU BALAYAGE, DEUX MONDES. Voir le commentaire d'appel plus bas :
// la densité de la fixture rend ce verdict STRUCTURELLEMENT impossible, donc mesurer sur elle
// seule ne dit rien de la production. Copier-coller le balayage aurait donné deux critères qui
// divergent ; c'est un paramètre, pas un doublon.
auto RunTileScan = [&](const FVoxelOpStack& S, const FStrateGenerationParams& TP,
const FVoxelOpContext& TCtx, const TCHAR* Label)
{
int32 NumProved = 0, NumMixed = 0;
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
int32 NumBruteSamples = 0, NumViolations = 0;
float WorstViolation = 0.0f;
TMap<FString, int32> SolidKillerCounts;
int32 NumRoomKilled = 0, NumTilesAwayFromSpine = 0;
// ±320 voxels = 4 x RoomSpacing. Hors de la boucle : la ligne de rapport en a besoin, et
// une étendue d'échantillonnage qu'on ne peut pas citer est une étendue qu'on ne surveille pas.
const int32 SpanCells = 40;
const int32 SpanVoxelsReported = SpanCells * 8; // Extent = Step * Cells = 1 * 8
int32 TilesHitByRooms = 0, TilesHitByTunnels = 0, TilesHitByPits = 0, TilesHitByChimneys = 0;
int32 SumHitRooms = 0, SumNumRooms = 0, SumHitTunnels = 0, SumNumTunnels = 0;
int32 SumHitRoomsNoWarp = 0, SumHitTunnelsNoWarp = 0;
float LastWarpDilation = 0.0f;
FRandomStream Rng(97531);
for (int32 t = 0; t < 40; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
// ⚠️ L'ÉTENDUE XY ÉTAIT ±32 VOXELS, ET C'EST CE QUI RENDAIT CE BLOC INEXPLOITABLE.
// `RandRange(-4, 4) * 8` échantillonnait 40 tuiles dans un cube de ±32 voxels autour de
// (0,0) — c'est-à-dire l'endroit le PLUS creusé du monde entier, et de loin :
// • `RoomSpacing = 80`, donc ±32 ne couvre même pas la moitié d'UNE cellule de salle ;
// • `OriginRoomRadius = 20` garantit une grosse salle exactement à (0,0), de rayon de
// cull `max(20·1.5, 8) + 3·4 = 42` — qui avale la quasi-totalité de la fenêtre ;
// • la spine (0,0) descend précisément là.
// La mesure « 4.9 salles sur 7.2 atteignent la boîte » ne décrivait donc pas la densité
// de grottes du monde, elle décrivait le hub de la spine. Aucune conclusion sur la
// prouvabilité du roc profond ne pouvait sortir de cet échantillon.
//
// ⚠️ CE N'EST PAS « ÉLARGIR JUSQU'À CE QUE ÇA PASSE ». Le verdict de chaque tuile reste
// brute-forcé voxel par voxel juste en dessous : un échantillonneur plus large qui
// produirait un verdict FAUX échoue exactement comme avant. On corrige ce que la mesure
// REGARDE, pas ce qu'elle exige.
//
// The XY extent was ±32 voxels around (0,0) -- with RoomSpacing = 80 and a guaranteed
// OriginRoomRadius = 20 room at the origin, that samples the single most cave-dense spot
// in the world and says nothing about deep rock. Widening changes what the measurement
// LOOKS AT, not what it demands: every verdict is still brute-forced below.
const FIntVector Origin(
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
// Combien de tuiles échappent vraiment au hub de la spine : sans ce compte, un futur
// resserrement de l'étendue redeviendrait invisible.
if (FMath::Square((float)Origin.X) + FMath::Square((float)Origin.Y)
> FMath::Square(3.0f * TP.OriginRoomRadius))
{
++NumTilesAwayFromSpine;
}
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
if (Stack.ClassifyBox(Box, Ctx) == EVoxelTileClass::Mixed) { ++NumMixed; }
else { ++NumProved; }
// ATTRIBUTION — le même pliage, mais il dit QUI tue chaque hypothèse. Le premier build
// de l'`EffectOverBox` spatial est revenu vert avec 0 tuile prouvée, et le rapport ne
// savait nommer aucun coupable : les deux causes que la mise en garde proposait étaient
// toutes les deux fausses, la vraie étant un troisième opérateur. On ne redevine pas.
int32 SolidKiller = INDEX_NONE, AirKiller = INDEX_NONE;
const EVoxelTileClass Verdict = S.ClassifyBoxAttributed(Box, TCtx, SolidKiller, AirKiller);
if (SolidKiller != INDEX_NONE)
{
const FString KillerName = S.GetOpDebugName(SolidKiller);
SolidKillerCounts.FindOrAdd(KillerName)++;
// VENTILATION PAR CLASSE DE PRIMITIVE. Quand c'est la source de salles qui tue,
// « les tuiles traversent une grotte » n'est pas une réponse : les salles, les
// tunnels, les pits et les cheminées ont chacun leur borne, de finesse très
// différente (une sphère englobante de capsule est un très mauvais tunnel). On lit
// ce que l'opérateur a RÉELLEMENT calculé plutôt que de rejouer le critère ici.
if (KillerName == TEXT("RoomGraphSource"))
{
const VoxelDensityOps::FRoomBoxDiagnostic D =
VoxelDensityOps::GetLastRoomBoxDiagnostic();
++NumRoomKilled;
if (D.HitRooms > 0) { ++TilesHitByRooms; }
if (D.HitTunnels > 0) { ++TilesHitByTunnels; }
if (D.HitPits > 0) { ++TilesHitByPits; }
if (D.HitChimneys > 0) { ++TilesHitByChimneys; }
SumHitRooms += D.HitRooms; SumNumRooms += D.NumRooms;
SumHitTunnels += D.HitTunnels; SumNumTunnels += D.NumTunnels;
SumHitRoomsNoWarp += D.HitRoomsNoWarp;
SumHitTunnelsNoWarp += D.HitTunnelsNoWarp;
LastWarpDilation = D.WarpDilation;
}
}
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved;
const bool bClaimSolid = (Verdict == EVoxelTileClass::AllSolid);
if (bClaimSolid) { ++NumSolid; } else { ++NumAir; }
// BRUTE FORCE — la boîte entière, pas un échantillonnage. `EvalMC` rend la convention
// du mesher (négatif = solide), donc « tout solide » veut dire qu'aucun échantillon
// n'est du côté air. On teste le SIGNE, c'est-à-dire l'existence d'une traversée
// d'isosurface : c'est exactement la propriété sur laquelle le mesher est sauté.
for (float Z = (float)Box.Min.Z; Z <= (float)Box.Max.Z; Z += 1.0f)
for (float Y = (float)Box.Min.Y; Y <= (float)Box.Max.Y; Y += 1.0f)
for (float X = (float)Box.Min.X; X <= (float)Box.Max.X; X += 1.0f)
{
const float D = S.EvalMC(X, Y, Z);
++NumBruteSamples;
const bool bViolates = bClaimSolid ? (D > 0.0f) : (D < 0.0f);
if (bViolates)
{
++NumViolations;
WorstViolation = FMath::Max(WorstViolation, FMath::Abs(D));
}
}
}
AddInfo(FString::Printf(
TEXT("Box verdicts over 40 TunnelNetwork tiles: %d proved, %d Mixed. %d proved is the ")
TEXT("EXPECTED result at stage A and not a defect: the room source answers Both (its ")
TEXT("bounds live in the SDF cache, which it would have to build for the queried box), ")
TEXT("and the worm source answers CarveOnly EVERYWHERE because a fielded noise carve ")
TEXT("has no spatial bound at all. Recovering these needs the numeric amplitude cap in ")
TEXT("OPSTACK-DECOMPOSITION 0.2 -- the largest single perf item in the whole plan, and ")
TEXT("the reason this archetype currently skips zero tiles."),
NumProved, NumMixed, NumProved));
TEXT("[%s] Tile sampler: 40 tiles of 10 voxels, XY drawn from +/-%d voxels (= %.1f x ")
TEXT("RoomSpacing %.0f), Z across the strate; %d of 40 landed further than 3 x ")
TEXT("OriginRoomRadius from the (0,0) spine. THIS LINE EXISTS BECAUSE THE SAMPLER WAS ")
TEXT("THE BUG ONCE: it drew XY from +/-32 voxels, i.e. entirely inside the origin room's ")
TEXT("cull sphere, so every number below described the spine hub rather than the world. ")
TEXT("If the last count is low, nothing below says anything about deep rock."),
Label, SpanVoxelsReported, (float)SpanVoxelsReported / FMath::Max(TP.RoomSpacing, 1.0f),
TP.RoomSpacing, NumTilesAwayFromSpine));
TestEqual(TEXT("stage A emits no unsound verdict (it emits none at all)"), NumProved, 0);
AddInfo(FString::Printf(
TEXT("[%s] Box verdicts over 40 TunnelNetwork tiles: %d proved (%d AllSolid, %d AllAir), ")
TEXT("%d Mixed -- brute-forced over %d voxels, %d violations. This number was 0 proved / ")
TEXT("40 Mixed until FRoomGraphSource::EffectOverBox learned to answer spatially, and it ")
TEXT("is the single largest perf item of the whole plan (OPSTACK-DECOMPOSITION 0.2): a ")
TEXT("proved tile skips GenerateMesh entirely, so it trades one BuildChunkCache against ")
TEXT("30000+ density evaluations. Read the PROVED count as a measurement, never as a ")
TEXT("contract -- what is asserted below is that none of them is WRONG, because a false ")
TEXT("verdict leaves no geometry and no collision behind it."),
Label, NumProved, NumSolid, NumAir, NumMixed, NumBruteSamples, NumViolations));
// QUI TUE `AllSolid`, ET COMBIEN DE FOIS. Toujours imprimé, pas seulement en cas d'échec :
// c'est aussi la ligne qui dit, quand des tuiles SONT prouvées, ce qui bloque les autres.
{
SolidKillerCounts.ValueSort([](int32 A, int32 B) { return A > B; });
FString Breakdown;
for (const TPair<FString, int32>& Kv : SolidKillerCounts)
{
if (!Breakdown.IsEmpty()) { Breakdown += TEXT(", "); }
Breakdown += FString::Printf(TEXT("%s x%d"), *Kv.Key, Kv.Value);
}
if (Breakdown.IsEmpty()) { Breakdown = TEXT("nothing -- AllSolid survived every tile"); }
AddInfo(FString::Printf(
TEXT("[%s] AllSolid killed by: %s. Names the first operator to kill the ")
TEXT("hypothesis, counted per tile. (Why this line exists, and the wrong guesses ")
TEXT("that preceded it, live in OPSTACK-PROGRESS and are deliberately NOT ")
TEXT("repeated here: a diagnostic that carries narrative gets its live numbers ")
TEXT("read as history and its history read as live numbers.)"),
Label, *Breakdown));
if (NumRoomKilled > 0)
{
AddInfo(FString::Printf(
TEXT("[%s] ...and when RoomGraphSource is the killer (%d tiles), WHICH primitive class ")
TEXT("reaches the box: rooms %d, tunnels %d, pits %d, chimneys %d (tiles, not ")
TEXT("primitives -- a tile can be hit by several). Averages per killed tile: ")
TEXT("%.1f of %.1f rooms reach, %.1f of %.1f tunnels reach. ")
TEXT("WARP SHARE: the query box is dilated by +/-%.1f voxels per axis for the ")
TEXT("warped room/tunnel query; with that dilation set to ZERO the same tests ")
TEXT("would keep only %.1f rooms and %.1f tunnels. The gap between those ")
TEXT("pairs is blocking caused by MY BOX rather than by geometry -- the term ")
TEXT("that went unmeasured while three rounds of tightening happened around ")
TEXT("it. If the gap dominates, tighten the warp bound, not the primitives."),
Label, NumRoomKilled, TilesHitByRooms, TilesHitByTunnels, TilesHitByPits, TilesHitByChimneys,
(float)SumHitRooms / (float)NumRoomKilled, (float)SumNumRooms / (float)NumRoomKilled,
(float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled,
LastWarpDilation,
(float)SumHitRoomsNoWarp / (float)NumRoomKilled,
(float)SumHitTunnelsNoWarp / (float)NumRoomKilled));
}
}
if (NumProved == 0)
{
AddWarning(FString::Printf(
TEXT("[%s] No tile was proved, so the brute force verified nothing -- it has no ")
TEXT("verdict to contradict. Do NOT re-derive the cause: read the two lines above, ")
TEXT("which name the operator and then the primitive class. ⚠️ On the DENSE ")
TEXT("FIXTURE this is the EXPECTED and correct result, not a defect: room cull ")
TEXT("radius (1.5R + 3*SDFBlendRadius, mean ~42) equals RoomSpacing 42 at 85%% ")
TEXT("occupancy, so cull spheres cover that world ~3.6x over and no box can be ")
TEXT("outside all of them. It is the 'production defaults' run that answers ")
TEXT("whether real worlds have skippable rock."),
Label));
}
TestEqual(FString::Printf(
TEXT("[%s] every proved TunnelNetwork tile survives brute force (worst ")
TEXT("|density| on the wrong side: %.9g)"), Label, WorstViolation),
NumViolations, 0);
};
//-------------------------------------------------------------------------
// LES DEUX MONDES, ET POURQUOI IL EN FAUT DEUX
//-------------------------------------------------------------------------
// ⚠️ LA FIXTURE REND CE VERDICT STRUCTURELLEMENT IMPOSSIBLE, ET CE N'EST PAS UN DÉFAUT DE LA
// FIXTURE. `EnableTunnelFeatures` densifie délibérément (`RoomSpacing` 80 → 42,
// `RoomDensity` 0.35 → 0.85) parce qu'aux défauts le premier run n'avait que 1,1 % des
// échantillons en grotte — l'équivalence comparait du roc plein à du roc plein. Cette
// densification est ce qui rend le contrôle 1 SIGNIFIANT.
//
// Mais elle est exactement ANTAGONISTE de la prouvabilité, et l'arithmétique le dit sans
// ambiguïté : le rayon de cull d'une salle vaut `max(R·1.5, R·HeightRatio) + 3·SDFBlendRadius`,
// soit `1.5R + 12` ⇒ entre 27 et 57 pour `R ∈ [10, 30]`, moyenne ≈ 42 — c'est-à-dire
// **exactement le pas du réseau**, à 85 % d'occupation. Des sphères de cull de rayon égal au pas
// du réseau recouvrent l'espace ~3,6 fois. **Aucune boîte de ce monde ne peut être hors de
// toutes les sphères de cull.** Le « 6.3 salles sur 8.3 atteignent la boîte » mesuré est
// exactement ça, et élargir l'échantillonneur n'y a rien changé (39 tuiles sur 40 étaient déjà
// loin de la spine, et le compte est resté 40 sur 40).
//
// Donc on mesure les DEUX : la fixture dense, où `0 prouvé` est le résultat CORRECT et
// informatif (le gain disparaît quand les grottes saturent), et un jeu de params aux DÉFAUTS
// DE PRODUCTION, qui est le monde dont la question « combien de tuiles peut-on sauter » parle
// réellement. Ce n'est pas « élargir jusqu'à ce que ça passe » : les deux sont rapportés, les
// deux sont brute-forcés, et le dense DOIT continuer à rendre ~0.
//
// Two worlds on purpose: the fixture is deliberately densified so the equivalence check means
// something, and that same densification makes tile-proving structurally impossible (room cull
// radius ~= the lattice spacing, at 85% occupancy). Both are measured and both are brute-forced;
// the dense one reporting ~0 is the correct answer, not a failure.
RunTileScan(Stack, P, Ctx, TEXT("dense fixture"));
{
FStrateGenerationParams SparseP = P;
SparseP.RoomSpacing = 80.0f; // le défaut d'`UVoxelStrateDefinition`
SparseP.RoomDensity = 0.35f; // idem — voir `EnableTunnelFeatures`
FVoxelOpStack SparseStack;
VoxelDensityOps::BuildTunnelNetworkStack(SparseStack, SparseP, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
SparseStack.PrepareChunk(Ctx);
// ⚠️ Les deux piles partagent les caches `thread_local` de `FRoomGraphSource`. C'est VOULU,
// et c'est exactement ce que le contrôle 3 vérifie : l'empreinte de params est dans la clé,
// donc l'une ne peut pas se servir les salles de l'autre. Le jour où ce contrôle tombe,
// cette ligne-ci devient fausse en même temps — elles se surveillent mutuellement.
RunTileScan(SparseStack, SparseP, Ctx, TEXT("production defaults"));
}
//=========================================================================
@@ -1172,7 +1433,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
for (int32 i = 0; i < UWSamples; ++i)
{
const float X = (float)UWPoints[i].X, Y = (float)UWPoints[i].Y, Z = (float)UWPoints[i].Z;
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP);
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP, VF_FP(UP), 0);
const float New = UWStack.EvalMC(X, Y, Z);
if (Z > UWInnerBot && Z < UWInnerTop && Old >= 0.0f) { ++UWInCave; }
if (!BitEqual(Old, New))
+649 -38
View File
@@ -79,6 +79,33 @@ namespace
// STAGE B5 DECISION: repeated early-out in each op, NOT a scoping container — the stack is a flat
// list that ClassifyBox folds op by op, and an op that only exists inside a container is not
// composable. Cost stated honestly: twelve predictable compares instead of one branch.
/** Distance d'un point à un SEGMENT (pas à une droite). Écrite ici plutôt que prise dans
* `FMath` : cinq lignes, aucune ambiguïté d'API, et elle sert une borne de correction le
* genre d'endroit « je crois que cette fonction fait ça » n'est pas suffisant. */
FORCEINLINE float VF_DistPointSegment(const FVector& P, const FVector& A, const FVector& B)
{
const FVector AB = B - A;
const double LenSq = FVector::DotProduct(AB, AB);
const double T = (LenSq > KINDA_SMALL_NUMBER)
? FMath::Clamp(FVector::DotProduct(P - A, AB) / LenSq, 0.0, 1.0)
: 0.0;
return (float)FVector::Dist(P, A + AB * T);
}
/** La même chose en 2D, pour les connecteurs de puits : ce sont des capsules HORIZONTALES, donc
* Z se teste exactement et seul XY demande une distance point-segment. */
FORCEINLINE float VF_DistPointSegment2D(const FVector2D& P, const FVector2D& A, const FVector2D& B)
{
const FVector2D AB = B - A;
const double LenSq = (double)AB.X * AB.X + (double)AB.Y * AB.Y;
const double T = (LenSq > KINDA_SMALL_NUMBER)
? FMath::Clamp(((double)(P.X - A.X) * AB.X + (double)(P.Y - A.Y) * AB.Y) / LenSq, 0.0, 1.0)
: 0.0;
const double DX = (double)P.X - ((double)A.X + AB.X * T);
const double DY = (double)P.Y - ((double)A.Y + AB.Y * T);
return (float)FMath::Sqrt(DX * DX + DY * DY);
}
FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius)
{
// Transcrit tel quel, ordre des comparaisons compris :
@@ -144,6 +171,8 @@ namespace
return FMath::Abs(Value);
}
const TCHAR* DebugName() const override { return TEXT("ConstantFieldSource"); }
private:
float Value;
};
@@ -1119,6 +1148,8 @@ namespace
return EVoxelOpEffect::Identity; // la source a répondu pour la paire
}
const TCHAR* DebugName() const override { return TEXT("SdfConvertOp"); }
private:
float Blend, BaseDensity, Sign, MinDivisor;
};
@@ -1159,6 +1190,8 @@ namespace
return EVoxelOpEffect::CarveOnly;
}
const TCHAR* DebugName() const override { return TEXT("OriginSpineOp"); }
private:
float TopZ, BotZ, Seal, Base, Radius;
};
@@ -1226,6 +1259,8 @@ namespace
return EVoxelOpEffect::FillOnly;
}
const TCHAR* DebugName() const override { return TEXT("BoundarySealOp"); }
private:
float TopZ, BotZ, Thickness, Base;
};
@@ -1258,6 +1293,8 @@ namespace
? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
const TCHAR* DebugName() const override { return TEXT("PassageCarveOp"); }
private:
const UVoxelStrateManager* Manager;
float Base, Seal;
@@ -1361,20 +1398,88 @@ namespace
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; }
}
// ⚠️ Les connecteurs ne sont PAS testés ici, et c'est délibérément conservatif dans le
// mauvais sens si on n'y prend pas garde : un connecteur ne peut exister qu'entre deux
// puits d'un voisinage, donc si AUCUN puits n'atteint la boîte élargie de `Spacing*1.6`
// (la portée max d'une paire), aucun connecteur ne peut l'atteindre non plus.
const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad);
const int32 KX0 = FMath::FloorToInt((float)ConnBox.Min.X / Spacing);
const int32 KX1 = FMath::FloorToInt((float)ConnBox.Max.X / Spacing);
const int32 KY0 = FMath::FloorToInt((float)ConnBox.Min.Y / Spacing);
const int32 KY1 = FMath::FloorToInt((float)ConnBox.Max.Y / Spacing);
for (int32 cy = KY0; cy <= KY1; ++cy)
for (int32 cx = KX0; cx <= KX1; ++cx)
//-----------------------------------------------------------------
// LES CONNECTEURS — LES VRAIES CAPSULES, PLUS « un puits existe dans le coin »
//-----------------------------------------------------------------
// ⚠️ CE BLOC RENDAIT `CarveOnly` DÈS QU'UN PUITS **EXISTAIT** dans la boîte élargie de
// `Spacing·1.6 + Pad`, sans jamais regarder un connecteur. Avec les défauts
// (`ShaftSpacing = 55`, `ShaftDensity = 0.6`) cette boîte élargie couvre ~4×4 cellules,
// donc une dizaine de puits : la condition était vraie PARTOUT et l'archétype prouvait
// 0 tuile sur 60. Conservatif, jamais faux — et totalement stérile.
//
// Ce qu'on fait à la place : reconstruire les connecteurs comme `GetCells` les
// construit, et tester la capsule réelle.
//
// ⚠️ POURQUOI L'ÉNUMÉRATION EST UN SUR-ENSEMBLE (donc sûre). `Eval` lit les connecteurs
// du voisinage 3×3 de la cellule DE SA REQUÊTE. Une paire visible depuis un point de la
// boîte a donc ses deux puits dans un même 3×3 centré sur une cellule que la boîte
// touche ⇒ les deux sont dans [cellules de la boîte] ± 1, qui est exactement la plage
// balayée ici. On peut produire des paires que personne ne voit jamais : c'est du
// `CarveOnly` en trop, pas un trou.
//
// ⚠️ ET POURQUOI L'ORDRE (A,B) EST LE MÊME QUE CELUI DE `GetCells`. Le hash de paire est
// pris sur (A puis B) dans l'ordre d'insertion, et `GetCells` insère en `(dy, dx)`,
// c'est-à-dire en balayage ligne par ligne. On balaie ici `(cy, cx)`, le même ordre — et
// un ordre ligne par ligne restreint à une sous-grille garde l'ordre relatif de deux
// cellules. Donc la même paire reçoit le même `VoxelHash::Pair`, sans supposer que
// celui-ci soit symétrique.
//
// Was: return CarveOnly as soon as any shaft EXISTED within Spacing*1.6 + Pad, which at
// ShaftSpacing 55 / ShaftDensity 0.6 is true everywhere -- 0 of 60 tiles proved. Now it
// rebuilds the connectors the way GetCells does and tests the real capsule. The pair
// enumeration is a superset (safe), and the row-major cell order reproduces GetCells'
// insertion order, so each pair gets the same hash without assuming Pair() is symmetric.
if (P.CrossConnectChance > 0.0f)
{
const int32 QX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1;
const int32 QX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1;
const int32 QY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1;
const int32 QY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1;
TArray<FShaft, TInlineAllocator<32>> Near;
for (int32 cy = QY0; cy <= QY1; ++cy)
for (int32 cx = QX0; cx <= QX1; ++cx)
{
FShaft Sh;
if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent
if (RollShaft(cx, cy, Sh)) { Near.Add(Sh); }
}
const float ConnReach = P.ConnectorRadius + ExtraReach;
const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness;
// Z est traité EXACTEMENT (le connecteur est une capsule horizontale à `Zc`), XY de
// façon conservative. Séparer les deux est bien plus serré qu'une demi-diagonale 3D.
const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z;
const FVector2D CtrXY(0.5f * (float)(VoxelBox.Min.X + VoxelBox.Max.X),
0.5f * (float)(VoxelBox.Min.Y + VoxelBox.Max.Y));
const float HalfDiagXY = 0.5f * FMath::Sqrt(
FMath::Square((float)(VoxelBox.Max.X - VoxelBox.Min.X)) +
FMath::Square((float)(VoxelBox.Max.Y - VoxelBox.Min.Y)));
for (int32 i = 0; i < Near.Num(); ++i)
for (int32 j = i + 1; j < Near.Num(); ++j)
{
const FShaft& A = Near[i];
const FShaft& B = Near[j];
const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y);
if (DSq > FMath::Square(Spacing * 1.6f)) { continue; }
const uint32 PH = VoxelHash::Pair(
FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y),
FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu);
if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) { continue; }
const float Zc = FMath::Lerp(BottomZ, TopZ,
VoxelHash::ToFloat01(VoxelHash::Mix(PH)));
if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { continue; }
const float DistXY = VF_DistPointSegment2D(
CtrXY, FVector2D(A.X, A.Y), FVector2D(B.X, B.Y));
if (DistXY - HalfDiagXY >= ConnReach) { continue; }
return EVoxelOpEffect::CarveOnly;
}
}
return EVoxelOpEffect::Identity;
@@ -2124,27 +2229,447 @@ namespace
InOut.Sdf = CaveSDF;
}
/**
* `Both` POUR L'INSTANT, ET C'EST UNE DETTE ASSUMÉE, PAS UN OUBLI.
*
* Les bornes existent pourtant : `FCachedRoom` / `FCachedTunnel` portent déjà leurs
* `Bound*` (c'est ce dont `§2` dit qu'il rend le bedrock profond prouvable, « le plus gros
* poste de perf de tout le plan »). Ce qui manque, c'est que répondre honnêtement demande de
* consulter le cache donc de le CONSTRUIRE pour la boîte interrogée, sur le thread qui
* interroge, ce qui n'est raisonnable qu'une fois `ClassifyBox` réellement branché dans
* `ClassifyTile` (il ne l'est toujours pas). Rendre `Both` coûte du CPU et ne peut pas faire
* de trou ; rendre le mauvais en ferait un.
*
* Conservative placeholder: the room/tunnel bounds needed for a real answer are already in
* the cache, but answering means building that cache for the queried box, which only pays
* once ClassifyTile actually consumes ClassifyBox. Both is always safe.
*/
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
//---------------------------------------------------------------------
// L'ÉTAT PAR BOÎTE — SÉPARÉ DE `FState`, ET DÉLIBÉRÉMENT
//---------------------------------------------------------------------
// `EffectOverBox` construit un cache pour la boîte INTERROGÉE, qui n'est pas la boîte de
// recherche que `Eval` construit pour le voxel courant. Les faire partager `FState::Cache`
// serait *correct* — la discipline d'invariance de fenêtre de §8.4 garantit qu'un cache bâti
// sur une boîte PLUS LARGE donne le même SDF par voxel — mais ça rendrait `ClassifyTile`
// capable de perturber le cache chaud d'une génération en cours, et un jour quelqu'un
// paierait cette élégance très cher. Un deuxième cache par worker coûte une allocation
// amortie ; on la paie.
//
// Le VERDICT est mémoïsé, et ce n'est pas du confort : `VF_NoCaveOverBox` fait poser la
// question par les DOUZE modificateurs de détail pour la même boîte. Sans mémo, une tuile
// coûterait treize `BuildChunkCache` au lieu d'un.
//
// Second per-worker cache, on purpose: sharing FState::Cache would be sound but would let
// tile classification disturb a live generation's hot cache. The verdict is memoised because
// all twelve detail modifiers ask the same question about the same box.
struct FBoxState
{
return (P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f) ? EVoxelOpEffect::Both
: EVoxelOpEffect::Identity;
FChunkSDFCache Cache;
FBox KeyBox = FBox(ForceInit);
int32 KeyStrate = INT32_MIN;
uint32 KeySeed = 0;
uint32 KeyFingerprint = 0xFFFFFFFFu;
uint32 KeyLayout = 0xFFFFFFFFu;
bool bValid = false;
EVoxelOpEffect Verdict = EVoxelOpEffect::Both;
/** DIAGNOSTIC — combien de primitives de chaque classe atteignent la dernière boîte
* interrogée, et combien le cache en contenait. Lu par les tests via
* `VoxelDensityOps::GetLastRoomBoxDiagnostic`. N'entre dans aucune décision. */
int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0;
int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0;
/** Les mêmes comptes avec une dilatation de warp NULLE, et de combien de voxels la
* boîte est dilatée. C'est la mesure qui manquait pendant trois builds : sans elle,
* « les tunnels bloquent » et « ma boîte est 125x trop grosse » sont indiscernables. */
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
float WarpDilation = 0.0f;
};
static FBoxState& BoxState()
{
thread_local FBoxState S;
return S;
}
/**
* BORNE **PROUVABLE** DE `|Perlin3D|`, ET ELLE N'EST PAS 1.0.
*
* L'en-tête de `VoxelNoise::Perlin3D` annonce « ~[-1,1] (typiquement [-0.7,0.7]) ». Le `~`
* est un aveu : c'est une observation, pas un théorème, et un verdict de boîte fondé sur une
* observation est exactement le genre de trou que ce fichier passe son temps à éviter.
*
* Ce qui EST démontrable, en lisant `GradDot` : il rend `ru + rv` `ru` et `rv` sont des
* composantes de l'offset fractionnaire, donc chacune dans `[-1, 1]` `|GradDot| 2`. La
* valeur finale est une interpolation trilinéaire de huit `GradDot`, et une interpolation
* convexe ne sort jamais de l'enveloppe de ses entrées `|Perlin3D| 2`. (La vraie borne
* de Perlin 3D est `3/2 0.87` ; on ne s'appuie pas dessus, elle dépend du jeu de
* gradients.) Se tromper ici coûte une boîte de recherche un peu plus large, jamais un
* verdict faux : plus large SUR-ensemble de primitives `Identity` plus rare.
*
* **CORRIGÉ DE 2.0 À 1.5 LE 2026-07-28, ET CETTE CONSTANTE ÉTAIT LE TERME DOMINANT DE
* TOUTE LA FONCTION PENDANT TROIS BUILDS.** À lire avant d'y retoucher.
*
* La dilatation vaut `CaveWarpStrength · VOXEL_NOISE_SCALE · CETTE BORNE`. Avec les défauts
* (`CaveWarpStrength = 8`, `SCALE = 1.25`) elle valait **20 voxels** appliquée des deux
* côtés de chaque axe d'une tuile de **10 voxels**, soit une boîte de requête de 50 voxels,
* **125× le volume de la tuile**. Trois passes de resserrement (le ver, les colonnes,
* l'échantillonneur, la disjonction des tunnels) ont é faites AUTOUR de ce terme sans que
* personne ne le mesure. Le test des tunnels, annoncé « un ordre de grandeur plus serré », ne
* gagnait en pratique que 25 % exactement parce que `BoxHalfDiag` était dominé par cette
* dilatation et non par la géométrie.
*
* ET LE RESTE DU PLUGIN N'A JAMAIS É AUSSI PRUDENT : `BuildChunkCache` est appelée avec
* `Expansion = CaveWarpStrength + 2` (ici comme dans `GetDensityWithParams`), ce qui suppose
* `|Perlin3D| · SCALE CaveWarpStrength`, donc `|Perlin3D| 0.8`. Le code qui tourne en
* production depuis toujours parie déjà -dessus. Prendre 2.0 était 2,5× plus conservateur
* que l'hypothèse dont dépend déjà la correction du cache.
*
* LA BORNE 1.5, DÉMONTRÉE (et non observée) :
* 1. `GradDot` rend `±u ± v` `u` et `v` sont deux composantes **distinctes** de l'offset
* du coin vérifié sur les quatre branches du `switch` de hash, pas supposé.
* 2. Pour l'axe x : les coins à `i=0` portent le poids `(1su)` et l'offset `fx`, ceux à
* `i=1` le poids `su` et l'offset `1fx`. Donc `Σ_c w_c·|dx_c| = (1su)·fx + su·(1fx)`,
* dont le maximum sur `[0,1]` vaut **0.5** (atteint en `fx = 0.5`, `su = 0.5` ;
* 0.302 en 0.25 comme en 0.75).
* 3. `|Perlin| Σ_c w_c(|a_c| + |b_c|) S_x + S_y + S_z 3 × 0.5 = 1.5.`
* (Le vrai maximum est plus bas encore seuls DEUX axes apparaissent par coin mais 1.5
* est la borne qui se démontre sans analyse de cas sur les hash. `3/2 0.87`, la borne
* classique de Perlin 3D, dépend du jeu de gradients : on ne s'appuie pas dessus.)
*
* Was 2.0, and that constant was the dominant term of this whole function for three builds:
* it inflated a 10-voxel tile into a 50-voxel query box (125x the volume), which is why the
* "order of magnitude tighter" tunnel test only won 25%. The rest of the plugin has always
* assumed |Perlin3D| <= 0.8 (BuildChunkCache's Expansion = CaveWarpStrength + 2). 1.5 is
* PROVED above from GradDot's two-distinct-axes form and the per-axis weighted bound of 0.5.
*/
static constexpr float PerlinAbsBound = 1.5f;
/**
* LA RÉPONSE SPATIALE. La dette annoncée ici pendant tout le portage est payée.
*
* Ce que ça débloque, en un mot : `FSdfConvertOp` renvoie déjà `Identity` (« la source a
* répondu pour la paire ») et les douze modificateurs de détail héritent de ce verdict par
* `VF_NoCaveOverBox`. Le jour cette fonction rend `Identity` pour une boîte, **quatorze
* opérateurs deviennent l'identité d'un coup** et la tuile est prouvable c'est pour ça que
* le câblage a é posé à UN endroit et pas treize.
*
* LE CRITÈRE **UNE PRIMITIVE NE COMPTE PAS SI ELLE RATE SON CULL *OU* SI SON SDF RESTE
* AU-DESSUS DU SEUIL `T`.** Une disjonction, pas une seule règle, et chaque branche gagne sur
* une classe différente. Le détail de `T` et sa condition de validité sont dans la note
* « LE SEUIL T » à l'intérieur de la fonction la lire avant toute modification.
*
* branche CULL `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive
* qui SURVIT à son cull par voxel. Aucune survivante `Sdf` reste `FLT_MAX`. C'est la
* même inégalité que le cull, élevée du point à la boîte. **Meilleure pour les salles** :
* leur cull (`Rmax + 3K`) est plus serré que le seuil (`Rmax + T + K`).
* branche SEUIL une primitive peut survivre à son cull et rester malgré tout trop loin
* pour qu'un consommateur s'allume. **Meilleure pour les tunnels**, dont le cull est la
* sphère englobante d'une capsule : rayon ~107 pour un tube de rayon 7 long de 200.
*
* Mélanger les deux est sûr : les primitives de la branche cull ne contribuent RIEN, les
* autres sont toutes `T + K`, donc le pli vaut `T` (voir la saturation de `SmoothMin`),
* et les trois consommateurs sont éteints. Une seule raison d'échouer, dans les deux cas.
*
* LES TROIS CHOSES QUI RENDENT LE TEST CONSERVATIF DU BON CÔTÉ :
* 1. le warp déplace la coordonnée de REQUÊTE, donc la boîte est dilatée de sa borne
* prouvable avant d'être confrontée aux salles et aux tunnels ;
* 2. les pits et les cheminées sont interrogés en coordonnées RÉELLES (voir `Eval`), donc
* ils sont confrontés à la boîte NON dilatée la dilater serait juste plus prudent, ne
* pas la dilater pour eux serait faux ;
* 3. la boîte de recherche du cache est PLUS LARGE que celle de `Eval`, ce qui donne un
* SUR-ensemble de primitives : si rien n'atteint la boîte ici, rien ne l'atteint -bas.
*
* The criterion is the per-voxel cull lifted from point to box: if no cached primitive can
* survive its own cull anywhere in the box, Sdf stays FLT_MAX across the whole box and the
* source with the converter and all twelve modifiers behind it is the identity.
*/
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return EVoxelOpEffect::Identity; }
//-----------------------------------------------------------------
// 1. LA BOÎTE DOIT TENIR DANS UNE SEULE STRATE, PARAMS COMPRIS
//-----------------------------------------------------------------
// ⚠️ C'est la moitié « boîte » de la garde d'AUDIT §C2. `Eval` résout l'index de strate
// et le pool d'ops PAR CHUNK ; une boîte qui traverse une frontière verrait donc deux
// graphes de salles différents, et un cache unique n'en représenterait aucun. On ne
// devine pas lequel : on rend `Both`. Ça arrive au plus sur les tuiles de bord.
const int32 CZ0 = FMath::FloorToInt((float)VoxelBox.Min.Z / (float)CHUNK_SIZE);
const int32 CZ1 = FMath::FloorToInt((float)VoxelBox.Max.Z / (float)CHUNK_SIZE);
const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / (float)CHUNK_SIZE);
const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / (float)CHUNK_SIZE);
const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / (float)CHUNK_SIZE);
const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / (float)CHUNK_SIZE);
// Une boîte qui couvre des dizaines de chunks n'est de toute façon jamais prouvable ;
// la borne évite qu'un appelant futur transforme ce test en boucle coûteuse.
if ((int64)(CX1 - CX0 + 1) * (CY1 - CY0 + 1) * (CZ1 - CZ0 + 1) > 64)
{
return EVoxelOpEffect::Both;
}
int32 StrateIdx = 0;
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr;
if (Manager)
{
StrateIdx = Manager->GetStrateIndex(((float)CZ0 + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
for (int32 CZ = CZ0 + 1; CZ <= CZ1; ++CZ)
{
if (Manager->GetStrateIndex(((float)CZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE) != StrateIdx)
{
return EVoxelOpEffect::Both;
}
}
// ⚠️ LE POOL D'OPS FAIT PARTIE DE LA GÉOMÉTRIE, contrairement à ce qu'on croit en
// lisant `FCachedRoom` : `BuildChunkCache` s'en sert pour cuire les PITS et les
// CHEMINÉES (`OpParams` y lit `PitDensity`, `PitMinRadius`…). Passer `nullptr`
// « puisque la forme des salles n'en dépend pas » sous-bornerait le cache et
// pourrait rendre `Identity` au-dessus d'un pit réel. Un trou, exactement.
UVoxelStrateDefinition* Def0 = Manager->GetStrateForChunk(FIntVector(CX0, CY0, CZ0));
for (int32 CZ = CZ0; CZ <= CZ1; ++CZ)
for (int32 CY = CY0; CY <= CY1; ++CY)
for (int32 CX = CX0; CX <= CX1; ++CX)
{
if (Manager->GetStrateForChunk(FIntVector(CX, CY, CZ)) != Def0)
{
return EVoxelOpEffect::Both;
}
}
if (Def0) { TerrainOps = &Def0->TerrainOperations; }
}
//-----------------------------------------------------------------
// 2. LE MÉMO — clé complète (§C2 : jamais de clé sans params ni LayoutVersion)
//-----------------------------------------------------------------
// `Ctx.LayoutVersion` plutôt que le membre rempli par `PrepareChunk` : rien ne garantit
// qu'un appelant de `ClassifyBox` ait ouvert un chunk, et une version périmée dans une
// clé de cache est précisément la régression du 2026-07-27.
const uint32 LV = Ctx.LayoutVersion;
FBoxState& B = BoxState();
if (B.bValid && B.KeyBox == VoxelBox && B.KeyStrate == StrateIdx
&& B.KeySeed == SeedU && B.KeyFingerprint == ParamsFingerprint && B.KeyLayout == LV)
{
return B.Verdict;
}
//-----------------------------------------------------------------
// 3. LE CACHE POUR LA BOÎTE INTERROGÉE
//-----------------------------------------------------------------
const float Warp = (P.CaveWarpStrength > 0.0f)
? P.CaveWarpStrength * VOXEL_NOISE_SCALE * PerlinAbsBound
: 0.0f;
// `+ 2` : la même marge de gradient que la boîte de recherche de `Eval`.
VoxelCaveMorphology::BuildChunkCache(
B.Cache,
(float)VoxelBox.Min.X - Warp - 2.0f, (float)VoxelBox.Min.Y - Warp - 2.0f,
(float)VoxelBox.Max.X + Warp + 2.0f, (float)VoxelBox.Max.Y + Warp + 2.0f,
P, SeedU, StrateIdx, TerrainOps);
//-----------------------------------------------------------------
// 4. LE CULL PAR VOXEL, ÉLEVÉ DU POINT À LA BOÎTE
//-----------------------------------------------------------------
// Espace de REQUÊTE des salles et des tunnels : XY dilaté du warp, Z passé par `EffZ`
// (monotone croissante tant que `VerticalScale > 0`, donc min et max se conservent)
// puis dilaté du warp lui aussi — `Eval` warpe bien les trois axes.
const FVector QMin((float)VoxelBox.Min.X - Warp,
(float)VoxelBox.Min.Y - Warp,
EffZ((float)VoxelBox.Min.Z) - Warp);
const FVector QMax((float)VoxelBox.Max.X + Warp,
(float)VoxelBox.Max.Y + Warp,
EffZ((float)VoxelBox.Max.Z) + Warp);
auto SphereHitsBox = [](const FVector& C, float RSq, const FVector& Mn, const FVector& Mx)
{
const float dx = FMath::Max3((float)(Mn.X - C.X), 0.0f, (float)(C.X - Mx.X));
const float dy = FMath::Max3((float)(Mn.Y - C.Y), 0.0f, (float)(C.Y - Mx.Y));
const float dz = FMath::Max3((float)(Mn.Z - C.Z), 0.0f, (float)(C.Z - Mx.Z));
return (dx * dx + dy * dy + dz * dz) <= RSq;
};
// Pits, cheminées et colonnes : coordonnées RÉELLES, donc boîte NON dilatée.
const float RMinX = (float)VoxelBox.Min.X, RMaxX = (float)VoxelBox.Max.X;
const float RMinY = (float)VoxelBox.Min.Y, RMaxY = (float)VoxelBox.Max.Y;
const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z;
auto CircleHitsBoxXY = [&](float CX, float CY, float RSq)
{
const float dx = FMath::Max3(RMinX - CX, 0.0f, CX - RMaxX);
const float dy = FMath::Max3(RMinY - CY, 0.0f, CY - RMaxY);
return (dx * dx + dy * dy) <= RSq;
};
//-----------------------------------------------------------------
// ⚠️ PAS D'EARLY-OUT : ON COMPTE PAR CLASSE, ET C'EST DÉLIBÉRÉ
//-----------------------------------------------------------------
// La version d'origine s'arrêtait à la première primitive atteinte. Elle donnait le bon
// verdict et AUCUNE information : quand `AllSolid killed by: RoomGraphSource x40` est
// tombé, il n'y avait aucun moyen de dire si le coupable était les salles, les tunnels
// ou les pits — donc aucun moyen de savoir quoi resserrer. Compter les cinq classes
// sépare les causes, et c'est la règle que ce projet a payée plusieurs fois : quand un
// zéro a plusieurs causes possibles, chacune a son propre nombre.
//
// Le coût est nul à l'échelle qui compte : on vient d'appeler `BuildChunkCache`, qui
// est de plusieurs ordres de grandeur au-dessus d'un parcours de ~100 structs, et le
// verdict est mémoïsé donc ce parcours arrive UNE fois par boîte, pas treize.
//
// No early-out on purpose: stopping at the first hit gives the right verdict and no
// information. When a zero has several possible causes, each gets its own number.
//-----------------------------------------------------------------
// ⚠️⚠️ LE SEUIL `T` — CE QUE `Identity` VEUT DIRE ICI, ET SA CONDITION DE VALIDITÉ
//-----------------------------------------------------------------
// Jusqu'ici `Identity` signifiait « `Sdf` reste `FLT_MAX` sur toute la boîte ». C'est
// vrai, mais c'est plus fort que nécessaire, et cette force coûtait la quasi-totalité du
// gain : aucun consommateur ne regarde `Sdf` au-delà d'un seuil.
//
// Les TROIS consommateurs du canal SDF de cette pile, RELUS un par un (pas supposés) :
// • `FSdfConvertOp::Eval` → `if (InOut.Sdf >= Blend) return;` et
// `BuildTunnelNetworkStack` l'instancie par `MakeSdfCarve(P.SDFBlendRadius, …)`
// ⇒ seuil = `K`.
// • les DOUZE modificateurs → `VF_NearCaveSurface` ⇒ seuil = `3·K`.
// • `FWormFieldSource::Eval` → `if (CaveSDF >= P.WormNetworkRange) NetworkMask = 0;`
// puis `if (NetworkMask <= 0) return;` ⇒ seuil = `WormNetworkRange`.
// (`FCaveTerraceMod` re-sonde le SDF en Z±1, donc HORS de la boîte — mais son gate
// `VF_NearCaveSurface` est testé AVANT la sonde, vérifié ligne par ligne. Un gate faux
// partout ⇒ aucune sonde n'est jamais émise.)
//
// Donc `Sdf ≥ T` avec `T = max(K, 3K, WormNetworkRange)` suffit à éteindre les trois.
//
// ⚠️ **TOUT NOUVEAU CONSOMMATEUR DU CANAL `Sdf` DOIT AVOIR UN SEUIL ≤ T, OU ÊTRE AJOUTÉ
// À CE `Max`.** C'est la seule dette de couplage de cette fonction, et elle est réelle :
// un opérateur qui regarderait `Sdf < 100` verrait des verdicts `Identity` faux, donc
// des tuiles sans géométrie ET SANS COLLISION. Écrit ici parce que c'est ici qu'on
// atterrit en l'ajoutant.
//
// ⚠️ ET LA RAISON POUR LAQUELLE ` K` SUFFIT MALGRÉ N PRIMITIVES. `SmoothMin(A,B,K)`
// vaut `min(A,B) H³K/6` avec `H = max(K |AB|, 0)/K`. Deux conséquences lues sur la
// formule : la pénalité est EXACTEMENT nulle dès que `|AB| ≥ K`, et le minimum courant
// ne peut donc jamais descendre plus de `K` sous le plus petit des termes — arrivé là,
// `H = 0` et les plis suivants le laissent intact. D'où `Sdf ≥ min_i(SDF_i) K` pour un
// nombre QUELCONQUE de primitives, et non ` N·K/6`. C'est ce qui rend ce critère
// utilisable au lieu d'être noyé sous le nombre de tunnels.
//
// Identity now means "Sdf >= T over the box", not "Sdf stays FLT_MAX" — no consumer
// looks past its own threshold, and the three that exist were read one by one. ANY NEW
// CONSUMER OF THE Sdf CHANNEL MUST HAVE A THRESHOLD <= T OR BE ADDED TO THIS MAX.
// The -K slack covers any number of primitives because SmoothMin's penalty is exactly
// zero once |A-B| >= K, so the running minimum saturates at K below the true minimum.
const float K = FMath::Max(P.SDFBlendRadius, 0.0f);
const float T = FMath::Max(3.0f * K, P.WormNetworkRange);
B.NumRooms = B.Cache.Rooms.Num();
B.NumTunnels = B.Cache.Tunnels.Num();
B.NumPits = B.Cache.Pits.Num();
B.NumChimneys = B.Cache.Chimneys.Num();
B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0;
// La MÊME boîte sans dilatation de warp — diagnostic seulement, voir plus bas.
const FVector NWMin((float)VoxelBox.Min.X, (float)VoxelBox.Min.Y, EffZ((float)VoxelBox.Min.Z));
const FVector NWMax((float)VoxelBox.Max.X, (float)VoxelBox.Max.Y, EffZ((float)VoxelBox.Max.Z));
const float NoWarpHalfDiag = 0.5f * (float)(NWMax - NWMin).Size();
B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0;
B.WarpDilation = Warp;
for (const FCachedRoom& R : B.Cache.Rooms)
{
if (SphereHitsBox(R.Center, R.CullRadiusSq, QMin, QMax)) { ++B.HitRooms; }
if (SphereHitsBox(R.Center, R.CullRadiusSq, NWMin, NWMax)) { ++B.HitRoomsNoWarp; }
}
//-----------------------------------------------------------------
// LES TUNNELS ONT DROIT À UN SECOND TEST, ET C'EST LÀ QUE SE TROUVE LE GAIN
//-----------------------------------------------------------------
// ⚠️ CECI CHANGE LE SENS D'`Identity` POUR CET OPÉRATEUR — lire la note « LE SEUIL T »
// ci-dessus avant de toucher quoi que ce soit ici.
//
// Le cull par voxel d'un tunnel est sa SPHÈRE ENGLOBANTE. Pour une capsule longue et
// fine c'est une sur-estimation énorme : avec `MaxTunnelLength = 200` et
// `TunnelMaxRadius = 7`, la sphère a un rayon jusqu'à ~107 pour un tube de rayon 7. La
// mesure le disait sans ambiguïté — 32 tuiles bloquées sur 34 par des tunnels, contre
// 21 par des salles.
//
// Donc : soit le tunnel rate son cull (il ne s'exécute pas), soit son PROPRE SDF reste
// ≥ `T + K` sur toute la boîte (il s'exécute mais ne peut pas descendre le champ assez
// bas pour qu'un consommateur s'allume). L'un ou l'autre suffit.
//
// La borne est exacte, pas prudente : `TaperedCapsule` rend
// `Dist(P, PlusProcheSurSegment) Lerp(Ra, Rb, t)`, donc
// `SDF ≥ dist(P, segment) max(Ra, Rb)` — RELU dans `VoxelCaveMorphology.h`, pas supposé.
// Et `dist(boîte, segment) ≥ dist(centre, segment) demi-diagonale` par inégalité
// triangulaire : conservatif du bon côté, et trivialement vrai.
//
// A tunnel's per-voxel cull is its BOUNDING SPHERE — for a 200-long tube of radius 7
// that sphere has radius ~107. So a tunnel does not matter if it fails that cull OR if
// its own SDF stays >= T + K over the box. The bound is exact: TaperedCapsule is
// genuinely dist-to-segment minus an interpolated radius, and box-to-segment distance is
// bounded below by centre-to-segment minus the half-diagonal.
const float TunnelClear = T + K;
const FVector QCenter = (QMin + QMax) * 0.5;
const float BoxHalfDiag = 0.5f * (float)(QMax - QMin).Size();
for (const FCachedTunnel& Tn : B.Cache.Tunnels)
{
if (!SphereHitsBox(Tn.BoundCenter, Tn.BoundRadiusSq, QMin, QMax)) { continue; }
float MaxR = FMath::Max(Tn.RadiusA, Tn.RadiusB);
float DistToAxis;
if (Tn.bHasMidpoint)
{
// Deux segments : le SDF du tunnel est le `Min` des deux, donc sa borne
// inférieure est le `Min` des deux bornes.
MaxR = FMath::Max(MaxR, Tn.RadiusMid);
DistToAxis = FMath::Min(
VF_DistPointSegment(QCenter, Tn.EndpointA, Tn.Midpoint),
VF_DistPointSegment(QCenter, Tn.Midpoint, Tn.EndpointB));
}
else
{
DistToAxis = VF_DistPointSegment(QCenter, Tn.EndpointA, Tn.EndpointB);
}
if (DistToAxis - BoxHalfDiag - MaxR >= TunnelClear) { continue; }
++B.HitTunnels;
// DIAGNOSTIC — le MÊME test avec une dilatation de warp NULLE. Ne participe à aucun
// verdict ; il répond à la seule question que trois builds de resserrement n'ont
// jamais posée : « combien de ce blocage est de la géométrie, et combien est ma
// propre boîte dilatée ? ». `HitTunnels - HitTunnelsNoWarp` est exactement la part
// que le warp coûte.
if (DistToAxis - NoWarpHalfDiag - MaxR < TunnelClear) { ++B.HitTunnelsNoWarp; }
}
// Miroir exact des deux `continue` de `Eval` : actif si `Z < TopZ + BlendK` ET
// `Z >= TopZ - Depth - BlendK`.
for (const FCachedPit& Pit : B.Cache.Pits)
{
if (!(RMinZ < Pit.TopZ + Pit.BlendK)) { continue; }
if (!(RMaxZ >= Pit.TopZ - Pit.Depth - Pit.BlendK)) { continue; }
if (CircleHitsBoxXY(Pit.CenterX, Pit.CenterY, Pit.BoundXYRadiusSq)) { ++B.HitPits; }
}
// Miroir exact : actif si `Z > BottomZ - BlendK` ET `Z <= BottomZ + Height + BlendK`.
for (const FCachedChimney& Ch : B.Cache.Chimneys)
{
if (!(RMaxZ > Ch.BottomZ - Ch.BlendK)) { continue; }
if (!(RMinZ <= Ch.BottomZ + Ch.Height + Ch.BlendK)) { continue; }
if (CircleHitsBoxXY(Ch.CenterX, Ch.CenterY, Ch.BoundXYRadiusSq)) { ++B.HitChimneys; }
}
//-----------------------------------------------------------------
// ✅ LES COLONNES NE SONT PLUS TESTÉES — ET C'EST PROUVÉ, PAS RELÂCHÉ
//-----------------------------------------------------------------
// La première version les traitait en cylindres INFINIS en Z (le cache ne leur donne
// aucune borne verticale), ce qui rendait `Both` pour une boîte située des centaines de
// voxels sous la salle propriétaire. Inutile : le seul consommateur des colonnes est
// `FRoomColumnMod`, dont l'`Eval` commence par
// `if (!VF_NearCaveSurface(InOut.Sdf, P.SDFBlendRadius)) { return; }`
// Si aucune salle, aucun tunnel, aucun pit et aucune cheminée n'atteint la boîte, `Sdf`
// y reste `FLT_MAX`, le gate est faux à chaque voxel, et **aucune colonne ne peut
// s'exécuter** — quelle que soit sa position XY. Le test était donc REDONDANT, pas
// prudent. Le retirer resserre le verdict sans toucher à sa correction.
//
// Columns are not tested: their only consumer gates on Sdf being near a cave surface,
// which cannot happen in a box no room/tunnel/pit/chimney reaches. The test was
// redundant rather than conservative, and it was the loosest one here.
const bool bReached = (B.HitRooms + B.HitTunnels + B.HitPits + B.HitChimneys) > 0;
B.Verdict = bReached ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity;
B.KeyBox = VoxelBox;
B.KeyStrate = StrateIdx;
B.KeySeed = SeedU;
B.KeyFingerprint = ParamsFingerprint;
B.KeyLayout = LV;
B.bValid = true;
return B.Verdict;
}
const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); }
private:
FStrateGenerationParams P;
int32 Seed;
@@ -3257,8 +3782,12 @@ namespace
class FWormFieldSource final : public IVoxelDensityOp
{
public:
FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed)
: P(InP), SeedU((uint32)Seed) {}
/** @param InRooms ⚠️ UNIQUEMENT pour `EffectOverBox` / `MaxCarveOverBox`. `Eval` lit le
* canal SDF de `InOut`, pas ce pointeur le ver n'interroge jamais la
* source directement, il consomme ce qu'elle a écrit. Peut être nullptr. */
FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed,
const FRoomGraphSource* InRooms = nullptr)
: P(InP), SeedU((uint32)Seed), Rooms(InRooms) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {}
@@ -3324,10 +3853,57 @@ namespace
* est solide de plus que la somme des carves restants » redevient prouvable et c'est le
* plus gros poste de perf du plan. Noté ici, au point exact la borne manque.
*/
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
/**
* **LE VER HÉRITE DU VERDICT DE LA SOURCE DE SALLES ET C'EST CE QUI DÉBLOQUE TOUT.**
*
* La note ci-dessus (« aucune borne spatiale, donc il tue `AllSolid` sur CHAQUE tuile »)
* était vraie, et pourtant elle passait à côté de ce que son propre `Eval` fait trois
* lignes plus haut :
*
* ```
* if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX)
* { NetworkMask = 0.0f; }
* ...
* if (NetworkMask <= 0.0f) { return; }
* ```
*
* **Le ver EST spatialement borné** pas par une borne à lui, mais par celle de la source
* de salles, exactement comme les douze modificateurs de détail. `FRoomGraphSource`
* prouve `Identity`, `Sdf` reste `FLT_MAX` sur toute la boîte, donc `NetworkMask` vaut 0
* partout, donc ce `return` est pris à chaque voxel. Le ver est l'identité, pas « un carve
* borné » : il ne s'exécute pas.
*
* POURQUOI CE CONTRÔLE COMPTAIT AUTANT. `BaseDensity = 8` et `WormStrength = 10` sont
* les DÉFAUTS, et le commentaire de `WormStrength` dit pourquoi (« must exceed BaseDensity
* to create air »). Donc `SolidMargin = 8 10 < 0` : tant que le ver rendait `CarveOnly`
* partout, il tuait `AllSolid` sur **toutes** les tuiles, et la réponse spatiale de la
* source de salles ne pouvait rien prouver derrière lui. Le premier build l'a montré
* 0 tuile prouvée sur 40, la source ayant pourtant appris à répondre.
*
* ET POURQUOI ON N'UTILISE **PAS** `VF_NoCaveOverBox` ICI. Cet assistant rend `true`
* quand `Rooms == nullptr` correct pour les douze modificateurs, qui n'existent que dans
* une pile la source de salles est le seul écrivain du canal SDF. Le ver, lui, est un
* opérateur dont un futur assemblage pourrait le placer derrière un AUTRE écrivain de SDF
* (`FLatticeCorridorSource` en écrit un). Sans source de salles, on ne sait pas : on rend
* `CarveOnly`. Ne pas savoir doit coûter du CPU, jamais un trou.
*
* The worm IS spatially bounded by the room source's bound, not one of its own, exactly
* like the twelve detail modifiers. Where the room source proves Identity, Sdf stays
* FLT_MAX, NetworkMask is 0 everywhere and Eval returns immediately. This mattered because
* BaseDensity=8 < WormStrength=10 BY DEFAULT, so an unconditional CarveOnly killed AllSolid
* on every tile. Deliberately not VF_NoCaveOverBox: its null-Rooms case answers "identity",
* which is wrong for an op that could sit behind a different SDF writer.
*/
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? EVoxelOpEffect::CarveOnly
: EVoxelOpEffect::Identity;
if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return EVoxelOpEffect::Identity; }
if (P.WormNetworkRange > 0.0f && Rooms != nullptr
&& Rooms->EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity)
{
return EVoxelOpEffect::Identity;
}
return EVoxelOpEffect::CarveOnly;
}
/**
@@ -3340,8 +3916,12 @@ namespace
* la seule sorte qui ait le droit d'être ici : sur-estimer coûte du CPU, sous-estimer fait
* un trou.
*/
float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override
float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
// Cohérent avec `EffectOverBox` PAR CONSTRUCTION plutôt que par relecture : deux
// conditions écrites deux fois finiraient par diverger. Le mémo de verdict de
// `FRoomGraphSource` rend ce second appel gratuit.
if (EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity) { return 0.0f; }
return MaxCarveAmplitude();
}
@@ -3351,15 +3931,19 @@ namespace
return 0.0f;
}
/** L'amplitude max de carve, en unités de densité. */
/** L'amplitude max de carve, en unités de densité. Borne BRUTE : elle ignore la portée du
* réseau, c'est `MaxCarveOverBox` qui l'applique. */
float MaxCarveAmplitude() const
{
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f;
}
const TCHAR* DebugName() const override { return TEXT("WormFieldSource"); }
private:
FStrateGenerationParams P;
uint32 SeedU;
const FRoomGraphSource* Rooms; // NON possédant — peut être nullptr (voir EffectOverBox)
};
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
@@ -3368,6 +3952,29 @@ namespace
// ajoutée avec elle ne ferme rien → C2059.
// END OF THE ANONYMOUS NAMESPACE — new operators go ABOVE this line.
//=============================================================================
// DIAGNOSTIC — voir la déclaration dans VoxelDensityOpStack.h
//=============================================================================
VoxelDensityOps::FRoomBoxDiagnostic VoxelDensityOps::GetLastRoomBoxDiagnostic()
{
const FRoomGraphSource::FBoxState& B = FRoomGraphSource::BoxState();
FRoomBoxDiagnostic D;
D.HitRooms = B.HitRooms;
D.HitTunnels = B.HitTunnels;
D.HitPits = B.HitPits;
D.HitChimneys = B.HitChimneys;
D.NumRooms = B.NumRooms;
D.NumTunnels = B.NumTunnels;
D.NumPits = B.NumPits;
D.NumChimneys = B.NumChimneys;
D.HitRoomsNoWarp = B.HitRoomsNoWarp;
D.HitTunnelsNoWarp = B.HitTunnelsNoWarp;
D.WarpDilation = B.WarpDilation;
return D;
}
//=============================================================================
// FVoxelOpStack
//=============================================================================
@@ -3571,7 +4178,11 @@ namespace VoxelDensityOps
OutStack.Add(MakeUnique<FDomeMod>(P, RoomPtr)); // 4g — dômes
OutStack.Add(MakeUnique<FPinchMod>(P, RoomPtr)); // 4h — pincement
OutStack.Add(MakeUnique<FFloorBiasMod>(P, RoomPtr)); // fin 4h — biais de sol
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed));
// ⚠️ `RoomPtr` N'EST PAS DÉCORATIF ICI. Le ver hérite du verdict de boîte de la source de
// salles, faute de quoi il rend `CarveOnly` partout et tue `AllSolid` sur chaque tuile —
// avec les défauts (`BaseDensity = 8`, `WormStrength = 10`) la marge part négative, donc
// aucune tuile n'est prouvable, quoi que la source de salles ait réussi à prouver.
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed, RoomPtr));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
+40 -5
View File
@@ -582,6 +582,10 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork;
thread_local FStrateGenerationParams CP_Tunnel;
// AUDIT §C2 — empreinte de `CP_Tunnel`, rafraîchie avec lui. Elle voyage jusqu'à la clé du
// cache SDF de `GetDensityWithParams` pour qu'un chunk ne puisse plus être évalué contre
// les salles d'un chunk voisin aux params blendés différemment.
thread_local uint32 CP_TunnelFP = 0xFFFFFFFFu;
thread_local FSlabGenerationParams CP_Slab;
thread_local FMazeGenerationParams CP_Maze;
thread_local FSurfaceGenerationParams CP_Surface;
@@ -639,7 +643,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
case ECaveGeneratorType::FloatingIslands:
CP_Float = StrateManager->GetFloatingIslandParamsForChunk(ChunkCoord); break;
default: // TunnelNetwork / Underwater
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); break;
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord);
// AUDIT §C2 — l'empreinte est calculée ICI, une fois par chunk, au seul endroit où
// les params changent. `FStrateGenerationParams` est du POD pur (aucun TArray /
// FString / pointeur), donc une CRC mémoire ne peut pas donner de FAUX POSITIF ; au
// pire un octet de padding donne un faux MANQUE, c'est-à-dire une reconstruction de
// cache. On se trompe du côté du CPU, jamais du côté d'une salle fausse.
CP_TunnelFP = FCrc::MemCrc32(&CP_Tunnel, sizeof(CP_Tunnel));
break;
}
CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord);
@@ -754,7 +765,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
case ECaveGeneratorType::TunnelNetwork:
default:
// Underwater shares tunnel rock (water table is a render-side overlay).
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel); break;
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel,
CP_TunnelFP, LayoutVersion); break;
}
// Disturbance layer (the "wow" post-process) — cached params, MC convention.
@@ -764,8 +776,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
{
// ── FALLBACK (no strate manager) ──
// Use default TunnelNetwork params — produces generic caves.
FStrateGenerationParams FallbackParams;
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams);
// `static` : ces params sont constants (construction par défaut), donc leur empreinte l'est
// aussi. La calculer une fois évite un CRC par voxel sur un chemin qui n'en a aucun besoin.
// `LayoutVersion = 0` : sans `StrateManager` il n'y a pas de layout, donc rien qui puisse
// périmer — et l'empreinte constante suffit à distinguer ce cache de tous les autres.
static const FStrateGenerationParams FallbackParams;
static const uint32 FallbackFP = FCrc::MemCrc32(&FallbackParams, sizeof(FallbackParams));
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0);
}
//=========================================================================
@@ -813,7 +830,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
}
float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
const FStrateGenerationParams& Params) const
const FStrateGenerationParams& Params,
uint32 ParamsFingerprint, uint32 LayoutVersion) const
{
//=========================================================================
// STRATE DENSITY FUNCTION (Morphology Pipeline)
@@ -930,6 +948,20 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f;
thread_local int32 CachedStrate = INT32_MIN;
thread_local uint32 CachedSeed = 0;
// ⚠️ AUDIT §C2 (corrigé le 2026-07-28). Les deux lignes qui manquaient à cette clé.
// La clé ci-dessus décrit la GÉOMÉTRIE de la fenêtre (boîte, strate, seed) et rien de ce qui
// détermine les PARAMS avec lesquels les salles ont été cuites. Comme `GetGenerationParams`
// blende à l'intérieur d'une strate (`Alpha` = f(chunk Z), et f(chunk XY) aussi en
// `Interleaved`), deux chunks voisins produisent la MÊME clé avec des params DIFFÉRENTS, et le
// deuxième se sert des salles du premier. Non déterministe entre pairs, parce que l'ordre des
// workers décide lequel est « le premier » — exactement ce que §2.6.1 interdit.
//
// Pourquoi ça ne casse PAS l'invariant de perf de §8.10 : la clé reste une BOÎTE, donc les
// sondes de gradient à `WorldX ± 1` ne font toujours pas tourner le cache. Ce qui le fait
// tourner en plus, c'est un changement RÉEL de params — une fois par chunk dans une bande de
// transition, ce qui est le nombre de reconstructions que ce cache aurait toujours dû faire.
thread_local uint32 CachedFingerprint = 0xFFFFFFFFu;
thread_local uint32 CachedLayout = 0xFFFFFFFFu;
// Index of the room with the smallest (most-inside) SDF for this voxel.
// Written by EvaluateSDFCached, read by the terrain ops block to pick the
@@ -968,6 +1000,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
// cached search box, or the strate/seed changed.
const bool bNeedRebuild =
StrateIdx != CachedStrate || (uint32)Seed != CachedSeed ||
ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout ||
WarpedX < CachedSMinX || WarpedX > CachedSMaxX ||
WarpedY < CachedSMinY || WarpedY > CachedSMaxY;
@@ -1011,6 +1044,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
CachedSMinY = SMinY; CachedSMaxY = SMaxY;
CachedStrate = StrateIdx;
CachedSeed = (uint32)Seed;
CachedFingerprint = ParamsFingerprint;
CachedLayout = LayoutVersion;
}
// Evaluate SDF using cached rooms and tunnels (WARPED coordinates).
+21
View File
@@ -415,6 +415,27 @@ public:
* ValidateDeterminism qui échantillonne le long d'une frontière en X ne le verrait pas.
*/
virtual bool IsXYPure() const { return false; }
/**
* DIAGNOSTIC UNIQUEMENT le nom que les rapports de test impriment pour cet opérateur.
*
* POURQUOI CETTE MÉTHODE EXISTE, ET CE QU'ELLE A COÛTÉ DE NE PAS AVOIR. Le premier build de
* l'`EffectOverBox` spatial est revenu **vert avec 0 tuile prouvée sur 40**, et la seule chose
* que le rapport pouvait dire était « ou bien les tuiles traversent toutes une grotte, ou bien
* la source n'atteint pas sa branche `Identity` ». Deux causes, zéro nombre pour les
* départager exactement le piège que ce projet a déjà payé plusieurs fois. La vraie cause
* était un TROISIÈME opérateur (le ver, qui rendait `CarveOnly` partout). Avec un nom par
* opérateur, `ClassifyBoxAttributed` répond « c'est celui- » au lieu de laisser deviner.
*
* N'entre dans AUCUNE clé de cache, dans aucun hash, dans aucune décision de génération : le
* changer ne peut pas changer le monde. Le défaut est volontairement laconique un opérateur
* sans nom se repère à son index, ce qui suffit à savoir regarder.
*
* Diagnostic only: the first build of the spatial EffectOverBox came back green with 0 tiles
* proved, and the report could not name which operator was killing the hypothesis. It was a
* third one nobody was looking at. Never part of a cache key or any generation decision.
*/
virtual const TCHAR* DebugName() const { return TEXT("(unnamed op)"); }
};
//=============================================================================
@@ -135,6 +135,50 @@ public:
return H.Resolve();
}
/**
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
*
* IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` même boucle, même early-out,
* même ordre. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
* l'autre change avec lui.
*
* `OutSolidKiller` / `OutAirKiller` reçoivent l'INDEX du premier opérateur qui fait passer
* l'hypothèse correspondante de vraie à fausse, ou `INDEX_NONE` si elle a survécu. Le nom
* lisible s'obtient par `GetOpDebugName(index)`.
*
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
* ClassifyBox a diagnostic that takes a slightly different path sends you hunting in the
* wrong place.
*/
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
int32& OutSolidKiller, int32& OutAirKiller) const
{
OutSolidKiller = INDEX_NONE;
OutAirKiller = INDEX_NONE;
FVoxelBoxHypotheses H;
for (int32 i = 0; i < Ops.Num(); ++i)
{
const bool bSolidBefore = H.bCanBeAllSolid;
const bool bAirBefore = H.bCanBeAllAir;
VF_FoldOp(H, *Ops[i], VoxelBox, Ctx);
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
if (H.IsDead()) { return EVoxelTileClass::Mixed; }
}
return H.Resolve();
}
/** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */
const TCHAR* GetOpDebugName(int32 Index) const
{
return Ops.IsValidIndex(Index) ? Ops[Index]->DebugName() : TEXT("(none)");
}
/**
* RÔLE 4 ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
* spine (0,0) seal de frontière carve de passage.
@@ -286,6 +330,38 @@ namespace VoxelDensityOps
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* DIAGNOSTIC la ventilation par CLASSE DE PRIMITIVE du dernier `FRoomGraphSource::EffectOverBox`
* évalué sur ce thread. **Tests uniquement. N'entre dans aucune décision de génération.**
*
* POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
* pour rejouer le critère il appelle `BuildChunkCache` ailleurs. Le rejouer serait une
* DEUXIÈME définition du critère, qui dériverait de la vraie et mentirait exactement le jour
* on la croirait. C'est la même raison qui a fait exister `VF_BuildOpStackForChunk`. On expose
* donc ce que l'opérateur a réellement calculé.
*
* `Hit*` = combien de primitives de cette classe atteignent la boîte (0 partout `Identity`).
* `Num*` = combien le cache en contenait, ce qui distingue « aucune n'atteint » de « il n'y en
* avait aucune » deux zéros de sens opposé.
*
* Reads back what the operator actually computed, rather than letting the test re-derive the
* criterion: a second copy would drift and would lie on the day it was believed.
*/
struct FRoomBoxDiagnostic
{
int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0;
int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0;
/** Les mêmes comptes si la dilatation de warp valait ZÉRO, et de combien de voxels la boîte
* est effectivement dilatée. `Hit* - Hit*NoWarp` = la part du blocage due à MA boîte plutôt
* qu'à la géométrie. Cette mesure manquait, et son absence a coûté trois builds de
* resserrement autour du mauvais terme. */
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
float WarpDilation = 0.0f;
};
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
/**
* FloatingIslands 7 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid IslandBlob SdfRoughness SdfFill [structural post ×3]
+27 -1
View File
@@ -118,9 +118,35 @@ public:
* Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise).
* Utilisée en interne par GetDensityAt quand la strate est de ce type.
* Exposée pour permettre des tests isolés avec des params custom.
*
* `ParamsFingerprint` ET `LayoutVersion` SONT OBLIGATOIRES, ET C'EST LE CORRECTIF
* D'`AUDIT §C2` (2026-07-28). Le cache SDF interne est clé sur (boîte XY, strate, seed) et
* PAS sur les params. Or `GetGenerationParams` BLENDE les params à l'intérieur d'une même
* strate `Alpha` dépend du chunk Z en mode `Gradient` (le DÉFAUT, avec
* `TransitionBlendChunks = 2`) et du chunk XY en plus en mode `Interleaved`. Deux chunks de la
* même strate, même seed, donc même clé, mais des params DIFFÉRENTS : le worker évalue le
* deuxième chunk qu'il construit contre les salles du premier. Et comme *quel* chunk vient en
* premier dépend de l'ordre des workers, **deux pairs divergent depuis la même seed** ce que
* `OPSTACK-PLAN §2.6.1` interdit explicitement.
*
* Pourquoi une empreinte PASSÉE plutôt qu'un `MemCrc32` calculé ici : ce serait ~300 octets de
* CRC PAR VOXEL sur le chemin le plus chaud du plugin. L'appelant la calcule UNE fois par
* chunk, le mémo de params vit déjà (`CP_*`), donc le coût par voxel est exactement deux
* comparaisons d'entiers. Pas de valeur par défaut : un appelant qui oublie doit ne pas
* compiler, pas hériter silencieusement du trou (la discipline de `FVoxelOpContext`).
*
* POUR LES TESTS : passez `FCrc::MemCrc32(&Params, sizeof(Params))`. Un oracle qui partage
* le défaut qu'il teste ne prouve rien c'est précisément ce que la note de
* `VoxelForgeOpStackTunnelTest.cpp` (contrôle 3) décrivait comme le trou de l'original.
*
* The SDF cache key had neither the params nor anything that determines them, while the params
* are blended per chunk INSIDE a strate so a worker could evaluate one chunk against another
* chunk's rooms, and which came first depends on worker order. Passing a once-per-chunk
* fingerprint keeps the fix off the per-voxel path. No default: forgetting it must not compile.
*/
float GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
const FStrateGenerationParams& Params) const;
const FStrateGenerationParams& Params,
uint32 ParamsFingerprint, uint32 LayoutVersion) const;
/**
* Densité pour une strate Slab (FlatPlain / CrystalChamber).