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>
This commit is contained in:
2026-07-28 14:48:18 +02:00
parent e55f12a9de
commit 03ddcde334
7 changed files with 587 additions and 62 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.
+2 -2
View File
@@ -154,7 +154,7 @@ 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`). |
| `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: it builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then lifts each primitive's per-voxel cull from point to box — rooms/tunnels as spheres vs the warp-dilated box, pits/chimneys vs the **undilated** box (real coords), columns as infinite cylinders. `Identity``Sdf` stays `FLT_MAX``FSdfConvertOp` **and all twelve detail modifiers** go identity with it. Verdict is memoised per box (the twelve all ask the same question). Warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`, not the header's observed `~[-1,1]`. |
| `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. |
| `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. |
@@ -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. |
+116
View File
@@ -2467,3 +2467,119 @@ 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.
@@ -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,10 +1083,30 @@ 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.
{
int32 NumProved = 0, NumMixed = 0;
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
int32 NumBruteSamples = 0, NumViolations = 0;
float WorstViolation = 0.0f;
FRandomStream Rng(97531);
for (int32 t = 0; t < 40; ++t)
{
@@ -1077,21 +1121,56 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
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; }
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
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 = Stack.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("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."),
NumProved, NumSolid, NumAir, NumMixed, NumBruteSamples, NumViolations));
TestEqual(TEXT("stage A emits no unsound verdict (it emits none at all)"), NumProved, 0);
if (NumProved == 0)
{
AddWarning(TEXT("No TunnelNetwork tile was proved. That is not a failure, but it means ")
TEXT("this check verified nothing: the brute force below has no verdict to ")
TEXT("contradict. Either the sampled tiles all genuinely straddle cave, or ")
TEXT("the spatial EffectOverBox is not reaching its Identity branch -- the ")
TEXT("bake-coverage line of check 5b is the one that tells those apart."));
}
TestEqual(FString::Printf(
TEXT("every proved TunnelNetwork tile survives brute force (worst |density| ")
TEXT("on the wrong side: %.9g)"), WorstViolation),
NumViolations, 0);
}
//=========================================================================
@@ -1172,7 +1251,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))
+266 -18
View File
@@ -2124,25 +2124,273 @@ 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;
};
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.
*
* Provable bound rather than the header's observed one: GradDot returns ru+rv with both in
* [-1,1], and a trilinear lerp stays inside the hull of its inputs. Erring high costs CPU.
*/
static constexpr float PerlinAbsBound = 2.0f;
/**
* 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, ET POURQUOI IL NE PEUT ÊTRE FAUX QUE DANS UN SENS.
* `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive qui SURVIT à son
* cull par voxel (sphère 3D pour les salles et les tunnels, bornes Z + cercle XY pour les
* pits et les cheminées). Donc : si AUCUNE primitive du cache ne peut survivre à son cull en
* un point quelconque de la boîte, `Sdf` reste `FLT_MAX` sur TOUTE la boîte, la source est
* l'identité, et tout ce qui en dépend l'est aussi. On teste exactement ça la même
* inégalité que le cull par voxel, élevée du point à la boîte. Une seule raison d'échouer.
*
* 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;
};
bool bReached = false;
for (const FCachedRoom& R : B.Cache.Rooms)
{
if (SphereHitsBox(R.Center, R.CullRadiusSq, QMin, QMax)) { bReached = true; break; }
}
if (!bReached)
{
for (const FCachedTunnel& T : B.Cache.Tunnels)
{
if (SphereHitsBox(T.BoundCenter, T.BoundRadiusSq, QMin, QMax)) { bReached = true; break; }
}
}
if (!bReached)
{
// 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))
{
bReached = true; break;
}
}
}
if (!bReached)
{
// 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))
{
bReached = true; break;
}
}
}
if (!bReached)
{
// Les colonnes ne sont pas lues par CETTE source (c'est `FRoomColumnMod`, STEP 4d,
// qui parcourt `GetCache()`), mais elles héritent de ce verdict. Elles n'ont aucune
// borne en Z dans le cache : on les traite donc comme des cylindres infinis, ce qui
// est le test le plus prudent qu'on puisse écrire à partir de ce qui est stocké.
for (const FCachedColumn& Col : B.Cache.Columns)
{
if (CircleHitsBoxXY(Col.CenterX, Col.CenterY, Col.BoundXYRadiusSq))
{
bReached = true; break;
}
}
}
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;
}
private:
+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).
+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).