From 10a8bbc67763604cec2c576407264ee0ca1cdcf8 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sat, 29 Aug 2026 16:30:47 +0200 Subject: [PATCH] feat(strate): the layout is a set, not a sequence -- and the suite is green Tier 0. Reordering Settings->StratePool in the editor used to change every world in the game, because Initialize Fisher-Yates shuffled the array in editor order. The pool is now sorted by stable soft-asset path before the shuffle, so the layout is a pure function of (seed, pool contents as a SET). The selection algorithm itself is untouched -- same Fisher-Yates, same cycling, same FixedStrates handling, same Z ranges. Sorting by pointer would have looked right and been load-order dependent, which is the trap here. GeneratePassages walked one sequential FRandomStream across every strate, so changing an early connection count shifted every later passage. Each draw is now a salted VoxelHash of (seed, boundary index, connection index), with the distributions preserved exactly -- ToFloat01 returns [0,1), so Lerp(Min,Max,t) reproduces FRandRange precisely. Codex implemented it and caught a real flaw in my spec: I asked for a test that permutes the pool and asserts the passages match, but once the layout is order-independent that test cannot fail whether or not the passage fix works -- the layout comes out identical, so even the old sequential stream would agree. It added the perturbation that actually bites: change an early connection count and verify every later boundary keeps its own values. It also contradicted me on passage type being randomly drawn. It isn't, and it preserved that. Built VoxelMEditor clean, then ran the suite headless: 16 succeeded, 0 failed, 0 not run. That also closes the thread OPSTACK-HANDOFF.md left open -- 4d33321 and 91585ea were built but never re-verified, and both acceptance conditions are now met: violations 0 everywhere (12 of 40 TunnelNetwork tiles proved over 15972 brute-forced voxels), all equivalences green. No revert needed. Expected: every seed now lays out differently than before. Accepted, pre-content-lock. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb --- ARCHITECTURE.md | 5 +- CODEMAP.md | 5 +- COMPOSER-NOTES.md | 64 ++- .../VoxelForgeLayoutOrderIndependenceTest.cpp | 458 ++++++++++++++++++ .../VoxelForge/Private/VoxelStrateManager.cpp | 70 ++- 5 files changed, 570 insertions(+), 32 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp 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++) {