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>
This commit is contained in:
2026-07-28 18:10:51 +02:00
parent 03ddcde334
commit f537648867
6 changed files with 287 additions and 15 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. | | `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. | | `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28: 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]`. | | `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. | | `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). | | `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. | | `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. | | `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. |
+96
View File
@@ -2583,3 +2583,99 @@ at the site rather than in a handoff.
- Everything else should be **unchanged and green**. The §C2 fix changes generated terrain only - 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 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. 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.
@@ -1106,6 +1106,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0; int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
int32 NumBruteSamples = 0, NumViolations = 0; int32 NumBruteSamples = 0, NumViolations = 0;
float WorstViolation = 0.0f; float WorstViolation = 0.0f;
TMap<FString, int32> SolidKillerCounts;
FRandomStream Rng(97531); FRandomStream Rng(97531);
for (int32 t = 0; t < 40; ++t) for (int32 t = 0; t < 40; ++t)
@@ -1121,7 +1122,18 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step), FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step)); FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx); // 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 = Stack.ClassifyBoxAttributed(Box, Ctx, SolidKiller, AirKiller);
if (SolidKiller != INDEX_NONE)
{
SolidKillerCounts.FindOrAdd(Stack.GetOpDebugName(SolidKiller))++;
}
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; } if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
++NumProved; ++NumProved;
@@ -1158,13 +1170,37 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
TEXT("verdict leaves no geometry and no collision behind it."), TEXT("verdict leaves no geometry and no collision behind it."),
NumProved, NumSolid, NumAir, NumMixed, NumBruteSamples, NumViolations)); 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("AllSolid killed by: %s. This is the line that replaced a guess. The first ")
TEXT("build of the spatial EffectOverBox reported 0 proved of 40, and the warning ")
TEXT("offered two candidate causes -- BOTH WRONG. The real one was a third operator ")
TEXT("nobody was looking at: FWormFieldSource answered CarveOnly everywhere, and ")
TEXT("since BaseDensity=8 < WormStrength=10 BY DEFAULT, its provable amplitude bound ")
TEXT("alone drove SolidMargin negative on every tile in the world. Attribution is ")
TEXT("cheap; a second wrong guess is not."),
*Breakdown));
}
if (NumProved == 0) if (NumProved == 0)
{ {
AddWarning(TEXT("No TunnelNetwork tile was proved. That is not a failure, but it means ") AddWarning(TEXT("No TunnelNetwork tile was proved, so the brute force below verified ")
TEXT("this check verified nothing: the brute force below has no verdict to ") TEXT("nothing -- it has no verdict to contradict. Do NOT re-derive the cause: ")
TEXT("contradict. Either the sampled tiles all genuinely straddle cave, or ") TEXT("read the 'AllSolid killed by' line above, which names the operator and ")
TEXT("the spatial EffectOverBox is not reaching its Identity branch -- the ") TEXT("counts how often. If it names RoomGraphSource, the tiles genuinely ")
TEXT("bake-coverage line of check 5b is the one that tells those apart.")); TEXT("straddle cave (check the bake-coverage line of 5b); anything else is an ")
TEXT("operator whose box answer is more pessimistic than its Eval."));
} }
TestEqual(FString::Printf( TestEqual(FString::Printf(
@@ -144,6 +144,8 @@ namespace
return FMath::Abs(Value); return FMath::Abs(Value);
} }
const TCHAR* DebugName() const override { return TEXT("ConstantFieldSource"); }
private: private:
float Value; float Value;
}; };
@@ -1119,6 +1121,8 @@ namespace
return EVoxelOpEffect::Identity; // la source a répondu pour la paire return EVoxelOpEffect::Identity; // la source a répondu pour la paire
} }
const TCHAR* DebugName() const override { return TEXT("SdfConvertOp"); }
private: private:
float Blend, BaseDensity, Sign, MinDivisor; float Blend, BaseDensity, Sign, MinDivisor;
}; };
@@ -1159,6 +1163,8 @@ namespace
return EVoxelOpEffect::CarveOnly; return EVoxelOpEffect::CarveOnly;
} }
const TCHAR* DebugName() const override { return TEXT("OriginSpineOp"); }
private: private:
float TopZ, BotZ, Seal, Base, Radius; float TopZ, BotZ, Seal, Base, Radius;
}; };
@@ -1226,6 +1232,8 @@ namespace
return EVoxelOpEffect::FillOnly; return EVoxelOpEffect::FillOnly;
} }
const TCHAR* DebugName() const override { return TEXT("BoundarySealOp"); }
private: private:
float TopZ, BotZ, Thickness, Base; float TopZ, BotZ, Thickness, Base;
}; };
@@ -1258,6 +1266,8 @@ namespace
? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
} }
const TCHAR* DebugName() const override { return TEXT("PassageCarveOp"); }
private: private:
const UVoxelStrateManager* Manager; const UVoxelStrateManager* Manager;
float Base, Seal; float Base, Seal;
@@ -2393,6 +2403,8 @@ namespace
return B.Verdict; return B.Verdict;
} }
const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); }
private: private:
FStrateGenerationParams P; FStrateGenerationParams P;
int32 Seed; int32 Seed;
@@ -3505,8 +3517,12 @@ namespace
class FWormFieldSource final : public IVoxelDensityOp class FWormFieldSource final : public IVoxelDensityOp
{ {
public: public:
FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed) /** @param InRooms ⚠️ UNIQUEMENT pour `EffectOverBox` / `MaxCarveOverBox`. `Eval` lit le
: P(InP), SeedU((uint32)Seed) {} * 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; } EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {} void PrepareChunk(const FVoxelOpContext&) override {}
@@ -3572,10 +3588,57 @@ namespace
* est solide de plus que la somme des carves restants » redevient prouvable et c'est le * 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. * 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 if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return EVoxelOpEffect::Identity; }
: EVoxelOpEffect::Identity;
if (P.WormNetworkRange > 0.0f && Rooms != nullptr
&& Rooms->EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity)
{
return EVoxelOpEffect::Identity;
}
return EVoxelOpEffect::CarveOnly;
} }
/** /**
@@ -3588,8 +3651,12 @@ namespace
* la seule sorte qui ait le droit d'être ici : sur-estimer coûte du CPU, sous-estimer fait * la seule sorte qui ait le droit d'être ici : sur-estimer coûte du CPU, sous-estimer fait
* un trou. * 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(); return MaxCarveAmplitude();
} }
@@ -3599,15 +3666,19 @@ namespace
return 0.0f; 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 float MaxCarveAmplitude() const
{ {
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f; return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f;
} }
const TCHAR* DebugName() const override { return TEXT("WormFieldSource"); }
private: private:
FStrateGenerationParams P; FStrateGenerationParams P;
uint32 SeedU; 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. } // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
@@ -3819,7 +3890,11 @@ namespace VoxelDensityOps
OutStack.Add(MakeUnique<FDomeMod>(P, RoomPtr)); // 4g — dômes OutStack.Add(MakeUnique<FDomeMod>(P, RoomPtr)); // 4g — dômes
OutStack.Add(MakeUnique<FPinchMod>(P, RoomPtr)); // 4h — pincement OutStack.Add(MakeUnique<FPinchMod>(P, RoomPtr)); // 4h — pincement
OutStack.Add(MakeUnique<FFloorBiasMod>(P, RoomPtr)); // fin 4h — biais de sol 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, OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
+21
View File
@@ -415,6 +415,27 @@ public:
* ValidateDeterminism qui échantillonne le long d'une frontière en X ne le verrait pas. * ValidateDeterminism qui échantillonne le long d'une frontière en X ne le verrait pas.
*/ */
virtual bool IsXYPure() const { return false; } 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(); 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. * 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. * spine (0,0) seal de frontière carve de passage.