feat(composer): the parameter roll -- infrastructure real, corpus starved

Tier 4a. The first piece of the composer that INVENTS rather than fixes.

The design, from COMPOSER-NOTES §3.3: do NOT roll 73 parameters
independently. The coherent region of a 73-dimensional space is a
vanishingly thin sheet and uniform sampling never lands on it -- and
independent rolling destroys the CORRELATIONS (room spacing <-> room
radius <-> tunnel length) that make a vector coherent. So blend known-good
vectors through the existing FStrateGenerationParams::Lerp instead:
constraint satisfaction is free, because a convex combination preserves
every relation holding in both parents. Min <= Max stays true because it
was never broken.

Built: FVoxelStrateCorpus (loads authored vectors, computes per-field
spread), VF_RollStrateParams (2-3 weighted parents, convex blend, jitter
by +-15% of each field's MEASURED corpus range -- not by the clamps, which
are "don't type nonsense" limits, not "this makes a good world" limits),
and VoxelForge.Composer.ParameterRoll. Determinism asserted by rolling
twice and comparing every field.

Exclusion list found by reflection, not assumption: StrateTopWorldZ and
StrateBottomWorldZ (manager-owned runtime bounds) plus 34 terrain-op
transport fields (TerraceStepHeight..PinchLength) that are copied in from
room operators rather than being strate tunables. Bools inherit from the
dominant parent; ints/enums use Lerp's existing snap.

⚠️ THE RESULT IS VACUOUS AND SAYS SO. 64/64 candidates "survived" -- but
the corpus loaded ONE vector (DA_Strate3), so all 64 are bit-identical.
100% survival of a single point is not evidence of anything. The box-
verdict check is vacuous too: 2,560 boxes, 0 proved, 0 voxels brute-forced,
so §6.2 is NOT closed. The test emits a warning saying exactly this rather
than reporting a clean pass, and no workaround was added to hide it.

Four strate assets exist (DA_Strate1..4); the loader found one. Fixing the
corpus is the next task -- with one parent there is nothing to blend, and
every number above is a placeholder.

Also owed: clamp metadata is editor-only, so the safety net needs a
cook-time baked table before this ships.

Nothing calls the composer from any runtime path (§3.1: offline, once per
season, on a build box).

Build clean, 23 tests / 0 failed (1 carries the deliberate vacuity
warning), verified in VoxelM.log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-04 07:19:11 +02:00
1 parent 8e346a3054
commit 358bd415a9
5 files changed
+1649 -2

No files matched your search

@@ -0,0 +1,507 @@
// Tier 4a — corpus spread, deterministic parameter roll, and candidate measurement.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "HAL/PlatformTime.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelStrateComposer.h"
#include "VoxelStrateMeasure.h"
namespace
{
constexpr int32 NumCandidates = 64;
constexpr float LargestComponentSurvivalThreshold = 0.50f;
constexpr int32 BoxChecksPerCandidate = 40;
const TCHAR* VF_FieldKindName(EVoxelStrateFieldKind Kind)
{
switch (Kind)
{
case EVoxelStrateFieldKind::Continuous: return TEXT("float");
case EVoxelStrateFieldKind::Integer: return TEXT("int");
case EVoxelStrateFieldKind::Boolean: return TEXT("bool");
case EVoxelStrateFieldKind::Enum: return TEXT("enum");
}
return TEXT("unknown");
}
FString VF_FormatSpreadValue(double Value)
{
return FString::Printf(TEXT("%.9g"), Value);
}
FString VF_FormatClamp(const FVoxelStrateFieldSpread& Spread)
{
if (!Spread.bHasClampMin && !Spread.bHasClampMax)
{
return TEXT("-");
}
if (Spread.bHasClampMin && Spread.bHasClampMax)
{
return FString::Printf(TEXT("[%s,%s]"),
*VF_FormatSpreadValue(Spread.ClampMin),
*VF_FormatSpreadValue(Spread.ClampMax));
}
if (Spread.bHasClampMin)
{
return FString::Printf(TEXT("[%s,+inf)"),
*VF_FormatSpreadValue(Spread.ClampMin));
}
return FString::Printf(TEXT("(-inf,%s]"),
*VF_FormatSpreadValue(Spread.ClampMax));
}
FString VF_FormatParents(const FVoxelStrateCorpus& Corpus,
const FVoxelStrateRollInfo& Roll)
{
FString Result;
for (int32 ParentSlot = 0; ParentSlot < Roll.ParentEntryIndices.Num(); ++ParentSlot)
{
if (ParentSlot > 0)
{
Result += TEXT("+");
}
const int32 EntryIndex = Roll.ParentEntryIndices[ParentSlot];
const FVoxelStrateCorpusEntry* Entry = Corpus.GetEntries().IsValidIndex(EntryIndex)
? &Corpus.GetEntries()[EntryIndex] : nullptr;
if (Entry == nullptr)
{
Result += TEXT("(invalid)");
}
else
{
Result += Entry->SourceName;
Result += FString::Printf(TEXT("@%.3f"), Roll.ParentWeights[ParentSlot]);
}
}
return Result;
}
FString VF_ConnectivityName(EVoxelConnectivityResult Result)
{
switch (Result)
{
case EVoxelConnectivityResult::Connected: return TEXT("Connected");
case EVoxelConnectivityResult::NotConnectedAtThisResolution:return TEXT("NotConnected");
case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid");
case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid");
case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow");
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudget");
}
return TEXT("Unknown");
}
int32 VF_FloorDiv(int32 A, int32 B)
{
const int32 Q = A / B;
const int32 R = A % B;
return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q;
}
struct FBoxVerdictReport
{
int32 Mixed = 0;
int32 AllSolid = 0;
int32 AllAir = 0;
int32 Proved = 0;
int64 CheckedVoxels = 0;
int32 Violations = 0;
FString FirstViolation;
};
/**
* Exercise the production ClassifyTile box verdict with a rolled candidate, then brute-force
* the exact mesher lattice. A false AllSolid/AllAir result is the §6.2 hole this pass is meant
* to expose. The report is aggregate: there is no per-sample logging.
*/
FBoxVerdictReport VF_CheckRolledBoxVerdicts(
const VoxelForgeTest::FTestWorld& World, int32 CandidateIndex, int32 StrateIndex)
{
FBoxVerdictReport Report;
const UVoxelGenerator* Generator = World.Generator.Get();
if (Generator == nullptr || !World.StrateManager->GetLayout().IsValidIndex(StrateIndex))
{
Report.FirstViolation = TEXT("fixture generator or target layout slot was invalid");
return Report;
}
int32 TopVoxelZ = 0;
int32 BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(StrateIndex, TopVoxelZ, BottomVoxelZ))
{
Report.FirstViolation = TEXT("target slot had no voxel Z range");
return Report;
}
constexpr int32 Step = 1;
constexpr int32 Cells = 8;
constexpr int32 Extent = Step * Cells;
constexpr int32 SpanCells = 40;
const int32 LoTile = VF_FloorDiv(BottomVoxelZ, Extent);
const int32 HiTile = FMath::Max(LoTile, VF_FloorDiv(TopVoxelZ, Extent));
FRandomStream Rng(0x6C617731 ^ CandidateIndex);
for (int32 BoxIndex = 0; BoxIndex < BoxChecksPerCandidate; ++BoxIndex)
{
const FIntVector Origin(
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(-SpanCells, SpanCells) * Extent,
Rng.RandRange(LoTile, HiTile) * Extent);
const FBox Box(
FVector((float)(Origin.X - Step), (float)(Origin.Y - Step), (float)(Origin.Z - Step)),
FVector((float)(Origin.X + (Cells + 1) * Step),
(float)(Origin.Y + (Cells + 1) * Step),
(float)(Origin.Z + (Cells + 1) * Step)));
(void)Box; // The production API receives the equivalent origin/step/cell box.
const EVoxelTileClass Verdict = Generator->ClassifyTile(Origin, Step, Cells);
if (Verdict == EVoxelTileClass::Mixed)
{
++Report.Mixed;
continue;
}
if (Verdict == EVoxelTileClass::AllSolid)
{
++Report.AllSolid;
}
else
{
++Report.AllAir;
}
++Report.Proved;
const int32 GridDim = Cells + 1;
const bool bClaimsSolid = Verdict == EVoxelTileClass::AllSolid;
bool bBoxBad = false;
for (int32 GZ = -1; GZ <= GridDim; ++GZ)
for (int32 GY = -1; GY <= GridDim; ++GY)
for (int32 GX = -1; GX <= GridDim; ++GX)
{
const float X = (float)(Origin.X + GX * Step);
const float Y = (float)(Origin.Y + GY * Step);
const float Z = (float)(Origin.Z + GZ * Step);
const float Density = Generator->GetDensityAt(X, Y, Z);
++Report.CheckedVoxels;
// MC convention: negative = solid; zero belongs to the air side, exactly as the
// existing ClassifyTile soundness test defines it.
const bool bAgrees = bClaimsSolid ? (Density < 0.0f) : (Density >= 0.0f);
if (!bAgrees)
{
++Report.Violations;
bBoxBad = true;
if (Report.FirstViolation.IsEmpty())
{
Report.FirstViolation = FString::Printf(
TEXT("candidate %d box %d origin (%d,%d,%d) said %s but density at "
"(%.0f,%.0f,%.0f) was %.9g"),
CandidateIndex, BoxIndex, Origin.X, Origin.Y, Origin.Z,
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
X, Y, Z, Density);
}
}
}
(void)bBoxBad;
}
return Report;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeComposerParameterRollTest,
"VoxelForge.Composer.ParameterRoll",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
(void)Parameters;
const double TestStartSeconds = FPlatformTime::Seconds();
const FString SettingsPath = TEXT("/Game/VoxelForge/DA_Settings.DA_Settings");
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(nullptr, *SettingsPath);
if (AuthoredSettings == nullptr)
{
AddError(FString::Printf(
TEXT("Could not load the hand-authored VoxelSettings asset at %s; the corpus was not "
"constructed and no invented candidates may be substituted."), *SettingsPath));
return false;
}
FVoxelStrateCorpus Corpus;
FString CorpusReport;
const double CorpusStartSeconds = FPlatformTime::Seconds();
const bool bCorpusLoaded = Corpus.LoadFromSettings(AuthoredSettings, CorpusReport);
const double CorpusSeconds = FPlatformTime::Seconds() - CorpusStartSeconds;
AddInfo(FString::Printf(TEXT("%s Load time %.3fs; contents hash 0x%08x; clamp metadata at "
"build=%s, promised at cooked runtime=%s."),
*CorpusReport, CorpusSeconds, Corpus.GetContentsHash(),
Corpus.AreClampMetadataAvailableAtBuild() ? TEXT("yes") : TEXT("no"),
Corpus.AreClampMetadataAvailableAtRuntime() ? TEXT("yes") : TEXT("no")));
FString SpreadTable = TEXT(
"CORPUS SPREAD (population stddev; jitter range = ±15% of max-min; clamps are safety only)\n"
"field | kind | excluded | reflected | samples | min | max | mean | stddev | clamp\n");
for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads())
{
SpreadTable += FString::Printf(
TEXT("%s | %s | %s | %s | %d | %s | %s | %s | %s | %s\n"),
*Spread.FieldName,
VF_FieldKindName(Spread.Kind),
Spread.bExcluded ? TEXT("yes") : TEXT("no"),
Spread.bReflected ? TEXT("yes") : TEXT("no"),
Spread.SampleCount,
*VF_FormatSpreadValue(Spread.Min),
*VF_FormatSpreadValue(Spread.Max),
*VF_FormatSpreadValue(Spread.Mean),
*VF_FormatSpreadValue(Spread.StdDev),
*VF_FormatClamp(Spread));
}
AddInfo(SpreadTable);
FString ExclusionTable = TEXT("EXPLICIT COMPOSER EXCLUSIONS\nfield | reason\n");
for (const FVoxelStrateFieldExclusion& Exclusion : FVoxelStrateCorpus::GetNonTunableFields())
{
ExclusionTable += FString::Printf(TEXT("%s | %s\n"),
*Exclusion.FieldName, *Exclusion.Reason);
}
AddInfo(ExclusionTable);
AddInfo(TEXT(
"Bool policy: bTunnelsFlowTowardOrigin is inherited from the dominant weighted parent; "
"int/enum SNAP fields use FStrateGenerationParams::Lerp and are not jittered. Parent "
"selection is weight-1.0 (equal) because UVoxelStrateDefinition has no corpus-weight field; "
"parents are selected only within one archetype group."));
TestTrue(TEXT("the reflected parameter schema is covered or explicitly excluded"),
Corpus.IsSchemaValid());
TestTrue(TEXT("hand-authored corpus loaded at least one usable vector"), bCorpusLoaded);
if (!bCorpusLoaded || !Corpus.IsValid())
{
AddError(FString::Printf(
TEXT("Cannot run the 64-candidate measurement: %s"),
Corpus.IsSchemaValid() ? TEXT("the settings asset exposed no TunnelNetwork/Underwater GenerationParams")
: *Corpus.GetSchemaError()));
return false;
}
FTestWorld World;
World.Build(1337, 2, true);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
TestTrue(TEXT("WorldRadiusVoxels remains the required default 0"),
World.Settings->WorldRadiusVoxels == 0.0f);
constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain;
if (!World.Definitions.IsValidIndex(CandidateStrateIndex))
{
AddError(TEXT("The fixture has no candidate slot for ParameterRoll."));
return false;
}
FVoxelStrateMeasureSettings MeasureSettings;
MeasureSettings.SampleStep = 4;
MeasureSettings.RadiusInVoxels = 256;
MeasureSettings.CenterXY = FVector2D::ZeroVector;
MeasureSettings.MaxCells = 8000000;
MeasureSettings.MaxRouteRetries = 16;
MeasureSettings.HeadroomCells = 2;
MeasureSettings.InteriorMarginVoxels = -1;
FString CandidateTable = TEXT(
"candidate | archetype | parents | air | largest share | walkable | feature scale | arrival->departure\n");
int32 NumSurvivors = 0;
int32 NumNonVacuous = 0;
int32 NumLargestEnough = 0;
int32 NumLawPasses = 0;
int32 NumRollFailures = 0;
int32 TotalBoxMixed = 0;
int32 TotalBoxAllSolid = 0;
int32 TotalBoxAllAir = 0;
int32 TotalBoxProved = 0;
int64 TotalBoxCheckedVoxels = 0;
int32 TotalBoxViolations = 0;
TArray<int32> BoxViolationCandidates;
const double RollAndMeasureStartSeconds = FPlatformTime::Seconds();
for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex)
{
const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailed(
Corpus, AuthoredSettings->Seed, CandidateIndex);
TestTrue(FString::Printf(TEXT("candidate %d roll is valid"), CandidateIndex), Roll.bValid);
FString ParentText = VF_FormatParents(Corpus, Roll);
FString AirText = TEXT("invalid");
FString LargestText = TEXT("invalid");
FString WalkableText = TEXT("invalid");
FString FeatureText = TEXT("invalid");
FString LawText = TEXT("not measured");
if (!Roll.bValid)
{
++NumRollFailures;
CandidateTable += FString::Printf(TEXT("%d | invalid | %s | invalid | invalid | invalid | invalid | %s\n"),
CandidateIndex, *ParentText, *Roll.FailureReason);
continue;
}
// Put the invented vector into a transient fixture definition. This is an offline test
// world only; the runtime manager/generation code and authored assets are untouched.
UVoxelStrateDefinition* CandidateDefinition =
World.Definitions[CandidateStrateIndex].Get();
CandidateDefinition->GeneratorType = Roll.Archetype;
CandidateDefinition->GenerationParams = Roll.Params;
CandidateDefinition->bUseOperatorStack = true;
CandidateDefinition->TransitionType = EVoxelStrateTransition::Hard;
World.Reinitialize();
const FStrateGenerationParams RuntimeParams = World.StrateManager->GetGenerationParams(
FIntVector(0, 0,
World.StrateManager->GetLayout()[CandidateStrateIndex].BottomChunkZ
+ (World.StrateManager->GetLayout()[CandidateStrateIndex].TopChunkZ
- World.StrateManager->GetLayout()[CandidateStrateIndex].BottomChunkZ) / 2));
TestTrue(FString::Printf(TEXT("candidate %d keeps runtime Z out of the rolled vector"), CandidateIndex),
Roll.Params.StrateTopWorldZ == 0.0f && Roll.Params.StrateBottomWorldZ == 0.0f);
const FVoxelStrateMetrics Metrics = VF_MeasureStrate(
*World.Generator, *World.StrateManager, CandidateStrateIndex, MeasureSettings);
FVector ArrivalPoint = FVector::ZeroVector;
FVector DeparturePoint = FVector::ZeroVector;
int32 ArrivalCount = 0;
int32 DepartureCount = 0;
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
{
if (Passage.LowerStrateIndex == CandidateStrateIndex
&& Passage.UpperStrateIndex + 1 == CandidateStrateIndex)
{
ArrivalPoint = Passage.LowerPoint;
++ArrivalCount;
}
if (Passage.UpperStrateIndex == CandidateStrateIndex
&& Passage.LowerStrateIndex == CandidateStrateIndex + 1)
{
DeparturePoint = Passage.UpperPoint;
++DepartureCount;
}
}
FVoxelConnectivityDiagnostics Law;
if (ArrivalCount == 1 && DepartureCount == 1)
{
Law = VF_DiagnoseConnectivity(
*World.Generator, *World.StrateManager, CandidateStrateIndex,
ArrivalPoint, DeparturePoint, MeasureSettings);
LawText = VF_ConnectivityName(Law.Result);
if (Law.bStartSnapped || Law.bGoalSnapped)
{
LawText += TEXT(" (snapped)");
}
}
else
{
LawText = FString::Printf(TEXT("mouths=%d/%d"), ArrivalCount, DepartureCount);
}
const bool bNonVacuous = Metrics.bValid
&& Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0;
const bool bLargestEnough = bNonVacuous
&& Metrics.LargestComponentShare >= LargestComponentSurvivalThreshold;
const bool bLawPass = ArrivalCount == 1 && DepartureCount == 1
&& Law.bValid && Law.Result == EVoxelConnectivityResult::Connected
&& !Law.bStartSnapped && !Law.bGoalSnapped;
if (bNonVacuous) { ++NumNonVacuous; }
if (bLargestEnough) { ++NumLargestEnough; }
if (bLawPass) { ++NumLawPasses; }
if (bNonVacuous && bLargestEnough && bLawPass) { ++NumSurvivors; }
if (Metrics.bValid)
{
AirText = FString::Printf(TEXT("%.6f"), Metrics.AirFraction);
LargestText = FString::Printf(TEXT("%.6f"), Metrics.LargestComponentShare);
WalkableText = FString::Printf(TEXT("%.6f"), Metrics.WalkableFraction);
FeatureText = FString::Printf(TEXT("%.6f"), Metrics.MedianFeatureScale);
}
const FBoxVerdictReport BoxReport = VF_CheckRolledBoxVerdicts(
World, CandidateIndex, CandidateStrateIndex);
TotalBoxMixed += BoxReport.Mixed;
TotalBoxAllSolid += BoxReport.AllSolid;
TotalBoxAllAir += BoxReport.AllAir;
TotalBoxProved += BoxReport.Proved;
TotalBoxCheckedVoxels += BoxReport.CheckedVoxels;
TotalBoxViolations += BoxReport.Violations;
if (BoxReport.Violations > 0)
{
BoxViolationCandidates.Add(CandidateIndex);
}
CandidateTable += FString::Printf(
TEXT("%d | %s | %s | %s | %s | %s | %s | %s%s\n"),
CandidateIndex,
VF_GetStrateArchetypeName(Roll.Archetype),
*ParentText,
*AirText,
*LargestText,
*WalkableText,
*FeatureText,
*LawText,
Metrics.bValid ? TEXT("") : *FString::Printf(TEXT(" [%s]"), *Metrics.RefusalReason));
// Keep the fact that this is a test fixture explicit and check the invariant after every
// rebuild, so a future composer cannot accidentally start assigning the global world radius.
TestTrue(FString::Printf(TEXT("candidate %d leaves WorldRadiusVoxels at 0"), CandidateIndex),
World.Settings->WorldRadiusVoxels == 0.0f);
}
const double RollAndMeasureSeconds = FPlatformTime::Seconds() - RollAndMeasureStartSeconds;
AddInfo(CandidateTable);
AddInfo(FString::Printf(
TEXT("Survival: %d/%d (%.1f%%). Criteria: non-vacuous (valid, sampled, air>0, solid>0), "
"largest component share >= %.2f, and exact unsnapped arrival->departure law "
"Result=Connected. Intermediate counts: non-vacuous %d, largest threshold %d, law "
"pass %d, roll failures %d. Roll+measure time %.3fs."),
NumSurvivors, NumCandidates, 100.0f * (float)NumSurvivors / (float)NumCandidates,
LargestComponentSurvivalThreshold, NumNonVacuous, NumLargestEnough, NumLawPasses,
NumRollFailures, RollAndMeasureSeconds));
AddInfo(FString::Printf(
TEXT("Rolled §6.2 box verdict check: mixed=%d, AllSolid=%d, AllAir=%d, proved=%d, "
"brute-force lattice samples=%lld, violations=%d; violating candidates=%s. "
"A zero-proved run is reported as vacuous rather than silently treated as sound."),
TotalBoxMixed, TotalBoxAllSolid, TotalBoxAllAir, TotalBoxProved,
TotalBoxCheckedVoxels, TotalBoxViolations,
BoxViolationCandidates.Num() == 0 ? TEXT("none") : TEXT("see first violation below")));
if (!BoxViolationCandidates.IsEmpty())
{
AddError(FString::Printf(
TEXT("ROLLED BOX-VERDICT VIOLATION: %d candidate(s) produced a false AllSolid/AllAir "
"claim; first candidate index %d. This closes the test red because §6.2 bounds "
"were not sound for the rolled parameter space."),
BoxViolationCandidates.Num(), BoxViolationCandidates[0]));
}
if (TotalBoxProved == 0)
{
AddWarning(TEXT(
"VACUOUS BOX CHECK: no rolled candidate emitted an AllSolid/AllAir verdict over the "
"40-box scan. This is not evidence of soundness; widen the scan or improve the bound "
"before using it as a §6.2 result."));
}
TestEqual(TEXT("exactly 64 candidate rows were requested"), NumCandidates, 64);
TestEqual(TEXT("rolled production box verdicts contain no false uniform claim"),
TotalBoxViolations, 0);
const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds;
AddInfo(FString::Printf(TEXT("ParameterRoll total runtime %.3fs (corpus %.3fs, candidate "
"roll/measurement %.3fs)."),
TestSeconds, CorpusSeconds, RollAndMeasureSeconds));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,931 @@
// Offline strate corpus and parameter roll.
#include "VoxelStrateComposer.h"
#include "Math/RandomStream.h"
#include "Misc/Crc.h"
#include "UObject/FieldIterator.h"
#include "UObject/UnrealType.h"
#include "VoxelSettings.h"
#include "VoxelStrateDefinition.h"
#include <cmath>
#include <type_traits>
#include <utility>
namespace
{
constexpr float GJitterFraction = 0.15f;
template <typename T>
EVoxelStrateFieldKind VF_FieldKindFor()
{
using ValueType = typename std::remove_cv<
typename std::remove_reference<T>::type>::type;
if (std::is_same<ValueType, bool>::value)
{
return EVoxelStrateFieldKind::Boolean;
}
if (std::is_enum<ValueType>::value)
{
return EVoxelStrateFieldKind::Enum;
}
if (std::is_integral<ValueType>::value)
{
return EVoxelStrateFieldKind::Integer;
}
return EVoxelStrateFieldKind::Continuous;
}
const FVoxelStrateFieldExclusion* VF_FindExclusion(const FString& FieldName)
{
for (const FVoxelStrateFieldExclusion& Exclusion : FVoxelStrateCorpus::GetNonTunableFields())
{
if (Exclusion.FieldName == FieldName)
{
return &Exclusion;
}
}
return nullptr;
}
void VF_AddExclusion(TArray<FVoxelStrateFieldExclusion>& Out,
const TCHAR* FieldName, const TCHAR* Reason)
{
FVoxelStrateFieldExclusion& Entry = Out.AddDefaulted_GetRef();
Entry.FieldName = FieldName;
Entry.Reason = Reason;
}
void VF_CollectGenerationProperties(TMap<FString, FProperty*>& OutProperties)
{
OutProperties.Reset();
UStruct* Struct = FStrateGenerationParams::StaticStruct();
for (TFieldIterator<FProperty> It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It)
{
FProperty* Property = *It;
if (Property != nullptr)
{
OutProperties.Add(Property->GetName(), Property);
}
}
}
void VF_AddPropertyMetadata(FVoxelStrateFieldSpread& Spread, const FProperty* Property)
{
#if WITH_EDITOR
if (Property == nullptr)
{
return;
}
const FString ClampMinText = Property->GetMetaData(TEXT("ClampMin"));
const FString ClampMaxText = Property->GetMetaData(TEXT("ClampMax"));
if (!ClampMinText.IsEmpty())
{
const double Value = FCString::Atod(*ClampMinText);
if (FMath::IsFinite(Value))
{
Spread.bHasClampMin = true;
Spread.ClampMin = Value;
}
}
if (!ClampMaxText.IsEmpty())
{
const double Value = FCString::Atod(*ClampMaxText);
if (FMath::IsFinite(Value))
{
Spread.bHasClampMax = true;
Spread.ClampMax = Value;
}
}
#else
// UPROPERTY metadata is not a cooked-runtime contract. The composer is an offline tool;
// runtime/cook-time clamp data must be baked by a future commandlet.
(void)Spread;
(void)Property;
#endif
}
void VF_AddSpreadDescriptor(TArray<FVoxelStrateFieldSpread>& OutSpreads,
TMap<FString, int32>& OutIndices,
const TMap<FString, FProperty*>& Properties,
const TCHAR* Name, EVoxelStrateFieldKind Kind)
{
FVoxelStrateFieldSpread& Spread = OutSpreads.AddDefaulted_GetRef();
Spread.FieldName = Name;
Spread.Kind = Kind;
Spread.bExcluded = VF_FindExclusion(Spread.FieldName) != nullptr;
if (const FProperty* const* Property = Properties.Find(Spread.FieldName))
{
Spread.bReflected = (*Property != nullptr);
VF_AddPropertyMetadata(Spread, *Property);
}
OutIndices.Add(Spread.FieldName, OutSpreads.Num() - 1);
}
void VF_AccumulateValue(const TCHAR* Name, double Value,
const TMap<FString, int32>& Indices,
TArray<TArray<double>>& Values)
{
const int32* Index = Indices.Find(FString(Name));
if (Index != nullptr && Values.IsValidIndex(*Index) && FMath::IsFinite(Value))
{
Values[*Index].Add(Value);
}
}
void VF_ResetNonTunableFields(FStrateGenerationParams& Params)
{
// These fields are deliberately not inherited from a parent, jittered, or clamped. The
// first group is terrain-op transport populated per room by UVoxelTerrainOpDefinition;
// the last two are manager-owned runtime Z bounds.
Params.TerraceStepHeight = 0.0f;
Params.TerraceHardness = 0.0f;
Params.TerraceNoiseDisplacement = 0.0f;
Params.LayerLineSpacing = 0.0f;
Params.LayerLineDepth = 0.0f;
Params.OverhangStrength = 0.0f;
Params.OverhangDepth = 0.0f;
Params.OverhangFrequency = 0.0f;
Params.RibbingSpacing = 0.0f;
Params.RibbingDepth = 0.0f;
Params.CliffStrength = 0.0f;
Params.ScallopStrength = 0.0f;
Params.ScallopFrequency = 0.0f;
Params.ArchDensity = 0.0f;
Params.ArchMinRadius = 0.0f;
Params.ArchMaxRadius = 0.0f;
Params.ColumnDensity = 0.0f;
Params.ColumnMinRadius = 0.0f;
Params.ColumnMaxRadius = 0.0f;
Params.PitDensity = 0.0f;
Params.PitMinRadius = 0.0f;
Params.PitMaxRadius = 0.0f;
Params.PitDepth = 0.0f;
Params.ChimneyDensity = 0.0f;
Params.ChimneyMinRadius = 0.0f;
Params.ChimneyMaxRadius = 0.0f;
Params.ChimneyHeight = 0.0f;
Params.DomeDensity = 0.0f;
Params.DomeMinRadius = 0.0f;
Params.DomeMaxRadius = 0.0f;
Params.DomeHeightRatio = 0.0f;
Params.PinchDensity = 0.0f;
Params.PinchStrength = 0.0f;
Params.PinchLength = 0.0f;
Params.StrateTopWorldZ = 0.0f;
Params.StrateBottomWorldZ = 0.0f;
}
void VF_JitterFloat(float& Value, const FVoxelStrateFieldSpread* Spread, FRandomStream& Rng)
{
if (Spread == nullptr || Spread->bExcluded || Spread->Kind != EVoxelStrateFieldKind::Continuous)
{
return;
}
const float CorpusRange = (float)FMath::Max(0.0, Spread->Max - Spread->Min);
if (CorpusRange > 0.0f)
{
Value += (Rng.FRand() * 2.0f - 1.0f) * GJitterFraction * CorpusRange;
}
}
void VF_ClampFloat(float& Value, const FVoxelStrateFieldSpread* Spread)
{
if (Spread == nullptr || Spread->bExcluded)
{
return;
}
if (Spread->bHasClampMin)
{
Value = FMath::Max(Value, (float)Spread->ClampMin);
}
if (Spread->bHasClampMax)
{
Value = FMath::Min(Value, (float)Spread->ClampMax);
}
}
void VF_ClampInt(int32& Value, const FVoxelStrateFieldSpread* Spread)
{
if (Spread == nullptr || Spread->bExcluded)
{
return;
}
if (Spread->bHasClampMin)
{
Value = FMath::Max(Value, FMath::CeilToInt((float)Spread->ClampMin));
}
if (Spread->bHasClampMax)
{
Value = FMath::Min(Value, FMath::FloorToInt((float)Spread->ClampMax));
}
}
void VF_RepairOrderedPair(float& MinValue, float& MaxValue)
{
// Convex blending preserves these relations, but independent jitter is allowed to cross
// one. Repairing the pair after the jitter keeps the relation without inventing a new
// range or touching generation code.
if (MinValue > MaxValue)
{
Swap(MinValue, MaxValue);
}
}
uint32 VF_Avalanche(uint32 Value)
{
Value ^= Value >> 16;
Value *= 0x7feb352dU;
Value ^= Value >> 15;
Value *= 0x846ca68bU;
Value ^= Value >> 16;
return Value;
}
uint32 VF_RollSeed(uint32 CorpusHash, int32 Seed, int32 Index)
{
uint32 Value = CorpusHash ^ 0x9e3779b9U;
Value = VF_Avalanche(Value ^ (uint32)Seed);
Value = VF_Avalanche(Value ^ ((uint32)Index + 0x85ebca6bU));
return Value;
}
struct FParentGroup
{
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
TArray<int32> EntryIndices;
};
int32 VF_WeightedPick(const TArray<int32>& Candidates,
const TArray<FVoxelStrateCorpusEntry>& Entries,
FRandomStream& Rng)
{
if (Candidates.Num() == 0)
{
return INDEX_NONE;
}
float TotalWeight = 0.0f;
for (const int32 Candidate : Candidates)
{
if (Entries.IsValidIndex(Candidate))
{
TotalWeight += FMath::Max(0.0f, Entries[Candidate].Weight);
}
}
if (TotalWeight <= 0.0f)
{
return Candidates[Rng.RandRange(0, Candidates.Num() - 1)];
}
float Cursor = Rng.FRand() * TotalWeight;
for (const int32 Candidate : Candidates)
{
if (!Entries.IsValidIndex(Candidate))
{
continue;
}
Cursor -= FMath::Max(0.0f, Entries[Candidate].Weight);
if (Cursor <= 0.0f)
{
return Candidate;
}
}
return Candidates.Last();
}
FVoxelStrateRollInfo VF_RollInternal(const FVoxelStrateCorpus& Corpus,
int32 Seed, int32 Index)
{
FVoxelStrateRollInfo Result;
if (!Corpus.IsValid())
{
Result.FailureReason = Corpus.IsSchemaValid()
? TEXT("The corpus is empty.")
: Corpus.GetSchemaError();
return Result;
}
const TArray<FVoxelStrateCorpusEntry>& Entries = Corpus.GetEntries();
TArray<FParentGroup> Groups;
for (int32 EntryIndex = 0; EntryIndex < Entries.Num(); ++EntryIndex)
{
int32 GroupIndex = INDEX_NONE;
for (int32 CandidateGroup = 0; CandidateGroup < Groups.Num(); ++CandidateGroup)
{
if (Groups[CandidateGroup].Archetype == Entries[EntryIndex].Archetype)
{
GroupIndex = CandidateGroup;
break;
}
}
if (GroupIndex == INDEX_NONE)
{
FParentGroup& Group = Groups.AddDefaulted_GetRef();
Group.Archetype = Entries[EntryIndex].Archetype;
GroupIndex = Groups.Num() - 1;
}
Groups[GroupIndex].EntryIndices.Add(EntryIndex);
}
if (Groups.Num() == 0)
{
Result.FailureReason = TEXT("The corpus contains no usable archetype group.");
return Result;
}
FRandomStream Rng((int32)VF_RollSeed(Corpus.GetContentsHash(), Seed, Index));
float TotalGroupWeight = 0.0f;
for (const FParentGroup& Group : Groups)
{
for (const int32 EntryIndex : Group.EntryIndices)
{
TotalGroupWeight += FMath::Max(0.0f, Entries[EntryIndex].Weight);
}
}
int32 GroupIndex = 0;
if (TotalGroupWeight > 0.0f)
{
float Cursor = Rng.FRand() * TotalGroupWeight;
for (int32 CandidateGroup = 0; CandidateGroup < Groups.Num(); ++CandidateGroup)
{
float GroupWeight = 0.0f;
for (const int32 EntryIndex : Groups[CandidateGroup].EntryIndices)
{
GroupWeight += FMath::Max(0.0f, Entries[EntryIndex].Weight);
}
Cursor -= GroupWeight;
if (Cursor <= 0.0f)
{
GroupIndex = CandidateGroup;
break;
}
}
}
else
{
GroupIndex = Rng.RandRange(0, Groups.Num() - 1);
}
const FParentGroup& Group = Groups[GroupIndex];
const int32 ParentCount = Rng.RandRange(2, 3);
TArray<int32> Available = Group.EntryIndices;
Result.ParentEntryIndices.Reserve(ParentCount);
Result.ParentWeights.Reserve(ParentCount);
for (int32 ParentSlot = 0; ParentSlot < ParentCount; ++ParentSlot)
{
// Once a group is exhausted, replacement is intentional. It keeps the requested
// 2–3-parent algorithm defined for a one-entry corpus and makes that limitation
// visible in the provenance list rather than silently inventing a second vector.
const TArray<int32>& Candidates = Available.Num() > 0 ? Available : Group.EntryIndices;
const int32 ParentIndex = VF_WeightedPick(Candidates, Entries, Rng);
if (ParentIndex == INDEX_NONE)
{
Result.FailureReason = TEXT("Parent selection failed for a non-empty group.");
return Result;
}
Result.ParentEntryIndices.Add(ParentIndex);
Result.ParentWeights.Add(0.1f + Rng.FRand());
Available.Remove(ParentIndex);
}
float WeightSum = 0.0f;
for (const float Weight : Result.ParentWeights)
{
WeightSum += Weight;
}
if (!(WeightSum > 0.0f))
{
Result.FailureReason = TEXT("Parent blend weights were not positive.");
return Result;
}
for (float& Weight : Result.ParentWeights)
{
Weight /= WeightSum;
}
Result.DominantParentPosition = 0;
for (int32 ParentSlot = 1; ParentSlot < Result.ParentWeights.Num(); ++ParentSlot)
{
if (Result.ParentWeights[ParentSlot] > Result.ParentWeights[Result.DominantParentPosition])
{
Result.DominantParentPosition = ParentSlot;
}
}
FStrateGenerationParams Params = Entries[Result.ParentEntryIndices[0]].Params;
float AccumulatedWeight = Result.ParentWeights[0];
for (int32 ParentSlot = 1; ParentSlot < Result.ParentEntryIndices.Num(); ++ParentSlot)
{
const float ThisWeight = Result.ParentWeights[ParentSlot];
const float Alpha = ThisWeight / (AccumulatedWeight + ThisWeight);
Params = FStrateGenerationParams::Lerp(
Params, Entries[Result.ParentEntryIndices[ParentSlot]].Params, Alpha);
AccumulatedWeight += ThisWeight;
}
// Discrete values are not jittered. Lerp already snaps int/enum fields at 0.5; bools use
// the explicit dominant-parent policy because a boolean has no meaningful linear blend.
const int32 DominantParent = Result.ParentEntryIndices[Result.DominantParentPosition];
Params.bTunnelsFlowTowardOrigin = Entries[DominantParent].Params.bTunnelsFlowTowardOrigin;
#define VF_COMPOSER_JITTER_LERPF(Name) \
VF_JitterFloat(Params.Name, Corpus.FindSpread(TEXT(#Name)), Rng);
#define VF_COMPOSER_JITTER_SNAP(Name)
VF_STRATE_PARAM_FIELDS(VF_COMPOSER_JITTER_LERPF, VF_COMPOSER_JITTER_SNAP)
#undef VF_COMPOSER_JITTER_LERPF
#undef VF_COMPOSER_JITTER_SNAP
// Clamp metadata is consulted only after blend+jitter. It is a hard editor-build safety
// net, not a distribution range.
#define VF_COMPOSER_CLAMP_LERPF(Name) \
VF_ClampFloat(Params.Name, Corpus.FindSpread(TEXT(#Name)));
#define VF_COMPOSER_CLAMP_INT(Name) \
VF_ClampInt(Params.Name, Corpus.FindSpread(TEXT(#Name)));
#define VF_COMPOSER_CLAMP_SNAP_OriginRoomMaxConnections() \
VF_ClampInt(Params.OriginRoomMaxConnections, Corpus.FindSpread(TEXT("OriginRoomMaxConnections")));
#define VF_COMPOSER_CLAMP_SNAP_bTunnelsFlowTowardOrigin()
#define VF_COMPOSER_CLAMP_SNAP_RoughnessNoiseType()
#define VF_COMPOSER_CLAMP_SNAP(Name) VF_COMPOSER_CLAMP_SNAP_##Name()
/* The SNAP expansion is dispatched field-by-field because the list contains one int,
* one bool, and one enum. */
VF_STRATE_PARAM_FIELDS(VF_COMPOSER_CLAMP_LERPF, VF_COMPOSER_CLAMP_SNAP)
#undef VF_COMPOSER_CLAMP_LERPF
#undef VF_COMPOSER_CLAMP_INT
#undef VF_COMPOSER_CLAMP_SNAP
#undef VF_COMPOSER_CLAMP_SNAP_OriginRoomMaxConnections
#undef VF_COMPOSER_CLAMP_SNAP_bTunnelsFlowTowardOrigin
#undef VF_COMPOSER_CLAMP_SNAP_RoughnessNoiseType
VF_RepairOrderedPair(Params.MinRoomRadius, Params.MaxRoomRadius);
VF_RepairOrderedPair(Params.RoomFloorCutMin, Params.RoomFloorCutMax);
VF_RepairOrderedPair(Params.TunnelMinRadius, Params.TunnelMaxRadius);
VF_RepairOrderedPair(Params.ArchMinRadius, Params.ArchMaxRadius);
VF_RepairOrderedPair(Params.ColumnMinRadius, Params.ColumnMaxRadius);
VF_RepairOrderedPair(Params.PitMinRadius, Params.PitMaxRadius);
VF_RepairOrderedPair(Params.ChimneyMinRadius, Params.ChimneyMaxRadius);
VF_RepairOrderedPair(Params.DomeMinRadius, Params.DomeMaxRadius);
VF_ResetNonTunableFields(Params);
Result.Params = Params;
Result.Archetype = Group.Archetype;
Result.bValid = true;
return Result;
}
}
const TArray<FVoxelStrateFieldExclusion>& FVoxelStrateCorpus::GetNonTunableFields()
{
static const TArray<FVoxelStrateFieldExclusion> Exclusions = []
{
TArray<FVoxelStrateFieldExclusion> Result;
const TCHAR* TerrainOpReason = TEXT(
"Internal terrain-op transport: populated per room by UVoxelTerrainOpDefinition, not an authored GenerationParams tunable.");
VF_AddExclusion(Result, TEXT("TerraceStepHeight"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("TerraceHardness"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("TerraceNoiseDisplacement"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("LayerLineSpacing"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("LayerLineDepth"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("OverhangStrength"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("OverhangDepth"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("OverhangFrequency"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("RibbingSpacing"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("RibbingDepth"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("CliffStrength"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ScallopStrength"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ScallopFrequency"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ArchDensity"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ArchMinRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ArchMaxRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ColumnDensity"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ColumnMinRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ColumnMaxRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("PitDensity"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("PitMinRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("PitMaxRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("PitDepth"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ChimneyDensity"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ChimneyMinRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ChimneyMaxRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("ChimneyHeight"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("DomeDensity"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("DomeMinRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("DomeMaxRadius"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("DomeHeightRatio"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("PinchDensity"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("PinchStrength"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("PinchLength"), TerrainOpReason);
VF_AddExclusion(Result, TEXT("StrateTopWorldZ"), TEXT(
"Runtime Z bound supplied by UVoxelStrateManager for the active layout slot; not a tunable."));
VF_AddExclusion(Result, TEXT("StrateBottomWorldZ"), TEXT(
"Runtime Z bound supplied by UVoxelStrateManager for the active layout slot; not a tunable."));
return Result;
}();
return Exclusions;
}
void FVoxelStrateCorpus::Reset()
{
Entries.Reset();
FieldSpreads.Reset();
SkippedDefinitions.Reset();
SchemaError.Reset();
bSchemaValid = true;
bClampMetadataAvailableAtBuild = false;
}
void FVoxelStrateCorpus::RebuildSpreads()
{
FieldSpreads.Reset();
SchemaError.Reset();
bSchemaValid = true;
TMap<FString, FProperty*> Properties;
VF_CollectGenerationProperties(Properties);
TArray<FString> ExpectedNames;
TSet<FString> ExpectedNameSet;
TMap<FString, int32> Indices;
#define VF_COMPOSER_ADD_LERPF(Name) \
ExpectedNames.Add(TEXT(#Name)); \
ExpectedNameSet.Add(TEXT(#Name)); \
VF_AddSpreadDescriptor(FieldSpreads, Indices, Properties, TEXT(#Name), EVoxelStrateFieldKind::Continuous);
#define VF_COMPOSER_ADD_SNAP(Name) \
ExpectedNames.Add(TEXT(#Name)); \
ExpectedNameSet.Add(TEXT(#Name)); \
VF_AddSpreadDescriptor(FieldSpreads, Indices, Properties, TEXT(#Name), \
VF_FieldKindFor<decltype(std::declval<FStrateGenerationParams>().Name)>());
VF_STRATE_PARAM_FIELDS(VF_COMPOSER_ADD_LERPF, VF_COMPOSER_ADD_SNAP)
#undef VF_COMPOSER_ADD_LERPF
#undef VF_COMPOSER_ADD_SNAP
for (const TPair<FString, FProperty*>& Pair : Properties)
{
if (!ExpectedNameSet.Contains(Pair.Key))
{
bSchemaValid = false;
SchemaError += FString::Printf(
TEXT("Reflected FStrateGenerationParams field '%s' is absent from VF_STRATE_PARAM_FIELDS. "),
*Pair.Key);
}
}
for (const FVoxelStrateFieldExclusion& Exclusion : GetNonTunableFields())
{
if (!ExpectedNameSet.Contains(Exclusion.FieldName))
{
bSchemaValid = false;
SchemaError += FString::Printf(
TEXT("Explicit exclusion '%s' is absent from VF_STRATE_PARAM_FIELDS. "),
*Exclusion.FieldName);
}
}
for (const FString& ExpectedName : ExpectedNames)
{
const FVoxelStrateFieldSpread* Spread = FindSpread(ExpectedName);
if (Spread != nullptr && !Spread->bReflected && !Spread->bExcluded)
{
bSchemaValid = false;
SchemaError += FString::Printf(
TEXT("Field '%s' is neither reflected nor explicitly excluded. "), *ExpectedName);
}
}
TArray<TArray<double>> Values;
Values.SetNum(FieldSpreads.Num());
for (const FVoxelStrateCorpusEntry& Entry : Entries)
{
#define VF_COMPOSER_ACCUM_LERPF(Name) \
VF_AccumulateValue(TEXT(#Name), static_cast<double>(Entry.Params.Name), Indices, Values);
#define VF_COMPOSER_ACCUM_SNAP(Name) \
VF_AccumulateValue(TEXT(#Name), static_cast<double>(Entry.Params.Name), Indices, Values);
VF_STRATE_PARAM_FIELDS(VF_COMPOSER_ACCUM_LERPF, VF_COMPOSER_ACCUM_SNAP)
#undef VF_COMPOSER_ACCUM_LERPF
#undef VF_COMPOSER_ACCUM_SNAP
}
for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex)
{
FVoxelStrateFieldSpread& Spread = FieldSpreads[FieldIndex];
const TArray<double>& Samples = Values[FieldIndex];
Spread.SampleCount = Samples.Num();
if (Samples.Num() == 0)
{
continue;
}
Spread.Min = Samples[0];
Spread.Max = Samples[0];
double Sum = 0.0;
for (const double Sample : Samples)
{
Spread.Min = FMath::Min(Spread.Min, Sample);
Spread.Max = FMath::Max(Spread.Max, Sample);
Sum += Sample;
}
Spread.Mean = Sum / (double)Samples.Num();
double Variance = 0.0;
for (const double Sample : Samples)
{
const double Delta = Sample - Spread.Mean;
Variance += Delta * Delta;
}
Spread.StdDev = FMath::Sqrt(FMath::Max(0.0, Variance / (double)Samples.Num()));
}
#if WITH_EDITOR
bClampMetadataAvailableAtBuild = true;
#else
bClampMetadataAvailableAtBuild = false;
#endif
}
bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& SourceName,
ECaveGeneratorType Archetype,
const FStrateGenerationParams& Params, float Weight)
{
if (Archetype != ECaveGeneratorType::TunnelNetwork
&& Archetype != ECaveGeneratorType::Underwater)
{
SkippedDefinitions.Add(FString::Printf(
TEXT("%s skipped: archetype %s does not author FStrateGenerationParams."),
*SourcePath, VF_GetStrateArchetypeName(Archetype)));
return false;
}
if (!FMath::IsFinite(Weight) || Weight <= 0.0f)
{
SkippedDefinitions.Add(FString::Printf(
TEXT("%s skipped: corpus weight %.9g is not positive and finite."),
*SourcePath, Weight));
return false;
}
FVoxelStrateCorpusEntry& Entry = Entries.AddDefaulted_GetRef();
Entry.SourcePath = SourcePath;
Entry.SourceName = SourceName.IsEmpty() ? SourcePath : SourceName;
Entry.Archetype = Archetype;
Entry.Params = Params;
Entry.Weight = Weight;
Entries.Sort([](const FVoxelStrateCorpusEntry& A, const FVoxelStrateCorpusEntry& B)
{
if (A.SourcePath != B.SourcePath) { return A.SourcePath < B.SourcePath; }
if ((uint8)A.Archetype != (uint8)B.Archetype)
{
return (uint8)A.Archetype < (uint8)B.Archetype;
}
return A.SourceName < B.SourceName;
});
RebuildSpreads();
return true;
}
bool FVoxelStrateCorpus::LoadFromDefinitions(
const TArray<UVoxelStrateDefinition*>& Definitions, FString& OutReport)
{
Reset();
TArray<UVoxelStrateDefinition*> SortedDefinitions;
for (UVoxelStrateDefinition* Definition : Definitions)
{
if (Definition != nullptr)
{
SortedDefinitions.Add(Definition);
}
}
SortedDefinitions.Sort([](const UVoxelStrateDefinition& A, const UVoxelStrateDefinition& B)
{
return A.GetPathName() < B.GetPathName();
});
TSet<FString> SeenPaths;
for (UVoxelStrateDefinition* Definition : SortedDefinitions)
{
const FString SourcePath = Definition->GetPathName();
if (SeenPaths.Contains(SourcePath))
{
continue;
}
SeenPaths.Add(SourcePath);
const FString SourceName = Definition->StrateName.ToString().IsEmpty()
? Definition->GetName()
: Definition->StrateName.ToString();
if (Definition->GeneratorType != ECaveGeneratorType::TunnelNetwork
&& Definition->GeneratorType != ECaveGeneratorType::Underwater)
{
SkippedDefinitions.Add(FString::Printf(
TEXT("%s (%s) skipped: %s uses a different authored parameter struct; its ")
TEXT("GenerationParams default is not corpus data."),
*SourcePath, *SourceName,
VF_GetStrateArchetypeName(Definition->GeneratorType)));
continue;
}
FVoxelStrateCorpusEntry& Entry = Entries.AddDefaulted_GetRef();
Entry.SourcePath = SourcePath;
Entry.SourceName = SourceName;
Entry.Archetype = Definition->GeneratorType;
Entry.Params = Definition->GenerationParams;
Entry.Weight = 1.0f; // There is no authoring weight on UVoxelStrateDefinition.
}
Entries.Sort([](const FVoxelStrateCorpusEntry& A, const FVoxelStrateCorpusEntry& B)
{
if (A.SourcePath != B.SourcePath) { return A.SourcePath < B.SourcePath; }
return (uint8)A.Archetype < (uint8)B.Archetype;
});
RebuildSpreads();
OutReport = FString::Printf(
TEXT("Corpus definitions: %d resolved usable entries, %d skipped, %d fields measured. "),
Entries.Num(), SkippedDefinitions.Num(), FieldSpreads.Num());
for (const FString& Skipped : SkippedDefinitions)
{
OutReport += Skipped;
OutReport += TEXT(" ");
}
if (!SchemaError.IsEmpty())
{
OutReport += TEXT("Schema error: ");
OutReport += SchemaError;
}
return IsValid();
}
bool FVoxelStrateCorpus::LoadFromSettings(const UVoxelSettings* Settings, FString& OutReport)
{
Reset();
if (Settings == nullptr)
{
OutReport = TEXT("Cannot load strate corpus: settings is null.");
return false;
}
TArray<TSoftObjectPtr<UVoxelStrateDefinition>> References;
for (const TPair<int32, TSoftObjectPtr<UVoxelStrateDefinition>>& Pair : Settings->FixedStrates)
{
References.Add(Pair.Value);
}
References.Append(Settings->StratePool);
References.Sort([](const TSoftObjectPtr<UVoxelStrateDefinition>& A,
const TSoftObjectPtr<UVoxelStrateDefinition>& B)
{
return A.ToSoftObjectPath().ToString() < B.ToSoftObjectPath().ToString();
});
TArray<UVoxelStrateDefinition*> Definitions;
TSet<FString> SeenPaths;
FString ResolutionNotes;
for (const TSoftObjectPtr<UVoxelStrateDefinition>& Reference : References)
{
const FString Path = Reference.ToSoftObjectPath().ToString();
if (Path.IsEmpty() || SeenPaths.Contains(Path))
{
continue;
}
SeenPaths.Add(Path);
TSoftObjectPtr<UVoxelStrateDefinition> LoadReference = Reference;
UVoxelStrateDefinition* Definition = LoadReference.LoadSynchronous();
if (Definition == nullptr)
{
ResolutionNotes += FString::Printf(TEXT("%s could not be resolved. "), *Path);
continue;
}
Definitions.Add(Definition);
}
FString DefinitionReport;
const bool bLoaded = LoadFromDefinitions(Definitions, DefinitionReport);
OutReport = FString::Printf(
TEXT("Settings references: %d unique soft paths, %d resolved. %s%s"),
SeenPaths.Num(), Definitions.Num(), *ResolutionNotes, *DefinitionReport);
return bLoaded;
}
const FVoxelStrateFieldSpread* FVoxelStrateCorpus::FindSpread(const FString& FieldName) const
{
for (const FVoxelStrateFieldSpread& Spread : FieldSpreads)
{
if (Spread.FieldName == FieldName)
{
return &Spread;
}
}
return nullptr;
}
uint32 FVoxelStrateCorpus::GetContentsHash() const
{
uint32 Hash = 2166136261U;
auto HashBytes = [&Hash](const void* Data, SIZE_T Size)
{
const uint8* Bytes = static_cast<const uint8*>(Data);
for (SIZE_T ByteIndex = 0; ByteIndex < Size; ++ByteIndex)
{
Hash ^= (uint32)Bytes[ByteIndex];
Hash *= 16777619U;
}
};
auto HashString = [&HashBytes](const FString& String)
{
for (int32 CharIndex = 0; CharIndex < String.Len(); ++CharIndex)
{
const uint32 Code = (uint32)String[CharIndex];
HashBytes(&Code, sizeof(Code));
}
const uint32 Terminator = 0U;
HashBytes(&Terminator, sizeof(Terminator));
};
for (const FVoxelStrateCorpusEntry& Entry : Entries)
{
HashString(Entry.SourcePath);
HashString(Entry.SourceName);
const uint8 Archetype = (uint8)Entry.Archetype;
HashBytes(&Archetype, sizeof(Archetype));
HashBytes(&Entry.Weight, sizeof(Entry.Weight));
#define VF_COMPOSER_HASH_LERPF(Name) \
HashBytes(&Entry.Params.Name, sizeof(Entry.Params.Name));
#define VF_COMPOSER_HASH_SNAP(Name) \
HashBytes(&Entry.Params.Name, sizeof(Entry.Params.Name));
VF_STRATE_PARAM_FIELDS(VF_COMPOSER_HASH_LERPF, VF_COMPOSER_HASH_SNAP)
#undef VF_COMPOSER_HASH_LERPF
#undef VF_COMPOSER_HASH_SNAP
}
return Hash;
}
const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork");
case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain");
case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber");
case ECaveGeneratorType::Maze: return TEXT("Maze");
case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld");
case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts");
case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands");
case ECaveGeneratorType::Underwater: return TEXT("Underwater");
}
return TEXT("Unknown");
}
bool VF_AreStrateParamsBitIdentical(const FStrateGenerationParams& A,
const FStrateGenerationParams& B)
{
#define VF_COMPOSER_COMPARE_LERPF(Name) \
if (FMemory::Memcmp(&A.Name, &B.Name, sizeof(A.Name)) != 0) { return false; }
#define VF_COMPOSER_COMPARE_SNAP(Name) \
if (A.Name != B.Name) { return false; }
VF_STRATE_PARAM_FIELDS(VF_COMPOSER_COMPARE_LERPF, VF_COMPOSER_COMPARE_SNAP)
#undef VF_COMPOSER_COMPARE_LERPF
#undef VF_COMPOSER_COMPARE_SNAP
return true;
}
FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus,
int32 Seed, int32 Index)
{
const FVoxelStrateRollInfo Result = VF_RollInternal(Corpus, Seed, Index);
#if DO_CHECK
if (Result.bValid)
{
const FVoxelStrateRollInfo Repeat = VF_RollInternal(Corpus, Seed, Index);
checkf(Repeat.bValid && Repeat.Archetype == Result.Archetype
&& VF_AreStrateParamsBitIdentical(Repeat.Params, Result.Params)
&& Repeat.ParentEntryIndices == Result.ParentEntryIndices
&& Repeat.ParentWeights == Result.ParentWeights,
TEXT("VF_RollStrateParams lost determinism for the same corpus/seed/index."));
}
#endif
return Result;
}
FStrateGenerationParams VF_RollStrateParams(const FVoxelStrateCorpus& Corpus,
int32 Seed, int32 Index)
{
return VF_RollStrateParamsDetailed(Corpus, Seed, Index).Params;
}
@@ -0,0 +1,151 @@
// Offline strate corpus and parameter composer.
//
// This is deliberately not part of the runtime generation path. The composer is a build-box
// tool/test API: it loads authored vectors, measures their spread, and makes deterministic
// candidates by blending those vectors before applying a small spread-relative jitter.
#pragma once
#include "CoreMinimal.h"
#include "VoxelStrateTypes.h"
class UVoxelSettings;
class UVoxelStrateDefinition;
/** The kind of value represented by one entry of VF_STRATE_PARAM_FIELDS. */
enum class EVoxelStrateFieldKind : uint8
{
Continuous,
Integer,
Boolean,
Enum,
};
/** A field deliberately kept out of the parameter roll. */
struct VOXELFORGE_API FVoxelStrateFieldExclusion
{
FString FieldName;
FString Reason;
};
/** Measured statistics for one field across the currently loaded corpus. */
struct VOXELFORGE_API FVoxelStrateFieldSpread
{
FString FieldName;
EVoxelStrateFieldKind Kind = EVoxelStrateFieldKind::Continuous;
bool bExcluded = false;
bool bReflected = false;
int32 SampleCount = 0;
double Min = 0.0;
double Max = 0.0;
double Mean = 0.0;
double StdDev = 0.0;
// These are copied from UPROPERTY ClampMin/ClampMax when reflection metadata is available in
// the current build. They are safety limits, never the source of a roll range.
bool bHasClampMin = false;
bool bHasClampMax = false;
double ClampMin = 0.0;
double ClampMax = 0.0;
};
/** One known-good authored vector and the archetype that gives it meaning. */
struct VOXELFORGE_API FVoxelStrateCorpusEntry
{
FString SourcePath;
FString SourceName;
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
FStrateGenerationParams Params;
// The current asset format has no corpus weight field. Loaded hand-authored entries therefore
// use 1.0. The field remains explicit so a future offline corpus can assign weights without
// changing the roll algorithm.
float Weight = 1.0f;
};
/** Result of one deterministic roll, including provenance for the offline report. */
struct VOXELFORGE_API FVoxelStrateRollInfo
{
bool bValid = false;
FString FailureReason;
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
FStrateGenerationParams Params;
TArray<int32> ParentEntryIndices;
TArray<float> ParentWeights;
int32 DominantParentPosition = INDEX_NONE;
};
/**
* Known-good strate vectors plus their measured field spread.
*
* `LoadFromSettings` walks both the pool and fixed-strate references, resolves each definition,
* de-duplicates it by asset path, and admits only TunnelNetwork/Underwater definitions because
* those are the two archetypes whose authored vector is `GenerationParams`. Other archetypes have
* different parameter structs; treating their default `GenerationParams` as authored data would
* manufacture a false corpus.
*/
class VOXELFORGE_API FVoxelStrateCorpus
{
public:
void Reset();
bool LoadFromSettings(const UVoxelSettings* Settings, FString& OutReport);
bool LoadFromDefinitions(const TArray<UVoxelStrateDefinition*>& Definitions, FString& OutReport);
/** Add an already-resolved vector, primarily for offline tools and focused tests. */
bool AddEntry(const FString& SourcePath, const FString& SourceName,
ECaveGeneratorType Archetype, const FStrateGenerationParams& Params,
float Weight = 1.0f);
bool IsValid() const { return bSchemaValid && Entries.Num() > 0; }
bool IsSchemaValid() const { return bSchemaValid; }
const FString& GetSchemaError() const { return SchemaError; }
int32 Num() const { return Entries.Num(); }
const TArray<FVoxelStrateCorpusEntry>& GetEntries() const { return Entries; }
const TArray<FVoxelStrateFieldSpread>& GetFieldSpreads() const { return FieldSpreads; }
const TArray<FString>& GetSkippedDefinitions() const { return SkippedDefinitions; }
const FVoxelStrateFieldSpread* FindSpread(const FString& FieldName) const;
/** Stable hash of source paths, archetypes, weights, and every listed scalar field. */
uint32 GetContentsHash() const;
/** Explicit exclusion list discovered from the struct reflection/use audit. */
static const TArray<FVoxelStrateFieldExclusion>& GetNonTunableFields();
/** True in an editor build where UPROPERTY metadata was available while spreads were built. */
bool AreClampMetadataAvailableAtBuild() const { return bClampMetadataAvailableAtBuild; }
/** False by policy: metadata is not promised to survive into a cooked runtime. */
bool AreClampMetadataAvailableAtRuntime() const { return false; }
private:
void RebuildSpreads();
TArray<FVoxelStrateCorpusEntry> Entries;
TArray<FVoxelStrateFieldSpread> FieldSpreads;
TArray<FString> SkippedDefinitions;
FString SchemaError;
bool bSchemaValid = true;
bool bClampMetadataAvailableAtBuild = false;
};
/** Human-readable name used by the offline report. */
VOXELFORGE_API const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype);
/** Full provenance result. Pure with respect to the corpus: no global or retained RNG state. */
VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailed(
const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index);
/** The compact API requested by the composer design. */
VOXELFORGE_API FStrateGenerationParams VF_RollStrateParams(
const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index);
/** Bitwise comparison over exactly the fields in FStrateGenerationParams::Lerp. */
VOXELFORGE_API bool VF_AreStrateParamsBitIdentical(
const FStrateGenerationParams& A, const FStrateGenerationParams& B);