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:
@@ -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<FString, int32> 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<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)
|
||||
{
|
||||
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(
|
||||
|
||||
@@ -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<FDomeMod>(P, RoomPtr)); // 4g — dômes
|
||||
OutStack.Add(MakeUnique<FPinchMod>(P, RoomPtr)); // 4h — pincement
|
||||
OutStack.Add(MakeUnique<FFloorBiasMod>(P, RoomPtr)); // fin 4h — biais de sol
|
||||
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed));
|
||||
// ⚠️ `RoomPtr` N'EST PAS DÉCORATIF ICI. Le ver hérite du verdict de boîte de la source de
|
||||
// salles, faute de quoi il rend `CarveOnly` partout et tue `AllSolid` sur chaque tuile —
|
||||
// avec les défauts (`BaseDensity = 8`, `WormStrength = 10`) la marge part négative, donc
|
||||
// aucune tuile n'est prouvable, quoi que la source de salles ait réussi à prouver.
|
||||
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed, RoomPtr));
|
||||
|
||||
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
|
||||
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
|
||||
|
||||
@@ -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)"); }
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user