fix: first-green-build follow-ups (diff-layer assertion + Maze float rounding)

The build passed and six tests ran; five green. Details in OPSTACK-PROGRESS.md.

1. DiffLayerContention's failure was the test, not the plugin.
   GetTotalModificationCount() sums STORED ENTRIES, not operations -- a stroke is
   filed under every chunk its AABB overlaps, so 400 radius-6 spheres straddling
   chunk corners store 3200 entries. The assertion now compares the stored count
   against the chunk fan-out ApplyModification itself returned, which also checks
   that the re-mesh list handed to the caller describes what was actually written.
   Everything the test exists for had already passed: 7 readers, 28.7M read
   rounds against 760 writes and 6 Clear()s, no crash, monotonic version.

2. MazeEquivalence: 454/20000 samples differed by at most 1.907e-06 -- exactly
   one ULP at magnitude 16 -- with ZERO crossing the isosurface, i.e. not one
   triangle would move. Leading hypothesis: the original routes noise coords
   through an FVector (double in UE5) and back to float, rounding twice, while
   the op passed floats straight through; under /fp:fast those round differently.
   The op now reproduces the detour on purpose, with a comment against
   "simplifying" it. Unverified -- it predicts 0 differences next run. If drift
   remains, next candidate is FMA contraction across translation units.

Also recorded, because it is the perf half of the whole refactor: the Maze op
stack proved 23 of 60 tiles uniform. ClassifyTile proves ZERO for any cave
archetype today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 02:40:51 +02:00
parent 62e3d5a933
commit 826a8c99dc
3 changed files with 87 additions and 5 deletions
@@ -128,6 +128,17 @@ bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters)
}
// ── Phase 1 : écritures pures. L'état final doit être exact. ──
// ⚠️ `GetTotalModificationCount()` ne compte PAS les opérations : il somme les entrées
// STOCKÉES, et un coup de pinceau est rangé dans CHAQUE chunk que son AABB recouvre. 400
// sphères de rayon 6 posées à cheval sur des coins de chunk donnent 3200 entrées, pas 400.
// (Le compteur d'opérations est le membre privé `ModificationCount`, non exposé.)
// C'est d'ailleurs la métrique qui compte pour AUDIT C6 : ce sont les ENTRÉES stockées qui
// grossissent sans borne, pas le nombre de coups de pioche. Le nom du getter induit en erreur.
//
// GetTotalModificationCount() does NOT count operations: it sums STORED entries, and a stroke
// is filed under EVERY chunk its AABB overlaps. It is also the metric that matters for AUDIT C6
// — stored entries are what grow without bound. The getter's name misleads.
int32 ExpectedEntries = 0;
for (int32 i = 0; i < NumWrites; ++i)
{
const TArray<FIntVector> Touched = Diff->ApplyModification(MakeCarve(i));
@@ -138,9 +149,14 @@ bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters)
TEXT("if this fires, SetBudget(0, ...) no longer means 'no cap'."), i));
break;
}
ExpectedEntries += Touched.Num();
}
TestEqual(TEXT("every carve was recorded"), Diff->GetTotalModificationCount(), NumWrites);
// Vérifie que le fan-out RÉELLEMENT stocké correspond à ce qu'ApplyModification a rapporté —
// un désaccord voudrait dire que la liste de chunks rendue à l'appelant (celle qui décide quoi
// re-mailler) ne décrit pas ce qui a été écrit. C'est un bien meilleur test que « == 400 ».
TestEqual(TEXT("stored diff entries match the chunk fan-out ApplyModification reported"),
Diff->GetTotalModificationCount(), ExpectedEntries);
TestTrue(TEXT("the lock-free bHasAnyMods fast-path agrees with the map"), Diff->HasAnyMods());
TestTrue(TEXT("at least one chunk holds mods"), Diff->GetModifiedChunkCount() > 0);
@@ -240,10 +240,24 @@ namespace
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
if (Strength <= 0.0f || InOut.Sdf >= ApplyWithin) { return; }
// VoxelNoise::FBM est exactement ce que FractalNoise3D appelle (VoxelGenerator.cpp) —
// le wrapper ne fait que transtyper. T2.b : les octaves passent par Eff() pour que les
// tuiles lointaines perdent les octaves sous-cellule.
InOut.Sdf += VoxelNoise::FBM(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency,
// ⚠️ LE DÉTOUR PAR FVector EST DÉLIBÉRÉ — ne pas « simplifier ».
// L'original écrit `FractalNoise3D(FVector(WorldX * 0.12f, ...), Eff(3))`, et
// FractalNoise3D fait `VoxelNoise::FBM((float)Position.X, ...)`. FVector étant en
// DOUBLE (UE5), le produit flottant y transite par un double avant d'être re-arrondi
// en float. Passer directement des floats saute cet aller-retour, et sous /fp:fast
// les deux chemins ne s'arrondissent pas au même endroit : ~1 ULP d'écart sur le SDF,
// qui ressort en 1 ULP sur la densité finale. Reproduire le détour, c'est reproduire
// l'arrondi. HYPOTHÈSE NON ENCORE VÉRIFIÉE : elle prédit que MazeEquivalence passe de
// 454 écarts à 0. Si le prochain run montre encore des écarts, c'est que la divergence
// vient d'ailleurs (candidat suivant : contraction FMA entre unités de compilation).
//
// THE FVector ROUND-TRIP IS DELIBERATE — do not "simplify" it. The original goes
// float -> double (FVector is double in UE5) -> float; going straight through floats
// skips a rounding step, and under /fp:fast the two paths round in different places.
// Reproducing the detour reproduces the rounding.
const FVector NoisePos(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency);
InOut.Sdf += VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z,
VoxelGenLOD::Eff(BaseOctaves), 2.0f, 0.5f)
* VOXEL_NOISE_SCALE * Strength;
}