From 358bd415a95e7ca0d97661c98bdbe5bb72daf239 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Fri, 4 Sep 2026 07:19:11 +0200 Subject: [PATCH] feat(composer): the parameter roll -- infrastructure real, corpus starved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU --- CODEMAP.md | 12 + COMPOSER-NOTES.md | 50 +- .../VoxelForgeComposerParameterRollTest.cpp | 507 ++++++++++ .../Private/VoxelStrateComposer.cpp | 931 ++++++++++++++++++ .../VoxelForge/Public/VoxelStrateComposer.h | 151 +++ 5 files changed, 1649 insertions(+), 2 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp create mode 100644 Source/VoxelForge/Private/VoxelStrateComposer.cpp create mode 100644 Source/VoxelForge/Public/VoxelStrateComposer.h diff --git a/CODEMAP.md b/CODEMAP.md index 3f73e74..90286fb 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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 `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` `UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; **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. | | `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`). | +| `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. | | `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. | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 034624e..355a8e1 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -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 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 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. ### Tier 4 — the composer proper -Structure roll (§3.2) · parameter roll (§3.3) · reject-and-resample driven by Tier 2 (§3.5) · the -**offline season pipeline** with Jahni's review and veto (§3.1). +- ✅ **Parameter roll (§3.3, Tier 4a)** — corpus spread + deterministic blend/jitter implementation + 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 **Promotion** (good strates rejoin the corpus) · theme and tag draws for materials, creatures and audio · diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp new file mode 100644 index 0000000..d20d843 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp @@ -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(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 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 + diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp new file mode 100644 index 0000000..8f1c31b --- /dev/null +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -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 +#include +#include + +namespace +{ + constexpr float GJitterFraction = 0.15f; + + template + EVoxelStrateFieldKind VF_FieldKindFor() + { + using ValueType = typename std::remove_cv< + typename std::remove_reference::type>::type; + + if (std::is_same::value) + { + return EVoxelStrateFieldKind::Boolean; + } + if (std::is_enum::value) + { + return EVoxelStrateFieldKind::Enum; + } + if (std::is_integral::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& Out, + const TCHAR* FieldName, const TCHAR* Reason) + { + FVoxelStrateFieldExclusion& Entry = Out.AddDefaulted_GetRef(); + Entry.FieldName = FieldName; + Entry.Reason = Reason; + } + + void VF_CollectGenerationProperties(TMap& OutProperties) + { + OutProperties.Reset(); + UStruct* Struct = FStrateGenerationParams::StaticStruct(); + for (TFieldIterator 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& OutSpreads, + TMap& OutIndices, + const TMap& 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& Indices, + TArray>& 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 EntryIndices; + }; + + int32 VF_WeightedPick(const TArray& Candidates, + const TArray& 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& Entries = Corpus.GetEntries(); + TArray 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 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& 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& FVoxelStrateCorpus::GetNonTunableFields() +{ + static const TArray Exclusions = [] + { + TArray 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 Properties; + VF_CollectGenerationProperties(Properties); + + TArray ExpectedNames; + TSet ExpectedNameSet; + TMap 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().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& 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> Values; + Values.SetNum(FieldSpreads.Num()); + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { +#define VF_COMPOSER_ACCUM_LERPF(Name) \ + VF_AccumulateValue(TEXT(#Name), static_cast(Entry.Params.Name), Indices, Values); +#define VF_COMPOSER_ACCUM_SNAP(Name) \ + VF_AccumulateValue(TEXT(#Name), static_cast(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& 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& Definitions, FString& OutReport) +{ + Reset(); + + TArray 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 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> References; + for (const TPair>& Pair : Settings->FixedStrates) + { + References.Add(Pair.Value); + } + References.Append(Settings->StratePool); + References.Sort([](const TSoftObjectPtr& A, + const TSoftObjectPtr& B) + { + return A.ToSoftObjectPath().ToString() < B.ToSoftObjectPath().ToString(); + }); + + TArray Definitions; + TSet SeenPaths; + FString ResolutionNotes; + for (const TSoftObjectPtr& Reference : References) + { + const FString Path = Reference.ToSoftObjectPath().ToString(); + if (Path.IsEmpty() || SeenPaths.Contains(Path)) + { + continue; + } + SeenPaths.Add(Path); + + TSoftObjectPtr 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(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; +} diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h new file mode 100644 index 0000000..17fbf1d --- /dev/null +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -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 ParentEntryIndices; + TArray 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& 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& GetEntries() const { return Entries; } + const TArray& GetFieldSpreads() const { return FieldSpreads; } + const TArray& 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& 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 Entries; + TArray FieldSpreads; + TArray 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); +