Files
VoxelForge/Source/VoxelForge/Private/Tests/VoxelForgeCrossPlatformTest.cpp
T
Fr0zkaandClaude Opus 5 10fd0b6e77 fix(determinism): own sin/cos on the world path -- same world on every CPU, 3.5% faster
On Win64, FMath::Sin/Cos resolve to CRT sinf/cosf (GenericPlatformMath.h:521/530; no platform
override), and the UCRT picks FMA3 or non-FMA3 implementations at startup by CPU. /fp:precise
closed only the compiler half, so the same build and seed could build a slightly different world
on two Windows PCs, and on Linux.

- VoxelMath::DetSin/DetCos/DetSinCos (VoxelTypes.h): fixed double-precision period reduction to
  a quarter period, then fixed float minimax polynomials; no CRT, FMath or CPU-dependent
  intrinsic. Measured max error vs sinf 5.96e-8 over the used range (|arg| <= 6.19).
- Every world-deciding FMath::Sin/Cos replaced (generator, op stacks, cave morphology, strate
  manager, content placement, strate measure); none remain in runtime code.
- Pinned test VoxelForge.Determinism.DetSinCos (golden bit patterns + error bound).
- Diagnostic start-up switch -voxel.CrtFma3=0 (Windows) forces the CRT's non-FMA3 path.
- Parse-once console switches now initialise in StartupModule on the game thread; workers only
  read them (was an unsynchronised first parse inside GetDensityAt).
- Corrected the CRC comment that claimed a CRC32 cannot give a false positive.

Proof (Codex round; orchestrator re-checked):
- Before the fix, FMA3 on vs off was identical on this Ryzen 7 5800X3D: the hole exists but was
  not observed in this world. After the fix, the canonical exports are byte-identical with
  FMA3 on and off.
- The world changed once, as expected. New canonical export SHA-256 CBB8AE18...906F (geometry
  B9EF84AC; was B3E5F4C3...377); worms region 9242C093 (was 311CA42C), walk OBJ 20CCF7BD....
  Triangle counts unchanged in every tile. Order-independent nearest-vertex distance, worms
  region: max 8.7 micrometres (the reported "11.4 m" was a line-order artefact of a line-by-line
  OBJ diff); export region max 3e-5 m.
- Capability exact: player-fit 20,830, walk-reachable 10,909, connected. TilePostReachProof,
  sealed-proof, cross-platform and strate-crossing tests pass.
- Performance, headless game path, S/M/S/M vs the committed-tip DLL: static worker 27.41 s vs
  28.44 s (-3.6%), moving 59.59 s vs 61.74 s (-3.5%); LOD0 p50/p95 not worse.

From Sol's code review (DESIGN-SOL-2026-09-15-CODE.md, section 4.1), branch sol/deterministic-math.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
2026-09-16 00:55:04 +02:00

278 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
int32 NumNearWide = 0, NumNearMid = 0, NumNearTight = 0;
// ─────────────────────────────────────────────────────────────────────────
// `NearIso` — À QUELLE DISTANCE DE ZÉRO UN ÉCHANTILLON PEUT-IL CHANGER DE SIGNE ?
// ─────────────────────────────────────────────────────────────────────────
// ⚠️ CORRIGÉ 2026-07-27, ET LA CORRECTION EST LE POINT INTÉRESSANT.
//
// Version d'origine : une seule bande à 1e-4, justifiée par « une différence de MODÈLE
// FLOTTANT ». Depuis `FPSemantics = Precise` (§C9), il n'y a plus de différence de modèle
// flottant : MSVC et Clang compilent tous deux en IEEE-754 sans contraction. J'ai d'abord cru
// que ça rendait cette mesure caduque. **C'est faux, et il a fallu vérifier plutôt que
// supposer.**
//
// The world path now owns its sine/cosine range reduction and polynomial in VoxelMath. Before
// that replacement, `sinf`/`cosf` were not specified by IEEE-754: MSVC's CRT and glibc's libm
// could return different results (typically <= 1 ULP). `FPSemantics` closed the compiler half
// of §C9; DetSin/DetCos close the library half as well.
//
// D'où trois bandes au lieu d'une : une bande unique à 1e-4 est **100× trop large** pour un
// écart de libm (~1e-6 en absolu sur des densités de magnitude ~10), donc elle sur-estime
// grossièrement le risque et crie au loup. Mesurer trois échelles donne un vrai profil, et
// seule la plus serrée — celle qui correspond réellement à un écart de libm — déclenche
// l'alerte.
// Three bands, not one: 1e-4 over-estimates a libm-scale delta by ~100x. Only the tight band,
// which actually matches a libm difference, raises a warning.
constexpr float NearIsoWide = 1.0e-4f; // profil : large, informatif
constexpr float NearIsoMid = 1.0e-5f; // profil
constexpr float NearIsoTight = 1.0e-6f; // ≈ l'échelle d'un écart libm ⇒ LE chiffre du risque
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)
{
const float A = FMath::Abs(D);
if (A < NearIsoWide) { ++NumNearWide; }
if (A < NearIsoMid) { ++NumNearMid; }
if (A < NearIsoTight) { ++NumNearTight; }
}
++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 <- expected bit-for-bit after DetSin/DetCos.\n")
TEXT(" solid %d / air %d / NaN %d"),
World.Settings->Seed, NumSamples, XYStep, ZStep,
ShapeDigest, FieldDigest, NumSolid, NumSamples - NumSolid, NumNaN));
// Le PROFIL de proximité à l'isosurface reste utile pour visualiser la marge géométrique,
// mais il ne mesure plus une divergence CRT : the world trig path is deterministic now.
AddInfo(FString::Printf(
TEXT("NearIso profile over %d samples: %d within 1e-4, %d within 1e-5, %d within 1e-6. ")
TEXT("This is a geometric margin profile; DetSin/DetCos removes the prior CRT/libm source ")
TEXT("of cross-platform variation."),
NumSamples, NumNearWide, NumNearMid, NumNearTight));
if (NumNearTight > 0)
{
AddInfo(FString::Printf(
TEXT("%d of %d samples sit within 1e-6 of the isosurface. The proximity is worth ")
TEXT("recording, but it is no longer a CRT/libm cross-platform risk because the production ")
TEXT("world path uses deterministic VoxelMath::DetSin/DetCos."),
NumNearTight, NumSamples));
}
else
{
AddInfo(TEXT("No sample sits within 1e-6 of the isosurface on this grid."));
}
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