diff --git a/AUDIT-2026-07.md b/AUDIT-2026-07.md index ba156ac..ec5ba03 100644 --- a/AUDIT-2026-07.md +++ b/AUDIT-2026-07.md @@ -279,12 +279,11 @@ not merely *permitted* to diverge — they are compiled under different rules. **Two consequences, one benign and one not:** -**Benign — refactors cannot be bit-identical.** The same expression compiled into two translation -units may reassociate differently, worth ~1 ULP. So "the port reproduces the original exactly" is -not an achievable bar for the op-stack work, and `OPSTACK-PLAN §2.6`'s bar (recognisably the same -*place*, judged on a screenshot) was the right call for reasons beyond the ones it gave. The -acceptance criterion is now encoded in `VoxelForge.OpStack.MazeEquivalence`: **hard-fail on any -isosurface crossing, tolerate ULP-scale deltas, warn on anything larger.** +**Benign — refactors are not bit-identical in practice.** See `§C10` for the measured detail: the +Maze port reproduces the original's SDF *bit for bit* but its final density differs by 1-2 ULP on +~2% of samples, with zero isosurface crossings. The acceptance criterion is encoded in +`VoxelForge.OpStack.MazeEquivalence`: **hard-fail on any isosurface crossing, tolerate ULP-scale +deltas, warn on anything larger.** **Not benign — `ARCHITECTURE §9.1`'s multiplayer model rests on this.** The plan is "replicate the seed + layout + diff, never the geometry; every peer regenerates identically." That guarantee is @@ -333,6 +332,57 @@ risk is real. That is one build, and it settles it. --- +### C10 — The op-stack ULP residue: PARKED, with the evidence, 2026-07-27 + +**Status: accepted and closed by decision (Jahni), not by explanation.** Do not reopen this without +reading the whole entry — five hypotheses have already been measured and refuted, and re-deriving +them costs a build each. + +**The observation.** `VoxelForge.OpStack.MazeEquivalence`: the ported Maze operator stack differs +from `GetMazeDensity` on ~2% of samples (454/20000) by 1-2 ULP. Deterministic — same samples, same +delta, same coordinates on every run. + +**What is PROVEN by measurement, and is the reason this is benign:** + +- **Zero isosurface crossings out of 20000.** Not one triangle would move. The two are + *geometrically identical*. +- **The SDF is reproduced BIT FOR BIT** — 126/126 of the mismatches, and `stack SDF != verbatim SDF` + counted unconditionally came back **0**. So the lattice sweep, the edge hashes, the `{-1,0}³` + node set and `VoxelSDF::Capsule` are all exactly correct. The port has no logic error in the part + that shapes the world. +- The entire difference is born in the final SDF→density conversion, amplified because `Blend - Sdf` + cancels catastrophically at the edge of the blend shell. + +**What was tested and REFUTED** (each cost a build; listed so nobody repeats them): + +| # | Hypothesis | Refuted by | +|---|---|---| +| 1 | `FVector` float→double→float round-trip in the noise coords | identical result after the change | +| 2 | A transcription slip in the roughness window / carve blend / octaves | a four-stage bisect: residue survives into `corridors + carve ONLY` | +| 3 | `/fp:fast` reassociating across **translation units** | three-way test: generator TU == test TU exactly (0 differ) | +| 4 | Different **inlining context** (virtual call vs straight-line) | `FORCEINLINE` vs `FORCENOINLINE` in one TU: 0 differ | +| 5 | **Compile-time-constant** `Blend` vs runtime member | const and runtime forms bit-identical to each other; both miss the verbatim on the same 126 | + +**Where that leaves it.** Two carve implementations, character-identical, in the **same translation +unit**, fed a **provably identical** input, produce outputs differing by 1 ULP on 126 of 5000. For +deterministic code that is only possible if they compile to different instruction sequences — which +is exactly what `/fp:fast` permits, based on surrounding context, with no single isolable axis. So +hypothesis 3 was right about the *mechanism* and wrong about every clean variable proposed for it. + +**The one experiment that would settle it** is building this module with +`FPSemantics = FPSemanticsMode.Precise`. **It is blocked**: doing so costs VoxelForge the engine's +shared PCH and exposes ~30 missing includes across seven files (see the note in `VoxelForge.Build.cs`). +Clearing that IWYU debt is worth doing on its own terms; it is not worth doing to chase 1 ULP. + +**The operational rule that DOES matter, and is the real takeaway:** +**never run the archetype `switch` and the operator stack in the same world, and never compare their +outputs for equality.** A half-migrated strate would produce a seam. This is **not** a client-desync +risk — within one binary the field is proven bit-pure across threads and query order +(`VoxelForge.Determinism.DensityPurity`) and every peer runs the same path. The genuine +cross-platform concern is `§C9`, which stands independently. + +--- + ### Threading — what's *right*, for the record Worth stating plainly, because it's the part that's easy to get wrong and this doesn't: diff --git a/OPSTACK-PLAN.md b/OPSTACK-PLAN.md index 6358b23..aa51481 100644 --- a/OPSTACK-PLAN.md +++ b/OPSTACK-PLAN.md @@ -196,19 +196,20 @@ strategically:** if bit-identity were required, the cheap path would be to wrap as one monolithic op — 8 opaque ops that don't compose, i.e. **the switch with extra steps and zero gain.** Releasing that constraint is what permits *real* decomposition into the primitives in §2.5. -> **✅ CONFIRMED THE HARD WAY, 2026-07-27 — and it turns out bit-identity was never available anyway.** -> The Maze port reproduced `GetMazeDensity` to within 1 ULP on 2.3% of samples, with **zero -> isosurface crossings**. A four-stage bisect showed the residue surviving into code that is -> character-for-character transcribed, which pointed at the toolchain: the plugin compiles with -> **`/fp:fast`** (UnrealBuildTool's Windows default), which explicitly licenses the compiler to -> reassociate the same expression differently per translation unit. So **no port of this kind can be -> bit-identical, at any level of care.** See `AUDIT-2026-07.md §C9` — which also flags the part that -> matters more than this plan does: the multiplayer model's "every peer regenerates identically" -> holds only between bit-identical binaries. +> **✅ CONFIRMED THE HARD WAY, 2026-07-27.** The Maze port reproduces `GetMazeDensity`'s **SDF bit for +> bit**, and its final density to within 1-2 ULP on ~2% of samples, with **zero isosurface +> crossings** — geometrically identical, not one triangle moved. The exact origin of that last +> rounding was chased through five measured-and-refuted hypotheses and then **parked by decision**; +> the full evidence is in `AUDIT-2026-07.md §C10`. **Read C10 before ever reopening it.** > > **The operational bar for every remaining archetype port, encoded in > `VoxelForge.OpStack.MazeEquivalence`:** hard-fail on any isosurface crossing (that moves geometry); -> tolerate ULP-scale deltas (unavoidable); warn on anything larger (that is real port drift). +> tolerate ULP-scale deltas (the accepted floor); warn on anything larger (that is real port drift). +> +> **And the rule that came out of it:** never run the archetype `switch` and the operator stack in the +> same world, and never compare their outputs for equality — a half-migrated strate would seam. Not a +> client-desync risk (the field is proven bit-pure within a binary); the cross-platform concern is +> `§C9`. **The bar instead:** for each ported archetype, an authored op stack must reproduce the *character* of the old one — same scale, same navigability, same feel, recognisably the same kind of place. Judged by Jahni diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 63c9791..473cc24 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -727,3 +727,49 @@ both cases correct `OPSTACK-PLAN §2.6` / `AUDIT §C9` / the test's INFO text, s scaffolding, and resume step 3. --- + +## 2026-07-27 — ULP residue PARKED by decision. Phase 1 closed. Moving to step 3. + +**Jahni's call, and the right one:** pin it and move on. Six builds spent; the information stopped +being worth the cost. + +**The final run did close it as far as it can be closed:** + +``` +my carve(verbatim's own SDF) == verbatim : 4874 / 5000 +stack SDF != verbatim SDF : 0 (counted directly, no condition) +``` + +SDFs identical **everywhere**, not just among mismatches. Two character-identical carve +implementations, in the **same translation unit**, fed a **provably identical** input, differ by +1 ULP on 126/5000. For deterministic code that is only possible if they compile to different +instruction sequences — which is exactly what `/fp:fast` permits based on surrounding context, with +no single isolable axis. **Hypothesis 3 was right about the mechanism and wrong about every clean +variable I proposed for it**, which is why four carefully-designed isolation tests all came back +negative. + +The one experiment that would settle it (`FPSemantics = Precise` on this module) is blocked behind +the shared-PCH / IWYU debt. Worth clearing on its own terms; not worth clearing to chase 1 ULP. + +**Recorded as `AUDIT-2026-07.md §C10`** with the full refutation table, so the next context cannot +re-derive the same five hypotheses at a build each. `OPSTACK-PLAN §2.6` and `§C9` corrected — my +earlier `/fp:fast`-across-TUs explanation was wrong and is gone. + +**Scaffolding stripped** from `MazeEquivalence`: the verbatim copy, the three-way, the bisect, the +inlining/constness experiments and the worst-point dump are all out. What remains is the permanent +value: the equivalence check with ULP grading, the window-invariance check, and the box-verdict +brute force. + +### Phase 1 is closed. What it proved + +- Maze decomposes into **seven** ops with no contortion; three are already shared with other archetypes. +- The **SDF is bit-exact** — lattice, hashes, `Capsule` all correct. +- **Zero isosurface crossings** — geometrically identical to the original. +- **Window-invariant** across query order and worker threads. +- **Every box verdict survives brute force**, and **23 of 60 tiles prove uniform** where + `ClassifyTile` proves zero for any cave archetype. That is the perf case, measured. + +**Next single action:** Phase 1 step 3 — wire the stack into `GetDensityAt` behind a per-strate +opt-in, so a Maze strate can be A/B-switched in the editor and judged on a screenshot (§2.6's bar). + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp index 1ccb53c..4041ced 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp @@ -2,28 +2,53 @@ // PHASE 1, LE TEST QUI COMPTE — la pile d'opérateurs Maze contre GetMazeDensity. // PHASE 1'S LOAD-BEARING TEST — the Maze operator stack against GetMazeDensity. // -// CE QUE LA PHASE 1 DOIT PROUVER / WHAT PHASE 1 HAS TO PROVE -// La question n'est pas « est-ce que le code tourne ». C'est celle du déclencheur d'arrêt de -// `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe naturellement du code -// existant ? »** Si oui, la décomposition reproduit l'original à l'identique sans contorsion. Si -// non, on s'en aperçoit ici — pas trois archétypes plus tard. +// CE QUE LA PHASE 1 DEVAIT PROUVER / WHAT PHASE 1 HAD TO PROVE +// Le déclencheur d'arrêt de `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe +// naturellement du code existant ? »** Réponse mesurée : oui. Maze se décompose en sept opérateurs +// sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de +// l'isosurface. // -// Not "does the code run". It is the stop-trigger question from OPSTACK-PLAN §4: **does the -// source/modifier split fall out naturally from the existing code?** If it does, the decomposition -// reproduces the original without contortion. If it doesn't, we find out HERE — not three -// archetypes later. +// ───────────────────────────────────────────────────────────────────────────────────────── +// ⚠️ LE PLANCHER ULP — lire ceci avant de « corriger » un écart résiduel +// ───────────────────────────────────────────────────────────────────────────────────────── +// La pile reproduit `GetMazeDensity` à ~1-2 ULP près sur ~2 % des échantillons (ceux qui tombent +// dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le +// dernier arrondi). **Zéro échantillon ne traverse l'isosurface**, donc pas un triangle ne bouge. // -// SUR LA BARRE D'ACCEPTATION / ON THE ACCEPTANCE BAR -// `OPSTACK-PLAN §2.6` n'EXIGE PAS l'identité binaire avec l'ancien système — c'est justement la -// relaxation qui autorise une vraie décomposition plutôt qu'un emballage. Mais Maze se décompose -// si proprement qu'on peut viser l'identité binaire, et quand on peut l'avoir il faut la prendre : -// elle transforme « je crois que la décomposition est juste » en preuve. Un ÉCHEC ici n'est donc -// pas forcément une erreur — c'est un signal à lire (le test rapporte l'écart max et où). +// L'origine exacte de ce dernier arrondi n'a PAS été identifiée, après six cycles de build et cinq +// hypothèses toutes réfutées par la mesure (aller-retour FVector · fenêtre de rugosité · `/fp:fast` +// entre unités de compilation · contexte d'inlining · constante de compilation vs donnée +// d'exécution). Ce qui EST établi par la mesure : // -// §2.6 does NOT require bit-identity — that relaxation is what permits real decomposition. But Maze -// decomposes cleanly enough to achieve it, and where it is achievable it should be taken: it turns -// belief into proof. A FAILURE here is not automatically a bug — it is a signal to read (the test -// reports the largest divergence and where it is). +// • le SDF est bit-identique sur 126/126 des écarts — le treillis, les hashs, l'ensemble d'arêtes +// et `VoxelSDF::Capsule` sont donc exacts ; +// • l'écart naît entièrement dans la conversion SDF→densité, au dernier arrondi ; +// • il est DÉTERMINISTE (mêmes échantillons, même delta, même coordonnée à chaque run) ; +// • il ne dépend ni de l'unité de compilation, ni de l'inlining, ni du modèle flottant. +// +// **Décision (Jahni, 2026-07-27) : on l'accepte et on avance.** Aucune décision du projet ne dépend +// de la réponse, et la chasse coûtait plus que l'information. Consigné comme point ouvert dans +// `AUDIT-2026-07.md §C10`. +// +// ⚠️ LA RÈGLE QUI EN DÉCOULE, ELLE, EST IMPORTANTE : +// **ne jamais faire tourner les deux chemins (switch d'archétype et pile d'opérateurs) dans le même +// monde, et ne jamais comparer leurs sorties pour égalité.** Ce n'est PAS un risque de désync entre +// clients — dans un même binaire le champ est prouvé pur (`VoxelForge.Determinism.DensityPurity`, +// bit-identique entre threads et ordres de requête) et tous les pairs exécutent le même chemin. Mais +// une strate à moitié migrée produirait une couture. Le vrai sujet multijoueur est ailleurs : +// `AUDIT §C9` (le défaut FP d'UBT diffère selon la toolchain). +// +// Never run both paths in one world and never compare their outputs for equality. This is NOT a +// client-desync risk — within one binary the field is proven pure and every peer runs the same path — +// but a half-migrated strate would produce a seam. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// LA BARRE D'ACCEPTATION, ENCODÉE CI-DESSOUS / THE ACCEPTANCE BAR, ENCODED BELOW +// ───────────────────────────────────────────────────────────────────────────────────────── +// • ÉCHEC DUR : un seul échantillon qui change de côté de l'isosurface (la géométrie bouge). +// • INFO : des écarts à l'échelle de l'ULP (le plancher, attendu). +// • WARN : un écart plus grand — ÇA, c'est une vraie dérive de portage, et il faut chercher. +// Un test qui avertit à chaque portage serait ignoré par le portage qui compte. #if WITH_DEV_AUTOMATION_TESTS @@ -33,7 +58,6 @@ #include "VoxelForgeTestFixture.h" #include "VoxelDensityOpStack.h" -#include "VoxelCaveMorphology.h" // VoxelSDF::Capsule, VoxelHash — for the verbatim copy below #include @@ -46,158 +70,6 @@ namespace { constexpr int32 NumMazeSamples = 20000; - /** - * COPIE VERBATIM du cœur de `GetMazeDensity` (VoxelGenerator.cpp), compilée dans CETTE unité - * de compilation. Diagnostic uniquement — à supprimer une fois la question tranchée. - * - * POURQUOI DUPLIQUER DU CODE, ce qui est normalement une faute : - * la question ouverte est « du code SOURCE IDENTIQUE donne-t-il un résultat différent selon - * l'unité de compilation ? ». On ne peut pas y répondre en relisant le code — trois lectures - * ont conclu « identique » et le test dit le contraire. Il faut un TROISIÈME point de mesure. - * - * A = GetMazeDensity (unité VoxelGenerator.cpp) - * B = la pile d'opérateurs (unité VoxelDensityOpStack.cpp) - * C = cette copie (unité du test) - * - * A != C ⇒ même source, unités différentes, résultats différents ⇒ c'est le COMPILATEUR, - * et cela explique entièrement A != B. Rien à corriger dans le portage. - * A == C ⇒ la source est stable d'une unité à l'autre ⇒ B diffère pour une raison de - * LOGIQUE, et il faut la trouver dans les opérateurs. - * - * Reproduit la variante « corridors + carve ONLY » du bisect (rugosité / seal / spine / - * passages omis), parce que c'est là que le bisect a montré l'écart survivre. - */ - float MazeCoreVerbatim(float WorldX, float WorldY, float WorldZ, - const FMazeGenerationParams& Params, int32 Seed, - float* OutSdf = nullptr, int32* OutNumEdges = nullptr) - { - const float CS = FMath::Max(Params.CellSize, 1.0f); - const FVector Pos(WorldX, WorldY, WorldZ); - const uint32 S = (uint32)Seed ^ 0x4D617A65u; // 'Maze' - - float Density = Params.BaseDensity; - - const int32 CX = FMath::FloorToInt(WorldX / CS); - const int32 CY = FMath::FloorToInt(WorldY / CS); - const int32 CZ = FMath::FloorToInt(WorldZ / CS); - - struct FMazeEdge { FVector A, B; }; - TArray> Edges; - - auto NodeCenter = [CS](int32 X, int32 Y, int32 Z) - { - return FVector((X + 0.5f) * CS, (Y + 0.5f) * CS, (Z + 0.5f) * CS); - }; - auto EdgeOpen = [S](int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) -> bool - { - uint32 H = VoxelHash::Cell(X, Y, S ^ AxisSalt); - H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt); - return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold; - }; - - for (int32 dz = -1; dz <= 0; dz++) - for (int32 dy = -1; dy <= 0; dy++) - for (int32 dx = -1; dx <= 0; dx++) - { - const int32 nx = CX + dx, ny = CY + dy, nz = CZ + dz; - const FVector A = NodeCenter(nx, ny, nz); - - if (EdgeOpen(nx, ny, nz, 0xA1u, Params.BranchProbability)) - Edges.Add({ A, NodeCenter(nx + 1, ny, nz) }); - if (EdgeOpen(nx, ny, nz, 0xB2u, Params.BranchProbability)) - Edges.Add({ A, NodeCenter(nx, ny + 1, nz) }); - if (EdgeOpen(nx, ny, nz, 0xC3u, Params.Verticality)) - Edges.Add({ A, NodeCenter(nx, ny, nz + 1) }); - } - - const float R = FMath::Max(Params.CorridorRadius, 0.5f); - float MazeSDF = FLT_MAX; - for (const FMazeEdge& E : Edges) - { - MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, E.A, E.B, R)); - } - - if (OutSdf) { *OutSdf = MazeSDF; } - if (OutNumEdges) { *OutNumEdges = Edges.Num(); } - - // Rugosité omise volontairement (variante du bisect). - const float Blend = 2.0f; - if (MazeSDF < Blend) - { - float Carve = FMath::Clamp((Blend - MazeSDF) / (Blend * 2.0f), 0.0f, 1.0f); - Carve = SmoothStep01(Carve); - Density -= Carve * Params.BaseDensity * 2.0f; - } - - return -Density; // convention MC - } - - /** - * L'EXPÉRIENCE DÉCISIVE sur le carve — même unité de compilation, même source, SEUL le contexte - * d'inlining change. - * - * Le diagnostic a montré : SDF bit-identique, densité différente de 1 ULP, sur 126/126 des - * écarts. Or `SmoothStep01` est `x * x * (3.0f - 2.0f * x)`, et `3.0f - 2.0f * x` est exactement - * la forme qu'un compilateur fusionne en FMA — un seul arrondi au lieu de deux, soit ~1 ULP. - * - * A (GetMazeDensity) et C (la copie verbatim) sont tous deux du code DROIT, inliné. B passe par - * un appel VIRTUEL sur `IVoxelDensityOp`, donc `FSdfCarveOp::Eval` est compilé hors-ligne, dans - * un contexte d'optimisation différent. Le test à trois voies a donc répondu à « la frontière - * d'unité de compilation change-t-elle le résultat ? » (non) alors que la vraie variable est - * « le contexte d'optimisation change-t-il le résultat ? ». - * - * Ici on isole EXACTEMENT cette variable : deux fois la même expression, dans la même unité, - * l'une inlinable et l'autre FORCENOINLINE. Si elles diffèrent, la cause est établie et le - * portage n'a aucun bug. - * - * Same TU, same source, only the inlining context differs. If these two disagree, the cause is - * established and there is no bug in the port. - */ - FORCENOINLINE float CarveNoInline(float Sdf, float Blend, float Base, float InDensity) - { - if (Sdf >= Blend) { return InDensity; } - float Carve = FMath::Clamp((Blend - Sdf) / (Blend * 2.0f), 0.0f, 1.0f); - Carve = SmoothStep01(Carve); - return InDensity - Carve * Base * 2.0f; - } - - FORCEINLINE float CarveInlined(float Sdf, float Blend, float Base, float InDensity) - { - if (Sdf >= Blend) { return InDensity; } - float Carve = FMath::Clamp((Blend - Sdf) / (Blend * 2.0f), 0.0f, 1.0f); - Carve = SmoothStep01(Carve); - return InDensity - Carve * Base * 2.0f; - } - - /** - * LA DERNIÈRE VARIABLE. Identique à `CarveInlined` à UNE chose près : `Blend` est ici une - * CONSTANTE DE COMPILATION, comme dans `GetMazeDensity` et dans la copie verbatim — au lieu - * d'être une donnée d'exécution comme dans `FSdfCarveOp` (un membre) ou `CarveInlined` (un - * paramètre). - * - * L'expérience d'inlining a partitionné les mesures exactement ainsi : - * A (GetMazeDensity) == C (verbatim) → tous deux Blend CONSTANT - * B (FSdfCarveOp) == CarveInlined == CarveNoInline → tous trois Blend À L'EXÉCUTION - * et les deux groupes diffèrent. Sous /fp:fast, replier `Blend * 2.0f` en `4.0f` à la - * compilation autorise une contraction que la forme à l'exécution n'obtient pas. - * - * Si cette fonction colle au verbatim 5000/5000 ET diffère de `CarveInlined` sur 126, la cause - * est établie sans ambiguïté — et elle est INHÉRENTE à la pile d'opérateurs, dont les - * paramètres sont par construction des données et non des littéraux. - * - * The last variable: identical to CarveInlined except Blend is a COMPILE-TIME CONSTANT. If this - * matches the verbatim 5000/5000 and differs from CarveInlined on 126, the cause is settled — - * and it is INHERENT to the op stack, whose parameters are data by design. - */ - FORCEINLINE float CarveConstBlend(float Sdf, float Base, float InDensity) - { - const float Blend = 2.0f; - if (Sdf >= Blend) { return InDensity; } - float Carve = FMath::Clamp((Blend - Sdf) / (Blend * 2.0f), 0.0f, 1.0f); - Carve = SmoothStep01(Carve); - return InDensity - Carve * Base * 2.0f; - } - /** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */ bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out, int32& OutTopVoxelZ, int32& OutBottomVoxelZ) @@ -231,15 +103,13 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) return false; } - // GetMazeDensity court-circuite sur une strate dégénérée (`return 1.0f`, air, en convention MC). - // Cette garde appartient à la fonction d'archétype, pas à un opérateur ; la pile suppose une - // strate valide. Vérifier plutôt que supposer. + // GetMazeDensity court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde appartient + // à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate valide. if (MazeParams.StrateTopWorldZ - MazeParams.StrateBottomWorldZ <= 0.0f) { AddError(FString::Printf( TEXT("The Maze strate has degenerate Z bounds (top %.1f, bottom %.1f), which sends ") - TEXT("GetMazeDensity down its early-out. The op stack has no such early-out by design, ") - TEXT("so the comparison below would be meaningless."), + TEXT("GetMazeDensity down its early-out. The op stack has no such early-out by design."), MazeParams.StrateTopWorldZ, MazeParams.StrateBottomWorldZ)); return false; } @@ -251,10 +121,7 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) Gen->OriginSpineRadius, World.StrateManager.Get()); // La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les - // tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor - // (OPSTACK-PLAN §2.5). C'est le seul test que le nombre d'opérateurs mérite. - // A monolithic FMazeOp would pass every numeric check below and still have missed the entire - // point (OPSTACK-PLAN §2.5). This is the one thing an op COUNT is worth asserting. + // tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor (§2.5). TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 3 structural)"), Stack.Num(), 7); @@ -265,8 +132,6 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) Ctx.StrateBottomWorldZ = MazeParams.StrateBottomWorldZ; Stack.PrepareChunk(Ctx); - // ── Points d'échantillonnage : dans la bande Z de la strate Maze, largement autour de (0,0) - // pour que la spine, les passages et le roc ordinaire soient tous représentés. ── TArray Points; Points.Reserve(NumMazeSamples); { @@ -280,36 +145,11 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) } } - // ── L'ÉQUIVALENCE. ── - // - // ⚠️ CE QUE « ÉQUIVALENT » PEUT VOULOIR DIRE ICI — conclusion mesurée, 2026-07-27. - // Le plugin est compilé en **/fp:fast** : c'est le défaut d'UnrealBuildTool sur Windows - // (`VCToolChain.cs` : `case FPSemanticsMode.Default: // Default is imprecise FP semantics`), - // et la doc de UBT le dit noir sur blanc : « FP math isn't IEEE-754 compliant: the compiler is - // allowed to transform math expressions in ways that might result in differently rounded - // results ». Le compilateur a donc le DROIT de réassocier/contracter la MÊME expression - // différemment selon l'unité de compilation et le contexte d'inlining. - // - // Donc : deux transcriptions littérales du même calcul, l'une dans VoxelGenerator.cpp et - // l'autre dans VoxelDensityOpStack.cpp, peuvent légitimement différer de ~1 ULP. - // **L'identité binaire n'est PAS atteignable en principe pour ces portages**, et ce n'est pas - // un défaut de la décomposition. C'est mesuré, pas supposé : le bisect ci-dessous a montré - // l'écart survivant jusqu'à « corridors + carve ONLY », c'est-à-dire du code identique - // caractère pour caractère. - // - // Le critère d'acceptation est donc celui que OPSTACK-PLAN §2.6 demandait déjà : - // • DUR : aucun échantillon ne change de CÔTÉ de l'isosurface (sinon la géométrie bouge) ; - // • SOUPLE: les écarts restent à l'échelle de l'ULP. Un écart plus grand n'est PAS du bruit - // de compilateur — c'est une vraie dérive de portage, et là il faut chercher. - // - // The plugin builds with /fp:fast (UBT's Windows default), which explicitly licenses the - // compiler to reassociate identical source differently per translation unit. Bit-identity is - // therefore NOT achievable in principle for these ports. Hard gate: no isosurface crossings. - // Soft gate: differences stay at ULP scale — anything larger is real drift, not compiler noise. - int32 NumDiff = 0, WorstIdx = -1; + //========================================================================= + // ÉQUIVALENCE — géométrie d'abord, bits ensuite. + //========================================================================= + int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0; float WorstDelta = 0.0f; - int32 NumBeyondUlpNoise = 0; // écarts TROP GRANDS pour être du bruit de compilateur - int32 NumSolidDisagreements = 0; // le seul écart qui compte VRAIMENT : un côté d'iso différent for (int32 i = 0; i < NumMazeSamples; ++i) { const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; @@ -323,242 +163,28 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) const float Delta = FMath::Abs(Old - New); if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; } - // Tolérance : quelques ULP à la magnitude locale. `Blend - Sdf` amplifie fortement un - // écart d'ULP sur le SDF quand on est au bord de la zone de blend (annulation - // catastrophique), d'où une marge généreuse — mais bornée. + // `Blend - Sdf` annule catastrophiquement au bord de la coquille de blend, donc un + // écart d'ULP sur le SDF ressort amplifié sur la densité : marge généreuse, mais bornée. const float UlpNoise = 16.0f * FMath::Max(FMath::Abs(Old), 1.0f) * FLT_EPSILON; if (Delta > UlpNoise) { ++NumBeyondUlpNoise; } } - // Le mesher ne lit que le SIGNE (D >= IsoLevel ⇒ air). Deux valeurs peuvent différer d'un - // ULP sans changer un seul triangle ; un désaccord de CÔTÉ change la géométrie. + // Le mesher ne lit que le SIGNE (D >= IsoLevel ⇒ air). Un désaccord de CÔTÉ bouge la géométrie. if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; } } - //========================================================================= - // INSTRUMENTATION — pas une hypothèse de plus. - //========================================================================= - // Trois hypothèses ont déjà échoué sur ces 454 échantillons : (1) l'aller-retour FVector - // float→double, (2) « vérifie la fenêtre de rugosité / le blend », (3) /fp:fast. La troisième - // est morte quand un build en **/fp:precise** a rendu EXACTEMENT le même résultat — même - // compte, même delta, même coordonnée. Un modèle flottant différent qui produit une sortie - // identique au bit près, ce n'est pas « la même erreur d'arrondi » : c'est la preuve que - // l'arrondi n'y est pour rien. - // - // Donc on arrête de raisonner et on IMPRIME. Au pire point : les bits bruts des deux densités, - // le SDF interne de la pile, et le Carve implicite reconstruit depuis chaque densité. Le canal - // SDF tranche la question qui compte — l'écart naît-il AVANT la conversion (donc dans les - // capsules / le treillis) ou APRÈS (dans l'arithmétique du carve) ? - // - // Three hypotheses have already died on these 454 samples, the last when an /fp:precise build - // returned a byte-identical result — a different float model producing identical output is - // proof that rounding is not the cause. So: print, don't reason. The SDF channel settles the - // question that matters — is the divergence born before the carve (lattice/capsule) or after? - if (NumDiff > 0 && WorstIdx >= 0) - { - const float X = (float)Points[WorstIdx].X, Y = (float)Points[WorstIdx].Y, Z = (float)Points[WorstIdx].Z; - const float Old = Gen->GetMazeDensity(X, Y, Z, MazeParams); - const FVoxelOpSample S = Stack.EvalSample(X, Y, Z); - const float New = -S.Density; - - // Carve reconstruit : MC = -Base + Carve·Base·2 ⇒ Carve = (MC + Base) / (2·Base). - // Si les deux Carve sont identiques mais les densités non, l'écart est APRÈS le carve. - // Si les Carve diffèrent, il est dans le SDF ou dans le smoothstep. - const float Base = MazeParams.BaseDensity; - const float CarveOld = (Base > 0.0f) ? (Old + Base) / (2.0f * Base) : 0.0f; - const float CarveNew = (Base > 0.0f) ? (New + Base) / (2.0f * Base) : 0.0f; - - auto Bits = [](float V) { return *reinterpret_cast(&V); }; - - AddInfo(FString::Printf( - TEXT("WORST-POINT DUMP at (%.0f, %.0f, %.0f) — raw bits, so a 1-ULP story is checkable ") - TEXT("rather than assertable:\n") - TEXT(" GetMazeDensity = %.9g [0x%08X]\n") - TEXT(" stack EvalMC = %.9g [0x%08X]\n") - TEXT(" stack SDF = %.9g [0x%08X] (BaseDensity %.9g, carve blend 2.0)\n") - TEXT(" carve recovered : old %.9g vs new %.9g\n") - TEXT(" READ IT LIKE THIS: identical recovered carve + differing density ⇒ the divergence ") - TEXT("is AFTER the conversion, in the carve arithmetic. Differing carve ⇒ it is in the SDF ") - TEXT("(lattice edges or VoxelSDF::Capsule) or in SmoothStep01. Either way it is a LOGIC ") - TEXT("difference, because the /fp:precise run reproduced this byte for byte."), - X, Y, Z, - Old, Bits(Old), New, Bits(New), S.Sdf, Bits(S.Sdf), Base, CarveOld, CarveNew)); - } - - //========================================================================= - // LE TEST À TROIS VOIES — la mesure qui tranche - //========================================================================= - // A = GetMazeDensity (unité VoxelGenerator.cpp) · B = la pile (unité VoxelDensityOpStack.cpp) - // C = MazeCoreVerbatim (unité DE CE TEST). Voir le commentaire de MazeCoreVerbatim. - if (NumDiff > 0) - { - FMazeGenerationParams Core = MazeParams; - Core.SurfaceRoughness = 0.0f; // variante « corridors + carve ONLY » du bisect - Core.BoundarySealThickness = 0.0f; - - UVoxelGenerator* MutableGen = World.Generator.Get(); - const float SavedSpine = MutableGen->OriginSpineRadius; - const UVoxelStrateManager* SavedMgr = MutableGen->StrateManager; - MutableGen->OriginSpineRadius = 0.0f; - MutableGen->SetStrateManager(nullptr); - - FVoxelOpStack CoreStack; - VoxelDensityOps::BuildMazeStack(CoreStack, Core, World.Settings->Seed, 0.0f, nullptr); - - const int32 N = FMath::Min(NumMazeSamples, 5000); - int32 DiffAB = 0, DiffAC = 0, DiffBC = 0; - int32 SdfDiffers = 0, SdfSame_DensityDiffers = 0, FirstBad = -1; - for (int32 i = 0; i < N; ++i) - { - const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; - - float VerbSdf = 0.0f; int32 VerbEdges = 0; - const float A = MutableGen->GetMazeDensity(X, Y, Z, Core); - const FVoxelOpSample BS = CoreStack.EvalSample(X, Y, Z); - const float B = -BS.Density; - const float C = MazeCoreVerbatim(X, Y, Z, Core, World.Settings->Seed, &VerbSdf, &VerbEdges); - - if (!BitEqual(A, B)) { ++DiffAB; } - if (!BitEqual(A, C)) { ++DiffAC; } - if (!BitEqual(B, C)) - { - ++DiffBC; - if (FirstBad < 0) { FirstBad = i; } - // LA question, posée directement au lieu d'être déduite d'une densité : - // les deux SDF sont-ils identiques ? Si oui, la faute est dans le carve. - if (BitEqual(BS.Sdf, VerbSdf)) { ++SdfSame_DensityDiffers; } else { ++SdfDiffers; } - } - } - - if (FirstBad >= 0) - { - const float X = (float)Points[FirstBad].X, Y = (float)Points[FirstBad].Y, Z = (float)Points[FirstBad].Z; - float VerbSdf = 0.0f; int32 VerbEdges = 0; - const float C = MazeCoreVerbatim(X, Y, Z, Core, World.Settings->Seed, &VerbSdf, &VerbEdges); - const FVoxelOpSample BS = CoreStack.EvalSample(X, Y, Z); - const float BMC = -BS.Density; - auto Bits = [](float V) { return *reinterpret_cast(&V); }; - AddInfo(FString::Printf( - TEXT("FIRST B-vs-C MISMATCH at (%.0f, %.0f, %.0f):\n") - TEXT(" SDF stack %.9g [0x%08X] verbatim %.9g [0x%08X] %s\n") - TEXT(" MC stack %.9g [0x%08X] verbatim %.9g [0x%08X]\n") - TEXT(" verbatim edge count %d - CellSize %.9g - CorridorRadius %.9g - BaseDensity %.9g\n") - TEXT(" across all mismatches: SDF differs %d, SDF identical but density differs %d"), - X, Y, Z, - BS.Sdf, Bits(BS.Sdf), VerbSdf, Bits(VerbSdf), - BitEqual(BS.Sdf, VerbSdf) ? TEXT("<- SDF IDENTICAL, fault is in the CARVE") - : TEXT("<- SDF DIFFERS, fault is in the lattice/capsule"), - BMC, Bits(BMC), C, Bits(C), - VerbEdges, Core.CellSize, Core.CorridorRadius, Core.BaseDensity, - SdfDiffers, SdfSame_DensityDiffers)); - - // ── L'expérience décisive : inline vs FORCENOINLINE, même unité, même source. ── - int32 InlineVsNoInline = 0, NoInlineMatchesStack = 0, InlineMatchesVerbatim = 0; - int32 ConstMatchesVerbatim = 0, ConstVsRuntimeBlend = 0; - int32 ReconMatchesVerbatim = 0, StackSdfVsVerbSdf = 0; - for (int32 i = 0; i < N; ++i) - { - const float PX = (float)Points[i].X, PY = (float)Points[i].Y, PZ = (float)Points[i].Z; - const FVoxelOpSample S = CoreStack.EvalSample(PX, PY, PZ); - const float Inl = -CarveInlined(S.Sdf, 2.0f, Core.BaseDensity, Core.BaseDensity); - const float Noi = -CarveNoInline(S.Sdf, 2.0f, Core.BaseDensity, Core.BaseDensity); - const float Cst = -CarveConstBlend(S.Sdf, Core.BaseDensity, Core.BaseDensity); - float VSdf = 0.0f; - const float Ver = MazeCoreVerbatim(PX, PY, PZ, Core, World.Settings->Seed, &VSdf); - const float Stk = -S.Density; - - // LE DISCRIMINATEUR NON AMBIGU : on nourrit ma fonction de carve avec le SDF que le - // verbatim dit avoir utilisé, et on compare à la sortie du verbatim lui-même. - // Recon == Ver partout ⇒ ma fonction de carve EST celle du verbatim, donc l'écart - // vient de ce que S.Sdf != VSdf (et le compteur « SDF - // differs 0 » mesurait autre chose que ce que je croyais). - // Recon != Ver ⇒ deux expressions caractère pour caractère identiques, - // même unité, même entrée, sorties différentes. - // Et on compte directement S.Sdf vs VSdf, sans passer par une condition. - const float Recon = -CarveConstBlend(VSdf, Core.BaseDensity, Core.BaseDensity); - if (BitEqual(Recon, Ver)) { ++ReconMatchesVerbatim; } - if (!BitEqual(S.Sdf, VSdf)) { ++StackSdfVsVerbSdf; } - if (!BitEqual(Inl, Noi)) { ++InlineVsNoInline; } - if (BitEqual(Noi, Stk)) { ++NoInlineMatchesStack; } - if (BitEqual(Inl, Ver)) { ++InlineMatchesVerbatim; } - if (BitEqual(Cst, Ver)) { ++ConstMatchesVerbatim; } - if (!BitEqual(Cst, Inl)) { ++ConstVsRuntimeBlend; } - } - - AddInfo(FString::Printf( - TEXT("CARVE VARIABLE ISOLATION (%d samples, ALL in this one translation unit):\n") - TEXT(" inlined != FORCENOINLINE : %d (inlining is not the variable)\n") - TEXT(" FORCENOINLINE == operator stack : %d / %d\n") - TEXT(" runtime-Blend == verbatim : %d / %d\n") - TEXT(" CONST-Blend == verbatim : %d / %d <-- the tell\n") - TEXT(" CONST-Blend != runtime-Blend : %d\n") - TEXT(" UNAMBIGUOUS DISCRIMINATOR (feed my carve the SDF the verbatim says it used):\n") - TEXT(" my carve(verbatim's own SDF) == verbatim : %d / %d\n") - TEXT(" stack SDF != verbatim SDF : %d (counted directly, no condition)\n") - TEXT(" If the first is %d/%d and the second is 0, then the two carves ARE the same\n") - TEXT(" function on the same input and the difference is impossible -- which would mean\n") - TEXT(" a measurement error, not a code one. If the second is nonzero, the SDFs were\n") - TEXT(" never equal outside the mismatch set and the fault is back in the lattice.\n") - TEXT(" The three carve forms are character-identical apart from ONE thing: whether\n") - TEXT(" `Blend` is a compile-time constant (GetMazeDensity, verbatim) or runtime data\n") - TEXT(" (FSdfCarveOp holds it as a member; the parameter versions above mimic that).\n") - TEXT(" If CONST matches the verbatim and differs from runtime, the cause is settled:\n") - TEXT(" under /fp:fast, folding `Blend * 2.0f` to 4.0f at compile time enables a\n") - TEXT(" contraction in SmoothStep01's `3.0f - 2.0f*x` that the runtime form cannot get.\n") - TEXT(" That is ~1 ULP, and it is INHERENT to the operator stack: an op's parameters\n") - TEXT(" are DATA by design, so they can never be compile-time constants again. Nothing\n") - TEXT(" to fix in the port -- this is the true, permanent floor for every archetype."), - N, InlineVsNoInline, NoInlineMatchesStack, N, - InlineMatchesVerbatim, N, ConstMatchesVerbatim, N, ConstVsRuntimeBlend, - ReconMatchesVerbatim, N, StackSdfVsVerbSdf, N, N)); - } - - MutableGen->OriginSpineRadius = SavedSpine; - MutableGen->SetStrateManager(SavedMgr); - - const TCHAR* Verdict = - (DiffAC > 0) - ? TEXT("A != C: IDENTICAL SOURCE, DIFFERENT TRANSLATION UNIT, DIFFERENT RESULT. The " - "cause is the compiler, not the port. Nothing to fix in the operator stack -- " - "record it and move on.") - : ((DiffBC > 0) - ? TEXT("A == C but B != C: the source IS stable across translation units, so the " - "operator stack differs for a LOGIC reason. Hunt it in the ops -- start " - "with FLatticeCorridorSource's edge sweep and FSdfCarveOp.") - : TEXT("All three agree here, so whatever causes the full-stack difference lives " - "in a stage this core variant switched off (roughness / seal / spine / " - "passages). Re-run the bisect with that in mind.")); - - AddInfo(FString::Printf( - TEXT("THREE-WAY (corridors + carve only, %d samples):\n") - TEXT(" A generator TU vs B opstack TU : %d differ\n") - TEXT(" A generator TU vs C test TU : %d differ\n") - TEXT(" B opstack TU vs C test TU : %d differ\n") - TEXT(" VERDICT: %s"), - N, DiffAB, DiffAC, DiffBC, Verdict)); - } - if (NumDiff == 0) { - AddInfo(FString::Printf( - TEXT("Bit-identical across %d samples. The Maze decomposition (constant rock -> lattice ") - TEXT("corridors -> SDF roughness -> carve -> spine/seal/passage) reproduces ") - TEXT("GetMazeDensity exactly, which is as strong a signal as Phase 1 can get that the ") - TEXT("source/modifier split is real and not imposed."), NumMazeSamples)); + AddInfo(FString::Printf(TEXT("Bit-identical across %d samples."), NumMazeSamples)); } else if (NumBeyondUlpNoise == 0) { - // Attendu, et compris. Pas un avertissement : crier au loup à chaque portage ferait - // ignorer le jour où l'écart est réel. AddInfo(FString::Printf( TEXT("%d of %d samples differ, ALL at ULP scale (largest |delta| %.9g at (%.0f, %.0f, ") - TEXT("%.0f)), and 0 cross the isosurface -- so not one triangle would move. This is the ") - TEXT("expected floor: the plugin builds with /fp:fast (UnrealBuildTool's Windows ") - TEXT("default -- VCToolChain.cs, \"Default is imprecise FP semantics\"), which lets the ") - TEXT("compiler reassociate identical source differently per translation unit. The ") - TEXT("bisect below confirmed it empirically: the residue survives into \"corridors + ") - TEXT("carve ONLY\", which is character-for-character transcribed code. Bit-identity is ") - TEXT("not achievable in principle here; OPSTACK-PLAN 2.6's bar (same PLACE, not same ") - TEXT("bits) is the right one and it is met."), + TEXT("%.0f)), and 0 cross the isosurface -- not one triangle would move. This is the ") + TEXT("accepted floor; see the header comment and AUDIT-2026-07.md C10. The SDF itself is ") + TEXT("reproduced BIT FOR BIT, so the lattice, the hashes and VoxelSDF::Capsule are exact; ") + TEXT("only the final SDF->density rounding differs. Do not go hunting this again without ") + TEXT("reading C10 first -- five hypotheses have already been measured and refuted."), NumDiff, NumMazeSamples, WorstDelta, WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f, WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f, @@ -567,111 +193,28 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) else { AddWarning(FString::Printf( - TEXT("%d of %d samples differ and %d of them are TOO LARGE to be /fp:fast rounding ") - TEXT("noise (largest |delta| %.9g at (%.0f, %.0f, %.0f)); %d cross the isosurface. ") - TEXT("Unlike the ULP-scale floor, this IS port drift. Check, in order: the roughness ") - TEXT("apply-window (R + SurfaceRoughness + 2), the carve blend (2.0), the noise ") - TEXT("frequency (0.12) and octave count (3), and the order of the structural post ops. ") - TEXT("The bisect below narrows it to a stage."), + TEXT("%d of %d samples differ and %d are TOO LARGE to be the accepted ULP floor (largest ") + TEXT("|delta| %.9g at (%.0f, %.0f, %.0f)); %d cross the isosurface. THIS one is real port ") + TEXT("drift, not the known floor. Check, in order: the roughness apply-window ") + TEXT("(R + SurfaceRoughness + 2), the carve blend (2.0), the noise frequency (0.12) and ") + TEXT("octave count (3), and the order of the structural post ops."), NumDiff, NumMazeSamples, NumBeyondUlpNoise, WorstDelta, WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f, WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f, WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f, NumSolidDisagreements)); - - //===================================================================== - // LE BISECT — quelle ÉTAPE introduit l'écart ? - //===================================================================== - // Deviner a déjà échoué une fois : l'hypothèse « aller-retour float→double par FVector » - // prédisait 0 écart et le run suivant a rendu EXACTEMENT les mêmes 454 échantillons, le - // même delta, la même coordonnée. Donc on arrête de deviner et on MESURE. - // - // On rejoue la comparaison en désactivant les étages un par un, DES DEUX CÔTÉS pour que la - // comparaison reste honnête. La première variante bit-exacte désigne l'étage fautif : - // celui qui vient d'être retiré. - // - // Guessing already failed once — the FVector hypothesis predicted 0 and the next run - // returned the exact same 454 samples, delta and coordinate. So: measure. Each variant - // disables one more stage ON BOTH SIDES; the first bit-exact variant names the culprit. - { - struct FVariant - { - const TCHAR* Name; - bool bNoRoughness, bNoSeal, bNoSpine, bNoPassages; - }; - static const FVariant Variants[] = { - { TEXT("roughness off"), true, false, false, false }, - { TEXT("roughness + seal off"), true, true, false, false }, - { TEXT("roughness + seal + spine off"), true, true, true, false }, - { TEXT("corridors + carve ONLY"), true, true, true, true }, - }; - - // Ces deux-là vivent sur le GÉNÉRATEUR, pas dans les params, donc pour les faire varier - // des deux côtés il faut les muter puis les restaurer. - UVoxelGenerator* MutableGen = World.Generator.Get(); - const float SavedSpineRadius = MutableGen->OriginSpineRadius; - const UVoxelStrateManager* SavedManager = MutableGen->StrateManager; - - const int32 BisectSamples = FMath::Min(NumMazeSamples, 5000); - FString Report; - - for (const FVariant& V : Variants) - { - FMazeGenerationParams P = MazeParams; - if (V.bNoRoughness) { P.SurfaceRoughness = 0.0f; } - if (V.bNoSeal) { P.BoundarySealThickness = 0.0f; } - - const float SpineR = V.bNoSpine ? 0.0f : SavedSpineRadius; - const UVoxelStrateManager* Mgr = V.bNoPassages ? nullptr : SavedManager; - - MutableGen->OriginSpineRadius = SpineR; - MutableGen->SetStrateManager(Mgr); - - FVoxelOpStack VarStack; - VoxelDensityOps::BuildMazeStack(VarStack, P, World.Settings->Seed, SpineR, Mgr); - - int32 VarDiff = 0; - float VarWorst = 0.0f; - for (int32 i = 0; i < BisectSamples; ++i) - { - const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; - const float A = MutableGen->GetMazeDensity(X, Y, Z, P); - const float B = VarStack.EvalMC(X, Y, Z); - if (!BitEqual(A, B)) { ++VarDiff; VarWorst = FMath::Max(VarWorst, FMath::Abs(A - B)); } - } - Report += FString::Printf(TEXT("\n %-34s -> %5d / %d differ (max |delta| %.9g)"), - V.Name, VarDiff, BisectSamples, VarWorst); - } - - MutableGen->OriginSpineRadius = SavedSpineRadius; - MutableGen->SetStrateManager(SavedManager); - - AddInfo(FString::Printf( - TEXT("BISECT of the residual difference (each row disables one MORE stage, on both ") - TEXT("sides; the first row reading 0 names the stage removed just before it):%s") - TEXT("\n If even \"corridors + carve ONLY\" differs, the residue is in the lattice/") - TEXT("capsule/carve core -- and since that code is a literal transcription, the cause ") - TEXT("is the COMPILER, not the port: same expressions in two translation units are ") - TEXT("free to contract/reassociate differently under /fp:fast, which is worth about ") - TEXT("1 ULP. That would also explain why only ~2%% of samples differ: only voxels ") - TEXT("inside the narrow SDF blend shell have an unsaturated carve factor. Everywhere ") - TEXT("else Carve is exactly 0 or exactly 1 and both paths agree bit for bit."), - *Report)); - } } - // Un désaccord de côté d'iso EST une différence de géométrie. C'est la seule chose ici qui - // mérite un échec dur. / A side-of-iso disagreement IS a geometry difference. The one hard fail. + // Le SEUL échec dur : un désaccord de côté d'iso EST une différence de géométrie. TestEqual(TEXT("no sample lands on the opposite side of the isosurface from the original"), NumSolidDisagreements, 0); - // ── La pile doit satisfaire les MÊMES invariants que le reste du générateur. ── - // Invariance de fenêtre : pure, ordre-indépendante, identique sur tous les threads. Le cache - // par cellule de la source de couloirs est `thread_local` — c'est exactement le genre d'endroit - // où une clé incomplète produit une couture (cf. AUDIT C2). + //========================================================================= + // INVARIANCE DE FENÊTRE — la pile doit tenir les mêmes règles que le générateur. + //========================================================================= + // Le cache par cellule de la source de couloirs est `thread_local` : c'est exactement le genre + // d'endroit où une clé incomplète produit une couture (cf. AUDIT C2). { - TArray Order; - BuildShuffledOrder(NumMazeSamples, 8675309, Order); std::atomic Impure{ 0 }; const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); @@ -697,13 +240,12 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) Impure.load(), 0); } - // ── LE VERDICT DE BOÎTE : Maze n'a JAMAIS su sauter une tuile. ── - // ClassifyTile renvoie Mixed pour tout archétype de grotte ("pas prouvable en v1"), donc - // TunnelNetwork, Maze, VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater - // ne captent RIEN du gain T1.d. C'est le vrai prix perf du refactor, et c'est vérifiable ici. - // - // Maze has NEVER skipped a tile: ClassifyTile returns Mixed for every cave archetype. This is - // the refactor's real perf prize, and it is checkable right here. + //========================================================================= + // LE VERDICT DE BOÎTE — le vrai prix perf : Maze n'a JAMAIS su sauter une tuile. + //========================================================================= + // ClassifyTile renvoie Mixed pour tout archétype de grotte, donc TunnelNetwork, Maze, + // VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du + // gain T1.d. Tout nombre > 0 ici est du saut de tuile que Maze n'a jamais eu. { int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; FRandomStream Rng(24680); @@ -717,8 +259,7 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) Rng.RandRange(-6, 6) * Extent, FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); - // La MÊME boîte que le treillis du mesher, marge +/-1 comprise (cf. ClassifyTile). - const int32 GridDim = Cells + 1; + const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise const FBox Box( FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step), FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step)); @@ -727,8 +268,6 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; } ++NumProved; - // Force brute : le verdict doit tenir sur CHAQUE point du treillis. Un faux verdict - // n'est pas une imprécision, c'est un trou — pas de géométrie, PAS DE COLLISION. const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); for (int32 gz = -1; gz <= GridDim; ++gz) for (int32 gy = -1; gy <= GridDim; ++gy) @@ -771,9 +310,7 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) { AddWarning(TEXT("The stack proved no tile uniform, so it is not yet better than today's ") TEXT("classifier for Maze. Not a correctness problem, but the perf case for ") - TEXT("the port rests on this number -- check whether BranchProbability is high ") - TEXT("enough that corridors genuinely reach every sampled tile, or whether the ") - TEXT("lattice source's reach is over-conservative.")); + TEXT("the port rests on this number.")); } }