build: FPSemantics = Precise + clear the IWYU debt that blocked it
Jahni: cross-platform play (Linux and Windows, either side hosting) is a product requirement, so fix C9 at the cause instead of measuring the symptom. Verified in UE 5.7 source rather than assumed: VCToolChain.cs:1264 Default -> /fp:fast Precise -> /fp:precise ClangToolChain.cs:712 Default -> -ffp-contract=off Precise -> -ffp-contract=off Default really did mean opposite float models per platform; Precise collapses them onto the same one, so a Windows host and a Linux client agree by construction. Losing the shared PCH is what the IWYU debt hid behind. Every use turned out to be a pointer, TWeakObjectPtr or TSubclassOf parameter, so forward declarations suffice; only the templates and macros needed real includes. Seven headers fixed. VoxelDensityVolume.h was the one worth catching: it tests ENABLE_DRAW_DEBUG in an #if, and an undefined macro there is silently 0 — the debug block would have vanished without a warning rather than failing the build. Include paths verified against the engine tree, not guessed. Expect a residual tail; the shared PCH hid these for years and only a build enumerates them all. Build.cs now says so, and says the fix is to add the include rather than revert FPSemantics. Also adds VoxelForge.Determinism.CrossPlatformDigest: SHAPE digest (sign of density = the world) and FIELD digest (bit-for-bit) over a fixed integer grid, plus NearIso to bound how many samples could flip sign at all. Reports rather than asserts until pinned. The cross-platform comparison itself is deferred per Jahni. Expect a perf regression from losing reassociation and contraction on a noise-heavy hot path — measure against ARCHITECTURE 8.10. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1fd1e0b05
commit
6a60732c98
10 files changed
+392
-16
No files matched your search
@@ -0,0 +1,251 @@
|
||||
// VoxelForgeCrossPlatformTest.cpp
|
||||
// LA QUESTION MULTIJOUEUR, RENDUE MESURABLE / THE MULTIPLAYER QUESTION, MADE MEASURABLE
|
||||
//
|
||||
// Jahni, 2026-07-27 : *« je voudrais que le jeu soit jouable sur les deux plateformes, Linux et
|
||||
// Windows, donc un hôte Windows avec un client Linux pourrait arriver, et l'inverse. »*
|
||||
// Et la barre d'acceptation : *« 99.99% au pire reproductible si deux personnes partagent la même
|
||||
// seed, puisque tout le monde le reconstruit en multijoueur. »*
|
||||
//
|
||||
// ⚠️ LE PROBLÈME (AUDIT §C9) : le MÊME `FPSemanticsMode.Default` d'UBT ne veut pas dire la même
|
||||
// chose selon la toolchain — `VCToolChain` (Windows/MSVC) le résout en **`/fp:fast`**,
|
||||
// `ClangToolChain` (Linux/Mac/Windows-Clang) le résout en **précis + `-ffp-contract=off`**. Deux
|
||||
// builds de la MÊME source sont donc compilés sous des règles flottantes OPPOSÉES. Un hôte Windows
|
||||
// et un client Linux ne sont pas seulement *autorisés* à diverger : ils sont compilés pour.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// CE TEST NE CORRIGE RIEN — IL MESURE, et c'est ce qui manque
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Aucune quantité de raisonnement ne dit à quel point les deux plateformes divergent : il faut le
|
||||
// LIRE. Ce test produit deux empreintes du même monde, à la même seed, et les affiche. On le lance
|
||||
// sur Windows, on le lance sur Linux, on compare les deux lignes.
|
||||
//
|
||||
// • **EMPREINTE DE FORME** — le SIGNE de la densité seulement (solide / air). C'est la SEULE
|
||||
// chose que le mesher lit (`D >= IsoLevel ⇒ air`). Si cette empreinte est identique, les deux
|
||||
// plateformes ont **le même monde** : mêmes cavités, mêmes murs, même navigabilité, même
|
||||
// collision au voxel près. C'est littéralement le critère « 99.99% reproductible » de Jahni.
|
||||
//
|
||||
// • **EMPREINTE DE CHAMP** — tous les bits de tous les floats. Identique ⇒ reproductibilité
|
||||
// BIT à BIT. Différente alors que la forme est identique ⇒ la divergence est un frémissement
|
||||
// sous-voxel de la position des sommets, sans conséquence de jeu.
|
||||
//
|
||||
// C'est le bon découpage parce qu'il sépare les deux échecs possibles, qui n'ont pas du tout la
|
||||
// même gravité :
|
||||
//
|
||||
// forme == && champ == ⇒ parfait, rien à faire.
|
||||
// forme == && champ != ⇒ ACCEPTABLE. Les sommets bougent de ~1e-5 voxel. Personne ne le voit,
|
||||
// rien ne s'y accroche — SAUF si un jour on compare des hashs de
|
||||
// géométrie entre pairs. À ne pas faire, donc.
|
||||
// forme != ⇒ **INACCEPTABLE**. Un voxel solide chez l'un est de l'air chez
|
||||
// l'autre : un joueur traverse un mur que l'autre voit plein.
|
||||
//
|
||||
// ⚠️ POURQUOI LA FORME A DE BONNES CHANCES DE TENIR MÊME AUJOURD'HUI — et pourquoi il faut quand
|
||||
// même la mesurer : toutes les décisions STRUCTURELLES du plugin (quelle arête de treillis est
|
||||
// ouverte, quelle cellule porte une colonne, où sont les salles et les passages) passent par
|
||||
// `VoxelHash::*`, c.-à-d. de l'ARITHMÉTIQUE ENTIÈRE, identique sur toute plateforme. Le flottant
|
||||
// ne décide que la POSITION de la surface. Un signe ne bascule donc que si un échantillon tombe à
|
||||
// ~1e-5 de l'isosurface — d'où le troisième chiffre affiché, `NearIso`, qui BORNE le risque au
|
||||
// lieu de le supposer.
|
||||
//
|
||||
// The structural decisions all go through integer hashing, so only the surface POSITION is
|
||||
// float-decided. A sign flips only where a sample sits within ~1e-5 of the isosurface, which is why
|
||||
// NearIso is reported: it bounds the risk instead of assuming it.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// COMMENT S'EN SERVIR / HOW TO USE THIS
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Lancer sur Windows, noter les deux empreintes.
|
||||
// 2. Lancer sur Linux, comparer.
|
||||
// 3. Une fois `FPSemantics = Precise` posé sur le module (AUDIT §C9, bloqué par la dette IWYU)
|
||||
// et les deux plateformes d'accord : **épingler** les valeurs dans `PinnedShapeDigest` /
|
||||
// `PinnedFieldDigest` ci-dessous. Le test devient alors un garde-fou permanent — toute
|
||||
// régression de déterminisme échoue bruyamment, sur la plateforme qui a dérivé.
|
||||
//
|
||||
// Tant que les constantes valent 0, le test ne peut pas échouer sur les empreintes : il RAPPORTE.
|
||||
// C'est délibéré — épingler une valeur avant que les plateformes soient d'accord ne ferait que
|
||||
// graver la divergence dans le test.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelGenerator.h"
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeCrossPlatformTest,
|
||||
"VoxelForge.Determinism.CrossPlatformDigest",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
//=========================================================================
|
||||
// ÉPINGLES / PINS — 0 = « pas encore d'accord de référence », le test rapporte sans juger.
|
||||
//=========================================================================
|
||||
// À remplir UNIQUEMENT quand Windows et Linux rendent la même valeur. Voir l'en-tête.
|
||||
constexpr uint64 PinnedShapeDigest = 0;
|
||||
constexpr uint64 PinnedFieldDigest = 0;
|
||||
|
||||
// FNV-1a 64 bits, octet par octet, **entier pur**. Volontairement écrit à la main plutôt que
|
||||
// pris dans le moteur : une empreinte de déterminisme ne doit dépendre d'aucune implémentation
|
||||
// qui pourrait, elle, varier. Ici il n'y a que des `^` et des `*` sur uint64.
|
||||
// Hand-rolled on purpose: a determinism digest must not depend on an implementation that could
|
||||
// itself vary. Nothing here but XOR and multiply on uint64.
|
||||
constexpr uint64 FnvOffsetBasis = 0xcbf29ce484222325ull;
|
||||
constexpr uint64 FnvPrime = 0x00000100000001b3ull;
|
||||
|
||||
FORCEINLINE void FnvAccumByte(uint64& H, uint8 B)
|
||||
{
|
||||
H ^= (uint64)B;
|
||||
H *= FnvPrime;
|
||||
}
|
||||
|
||||
FORCEINLINE void FnvAccumU32(uint64& H, uint32 V)
|
||||
{
|
||||
FnvAccumByte(H, (uint8)( V & 0xFFu));
|
||||
FnvAccumByte(H, (uint8)((V >> 8) & 0xFFu));
|
||||
FnvAccumByte(H, (uint8)((V >> 16) & 0xFFu));
|
||||
FnvAccumByte(H, (uint8)((V >> 24) & 0xFFu));
|
||||
}
|
||||
|
||||
/** Bits d'un float, avec les NaN NORMALISÉS : un NaN a plusieurs représentations et rien ne
|
||||
* garantit que deux plateformes produisent la même. On les compte à part. */
|
||||
FORCEINLINE uint32 FloatBitsNormalised(float V, bool& bOutWasNaN)
|
||||
{
|
||||
bOutWasNaN = FMath::IsNaN(V);
|
||||
if (bOutWasNaN) { return 0x7FC00000u; }
|
||||
return *reinterpret_cast<const uint32*>(&V);
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeCrossPlatformTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
//=========================================================================
|
||||
// LA GRILLE — entièrement déterministe, SANS RNG
|
||||
//=========================================================================
|
||||
// Pas de `FRandomStream` ici, contrairement aux autres tests : l'ensemble des points doit être
|
||||
// identique sur les deux plateformes SANS dépendre d'une seule ligne de code partagé. Une
|
||||
// boucle entière sur des bornes entières ne peut pas diverger.
|
||||
// No RNG: the point set must be identical across platforms without depending on any shared
|
||||
// code at all. An integer loop over integer bounds cannot diverge.
|
||||
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
|
||||
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
|
||||
|
||||
constexpr int32 XYStep = 8;
|
||||
constexpr int32 ZStep = 8;
|
||||
const int32 XYExtent = 3 * CHUNK_SIZE; // couvre la spine (0,0), les passages et le rocher
|
||||
|
||||
uint64 ShapeDigest = FnvOffsetBasis;
|
||||
uint64 FieldDigest = FnvOffsetBasis;
|
||||
|
||||
int32 NumSamples = 0, NumSolid = 0, NumNaN = 0, NumNearIso = 0;
|
||||
|
||||
// `NearIso` : à quelle distance de zéro un échantillon doit-il être pour qu'une différence
|
||||
// d'ULP puisse faire basculer son SIGNE ? Les écarts mesurés entre le `switch` et la pile
|
||||
// valent ~1e-5 au pire (AUDIT §C10) ; un écart entre modèles flottants est du même ordre.
|
||||
// Tout échantillon plus loin que ça de l'isosurface ne peut PAS changer de côté.
|
||||
constexpr float NearIsoBand = 1.0e-4f; // 10× la pire divergence observée : marge délibérée
|
||||
|
||||
for (int32 Z = BottomVoxelZ; Z <= TopVoxelZ; Z += ZStep)
|
||||
{
|
||||
for (int32 Y = -XYExtent; Y <= XYExtent; Y += XYStep)
|
||||
{
|
||||
for (int32 X = -XYExtent; X <= XYExtent; X += XYStep)
|
||||
{
|
||||
const float D = Gen->GetDensityAt((float)X, (float)Y, (float)Z);
|
||||
|
||||
bool bWasNaN = false;
|
||||
const uint32 Bits = FloatBitsNormalised(D, bWasNaN);
|
||||
if (bWasNaN) { ++NumNaN; }
|
||||
|
||||
// FORME : un seul bit par échantillon — le côté de l'isosurface, ce que lit le
|
||||
// mesher. C'est l'empreinte qui doit tenir entre plateformes.
|
||||
const bool bAir = (D >= 0.0f);
|
||||
if (!bAir) { ++NumSolid; }
|
||||
FnvAccumByte(ShapeDigest, bAir ? 1u : 0u);
|
||||
|
||||
// CHAMP : tous les bits.
|
||||
FnvAccumU32(FieldDigest, Bits);
|
||||
|
||||
if (!bWasNaN && FMath::Abs(D) < NearIsoBand) { ++NumNearIso; }
|
||||
++NumSamples;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// LE RAPPORT — c'est le produit de ce test
|
||||
//=========================================================================
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("CROSS-PLATFORM DIGEST (seed %d, %d samples, step %d/%d)\n")
|
||||
TEXT(" SHAPE digest : 0x%016llX <- must match across Windows/Linux. This is the world.\n")
|
||||
TEXT(" FIELD digest : 0x%016llX <- bit-for-bit. May differ; see NearIso below.\n")
|
||||
TEXT(" solid %d / air %d / NaN %d"),
|
||||
World.Settings->Seed, NumSamples, XYStep, ZStep,
|
||||
ShapeDigest, FieldDigest, NumSolid, NumSamples - NumSolid, NumNaN));
|
||||
|
||||
// LE chiffre qui borne le risque, plutôt que de le supposer.
|
||||
if (NumNearIso == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("NearIso: 0 of %d samples sit within %.1e of the isosurface. No sample is close ")
|
||||
TEXT("enough for a float-model difference (~1e-5 worst observed) to flip its SIGN, so ")
|
||||
TEXT("the SHAPE digest is robust to the /fp:fast-vs-precise split by a 10x margin at ")
|
||||
TEXT("these sample points. That is evidence, not proof -- it covers this grid, not ")
|
||||
TEXT("every voxel in a world."),
|
||||
NumSamples, NearIsoBand));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("NearIso: %d of %d samples sit within %.1e of the isosurface -- close enough that ")
|
||||
TEXT("a float-model difference could flip their SIGN, which is a solid-vs-air ")
|
||||
TEXT("disagreement between a Windows host and a Linux client. This is the concrete ")
|
||||
TEXT("mechanism behind AUDIT C9, and the count is roughly how many voxels per %d are at ")
|
||||
TEXT("risk. It does not mean they DO differ -- run this on both platforms and compare ")
|
||||
TEXT("the SHAPE digest to find out."),
|
||||
NumNearIso, NumSamples, NearIsoBand, NumSamples));
|
||||
}
|
||||
|
||||
TestEqual(TEXT("no sample produced NaN"), NumNaN, 0);
|
||||
|
||||
// Un monde entièrement solide ou entièrement vide rendrait les empreintes vraies mais vides de
|
||||
// sens. Garde-fou minimal contre un test qui se félicite de ne rien mesurer.
|
||||
TestTrue(TEXT("the sampled world contains both solid and air (the digest is meaningful)"),
|
||||
NumSolid > 0 && NumSolid < NumSamples);
|
||||
|
||||
//=========================================================================
|
||||
// LES ÉPINGLES — inertes tant que personne ne les a posées
|
||||
//=========================================================================
|
||||
if (PinnedShapeDigest != 0)
|
||||
{
|
||||
TestEqual(TEXT("SHAPE digest matches the pinned cross-platform reference"),
|
||||
ShapeDigest, PinnedShapeDigest);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddInfo(TEXT("SHAPE digest is not pinned yet. Pin it only once Windows and Linux agree -- ")
|
||||
TEXT("pinning first would just carve the divergence into the test."));
|
||||
}
|
||||
|
||||
if (PinnedFieldDigest != 0)
|
||||
{
|
||||
TestEqual(TEXT("FIELD digest matches the pinned cross-platform reference"),
|
||||
FieldDigest, PinnedFieldDigest);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
Reference in new issue
Block a user