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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPUuCtn5zTLui6WybLbkNb
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-08-29 16:30:47 +02:00
1 parent a60138b19c
commit 10a8bbc677
5 files changed
+570 -32

No files matched your search

+4 -1
View File
@@ -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 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 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 (`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 ### 8.9 Carving — brush shapes + editor controls
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/ `FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
+3 -2
View File
@@ -353,8 +353,8 @@ Maps depth→strate at runtime; owns passages.
- `FStrateSlot` (h:84): definition + chunk-Z range + index. - `FStrateSlot` (h:84): definition + chunk-Z range + index.
| Method | .cpp line | Role | | 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. | | `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-type control points). | | `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. | | `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. | | `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. | | `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. | | `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. | | `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`). | | `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) ## 4. The density pipeline (most-edited hot path)
+50 -14
View File
@@ -345,11 +345,12 @@ needs a guard, or the caves vanish with no error.
### ⚠️ 6.4 The primordial law has a real hole TODAY ### ⚠️ 6.4 The primordial law has a real hole TODAY
`UVoxelStrateManager::GeneratePassages` places every inter-strate passage as: `FRandomStream(CachedSeed `UVoxelStrateManager::GeneratePassages` places every inter-strate passage as independently salted
^ 0x50A55A6E)` → random angle around the (0,0) spine → random distance in the config range → random hashes of `(seed, upper-strate index, connection index)` → random angle around the (0,0) spine →
reach into each strate. **Nothing consults the destination strate's cave layout.** Carving is a random distance in the config range → random reach into each strate. **Nothing consults the
structural-post invariant so the tube *is* air — but whether its lower mouth joins the lower strate's destination strate's cave layout.** Carving is a structural-post invariant so the tube *is* air — but
connected space is pure luck. **A player can descend a passage and arrive in a sealed pocket.** 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 **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 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 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. 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 Before Tier 0, `Initialize` Fisher-Yates shuffled `Settings->StratePool` in editor order with
(`PoolCursor % Num`) when `TotalStrates` exceeds the pool. So **reordering the pool asset in the editor `FRandomStream(WorldSeed)`, then cycled it (`PoolCursor % Num`) when `TotalStrates` exceeded the pool.
changes every world**, and a pool smaller than the strate count repeats entries in a fixed order. 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 ⇒ **Tier 0 is now implemented:** load the pool, sort it by the stable soft-asset path, then keep the
**set** rather than a sequence: tidying the asset stops changing worlds, and each slot's roll becomes existing Fisher-Yates shuffle and cycling algorithm. The pool is a **set** rather than an editor-ordered
independent of its neighbours'. Adding or removing a strate type still changes worlds — unavoidable, and sequence: tidying the asset stops changing worlds. Adding or removing a strate type still changes
the reason content lands at wipes. 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 ### ⚠️ 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 ### Tier 0 — safety nets, true regardless of the composer
- ✅ **X-macro guard test** — DONE, green. - ✅ **X-macro guard test** — DONE, green.
- ✅ **Actor-space sweep** — DONE, confirmed off-origin. - ✅ **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. tidied without changing every world. Small and isolated.
### Tier 1 — the primordial law (a BUG FIX, not composer work) ### 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. 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. Parked: the existing parameter space is already vastly larger than a hundred seasons could explore.
**The scarce resource is judgment, not vocabulary.** **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.
@@ -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<FStrateSlot> Layout;
TArray<FVoxelPassage> 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<UVoxelSettings> Settings;
TStrongObjectPtr<UVoxelStrateManager> StrateManager;
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> Definitions;
void Build()
{
Settings = TStrongObjectPtr<UVoxelSettings>(
NewObject<UVoxelSettings>(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<UVoxelStrateDefinition>(
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<UVoxelStrateDefinition>(Def));
}
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
}
void SetPoolOrder(const TArray<int32>& Order)
{
Settings->StratePool.Reset(Order.Num());
for (const int32 DefinitionIndex : Order)
{
Settings->StratePool.Add(
TSoftObjectPtr<UVoxelStrateDefinition>(Definitions[DefinitionIndex].Get()));
}
}
bool Rebuild(const TArray<int32>& 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<FStrateSlot>& Expected, const TArray<FStrateSlot>& 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<int32> OriginalOrder;
for (int32 Index = 0; Index < NumPoolDefinitions; ++Index)
{
OriginalOrder.Add(Index);
}
TArray<int32> ReversedOrder;
for (int32 Index = NumPoolDefinitions - 1; Index >= 0; --Index)
{
ReversedOrder.Add(Index);
}
TArray<int32> PermutedOrder = OriginalOrder;
PermutedOrder.Swap(0, 2);
TArray<TArray<int32>> Orders;
Orders.Add(OriginalOrder);
Orders.Add(ReversedOrder);
Orders.Add(PermutedOrder);
static const TCHAR* OrderNames[] = {
TEXT("original"),
TEXT("reversal"),
TEXT("swap"),
};
TArray<FLayoutSnapshot> 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<const FVoxelPassage*> ReferenceBoundary;
TArray<const FVoxelPassage*> 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
@@ -45,17 +45,29 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
// Fixed strates are excluded from the shuffle — they always use their // Fixed strates are excluded from the shuffle — they always use their
// assigned definition regardless of seed. // assigned definition regardless of seed.
TArray<UVoxelStrateDefinition*> ShuffledPool; using FLoadedPoolEntry = TPair<FString, UVoxelStrateDefinition*>;
TArray<FLoadedPoolEntry> ShuffledPool;
for (const TSoftObjectPtr<UVoxelStrateDefinition>& SoftPtr : Settings->StratePool) for (const TSoftObjectPtr<UVoxelStrateDefinition>& SoftPtr : Settings->StratePool)
{ {
// Load the asset (synchronous for now — could be async later) // Load the asset (synchronous for now — could be async later)
UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous(); UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous();
if (Def) 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 // Seed-based shuffle using Fisher-Yates
// FRandomStream gives us deterministic random numbers from a seed // FRandomStream gives us deterministic random numbers from a seed
FRandomStream Rng(WorldSeed); FRandomStream Rng(WorldSeed);
@@ -102,7 +114,7 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
else if (ShuffledPool.Num() > 0) else if (ShuffledPool.Num() > 0)
{ {
// Cycle through the pool (wraps around if more strates than pool entries) // 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++; PoolCursor++;
} }
else else
@@ -238,8 +250,21 @@ void UVoxelStrateManager::GeneratePassages()
if (StrateLayout.Num() < 1) return; if (StrateLayout.Num() < 1) return;
// Deterministic RNG from world seed // Deterministic per-value hashes from the world seed. Every draw is keyed by its boundary
FRandomStream Rng(CachedSeed ^ 0x50A55A6E); // XOR with "PASSAGE" hash // 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<uint32>(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. // INTER-STRATE PASSAGES: tunnels connecting consecutive strates.
@@ -269,19 +294,34 @@ void UVoxelStrateManager::GeneratePassages()
const int32 Conns = FMath::Max(0, Cfg.Connections); const int32 Conns = FMath::Max(0, Cfg.Connections);
for (int32 c = 0; c < Conns; c++) 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; FVoxelPassage Passage;
Passage.UpperStrateIndex = i; Passage.UpperStrateIndex = i;
Passage.LowerStrateIndex = i + 1; 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. // PLACEMENT: random angle, distance from the (0,0) spine within config range.
const float Angle = Rng.FRandRange(0.0f, 2.0f * PI); const float Angle = PassageRandom01(PassageSaltAngle) * (2.0f * PI);
const float Distance = Rng.FRandRange(DistLo, DistHi); const float Distance = PassageRandomRange(DistLo, DistHi, PassageSaltDistance);
const float PX = FMath::Cos(Angle) * Distance; const float PX = FMath::Cos(Angle) * Distance;
const float PY = FMath::Sin(Angle) * Distance; const float PY = FMath::Sin(Angle) * Distance;
// LENGTH: reach into each strate, capped to the interior. // LENGTH: reach into each strate, capped to the interior.
const float UpperReach = FMath::Min(Rng.FRandRange(Cfg.ReachMin, Cfg.ReachMax), UpperMax); const float UpperReach = FMath::Min(
const float LowerReach = FMath::Min(Rng.FRandRange(Cfg.ReachMin, Cfg.ReachMax), LowerMax); 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 TopZ = UpperBottomZ + UpperReach;
const float BottomZ = LowerTopZ - LowerReach; 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)); }; auto RadiusAt = [&](float t) { return FMath::Lerp(Cfg.MouthRadius, Cfg.MidRadius, FMath::Sin(t * PI)); };
// Per-passage shape seeds. // Per-passage shape seeds.
const float WormFreq = Rng.FRandRange(0.8f, 1.8f); // (vertical wobble only) const float WormFreq = PassageRandomRange(0.8f, 1.8f, PassageSaltWormFreq); // (vertical wobble only)
const float NSeedX = Rng.FRandRange(0.0f, 500.0f); const float NSeedX = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseX);
const float NSeedY = Rng.FRandRange(0.0f, 500.0f); const float NSeedY = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseY);
const float NSeedZ = Rng.FRandRange(0.0f, 500.0f); const float NSeedZ = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseZ);
const float PhaseA = Rng.FRandRange(0.0f, 2.0f * PI); const float PhaseA = PassageRandom01(PassageSaltPhase) * (2.0f * PI);
// Base fBM frequency for the worm's wander (octaves add finer detail on top). // 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++) for (int32 s = 0; s <= Segments; s++)
{ {