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

@@ -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
// assigned definition regardless of seed.
TArray<UVoxelStrateDefinition*> ShuffledPool;
using FLoadedPoolEntry = TPair<FString, UVoxelStrateDefinition*>;
TArray<FLoadedPoolEntry> ShuffledPool;
for (const TSoftObjectPtr<UVoxelStrateDefinition>& 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<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.
@@ -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++)
{