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:
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);
|
||||
|
||||
Reference in new issue
Block a user