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

+12
View File
@@ -438,6 +438,17 @@ One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMi
atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to vertex colour, §8.15)), `GameplayTags`. Referenced from atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to vertex colour, §8.15)), `GameplayTags`. Referenced from
`UVoxelStrateDefinition::Biomes[]`. Generator-agnostic (surface biomes now, cave biomes later). §8.14. `UVoxelStrateDefinition::Biomes[]`. Generator-agnostic (surface biomes now, cave biomes later). §8.14.
**`Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp`** (NEW, Tier 4a) — offline
`FVoxelStrateCorpus` and `VF_RollStrateParams`. Walks the settings asset's fixed/pool definition
references, admits only `TunnelNetwork` / `Underwater` vectors (the archetypes that author
`GenerationParams`), measures every X-macro field's min/max/mean/population stddev, and exposes the
explicit non-tunable list. Rolls are pure in `(corpus contents, seed, index)`: weighted 2–3-parent
same-archetype selection → `FStrateGenerationParams::Lerp` → ±15% of measured field range jitter →
editor-reflection clamps → ordered-pair repair. Bool `bTunnelsFlowTowardOrigin` inherits from the
dominant parent; integer/enum SNAP fields are not jittered. This API is not called by runtime
generation. The first project corpus run found one eligible `DA_Strate3` vector, so all measured
spreads were zero; that limitation is recorded by the Tier 4a test/report rather than hidden.
### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp` ### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp`
`UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; `UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength;
**negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`. **negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`.
@@ -501,6 +512,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. |
| `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). |
| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Loads the hand-authored settings corpus, prints the complete per-field spread/exclusion/clamp table, asserts deterministic bit-identical rolls, measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law, and brute-forces every rolled production box verdict. The first run found one eligible vector (all spreads zero), 64/64 survival, and a deliberately reported vacuous §6.2 scan: 2,560 Mixed, 0 proved, 0 violations. |
| `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | | `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. |
| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. | | `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. |
| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test fits the measurement AABB to each arrival/departure mouth pair, reports exact before/after seed-6 cell counts, reruns every negative with doubled margin, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts axis landings, checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. | | `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test fits the measurement AABB to each arrival/departure mouth pair, reports exact before/after seed-6 cell counts, reruns every negative with doubled margin, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts axis landings, checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. |
+48 -2
View File
@@ -196,6 +196,49 @@ small commandlet walking the struct by reflection. Clamp coverage is partial. A
tunables at all (`StrateTopWorldZ` / `StrateBottomWorldZ` are runtime Z bounds) and need an explicit tunables at all (`StrateTopWorldZ` / `StrateBottomWorldZ` are runtime Z bounds) and need an explicit
exclusion list. Bools cannot be blended — roll them by probability or inherit from the dominant parent. exclusion list. Bools cannot be blended — roll them by probability or inherit from the dominant parent.
### ✅ Tier 4a status — parameter roll built (2026-09-04)
`Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp` now implement the corpus and
the deterministic roll. `Private/Tests/VoxelForgeComposerParameterRollTest.cpp` is the offline
measurement harness. The roll uses `FStrateGenerationParams::Lerp`, chooses 2–3 weighted parents
inside one archetype group, applies jitter of **±15% of each field's measured corpus range
(`max-min`)**, then applies reflected `ClampMin`/`ClampMax` metadata. It does not use clamps as
distribution ranges, does not touch generation, and leaves `WorldRadiusVoxels` at its default 0.
The loader walks the settings asset's fixed-strate and pool references, de-duplicates by asset path,
and accepts only `TunnelNetwork` / `Underwater`: those are the only archetypes whose authored vector
is `GenerationParams`. The current `DA_Settings` asset contains **one unique resolved reference**,
`DA_Strate3`, and it is `TunnelNetwork`; the other authored archetype assets are not in that
settings corpus. Consequently every measured field has zero spread, all 64 rolls repeat the same
known-good vector (parent weights still roll deterministically), and this run did not exercise
non-zero jitter. That is a corpus-size finding, not a rate optimization.
The reflection/use audit found **36 excluded fields**: the 34 terrain-op transport fields
`TerraceStepHeight`, `TerraceHardness`, `TerraceNoiseDisplacement`, `LayerLineSpacing`,
`LayerLineDepth`, `OverhangStrength`, `OverhangDepth`, `OverhangFrequency`, `RibbingSpacing`,
`RibbingDepth`, `CliffStrength`, `ScallopStrength`, `ScallopFrequency`, `ArchDensity`,
`ArchMinRadius`, `ArchMaxRadius`, `ColumnDensity`, `ColumnMinRadius`, `ColumnMaxRadius`,
`PitDensity`, `PitMinRadius`, `PitMaxRadius`, `PitDepth`, `ChimneyDensity`, `ChimneyMinRadius`,
`ChimneyMaxRadius`, `ChimneyHeight`, `DomeDensity`, `DomeMinRadius`, `DomeMaxRadius`,
`DomeHeightRatio`, `PinchDensity`, `PinchStrength`, `PinchLength` — populated per room by
`UVoxelTerrainOpDefinition` — plus the manager-owned runtime bounds `StrateTopWorldZ` and
`StrateBottomWorldZ`. The test prints the complete field-by-field table, including excluded fields,
reflection status, and clamp metadata.
The bool policy is explicit: `bTunnelsFlowTowardOrigin` is inherited from the dominant parent.
`OriginRoomMaxConnections` and `RoughnessNoiseType` retain `Lerp`'s existing SNAP behavior and
are not jittered. In this editor build clamp metadata was available while the table was built; it
is **not promised in a cooked runtime**, so the commandlet/cook-time bake remains owed.
The first 64-candidate run (step 4, radius 256, max 8,000,000 cells, fixture seed 1337) produced
64/64 non-vacuous candidates, 64/64 largest-component share ≥ 0.50, and 64/64 exact unsnapped
arrival→departure law passes. Every row was `air=0.520272`, `largest share=0.999996`,
`walkable=0.057995`, `feature scale=20.000000`, `Connected`; total runtime was **542.220 s**.
The §6.2 check saw **2,560 Mixed, 0 AllSolid, 0 AllAir, 0 brute-force voxels, 0 violations**:
this is explicitly **vacuous**, not evidence that rolled bounds are sound, because the current cave
box path emitted no uniform verdict. The test leaves §6.2 open and reports that fact rather than
turning it into a workaround.
### 3.4 The measurement pass — one grid, one flood fill, three jobs ### 3.4 The measurement pass — one grid, one flood fill, three jobs
Sample a candidate strate into a coarse voxel grid, flood-fill the air **once**, derive everything from Sample a candidate strate into a coarse voxel grid, flood-fill the air **once**, derive everything from
@@ -533,8 +576,11 @@ an open semantic decision; the Part 0 analysis above concludes that FloorBias is
roughness and should stay late in the stack. roughness and should stay late in the stack.
### Tier 4 — the composer proper ### Tier 4 — the composer proper
Structure roll (§3.2) · parameter roll (§3.3) · reject-and-resample driven by Tier 2 (§3.5) · the - ✅ **Parameter roll (§3.3, Tier 4a)** — corpus spread + deterministic blend/jitter implementation
**offline season pipeline** with Jahni's review and veto (§3.1). and the 64-candidate measurement pass are built; the first run exposed a one-vector settings corpus
and a vacuous cave box-verdict scan, both recorded above.
- ⬜ Structure roll (§3.2) · reject-and-resample driven by Tier 2 (§3.5) · the **offline season
pipeline** with Jahni's review and veto (§3.1).
### Tier 5 — the long game ### Tier 5 — the long game
**Promotion** (good strates rejoin the corpus) · theme and tag draws for materials, creatures and audio · **Promotion** (good strates rejoin the corpus) · theme and tag draws for materials, creatures and audio ·
@@ -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);