diff --git a/CODEMAP.md b/CODEMAP.md index 2ce4766..a8144c2 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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::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]`. | -| `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 4b–4h (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. | diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 1309237..681bf9d 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -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 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: x, x, ... +``` + +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::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. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index 1b2c990..182213e 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -1106,6 +1106,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0; int32 NumBruteSamples = 0, NumViolations = 0; float WorstViolation = 0.0f; + TMap SolidKillerCounts; FRandomStream Rng(97531); 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 + 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; } ++NumProved; @@ -1158,13 +1170,37 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) TEXT("verdict leaves no geometry and no collision behind it."), 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& 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) { - 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.")); + AddWarning(TEXT("No TunnelNetwork tile was proved, so the brute force below verified ") + TEXT("nothing -- it has no verdict to contradict. Do NOT re-derive the cause: ") + TEXT("read the 'AllSolid killed by' line above, which names the operator and ") + TEXT("counts how often. If it names RoomGraphSource, the tiles genuinely ") + 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( diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 5ed289b..70bd349 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -144,6 +144,8 @@ namespace return FMath::Abs(Value); } + const TCHAR* DebugName() const override { return TEXT("ConstantFieldSource"); } + private: float Value; }; @@ -1119,6 +1121,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 +1163,8 @@ namespace return EVoxelOpEffect::CarveOnly; } + const TCHAR* DebugName() const override { return TEXT("OriginSpineOp"); } + private: float TopZ, BotZ, Seal, Base, Radius; }; @@ -1226,6 +1232,8 @@ namespace return EVoxelOpEffect::FillOnly; } + const TCHAR* DebugName() const override { return TEXT("BoundarySealOp"); } + private: float TopZ, BotZ, Thickness, Base; }; @@ -1258,6 +1266,8 @@ namespace ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; } + const TCHAR* DebugName() const override { return TEXT("PassageCarveOp"); } + private: const UVoxelStrateManager* Manager; float Base, Seal; @@ -2393,6 +2403,8 @@ namespace return B.Verdict; } + const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); } + private: FStrateGenerationParams P; int32 Seed; @@ -3505,8 +3517,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 {} @@ -3572,10 +3588,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 où 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. Là où `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 où 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; } /** @@ -3588,8 +3651,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(); } @@ -3599,15 +3666,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. @@ -3819,7 +3890,11 @@ namespace VoxelDensityOps OutStack.Add(MakeUnique(P, RoomPtr)); // 4g — dômes OutStack.Add(MakeUnique(P, RoomPtr)); // 4h — pincement OutStack.Add(MakeUnique(P, RoomPtr)); // fin 4h — biais de sol - OutStack.Add(MakeUnique(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(P, Seed, RoomPtr)); OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index e973eeb..df95fd9 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -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-là » 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 où 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)"); } }; //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 582feb7..ced5887 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -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.