diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af13296..2184ce8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -285,7 +285,10 @@ with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2- whose bounds reach this chunk (rebuilt on chunk change / `PassagesVersion` bump) — chunks with no passage near return `FLT_MAX` immediately — then **bounding-sphere-culls** each shortlisted passage (`FVoxelPassage::BoundCenter/BoundRadiusSq`). Both are perf-critical (§8.10). The (0,0) surface entry -is a simple straight tube. Global passage settings were removed from `VoxelSettings`. +is a simple straight tube. Placement, reach, and shape draws for inter-strate passages are keyed by +`(seed, upper-strate index, connection index, per-value salt)` through `VoxelHash`, so one passage's +connection count cannot shift any later passage. Global passage settings were removed from +`VoxelSettings`. ### 8.9 Carving — brush shapes + editor controls `FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/ diff --git a/CODEMAP.md b/CODEMAP.md index 16632d1..1352cee 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -353,8 +353,8 @@ Maps depth→strate at runtime; owns passages. - `FStrateSlot` (h:84): definition + chunk-Z range + index. | Method | .cpp line | Role | |--------|-----------|------| -| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. | -| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-type control points). | +| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + pool sorted by soft-asset path, then unchanged Fisher-Yates shuffle), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. | +| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. | | `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. | | `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. | | `FindSlotIndexForChunkZ` | 427 | Z → layout index. | @@ -453,6 +453,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | +| `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | ## 4. The density pipeline (most-edited hot path) diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index e7142cd..def8dc6 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -345,11 +345,12 @@ needs a guard, or the caves vanish with no error. ### ⚠️ 6.4 The primordial law has a real hole TODAY -`UVoxelStrateManager::GeneratePassages` places every inter-strate passage as: `FRandomStream(CachedSeed -^ 0x50A55A6E)` → random angle around the (0,0) spine → random distance in the config range → random -reach into each strate. **Nothing consults the destination strate's cave layout.** Carving is a -structural-post invariant so the tube *is* air — but whether its lower mouth joins the lower strate's -connected space is pure luck. **A player can descend a passage and arrive in a sealed pocket.** +`UVoxelStrateManager::GeneratePassages` places every inter-strate passage as independently salted +hashes of `(seed, upper-strate index, connection index)` → random angle around the (0,0) spine → +random distance in the config range → random reach into each strate. **Nothing consults the +destination strate's cave layout.** Carving is a structural-post invariant so the tube *is* air — but +whether its lower mouth joins the lower strate's connected space is pure luck. **A player can descend +a passage and arrive in a sealed pocket.** **Fix — make the strate OFFER a landing site, don't let the passage gamble.** Add to the field-source contract something like `SuggestOpenPoint(WorldX, WorldY) -> optional Z`. Every source already knows @@ -372,16 +373,18 @@ you see the hole); and ⭐ **make the mouth a PLACE, not a hole** — the `Sugge lands the passage in a room, so make that room distinctive. Cracks in walls are forgettable; rooms are landmarks, and that is what makes it findable on the *second* visit, which quests need. -### ⚠️ 6.6 The layout depends on the pool's CONTENTS *and its ORDER* +### ✅ 6.6 The layout depends on the pool's CONTENTS, not its ORDER (BUILT + TESTS GREEN) -`Initialize` Fisher-Yates shuffles `Settings->StratePool` with `FRandomStream(WorldSeed)`, then cycles it -(`PoolCursor % Num`) when `TotalStrates` exceeds the pool. So **reordering the pool asset in the editor -changes every world**, and a pool smaller than the strate count repeats entries in a fixed order. +Before Tier 0, `Initialize` Fisher-Yates shuffled `Settings->StratePool` in editor order with +`FRandomStream(WorldSeed)`, then cycled it (`PoolCursor % Num`) when `TotalStrates` exceeded the pool. +So **reordering the pool asset in the editor changed every world**, and a pool smaller than the strate +count repeated entries in a fixed order. -⇒ Use `hash(seed, slotIndex[, attempt])` instead, over a pool sorted by a stable key. The pool becomes a -**set** rather than a sequence: tidying the asset stops changing worlds, and each slot's roll becomes -independent of its neighbours'. Adding or removing a strate type still changes worlds — unavoidable, and -the reason content lands at wipes. +⇒ **Tier 0 is now implemented:** load the pool, sort it by the stable soft-asset path, then keep the +existing Fisher-Yates shuffle and cycling algorithm. The pool is a **set** rather than an editor-ordered +sequence: tidying the asset stops changing worlds. Adding or removing a strate type still changes +worlds — unavoidable, and the reason content lands at wipes. A per-slot hash would be a different +sampling algorithm and is intentionally deferred. ### ⚠️ 6.7 A bounded world needs an XY EDGE SEAL, which does not exist @@ -455,7 +458,7 @@ refactor, where three weeks of correct work produced a world unchanged by a sing ### Tier 0 — safety nets, true regardless of the composer - ✅ **X-macro guard test** — DONE, green. - ✅ **Actor-space sweep** — DONE, confirmed off-origin. -- **Order-independent placement** (§6.6). *Standalone value:* the strate pool asset can be reordered or +- ✅ **Order-independent placement** (§6.6) — DONE. Built clean, `VoxelForge.Determinism.LayoutOrderIndependence` green. *Standalone value:* the strate pool asset can be reordered or tidied without changing every world. Small and isolated. ### Tier 1 — the primordial law (a BUG FIX, not composer work) @@ -525,3 +528,36 @@ Game-design questions live in **[GDD.md](GDD.md) §16**. These are the ones that 5. **Whether op discovery** (promoting good sub-stacks into named reusable units) is ever worth it. Parked: the existing parameter space is already vastly larger than a hundred seasons could explore. **The scarce resource is judgment, not vocabulary.** + +--- + +## 10. Verification log + +**2026-08-17 — full suite run headless, first time this session.** Built `VoxelMEditor Win64 +Development` clean (no errors, no warnings), then: + +``` +UnrealEditor-Cmd.exe VoxelM.uproject -ExecCmds="Automation RunTests VoxelForge;Quit" + -unattended -nopause -nosplash -NullRHI -ReportExportPath=... +``` + +**Result: 16 succeeded, 0 failed, 0 not run, 0 succeeded-with-warnings.** 25.7 s. + +Tests: BoxVerdictFold · ClassifyTileSoundness (×2) · CrossPlatformDigest · DensityPurity · +DiffLayerContention · FloatingIslandEquivalence · LargeSeedSurvives · **LayoutOrderIndependence** · +LiveEditInvalidation · MazeEquivalence · SlabEquivalence · **StrateParamBlendCoverage** · +SurfaceHeightEquivalence · TunnelNetworkSpineEquivalence · VerticalShaftEquivalence. + +**⭐ This closes the pending-verification thread opened by `OPSTACK-HANDOFF.md`.** That file flagged +commits `4d33321` (Sol's boundary fold) and `91585ea` as *built but NOT re-verified*, with two +acceptance conditions. Both now met: +- **`violations` = 0** everywhere. Measured: TunnelNetwork at production defaults **12 of 40 tiles + proved AllSolid, 15 972 voxels brute-forced, 0 violations**; VerticalShafts **32 of 60 proved, 0 + violations**; dense fixture 0 proved / 40 Mixed, 0 violations. +- **All equivalence tests green** — Maze, Slab, TunnelNetworkSpine, VerticalShaft, FloatingIsland, + SurfaceHeight. + +⇒ **No revert of `4d33321` is needed.** The 39 % tile-skip win stands, verified rather than assumed. + +⚠️ **Read the PROVED counts as measurements, never as contracts.** What is asserted is that none of +them is *wrong* — a false verdict leaves no geometry and no collision behind it. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp new file mode 100644 index 0000000..2d7caeb --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp @@ -0,0 +1,458 @@ +// VoxelForgeLayoutOrderIndependenceTest.cpp +// Test de déterminisme du layout et des passages quand l'ordre du pool change. +// Determinism test for the layout and passages when the pool order changes. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelForgeTestFixture.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeLayoutOrderIndependenceTest, + "VoxelForge.Determinism.LayoutOrderIndependence", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + constexpr int32 LayoutSeed = 20260829; + constexpr int32 NumPoolDefinitions = 5; + + struct FLayoutSnapshot + { + TArray Layout; + TArray Passages; + }; + + /** + * Small headless world whose pool has no FixedStrates entries. That is important: the test + * must exercise the shuffled pool path rather than accidentally testing only pinned slots. + * + * Petit monde sans entrée FixedStrates. C'est essentiel : le test doit exercer le chemin du + * pool mélangé, et ne pas tester par accident uniquement des slots épinglés. + */ + struct FLayoutTestWorld + { + TStrongObjectPtr Settings; + TStrongObjectPtr StrateManager; + TArray> Definitions; + + void Build() + { + Settings = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Settings->Seed = LayoutSeed; + Settings->TotalStrates = NumPoolDefinitions; + Settings->InterStrateGapChunks = 1; + Settings->bOpenSurfaceEntry = true; + + static const ECaveGeneratorType GeneratorTypes[] = { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + }; + static const EVoxelPassageStyle PassageStyles[] = { + EVoxelPassageStyle::Straight, + EVoxelPassageStyle::Worm, + EVoxelPassageStyle::Spiral, + EVoxelPassageStyle::Cascading, + EVoxelPassageStyle::Worm, + }; + + for (int32 Index = 0; Index < NumPoolDefinitions; ++Index) + { + UVoxelStrateDefinition* Def = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + Def->StrateName = FText::FromString( + FString::Printf(TEXT("Layout order test strate %d"), Index)); + Def->GeneratorType = GeneratorTypes[Index]; + Def->StrateHeightInChunks = 3 + Index; + Def->TransitionType = EVoxelStrateTransition::Hard; + + // Non-trivial, distinct passage configs exercise the connection index and the + // shape seeds while keeping every generated passage observable in the snapshot. + // Des configs distinctes rendent visibles l'index de connexion et les seeds de + // forme, tout en gardant chaque passage généré observable dans le snapshot. + FStratePassageConfig& Passage = Def->PassageConfig; + Passage.Connections = Index + 1; + Passage.Style = PassageStyles[Index]; + Passage.MouthRadius = 5.0f + (float)Index; + Passage.MidRadius = 3.0f + (float)Index * 0.5f; + Passage.ReachMin = 12.0f + (float)Index * 3.0f; + Passage.ReachMax = 48.0f + (float)Index * 4.0f; + Passage.DistanceMin = 20.0f + (float)Index * 5.0f; + Passage.DistanceMax = 80.0f + (float)Index * 7.0f; + Passage.Wander = 20.0f + (float)Index; + Passage.Segments = 4 + Index; + Passage.VerticalWobble = 4.0f; + Passage.SpiralRadius = 10.0f + (float)Index; + Passage.SpiralTurns = 1.0f + (float)Index * 0.25f; + Passage.CascadeSteps = 2 + Index; + Passage.CascadeLedge = 10.0f + (float)Index; + + Definitions.Add(TStrongObjectPtr(Def)); + } + + StrateManager = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + } + + void SetPoolOrder(const TArray& Order) + { + Settings->StratePool.Reset(Order.Num()); + for (const int32 DefinitionIndex : Order) + { + Settings->StratePool.Add( + TSoftObjectPtr(Definitions[DefinitionIndex].Get())); + } + } + + bool Rebuild(const TArray& Order) + { + if (!Settings.IsValid() || !StrateManager.IsValid()) + { + return false; + } + SetPoolOrder(Order); + StrateManager->Initialize(Settings.Get(), LayoutSeed); + return true; + } + }; + + FLayoutSnapshot CaptureSnapshot(const UVoxelStrateManager& Manager) + { + FLayoutSnapshot Snapshot; + Snapshot.Layout = Manager.GetLayout(); + Snapshot.Passages = Manager.GetPassages(); + return Snapshot; + } + + bool SameVectorBits(const FVector& A, const FVector& B) + { + return VoxelForgeTest::BitEqual(A.X, B.X) + && VoxelForgeTest::BitEqual(A.Y, B.Y) + && VoxelForgeTest::BitEqual(A.Z, B.Z); + } + + bool SamePassage(const FVoxelPassage& A, const FVoxelPassage& B, FString& OutMismatch, + int32 PassageIndex) + { + if (A.UpperStrateIndex != B.UpperStrateIndex || A.LowerStrateIndex != B.LowerStrateIndex) + { + OutMismatch = FString::Printf( + TEXT("passage %d strate indices differ (%d/%d vs %d/%d)"), PassageIndex, + A.UpperStrateIndex, A.LowerStrateIndex, B.UpperStrateIndex, B.LowerStrateIndex); + return false; + } + + if (!SameVectorBits(A.UpperPoint, B.UpperPoint) + || !SameVectorBits(A.LowerPoint, B.LowerPoint)) + { + OutMismatch = FString::Printf( + TEXT("passage %d endpoints differ"), PassageIndex); + return false; + } + + if (!VoxelForgeTest::BitEqual(A.Radius, B.Radius)) + { + OutMismatch = FString::Printf( + TEXT("passage %d radius differs (%.9g vs %.9g)"), PassageIndex, A.Radius, B.Radius); + return false; + } + + if (A.PassageType != B.PassageType) + { + OutMismatch = FString::Printf( + TEXT("passage %d type differs (%d vs %d)"), PassageIndex, + (int32)A.PassageType, (int32)B.PassageType); + return false; + } + + if (A.ControlPoints.Num() != B.ControlPoints.Num()) + { + OutMismatch = FString::Printf( + TEXT("passage %d control-point count differs (%d vs %d)"), PassageIndex, + A.ControlPoints.Num(), B.ControlPoints.Num()); + return false; + } + for (int32 PointIndex = 0; PointIndex < A.ControlPoints.Num(); ++PointIndex) + { + if (!SameVectorBits(A.ControlPoints[PointIndex], B.ControlPoints[PointIndex])) + { + OutMismatch = FString::Printf( + TEXT("passage %d control point %d differs"), PassageIndex, PointIndex); + return false; + } + } + + if (A.ControlRadii.Num() != B.ControlRadii.Num()) + { + OutMismatch = FString::Printf( + TEXT("passage %d control-radius count differs (%d vs %d)"), PassageIndex, + A.ControlRadii.Num(), B.ControlRadii.Num()); + return false; + } + for (int32 RadiusIndex = 0; RadiusIndex < A.ControlRadii.Num(); ++RadiusIndex) + { + if (!VoxelForgeTest::BitEqual(A.ControlRadii[RadiusIndex], B.ControlRadii[RadiusIndex])) + { + OutMismatch = FString::Printf( + TEXT("passage %d control radius %d differs"), PassageIndex, RadiusIndex); + return false; + } + } + + if (!SameVectorBits(A.BoundCenter, B.BoundCenter) + || !VoxelForgeTest::BitEqual(A.BoundRadius, B.BoundRadius) + || !VoxelForgeTest::BitEqual(A.BoundRadiusSq, B.BoundRadiusSq)) + { + OutMismatch = FString::Printf( + TEXT("passage %d bounding sphere differs"), PassageIndex); + return false; + } + + return true; + } + + bool SameLayout(const TArray& Expected, const TArray& Actual, + FString& OutMismatch) + { + if (Expected.Num() != Actual.Num()) + { + OutMismatch = FString::Printf( + TEXT("layout slot count differs (%d vs %d)"), Expected.Num(), Actual.Num()); + return false; + } + + for (int32 SlotIndex = 0; SlotIndex < Expected.Num(); ++SlotIndex) + { + const FStrateSlot& A = Expected[SlotIndex]; + const FStrateSlot& B = Actual[SlotIndex]; + if (A.Definition != B.Definition || A.StrateIndex != B.StrateIndex) + { + OutMismatch = FString::Printf( + TEXT("slot %d definition or strate index differs"), SlotIndex); + return false; + } + if (A.TopChunkZ != B.TopChunkZ || A.BottomChunkZ != B.BottomChunkZ + || A.HeightInChunks != B.HeightInChunks) + { + OutMismatch = FString::Printf( + TEXT("slot %d Z/height differs (top/bottom/height %d/%d/%d vs %d/%d/%d)"), + SlotIndex, A.TopChunkZ, A.BottomChunkZ, A.HeightInChunks, + B.TopChunkZ, B.BottomChunkZ, B.HeightInChunks); + return false; + } + } + + return true; + } + + bool SameSnapshot(const FLayoutSnapshot& Expected, const FLayoutSnapshot& Actual, + FString& OutMismatch) + { + if (!SameLayout(Expected.Layout, Actual.Layout, OutMismatch)) + { + return false; + } + + if (Expected.Passages.Num() != Actual.Passages.Num()) + { + OutMismatch = FString::Printf( + TEXT("passage count differs (%d vs %d)"), + Expected.Passages.Num(), Actual.Passages.Num()); + return false; + } + + for (int32 PassageIndex = 0; PassageIndex < Expected.Passages.Num(); ++PassageIndex) + { + if (!SamePassage(Expected.Passages[PassageIndex], Actual.Passages[PassageIndex], + OutMismatch, PassageIndex)) + { + return false; + } + } + + return true; + } +} + +bool FVoxelForgeLayoutOrderIndependenceTest::RunTest(const FString& Parameters) +{ + FLayoutTestWorld World; + World.Build(); + + TArray OriginalOrder; + for (int32 Index = 0; Index < NumPoolDefinitions; ++Index) + { + OriginalOrder.Add(Index); + } + + TArray ReversedOrder; + for (int32 Index = NumPoolDefinitions - 1; Index >= 0; --Index) + { + ReversedOrder.Add(Index); + } + + TArray PermutedOrder = OriginalOrder; + PermutedOrder.Swap(0, 2); + + TArray> Orders; + Orders.Add(OriginalOrder); + Orders.Add(ReversedOrder); + Orders.Add(PermutedOrder); + + static const TCHAR* OrderNames[] = { + TEXT("original"), + TEXT("reversal"), + TEXT("swap"), + }; + + TArray Snapshots; + Snapshots.Reserve(Orders.Num()); + for (int32 OrderIndex = 0; OrderIndex < Orders.Num(); ++OrderIndex) + { + const bool bRebuilt = World.Rebuild(Orders[OrderIndex]); + const int32 NumStrates = World.StrateManager.IsValid() + ? World.StrateManager->GetNumStrates() + : 0; + if (!bRebuilt || NumStrates != NumPoolDefinitions) + { + AddError(FString::Printf( + TEXT("Permutation '%s' could not build a strate layout at all. The test cannot ") + TEXT("silently pass with an incomplete layout; expected %d resolved slots, got %d. ") + TEXT("Check transient soft-pointer resolution."), + OrderNames[OrderIndex], NumPoolDefinitions, NumStrates)); + return false; + } + + Snapshots.Add(CaptureSnapshot(*World.StrateManager.Get())); + } + + if (Snapshots.Num() == 0 || Snapshots[0].Layout.Num() == 0) + { + AddError(TEXT("No layout snapshot was inspected. This test would otherwise pass without ") + TEXT("checking any slots.")); + return false; + } + if (Snapshots[0].Passages.Num() == 0) + { + AddError(TEXT("The known pool generated no passages, so the passage invariant was not ") + TEXT("actually inspected.")); + return false; + } + + int32 NumSlotsInspected = 0; + int32 NumPassagesInspected = 0; + for (int32 SnapshotIndex = 1; SnapshotIndex < Snapshots.Num(); ++SnapshotIndex) + { + NumSlotsInspected += Snapshots[0].Layout.Num(); + NumPassagesInspected += Snapshots[0].Passages.Num(); + + FString Mismatch; + if (!SameSnapshot(Snapshots[0], Snapshots[SnapshotIndex], Mismatch)) + { + AddError(FString::Printf( + TEXT("Pool order changed the built result for permutation '%s': %s"), + OrderNames[SnapshotIndex], *Mismatch)); + } + } + + // A pool permutation alone cannot expose the old sequential passage stream once the layout + // itself is stable. Perturb one early connection count and verify that every later boundary + // keeps its own (strate index, connection index) values unchanged. + // Une permutation seule ne peut plus révéler l'ancien stream séquentiel une fois le layout + // stable. On perturbe donc une connexion précoce et on vérifie toutes les frontières suivantes. + UVoxelStrateDefinition* EarlyDefinition = Snapshots[0].Layout[0].Definition; + if (!EarlyDefinition) + { + AddError(TEXT("The reference layout has a null definition in slot 0; no passage ") + TEXT("independence check can be performed.")); + return false; + } + + const int32 OriginalConnections = EarlyDefinition->PassageConfig.Connections; + EarlyDefinition->PassageConfig.Connections = OriginalConnections + 1; + const bool bPerturbedRebuild = World.Rebuild(OriginalOrder); + const int32 NumPerturbedStrates = World.StrateManager.IsValid() + ? World.StrateManager->GetNumStrates() + : 0; + if (!bPerturbedRebuild || NumPerturbedStrates != NumPoolDefinitions) + { + AddError(TEXT("Changing the early connection count prevented the known layout from ") + TEXT("rebuilding; the passage-independence check could not run.")); + EarlyDefinition->PassageConfig.Connections = OriginalConnections; + return false; + } + const FLayoutSnapshot Perturbed = CaptureSnapshot(*World.StrateManager.Get()); + EarlyDefinition->PassageConfig.Connections = OriginalConnections; + + FString LayoutMismatch; + if (!SameLayout(Snapshots[0].Layout, Perturbed.Layout, LayoutMismatch)) + { + AddError(FString::Printf( + TEXT("Changing only the early connection count changed the strate layout: %s"), + *LayoutMismatch)); + } + + for (int32 BoundaryIndex = 1; BoundaryIndex < Snapshots[0].Layout.Num() - 1; ++BoundaryIndex) + { + TArray ReferenceBoundary; + TArray PerturbedBoundary; + for (const FVoxelPassage& Passage : Snapshots[0].Passages) + { + if (Passage.UpperStrateIndex == BoundaryIndex) + { + ReferenceBoundary.Add(&Passage); + } + } + for (const FVoxelPassage& Passage : Perturbed.Passages) + { + if (Passage.UpperStrateIndex == BoundaryIndex) + { + PerturbedBoundary.Add(&Passage); + } + } + + ++NumPassagesInspected; + if (ReferenceBoundary.Num() != PerturbedBoundary.Num()) + { + AddError(FString::Printf( + TEXT("Boundary %d passage count changed after an earlier connection count ") + TEXT("was perturbed (%d vs %d)."), + BoundaryIndex, ReferenceBoundary.Num(), PerturbedBoundary.Num())); + continue; + } + + for (int32 PassageIndex = 0; PassageIndex < ReferenceBoundary.Num(); ++PassageIndex) + { + FString Mismatch; + if (!SamePassage(*ReferenceBoundary[PassageIndex], *PerturbedBoundary[PassageIndex], + Mismatch, PassageIndex)) + { + AddError(FString::Printf( + TEXT("Passage at boundary %d changed when an earlier connection count was ") + TEXT("perturbed: %s"), + BoundaryIndex, *Mismatch)); + } + } + } + + if (NumSlotsInspected == 0 || NumPassagesInspected == 0) + { + AddError(FString::Printf( + TEXT("The test inspected no comparable data (slots=%d, passages=%d); this is a ") + TEXT("test-harness failure, not a determinism pass."), + NumSlotsInspected, NumPassagesInspected)); + return false; + } + + AddInfo(FString::Printf( + TEXT("Compared %d layout slots and %d passages across %d pool orders (seed=%d)."), + NumSlotsInspected, NumPassagesInspected, Snapshots.Num(), LayoutSeed)); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 062cb8b..54070b6 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -45,17 +45,29 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) // Fixed strates are excluded from the shuffle — they always use their // assigned definition regardless of seed. - TArray ShuffledPool; + using FLoadedPoolEntry = TPair; + TArray ShuffledPool; for (const TSoftObjectPtr& SoftPtr : Settings->StratePool) { // Load the asset (synchronous for now — could be async later) UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous(); if (Def) { - ShuffledPool.Add(Def); + ShuffledPool.Emplace(SoftPtr.ToString(), Def); } } + // Sort by the soft asset path before shuffling. Pointer addresses depend on load order and + // would make the layout machine-dependent. The pool is a set for layout purposes: editor + // reordering must not change the input sequence seen by Fisher-Yates. + // Trier par chemin de soft asset avant le shuffle. Les adresses de pointeurs dépendent de + // l'ordre de chargement et rendraient le layout dépendant de la machine. Le pool est un set + // pour le layout : réordonner l'asset dans l'éditeur ne doit pas changer l'entrée de Fisher-Yates. + ShuffledPool.Sort([](const FLoadedPoolEntry& A, const FLoadedPoolEntry& B) + { + return FCString::Strcmp(*A.Key, *B.Key) < 0; + }); + // Seed-based shuffle using Fisher-Yates // FRandomStream gives us deterministic random numbers from a seed FRandomStream Rng(WorldSeed); @@ -102,7 +114,7 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) else if (ShuffledPool.Num() > 0) { // Cycle through the pool (wraps around if more strates than pool entries) - Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()]; + Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()].Value; PoolCursor++; } else @@ -238,8 +250,21 @@ void UVoxelStrateManager::GeneratePassages() if (StrateLayout.Num() < 1) return; - // Deterministic RNG from world seed - FRandomStream Rng(CachedSeed ^ 0x50A55A6E); // XOR with "PASSAGE" hash + // Deterministic per-value hashes from the world seed. Every draw is keyed by its boundary + // strate index, connection index, and a unique salt, so one passage cannot shift another. + // Hachages déterministes par valeur depuis le seed du monde. Chaque tirage est indexé par la + // strate de frontière, la connexion et un sel unique : un passage ne peut plus décaler l'autre. + const uint32 PassageSeed = static_cast(CachedSeed) ^ 0x50A55A6Eu; // "PASSAGE" + constexpr uint32 PassageSaltAngle = 0xA1100001u; + constexpr uint32 PassageSaltDistance = 0xA1100002u; + constexpr uint32 PassageSaltUpperReach = 0xA1100003u; + constexpr uint32 PassageSaltLowerReach = 0xA1100004u; + constexpr uint32 PassageSaltWormFreq = 0xA1100005u; + constexpr uint32 PassageSaltNoiseX = 0xA1100006u; + constexpr uint32 PassageSaltNoiseY = 0xA1100007u; + constexpr uint32 PassageSaltNoiseZ = 0xA1100008u; + constexpr uint32 PassageSaltPhase = 0xA1100009u; + constexpr uint32 PassageSaltBendFreq = 0xA110000Au; //========================================================================= // INTER-STRATE PASSAGES: tunnels connecting consecutive strates. @@ -269,19 +294,34 @@ void UVoxelStrateManager::GeneratePassages() const int32 Conns = FMath::Max(0, Cfg.Connections); for (int32 c = 0; c < Conns; c++) { + const auto PassageRandom01 = [PassageSeed, i, c](uint32 Salt) + { + return VoxelHash::ToFloat01(VoxelHash::Cell(i, c, PassageSeed ^ Salt)); + }; + const auto PassageRandomRange = [&PassageRandom01](float Min, float Max, uint32 Salt) + { + return FMath::Lerp(Min, Max, PassageRandom01(Salt)); + }; + FVoxelPassage Passage; Passage.UpperStrateIndex = i; Passage.LowerStrateIndex = i + 1; + // PassageType has no random draw in the current implementation; preserve its existing + // default while PassageConfig::Style controls the generated control-point shape. + // PassageType n'a pas de tirage aléatoire ici ; conserver son défaut, tandis que + // PassageConfig::Style contrôle la forme des points de contrôle générés. // PLACEMENT: random angle, distance from the (0,0) spine within config range. - const float Angle = Rng.FRandRange(0.0f, 2.0f * PI); - const float Distance = Rng.FRandRange(DistLo, DistHi); + const float Angle = PassageRandom01(PassageSaltAngle) * (2.0f * PI); + const float Distance = PassageRandomRange(DistLo, DistHi, PassageSaltDistance); const float PX = FMath::Cos(Angle) * Distance; const float PY = FMath::Sin(Angle) * Distance; // LENGTH: reach into each strate, capped to the interior. - const float UpperReach = FMath::Min(Rng.FRandRange(Cfg.ReachMin, Cfg.ReachMax), UpperMax); - const float LowerReach = FMath::Min(Rng.FRandRange(Cfg.ReachMin, Cfg.ReachMax), LowerMax); + const float UpperReach = FMath::Min( + PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltUpperReach), UpperMax); + const float LowerReach = FMath::Min( + PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltLowerReach), LowerMax); const float TopZ = UpperBottomZ + UpperReach; const float BottomZ = LowerTopZ - LowerReach; @@ -295,13 +335,13 @@ void UVoxelStrateManager::GeneratePassages() auto RadiusAt = [&](float t) { return FMath::Lerp(Cfg.MouthRadius, Cfg.MidRadius, FMath::Sin(t * PI)); }; // Per-passage shape seeds. - const float WormFreq = Rng.FRandRange(0.8f, 1.8f); // (vertical wobble only) - const float NSeedX = Rng.FRandRange(0.0f, 500.0f); - const float NSeedY = Rng.FRandRange(0.0f, 500.0f); - const float NSeedZ = Rng.FRandRange(0.0f, 500.0f); - const float PhaseA = Rng.FRandRange(0.0f, 2.0f * PI); + const float WormFreq = PassageRandomRange(0.8f, 1.8f, PassageSaltWormFreq); // (vertical wobble only) + const float NSeedX = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseX); + const float NSeedY = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseY); + const float NSeedZ = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseZ); + const float PhaseA = PassageRandom01(PassageSaltPhase) * (2.0f * PI); // Base fBM frequency for the worm's wander (octaves add finer detail on top). - const float BendFreq = Rng.FRandRange(1.5f, 2.5f); + const float BendFreq = PassageRandomRange(1.5f, 2.5f, PassageSaltBendFreq); for (int32 s = 0; s <= Segments; s++) {