diff --git a/CODEMAP.md b/CODEMAP.md index 90286fb..cefb23a 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -438,16 +438,21 @@ 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. +**`Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp`** (Tier 4a) — offline +`FVoxelStrateCorpus` and `VF_RollStrateParams`. `LoadFromAssetRegistry` enumerates every project +`UVoxelStrateDefinition` through the Asset Registry, excludes `Saved/Autosaves` and `Saved/Cooked` +copies, and adds one `VoxelStrateTypes.h` default vector for each of the eight exact archetypes. +The corpus is grouped by exact `ECaveGeneratorType`: the 2026-09-04 run loaded **4 project +vectors + 8 defaults = 12 members** in **8 groups**. Sibling families use their native structs +(`FSlabGenerationParams`, `FMazeGenerationParams`, `FSurfaceGenerationParams`, +`FVerticalShaftParams`, `FFloatingIslandParams`); no cross-archetype blend is attempted. +Spreads are measured per group, including the plain-native tunnel transport fields for reporting. +Rolls are pure in `(corpus contents, seed, index)`: weighted 2–3-parent same-archetype selection → +`FStrateGenerationParams::Lerp` or reflected native-family blend → ±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 settings audit found **one unique path**, `/Game/VoxelForge/DA_Strate3`, +which explains the old one-vector corpus; the project assets were never in that pool. ### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp` `UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; @@ -512,7 +517,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. | +| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. The final full-suite run: **59/64 survival (92.2%)**, **1,336 Mixed + 549 AllSolid + 675 AllAir = 1,224 proved boxes**, **1,629,144 lattice voxels checked, 0 violations**, 112.574 s total. | | `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 355a8e1..10ff295 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -200,18 +200,66 @@ exclusion list. Bools cannot be blended — roll them by probability or inherit `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. +measurement harness. The roll uses the native family blend (`FStrateGenerationParams::Lerp` for +the tunnel family), chooses 2–3 weighted parents **inside one exact 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 sibling native parameter structs +are carried in the same corpus entry, so a `SurfaceWorld` vector is never blended with a +`TunnelNetwork` vector merely because both happen to be stored in a strate asset. -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 original one-vector result was caused by the old loader following `DA_Settings`' fixed-strate +and pool references. An audit of those references finds **one unique path**, +`/Game/VoxelForge/DA_Strate3.DA_Strate3`, whose archetype is `TunnelNetwork`; that is why the +previous corpus only loaded one asset. The current loader instead asks the Asset Registry for every +project `UVoxelStrateDefinition`, sorts and de-duplicates the results, accepts `/Game` packages, +and ignores `Saved/Autosaves` and `Saved/Cooked` copies. The scan found **4 project assets**, with +no saved-copy, outside-project, duplicate, or unresolved records: + +| Asset | Archetype | +|---|---| +| `DA_Strate1` | `SurfaceWorld` | +| `DA_Strate2` | `FloatingIslands` | +| `DA_Strate3` | `TunnelNetwork` | +| `DA_Strate4` | `TunnelNetwork` | + +The hand-authored C++ defaults in `VoxelStrateTypes.h` are also corpus members: one for each of the +8 supported archetypes (`TunnelNetwork`, `FlatPlain`, `CrystalChamber`, `Maze`, `SurfaceWorld`, +`VerticalShafts`, `FloatingIslands`, `Underwater`). The fed corpus is therefore **12 members** in +**8 exact-archetype groups**. Group membership is `TunnelNetwork=3`, `FlatPlain=1`, +`CrystalChamber=1`, `Maze=1`, `SurfaceWorld=2`, `VerticalShafts=1`, `FloatingIslands=2`, and +`Underwater=1`. + +The complete 253-descriptor spread table is emitted by the automation test, including sample count, +min/max/mean/standard deviation, excluded/reflected status, and clamp metadata. The 230 zero-range +rows are intentionally omitted below; these are the **23 rows with real spread** (float formatting +is the test's measured output): + +| Archetype | Field | N | Min | Max | Mean | StdDev | +|---|---|---:|---:|---:|---:|---:| +| TunnelNetwork | MinRoomRadius | 3 | 10 | 25 | 15 | 7.07106781 | +| TunnelNetwork | MaxRoomRadius | 3 | 30 | 125 | 61.6666667 | 44.7834295 | +| TunnelNetwork | OriginRoomRadius | 3 | 20 | 120 | 53.3333333 | 47.1404521 | +| TunnelNetwork | TunnelMinRadius | 3 | 3 | 9 | 5 | 2.82842712 | +| TunnelNetwork | TunnelMaxRadius | 3 | 7 | 15 | 9.66666667 | 3.77123617 | +| SurfaceWorld | BaseGroundRelative | 2 | 0.200000003 | 0.300000012 | 0.250000007 | 0.0500000045 | +| SurfaceWorld | ElevationRange | 2 | 60 | 200 | 130 | 70 | +| SurfaceWorld | ContinentFrequency | 2 | 0.00100000005 | 0.00600000005 | 0.00350000005 | 0.0025 | +| SurfaceWorld | MountainStrength | 2 | 0.5 | 1 | 0.75 | 0.25 | +| SurfaceWorld | MountainFrequency | 2 | 0.00200000009 | 0.0120000001 | 0.0070000001 | 0.005 | +| SurfaceWorld | SurfaceRoughness | 2 | 2 | 3 | 2.5 | 0.5 | +| SurfaceWorld | ReliefStrength | 2 | 0 | 0.699999988 | 0.349999994 | 0.349999994 | +| SurfaceWorld | ReliefContrast | 2 | 1.60000002 | 2 | 1.80000001 | 0.199999988 | +| SurfaceWorld | TerraceHeight | 2 | 12 | 20 | 16 | 4 | +| SurfaceWorld | BeachWidth | 2 | 8 | 200 | 104 | 96 | +| FloatingIslands | IslandSpacing | 2 | 95 | 1000 | 547.5 | 452.5 | +| FloatingIslands | IslandDensity | 2 | 0.100000001 | 0.5 | 0.300000001 | 0.199999999 | +| FloatingIslands | IslandMinRadius | 2 | 18 | 50 | 34 | 16 | +| FloatingIslands | IslandMaxRadius | 2 | 42 | 500 | 271 | 229 | +| FloatingIslands | ThicknessRatio | 2 | 0.100000001 | 0.699999988 | 0.399999995 | 0.299999993 | +| FloatingIslands | VerticalJitter | 2 | 0.200000003 | 0.600000024 | 0.400000013 | 0.20000001 | +| FloatingIslands | TopFlatten | 2 | 0.600000024 | 0.800000012 | 0.700000018 | 0.099999994 | +| FloatingIslands | SurfaceRoughness | 2 | 0 | 4 | 2 | 2 | The reflection/use audit found **36 excluded fields**: the 34 terrain-op transport fields `TerraceStepHeight`, `TerraceHardness`, `TerraceNoiseDisplacement`, `LayerLineSpacing`, @@ -222,22 +270,45 @@ The reflection/use audit found **36 excluded fields**: the 34 terrain-op transpo `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, +`StrateBottomWorldZ`. The tunnel transport fields are measured directly from the native +`FStrateGenerationParams` X-macro even though they are not reflected `UPROPERTY`s; their values are +reported but deliberately not jittered. Same-named fields in sibling structs are not automatically +excluded: the exclusion applies to the tunnel transport slots whose generation source is the +terrain-op pool. The test prints the complete field-by-field table, including excluded fields, reflection status, and clamp metadata. +The terrain-field source is now traced in code. `VoxelStrateManager.cpp` loads the active strate's +`TerrainOperations` soft-object pool, and `VoxelGenerator.cpp` passes that pool to +`VoxelCaveMorphology::BuildChunkCache`. For each nearest room, `FVoxelTerrainOpDefinition::ApplyTo` +overlays the selected operation onto a local copy (`FStrateGenerationParams LocalTerrainParams = +Params`) before the room operator runs. Thus the 34 values are transported through the strate +params, but their authored cave-detail values come from `UVoxelTerrainOpDefinition` assets selected +by the active strate's terrain-operation pool. Rolling those slots in the strate composer is not a +useful way to roll cave detail: selected room operations overwrite them. It is not a literal +absolute no-op, because the local base copy is still used where no operation overwrites a slot. +The correct future design is a **second corpus** over terrain-op assets plus the active pool's +membership/weights; this task does not build it. The project currently has **1** +`UVoxelTerrainOpDefinition` asset (the Asset Registry count used by the test). + 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. +The fed 64-candidate run (step 4 sweep, radius 256, max 8,000,000 cells, fixture seed 1337) +produced **59/64 survivors (92.2%)**. There were 60 non-vacuous candidates, 60 with largest +component share ≥ 0.50, 63 exact unsnapped arrival→departure law passes, and no roll failures. +The complete 64-row table is emitted by `VoxelForge.Composer.ParameterRoll`; each row reports +archetype, weighted parents, air fraction, largest share, walkable fraction, feature scale, and +arrival→departure verdict. The final full-suite run took **112.574 s** total (**0.015 s** corpus +load, **112.556 s** roll/measurement). + +The §6.2 check is no longer vacuous: **1,224 boxes proved**, **1,629,144 voxels checked**, +**0 violations**. Its verdict mix was 1,336 Mixed, 549 AllSolid, and 675 AllAir; the law check +reported no violating candidate. The box law used unit step and brute-forced every voxel of each +uniform box, while the candidate sweep retained step 4. The test asserts zero violations and keeps +the law open to future changed candidates rather than treating this clean run as a license to tune +the survival rate. ### 3.4 The measurement pass — one grid, one flood fill, three jobs diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp index d20d843..96dff41 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp @@ -4,10 +4,15 @@ #include "Misc/AutomationTest.h" #include "HAL/PlatformTime.h" +#include "AssetRegistry/AssetData.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "Modules/ModuleManager.h" #include "VoxelForgeTestFixture.h" +#include "VoxelSettings.h" #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" +#include "VoxelTerrainOpDefinition.h" namespace { @@ -53,6 +58,124 @@ namespace *VF_FormatSpreadValue(Spread.ClampMax)); } + bool VF_IsProjectPackagePath(const FString& PackagePath) + { + return PackagePath == TEXT("/Game") + || PackagePath.StartsWith(TEXT("/Game/"), ESearchCase::IgnoreCase); + } + + bool VF_IsIgnoredSavedCopy(const FString& PackagePath) + { + return PackagePath.Contains(TEXT("/Saved/Autosaves"), ESearchCase::IgnoreCase) + || PackagePath.Contains(TEXT("/Saved/Cooked"), ESearchCase::IgnoreCase); + } + + TArray VF_GetProjectAssetPaths(UClass* AssetClass) + { + TArray Paths; + if (AssetClass == nullptr) + { + return Paths; + } + + FAssetRegistryModule& AssetRegistryModule = + FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + TArray Assets; + AssetRegistryModule.Get().GetAssetsByClass(AssetClass->GetClassPathName(), Assets, true); + + TSet SeenPaths; + for (const FAssetData& Asset : Assets) + { + const FString PackagePath = Asset.PackageName.ToString(); + const FString ObjectPath = Asset.GetObjectPathString(); + if (!VF_IsProjectPackagePath(PackagePath) + || VF_IsIgnoredSavedCopy(PackagePath) + || VF_IsIgnoredSavedCopy(ObjectPath) + || SeenPaths.Contains(ObjectPath)) + { + continue; + } + SeenPaths.Add(ObjectPath); + Paths.Add(ObjectPath); + } + Paths.Sort(); + return Paths; + } + + FString VF_FormatSettingsAudit(const UVoxelSettings* Settings) + { + TArray Paths; + if (Settings != nullptr) + { + TSet UniquePaths; + for (const TPair>& Pair : Settings->FixedStrates) + { + const FString Path = Pair.Value.ToSoftObjectPath().ToString(); + if (!Path.IsEmpty()) + { + UniquePaths.Add(Path); + } + } + for (const TSoftObjectPtr& Reference : Settings->StratePool) + { + const FString Path = Reference.ToSoftObjectPath().ToString(); + if (!Path.IsEmpty()) + { + UniquePaths.Add(Path); + } + } + for (const FString& Path : UniquePaths) + { + Paths.Add(Path); + } + Paths.Sort(); + } + + FString Result = FString::Printf(TEXT("Settings corpus audit: %d unique fixed/pool paths"), + Paths.Num()); + for (const FString& Path : Paths) + { + Result += FString::Printf(TEXT(" [%s]"), *Path); + } + Result += TEXT("; these settings references are not the corpus source."); + return Result; + } + + bool VF_RollRuntimeZIsZero(const FVoxelStrateRollInfo& Roll) + { + switch (Roll.Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Roll.ArchetypeParams.TunnelNetworkParams.StrateTopWorldZ == 0.0f + && Roll.ArchetypeParams.TunnelNetworkParams.StrateBottomWorldZ == 0.0f; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Roll.ArchetypeParams.SlabParams.StrateTopWorldZ == 0.0f + && Roll.ArchetypeParams.SlabParams.StrateBottomWorldZ == 0.0f; + case ECaveGeneratorType::Maze: + return Roll.ArchetypeParams.MazeParams.StrateTopWorldZ == 0.0f + && Roll.ArchetypeParams.MazeParams.StrateBottomWorldZ == 0.0f; + case ECaveGeneratorType::SurfaceWorld: + return Roll.ArchetypeParams.SurfaceParams.StrateTopWorldZ == 0.0f + && Roll.ArchetypeParams.SurfaceParams.StrateBottomWorldZ == 0.0f; + case ECaveGeneratorType::VerticalShafts: + return Roll.ArchetypeParams.VerticalShaftParams.StrateTopWorldZ == 0.0f + && Roll.ArchetypeParams.VerticalShaftParams.StrateBottomWorldZ == 0.0f; + case ECaveGeneratorType::FloatingIslands: + return Roll.ArchetypeParams.FloatingIslandParams.StrateTopWorldZ == 0.0f + && Roll.ArchetypeParams.FloatingIslandParams.StrateBottomWorldZ == 0.0f; + default: + return false; + } + } + + bool VF_IsComposerDefault(const FVoxelStrateCorpusEntry& Entry) + { + return Entry.SourcePath.StartsWith(TEXT("/VoxelForge/ComposerDefaults/"), + ESearchCase::IgnoreCase); + } + FString VF_FormatParents(const FVoxelStrateCorpus& Corpus, const FVoxelStrateRollInfo& Roll) { @@ -233,21 +356,81 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) FVoxelStrateCorpus Corpus; FString CorpusReport; const double CorpusStartSeconds = FPlatformTime::Seconds(); - const bool bCorpusLoaded = Corpus.LoadFromSettings(AuthoredSettings, CorpusReport); + const bool bCorpusLoaded = Corpus.LoadFromAssetRegistry(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"))); + AddInfo(VF_FormatSettingsAudit(AuthoredSettings)); + + const TArray ProjectStrateAssets = + VF_GetProjectAssetPaths(UVoxelStrateDefinition::StaticClass()); + const TArray ProjectTerrainOpAssets = + VF_GetProjectAssetPaths(UVoxelTerrainOpDefinition::StaticClass()); + int32 NumProjectCorpusEntries = 0; + int32 NumDefaultCorpusEntries = 0; + FString CorpusMembership = TEXT("CORPUS MEMBERSHIP\n"); + for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries()) + { + if (VF_IsComposerDefault(Entry)) + { + ++NumDefaultCorpusEntries; + } + else + { + ++NumProjectCorpusEntries; + } + CorpusMembership += FString::Printf(TEXT("%s | %s | %s\n"), + VF_IsComposerDefault(Entry) ? TEXT("default") : TEXT("project"), + VF_GetStrateArchetypeName(Entry.Archetype), + *Entry.SourcePath); + } + AddInfo(FString::Printf( + TEXT("Corpus membership: %d project strate assets discovered, %d project vectors loaded, " + "%d defaults, %d total members. Terrain-op Asset Registry count (not rolled in this " + "task): %d."), + ProjectStrateAssets.Num(), NumProjectCorpusEntries, NumDefaultCorpusEntries, Corpus.Num(), + ProjectTerrainOpAssets.Num())); + AddInfo(CorpusMembership); + for (const ECaveGeneratorType Archetype : { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::CrystalChamber, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + ECaveGeneratorType::Underwater}) + { + int32 NumDefaultsForArchetype = 0; + for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries()) + { + if (Entry.Archetype == Archetype && VF_IsComposerDefault(Entry)) + { + ++NumDefaultsForArchetype; + } + } + TestEqual(FString::Printf(TEXT("one hand-tuned default for %s"), + VF_GetStrateArchetypeName(Archetype)), + NumDefaultsForArchetype, 1); + TestTrue(FString::Printf(TEXT("%s has a same-archetype corpus group"), + VF_GetStrateArchetypeName(Archetype)), + Corpus.NumForArchetype(Archetype) > 0); + } + TestEqual(TEXT("all project strate assets plus eight defaults are corpus members"), + Corpus.Num(), ProjectStrateAssets.Num() + 8); 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"); + "archetype | struct | 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"), + TEXT("%s | %s | %s | %s | %s | %s | %d | %s | %s | %s | %s | %s\n"), + VF_GetStrateArchetypeName(Spread.Archetype), + *Spread.ParamStructName, *Spread.FieldName, VF_FieldKindName(Spread.Kind), Spread.bExcluded ? TEXT("yes") : TEXT("no"), @@ -270,9 +453,10 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) 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.")); + "int/enum SNAP fields use the native family blend and are not jittered. Parent selection " + "is weight-1.0 (equal) because UVoxelStrateDefinition has no corpus-weight field; parents " + "are selected only within one exact archetype group. FStrateGenerationParams terrain-op " + "transport fields remain excluded; the sibling families' authored fields are rolled.")); TestTrue(TEXT("the reflected parameter schema is covered or explicitly excluded"), Corpus.IsSchemaValid()); @@ -281,7 +465,7 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) { AddError(FString::Printf( TEXT("Cannot run the 64-candidate measurement: %s"), - Corpus.IsSchemaValid() ? TEXT("the settings asset exposed no TunnelNetwork/Underwater GenerationParams") + Corpus.IsSchemaValid() ? TEXT("the Asset Registry exposed no usable strate vectors") : *Corpus.GetSchemaError())); return false; } @@ -326,6 +510,7 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) int64 TotalBoxCheckedVoxels = 0; int32 TotalBoxViolations = 0; TArray BoxViolationCandidates; + FString FirstBoxViolation; const double RollAndMeasureStartSeconds = FPlatformTime::Seconds(); for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex) @@ -349,23 +534,47 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) continue; } + bool bParentsMatchArchetype = true; + for (const int32 ParentIndex : Roll.ParentEntryIndices) + { + if (!Corpus.GetEntries().IsValidIndex(ParentIndex) + || Corpus.GetEntries()[ParentIndex].Archetype != Roll.Archetype) + { + bParentsMatchArchetype = false; + break; + } + } + TestTrue(FString::Printf(TEXT("candidate %d parents stay within %s"), + CandidateIndex, VF_GetStrateArchetypeName(Roll.Archetype)), + bParentsMatchArchetype); + + const FVoxelStrateRollInfo RepeatRoll = VF_RollStrateParamsDetailed( + Corpus, AuthoredSettings->Seed, CandidateIndex); + TestTrue(FString::Printf(TEXT("candidate %d is bit-identical on deterministic reroll"), + CandidateIndex), + RepeatRoll.bValid && RepeatRoll.Archetype == Roll.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + RepeatRoll.ArchetypeParams, Roll.ArchetypeParams, Roll.Archetype) + && RepeatRoll.ParentEntryIndices == Roll.ParentEntryIndices + && RepeatRoll.ParentWeights == Roll.ParentWeights); + // 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->GenerationParams = Roll.ArchetypeParams.TunnelNetworkParams; + CandidateDefinition->SlabParams = Roll.ArchetypeParams.SlabParams; + CandidateDefinition->MazeParams = Roll.ArchetypeParams.MazeParams; + CandidateDefinition->SurfaceParams = Roll.ArchetypeParams.SurfaceParams; + CandidateDefinition->VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams; + CandidateDefinition->FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams; 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); + VF_RollRuntimeZIsZero(Roll)); const FVoxelStrateMetrics Metrics = VF_MeasureStrate( *World.Generator, *World.StrateManager, CandidateStrateIndex, MeasureSettings); @@ -438,6 +647,10 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) if (BoxReport.Violations > 0) { BoxViolationCandidates.Add(CandidateIndex); + if (FirstBoxViolation.IsEmpty()) + { + FirstBoxViolation = BoxReport.FirstViolation; + } } CandidateTable += FString::Printf( @@ -481,8 +694,8 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) 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])); + "were not sound for the rolled parameter space. First violation: %s"), + BoxViolationCandidates.Num(), BoxViolationCandidates[0], *FirstBoxViolation)); } if (TotalBoxProved == 0) { @@ -504,4 +717,3 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) } #endif // WITH_DEV_AUTOMATION_TESTS - diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 8f1c31b..aa02a9a 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -2,15 +2,18 @@ #include "VoxelStrateComposer.h" +#include "AssetRegistry/AssetData.h" +#include "AssetRegistry/AssetRegistryModule.h" #include "Math/RandomStream.h" -#include "Misc/Crc.h" +#include "Modules/ModuleManager.h" +#include "UObject/EnumProperty.h" #include "UObject/FieldIterator.h" #include "UObject/UnrealType.h" +#include "UObject/UObjectGlobals.h" #include "VoxelSettings.h" #include "VoxelStrateDefinition.h" -#include #include #include @@ -18,6 +21,124 @@ namespace { constexpr float GJitterFraction = 0.15f; + static const ECaveGeneratorType GAllArchetypes[] = + { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::CrystalChamber, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + ECaveGeneratorType::Underwater, + }; + + bool VF_IsTunnelArchetype(ECaveGeneratorType Archetype) + { + return Archetype == ECaveGeneratorType::TunnelNetwork + || Archetype == ECaveGeneratorType::Underwater; + } + + bool VF_IsSupportedArchetype(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + case ECaveGeneratorType::Maze: + case ECaveGeneratorType::SurfaceWorld: + case ECaveGeneratorType::VerticalShafts: + case ECaveGeneratorType::FloatingIslands: + case ECaveGeneratorType::Underwater: + return true; + default: + return false; + } + } + + UStruct* VF_GetParamStruct(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return FStrateGenerationParams::StaticStruct(); + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return FSlabGenerationParams::StaticStruct(); + case ECaveGeneratorType::Maze: + return FMazeGenerationParams::StaticStruct(); + case ECaveGeneratorType::SurfaceWorld: + return FSurfaceGenerationParams::StaticStruct(); + case ECaveGeneratorType::VerticalShafts: + return FVerticalShaftParams::StaticStruct(); + case ECaveGeneratorType::FloatingIslands: + return FFloatingIslandParams::StaticStruct(); + default: + return nullptr; + } + } + + void* VF_GetParamMemory(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return &Params.TunnelNetworkParams; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return &Params.SlabParams; + case ECaveGeneratorType::Maze: + return &Params.MazeParams; + case ECaveGeneratorType::SurfaceWorld: + return &Params.SurfaceParams; + case ECaveGeneratorType::VerticalShafts: + return &Params.VerticalShaftParams; + case ECaveGeneratorType::FloatingIslands: + return &Params.FloatingIslandParams; + default: + return nullptr; + } + } + + const void* VF_GetParamMemory(const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return &Params.TunnelNetworkParams; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return &Params.SlabParams; + case ECaveGeneratorType::Maze: + return &Params.MazeParams; + case ECaveGeneratorType::SurfaceWorld: + return &Params.SurfaceParams; + case ECaveGeneratorType::VerticalShafts: + return &Params.VerticalShaftParams; + case ECaveGeneratorType::FloatingIslands: + return &Params.FloatingIslandParams; + default: + return nullptr; + } + } + + FString VF_SpreadKey(ECaveGeneratorType Archetype, const FString& FieldName) + { + return FString::Printf(TEXT("%d:%s"), static_cast(static_cast(Archetype)), + *FieldName); + } + + FString VF_SpreadKey(ECaveGeneratorType Archetype, const TCHAR* FieldName) + { + return VF_SpreadKey(Archetype, FString(FieldName)); + } + template EVoxelStrateFieldKind VF_FieldKindFor() { @@ -51,6 +172,23 @@ namespace return nullptr; } + bool VF_IsRuntimeField(const FString& FieldName) + { + return FieldName == TEXT("StrateTopWorldZ") + || FieldName == TEXT("StrateBottomWorldZ"); + } + + bool VF_IsExcludedForArchetype(ECaveGeneratorType Archetype, const FString& FieldName) + { + // These names are transport slots only for FStrateGenerationParams. Sibling families + // have real authored fields with some of the same names (e.g. Surface::TerraceHardness). + if (VF_IsTunnelArchetype(Archetype)) + { + return VF_FindExclusion(FieldName) != nullptr; + } + return VF_IsRuntimeField(FieldName); + } + void VF_AddExclusion(TArray& Out, const TCHAR* FieldName, const TCHAR* Reason) { @@ -59,10 +197,14 @@ namespace Entry.Reason = Reason; } - void VF_CollectGenerationProperties(TMap& OutProperties) + void VF_CollectProperties(UStruct* Struct, TMap& OutProperties) { OutProperties.Reset(); - UStruct* Struct = FStrateGenerationParams::StaticStruct(); + if (Struct == nullptr) + { + return; + } + for (TFieldIterator It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It) { FProperty* Property = *It; @@ -73,6 +215,337 @@ namespace } } + EVoxelStrateFieldKind VF_PropertyKind(const FProperty* Property) + { + if (Property == nullptr) + { + return EVoxelStrateFieldKind::Continuous; + } + if (CastField(Property) != nullptr) + { + return EVoxelStrateFieldKind::Boolean; + } + if (CastField(Property) != nullptr) + { + return EVoxelStrateFieldKind::Enum; + } + if (const FNumericProperty* NumericProperty = CastField(Property)) + { + if (NumericProperty->IsEnum()) + { + return EVoxelStrateFieldKind::Enum; + } + if (NumericProperty->IsInteger()) + { + return EVoxelStrateFieldKind::Integer; + } + } + return EVoxelStrateFieldKind::Continuous; + } + + bool VF_IsScalarProperty(const FProperty* Property) + { + return CastField(Property) != nullptr + || CastField(Property) != nullptr + || CastField(Property) != nullptr; + } + + bool VF_ReadPropertyValue(const FProperty* Property, const void* Memory, double& OutValue) + { + if (Property == nullptr || Memory == nullptr || !VF_IsScalarProperty(Property)) + { + return false; + } + + const void* ValuePtr = Property->ContainerPtrToValuePtr(Memory); + if (const FBoolProperty* BoolProperty = CastField(Property)) + { + OutValue = BoolProperty->GetPropertyValue(ValuePtr) ? 1.0 : 0.0; + return true; + } + + const FNumericProperty* NumericProperty = CastField(Property); + const FNumericProperty* UnderlyingProperty = NumericProperty; + if (const FEnumProperty* EnumProperty = CastField(Property)) + { + UnderlyingProperty = EnumProperty->GetUnderlyingProperty(); + } + if (UnderlyingProperty == nullptr) + { + return false; + } + + if (UnderlyingProperty->IsFloatingPoint()) + { + OutValue = UnderlyingProperty->GetFloatingPointPropertyValue(ValuePtr); + } + else + { + // Current integer/enum fields are non-negative; the signed accessor gives the + // expected value for the byte-backed enum properties used by the strate structs. + OutValue = static_cast(UnderlyingProperty->GetSignedIntPropertyValue(ValuePtr)); + } + return FMath::IsFinite(OutValue); + } + + bool VF_WritePropertyValue(const FProperty* Property, void* Memory, double Value) + { + if (Property == nullptr || Memory == nullptr || !VF_IsScalarProperty(Property) + || !FMath::IsFinite(Value)) + { + return false; + } + + void* ValuePtr = Property->ContainerPtrToValuePtr(Memory); + if (const FBoolProperty* BoolProperty = CastField(Property)) + { + BoolProperty->SetPropertyValue(ValuePtr, Value >= 0.5); + return true; + } + + const FNumericProperty* NumericProperty = CastField(Property); + const FNumericProperty* UnderlyingProperty = NumericProperty; + if (const FEnumProperty* EnumProperty = CastField(Property)) + { + UnderlyingProperty = EnumProperty->GetUnderlyingProperty(); + } + if (UnderlyingProperty == nullptr) + { + return false; + } + + if (UnderlyingProperty->IsFloatingPoint()) + { + UnderlyingProperty->SetFloatingPointPropertyValue(ValuePtr, Value); + } + else + { + UnderlyingProperty->SetIntPropertyValue(ValuePtr, static_cast(FMath::RoundToInt(Value))); + } + return true; + } + + template + double VF_NativeValueAsDouble(T Value) + { + if constexpr (std::is_enum::value) + { + using UnderlyingType = typename std::underlying_type::type; + return static_cast(static_cast(Value)); + } + else + { + return static_cast(Value); + } + } + + template ::value, int>::type = 0> + void VF_AssignNativeValue(T& Target, double Value) + { + Target = static_cast(Value); + } + + template ::value, int>::type = 0> + void VF_AssignNativeValue(T& Target, double Value) + { + using UnderlyingType = typename std::underlying_type::type; + Target = static_cast(static_cast(FMath::RoundToInt(Value))); + } + + bool VF_ReadFStrateField(const FStrateGenerationParams& Params, + const FString& FieldName, double& OutValue) + { +#define VF_READ_FSTRATE_FIELD(Name) \ + if (FieldName == TEXT(#Name)) { OutValue = VF_NativeValueAsDouble(Params.Name); return true; } + VF_STRATE_PARAM_FIELDS(VF_READ_FSTRATE_FIELD, VF_READ_FSTRATE_FIELD) +#undef VF_READ_FSTRATE_FIELD + return false; + } + + bool VF_WriteFStrateField(FStrateGenerationParams& Params, + const FString& FieldName, double Value) + { +#define VF_WRITE_FSTRATE_FIELD(Name) \ + if (FieldName == TEXT(#Name)) { VF_AssignNativeValue(Params.Name, Value); return true; } + VF_STRATE_PARAM_FIELDS(VF_WRITE_FSTRATE_FIELD, VF_WRITE_FSTRATE_FIELD) +#undef VF_WRITE_FSTRATE_FIELD + return false; + } + + bool VF_ReadRuntimeField(const void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, double& OutValue) + { + if (Memory == nullptr || !VF_IsRuntimeField(FieldName)) + { + return false; + } + + if (FieldName == TEXT("StrateTopWorldZ")) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::Maze: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::SurfaceWorld: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::VerticalShafts: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::FloatingIslands: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + default: + return false; + } + } + + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::Maze: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::SurfaceWorld: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::VerticalShafts: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::FloatingIslands: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + default: + return false; + } + } + + bool VF_WriteRuntimeField(void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, double Value) + { + if (Memory == nullptr || !VF_IsRuntimeField(FieldName)) + { + return false; + } + + if (FieldName == TEXT("StrateTopWorldZ")) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::Maze: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::SurfaceWorld: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::VerticalShafts: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::FloatingIslands: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + default: + return false; + } + } + + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::Maze: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::SurfaceWorld: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::VerticalShafts: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::FloatingIslands: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + default: + return false; + } + } + + bool VF_ReadNamedField(const void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, double& OutValue) + { + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Struct == nullptr || Memory == nullptr) + { + return false; + } + + if (VF_IsTunnelArchetype(Archetype) + && VF_ReadFStrateField(*static_cast(Memory), + FieldName, OutValue)) + { + return true; + } + + if (const FProperty* Property = FindFProperty(Struct, FName(*FieldName))) + { + return VF_ReadPropertyValue(Property, Memory, OutValue); + } + return VF_ReadRuntimeField(Memory, Archetype, FieldName, OutValue); + } + + bool VF_WriteNamedField(void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, double Value) + { + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Struct == nullptr || Memory == nullptr) + { + return false; + } + + if (VF_IsTunnelArchetype(Archetype) + && VF_WriteFStrateField(*static_cast(Memory), + FieldName, Value)) + { + return true; + } + + if (FProperty* Property = FindFProperty(Struct, FName(*FieldName))) + { + return VF_WritePropertyValue(Property, Memory, Value); + } + return VF_WriteRuntimeField(Memory, Archetype, FieldName, Value); + } + void VF_AddPropertyMetadata(FVoxelStrateFieldSpread& Spread, const FProperty* Property) { #if WITH_EDITOR @@ -112,39 +585,31 @@ namespace void VF_AddSpreadDescriptor(TArray& OutSpreads, TMap& OutIndices, + ECaveGeneratorType Archetype, + const FString& ParamStructName, const TMap& Properties, - const TCHAR* Name, EVoxelStrateFieldKind Kind) + const FString& Name, EVoxelStrateFieldKind Kind) { FVoxelStrateFieldSpread& Spread = OutSpreads.AddDefaulted_GetRef(); + Spread.Archetype = Archetype; + Spread.ParamStructName = ParamStructName; Spread.FieldName = Name; Spread.Kind = Kind; - Spread.bExcluded = VF_FindExclusion(Spread.FieldName) != nullptr; + Spread.bExcluded = VF_IsExcludedForArchetype(Archetype, Name); - if (const FProperty* const* Property = Properties.Find(Spread.FieldName)) + if (const FProperty* const* Property = Properties.Find(Name)) { 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); - } + OutIndices.Add(VF_SpreadKey(Archetype, Name), OutSpreads.Num() - 1); } 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. + // These are terrain-op transport values (written per room by UVoxelTerrainOpDefinition) + // plus manager-owned runtime Z bounds. They are not rolled in this composer pass. Params.TerraceStepHeight = 0.0f; Params.TerraceHardness = 0.0f; Params.TerraceNoiseDisplacement = 0.0f; @@ -183,60 +648,283 @@ namespace Params.StrateBottomWorldZ = 0.0f; } - void VF_JitterFloat(float& Value, const FVoxelStrateFieldSpread* Spread, FRandomStream& Rng) + void VF_ResetRuntimeFields(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) { - if (Spread == nullptr || Spread->bExcluded || Spread->Kind != EVoxelStrateFieldKind::Continuous) + void* Memory = VF_GetParamMemory(Params, Archetype); + VF_WriteRuntimeField(Memory, Archetype, TEXT("StrateTopWorldZ"), 0.0); + VF_WriteRuntimeField(Memory, Archetype, TEXT("StrateBottomWorldZ"), 0.0); + } + + void VF_ResetExcludedFields(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + if (VF_IsTunnelArchetype(Archetype)) + { + VF_ResetNonTunableFields(Params.TunnelNetworkParams); + } + else + { + VF_ResetRuntimeFields(Params, Archetype); + } + } + + void VF_AppendSchemaError(FString& SchemaError, const FString& Message) + { + SchemaError += Message; + SchemaError += TEXT(" "); + } + + void VF_ValidateFieldDescriptor(const TArray& Spreads, + ECaveGeneratorType Archetype, + const FString& FieldName, + bool& bSchemaValid, FString& SchemaError) + { + const FVoxelStrateFieldSpread* Spread = nullptr; + for (const FVoxelStrateFieldSpread& Candidate : Spreads) + { + if (Candidate.Archetype == Archetype && Candidate.FieldName == FieldName) + { + Spread = &Candidate; + break; + } + } + if (Spread != nullptr && !Spread->bReflected && !Spread->bExcluded) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("Field '%s.%s' is neither reflected nor explicitly excluded."), + *Spread->ParamStructName, *FieldName)); + } + } + + void VF_ValidateStructProperties(const TMap& Properties, + const TSet& ExpectedNames, + const FString& ParamStructName, + bool& bSchemaValid, FString& SchemaError) + { + for (const TPair& Pair : Properties) + { + if (!ExpectedNames.Contains(Pair.Key)) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("Reflected %s field '%s' is absent from the composer field list."), + *ParamStructName, *Pair.Key)); + } + if (!VF_IsScalarProperty(Pair.Value)) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("Reflected %s field '%s' is not scalar and cannot be rolled."), + *ParamStructName, *Pair.Key)); + } + } + } + + void VF_BlendParamStruct(FVoxelStrateArchetypeParams& Destination, + const FVoxelStrateArchetypeParams& Source, + ECaveGeneratorType Archetype, float Alpha) + { + void* DestinationMemory = VF_GetParamMemory(Destination, Archetype); + const void* SourceMemory = VF_GetParamMemory(Source, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (DestinationMemory == nullptr || SourceMemory == nullptr || Struct == nullptr) { return; } - const float CorpusRange = (float)FMath::Max(0.0, Spread->Max - Spread->Min); - if (CorpusRange > 0.0f) + if (VF_IsTunnelArchetype(Archetype)) { - Value += (Rng.FRand() * 2.0f - 1.0f) * GJitterFraction * CorpusRange; + Destination.TunnelNetworkParams = FStrateGenerationParams::Lerp( + Destination.TunnelNetworkParams, Source.TunnelNetworkParams, Alpha); + return; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + if (!VF_IsScalarProperty(Pair.Value)) + { + continue; + } + + double A = 0.0; + double B = 0.0; + if (!VF_ReadPropertyValue(Pair.Value, DestinationMemory, A) + || !VF_ReadPropertyValue(Pair.Value, SourceMemory, B)) + { + continue; + } + + const EVoxelStrateFieldKind Kind = VF_PropertyKind(Pair.Value); + const double Value = Kind == EVoxelStrateFieldKind::Continuous + ? static_cast(FMath::Lerp(static_cast(A), static_cast(B), Alpha)) + : (Alpha < 0.5f ? A : B); + VF_WritePropertyValue(Pair.Value, DestinationMemory, Value); } } - void VF_ClampFloat(float& Value, const FVoxelStrateFieldSpread* Spread) + void VF_CopyDominantBooleans(FVoxelStrateArchetypeParams& Destination, + const FVoxelStrateArchetypeParams& Dominant, + ECaveGeneratorType Archetype) { - if (Spread == nullptr || Spread->bExcluded) + void* DestinationMemory = VF_GetParamMemory(Destination, Archetype); + const void* DominantMemory = VF_GetParamMemory(Dominant, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (DestinationMemory == nullptr || DominantMemory == nullptr || Struct == nullptr) { return; } - if (Spread->bHasClampMin) + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) { - Value = FMath::Max(Value, (float)Spread->ClampMin); - } - if (Spread->bHasClampMax) - { - Value = FMath::Min(Value, (float)Spread->ClampMax); + if (VF_PropertyKind(Pair.Value) != EVoxelStrateFieldKind::Boolean) + { + continue; + } + double Value = 0.0; + if (VF_ReadPropertyValue(Pair.Value, DominantMemory, Value)) + { + VF_WritePropertyValue(Pair.Value, DestinationMemory, Value); + } } } - void VF_ClampInt(int32& Value, const FVoxelStrateFieldSpread* Spread) + void VF_JitterNativeParams(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype, + const FVoxelStrateCorpus& Corpus, + FRandomStream& Rng) { - if (Spread == nullptr || Spread->bExcluded) + void* Memory = VF_GetParamMemory(Params, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) { return; } - if (Spread->bHasClampMin) + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) { - Value = FMath::Max(Value, FMath::CeilToInt((float)Spread->ClampMin)); - } - if (Spread->bHasClampMax) - { - Value = FMath::Min(Value, FMath::FloorToInt((float)Spread->ClampMax)); + if (VF_PropertyKind(Pair.Value) != EVoxelStrateFieldKind::Continuous) + { + continue; + } + + const FVoxelStrateFieldSpread* Spread = Corpus.FindSpread(Archetype, Pair.Key); + if (Spread == nullptr || Spread->bExcluded) + { + continue; + } + + const double CorpusRange = FMath::Max(0.0, Spread->Max - Spread->Min); + if (!(CorpusRange > 0.0)) + { + continue; + } + + double Value = 0.0; + if (VF_ReadPropertyValue(Pair.Value, Memory, Value)) + { + Value += static_cast((Rng.FRand() * 2.0f - 1.0f) * GJitterFraction) + * CorpusRange; + VF_WritePropertyValue(Pair.Value, Memory, Value); + } } } - void VF_RepairOrderedPair(float& MinValue, float& MaxValue) + void VF_ClampNativeParams(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype, + const FVoxelStrateCorpus& Corpus) { - // 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) + void* Memory = VF_GetParamMemory(Params, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) { - Swap(MinValue, MaxValue); + return; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + const EVoxelStrateFieldKind Kind = VF_PropertyKind(Pair.Value); + if (Kind == EVoxelStrateFieldKind::Boolean) + { + continue; + } + + const FVoxelStrateFieldSpread* Spread = Corpus.FindSpread(Archetype, Pair.Key); + if (Spread == nullptr || Spread->bExcluded) + { + continue; + } + + double Value = 0.0; + if (!VF_ReadPropertyValue(Pair.Value, Memory, Value)) + { + continue; + } + if (Spread->bHasClampMin) + { + Value = FMath::Max(Value, Spread->ClampMin); + } + if (Spread->bHasClampMax) + { + Value = FMath::Min(Value, Spread->ClampMax); + } + VF_WritePropertyValue(Pair.Value, Memory, Value); + } + } + + void VF_RepairOrderedPair(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype, + const TCHAR* MinField, const TCHAR* MaxField) + { + void* Memory = VF_GetParamMemory(Params, Archetype); + double MinValue = 0.0; + double MaxValue = 0.0; + if (VF_ReadNamedField(Memory, Archetype, MinField, MinValue) + && VF_ReadNamedField(Memory, Archetype, MaxField, MaxValue) + && MinValue > MaxValue) + { + VF_WriteNamedField(Memory, Archetype, MinField, MaxValue); + VF_WriteNamedField(Memory, Archetype, MaxField, MinValue); + } + } + + void VF_RepairNativeOrderedPairs(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + VF_RepairOrderedPair(Params, Archetype, TEXT("MinRoomRadius"), TEXT("MaxRoomRadius")); + VF_RepairOrderedPair(Params, Archetype, TEXT("RoomFloorCutMin"), TEXT("RoomFloorCutMax")); + VF_RepairOrderedPair(Params, Archetype, TEXT("TunnelMinRadius"), TEXT("TunnelMaxRadius")); + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + VF_RepairOrderedPair(Params, Archetype, TEXT("FloorRelativeHeight"), TEXT("CeilingRelativeHeight")); + VF_RepairOrderedPair(Params, Archetype, TEXT("ColumnMinRadius"), TEXT("ColumnMaxRadius")); + break; + case ECaveGeneratorType::SurfaceWorld: + VF_RepairOrderedPair(Params, Archetype, TEXT("BaseGroundRelative"), TEXT("CeilingRelative")); + break; + case ECaveGeneratorType::VerticalShafts: + VF_RepairOrderedPair(Params, Archetype, TEXT("ShaftMinRadius"), TEXT("ShaftMaxRadius")); + break; + case ECaveGeneratorType::FloatingIslands: + VF_RepairOrderedPair(Params, Archetype, TEXT("IslandMinRadius"), TEXT("IslandMaxRadius")); + break; + case ECaveGeneratorType::Maze: + default: + break; } } @@ -253,8 +941,8 @@ namespace 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)); + Value = VF_Avalanche(Value ^ static_cast(Seed)); + Value = VF_Avalanche(Value ^ (static_cast(Index) + 0x85ebca6bU)); return Value; } @@ -343,7 +1031,7 @@ namespace return Result; } - FRandomStream Rng((int32)VF_RollSeed(Corpus.GetContentsHash(), Seed, Index)); + FRandomStream Rng(static_cast(VF_RollSeed(Corpus.GetContentsHash(), Seed, Index))); float TotalGroupWeight = 0.0f; for (const FParentGroup& Group : Groups) @@ -387,8 +1075,8 @@ namespace 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. + // 2–3-parent algorithm defined for a one-entry group and exposes that limitation in + // provenance instead of 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) @@ -425,63 +1113,31 @@ namespace } } - FStrateGenerationParams Params = Entries[Result.ParentEntryIndices[0]].Params; + Result.Archetype = Group.Archetype; + Result.ArchetypeParams = Entries[Result.ParentEntryIndices[0]].ArchetypeParams; 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); + VF_BlendParamStruct(Result.ArchetypeParams, + Entries[Result.ParentEntryIndices[ParentSlot]].ArchetypeParams, + Result.Archetype, 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; + VF_CopyDominantBooleans(Result.ArchetypeParams, + Entries[DominantParent].ArchetypeParams, + Result.Archetype); + VF_JitterNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus, Rng); + VF_ClampNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus); + VF_RepairNativeOrderedPairs(Result.ArchetypeParams, Result.Archetype); + VF_ResetExcludedFields(Result.ArchetypeParams, Result.Archetype); -#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; + // Preserve the original compact API's compatibility view. Detailed callers use the + // correctly typed active family in ArchetypeParams. + Result.Params = Result.ArchetypeParams.TunnelNetworkParams; Result.bValid = true; return Result; } @@ -494,7 +1150,9 @@ const TArray& FVoxelStrateCorpus::GetNonTunableField TArray Result; const TCHAR* TerrainOpReason = TEXT( - "Internal terrain-op transport: populated per room by UVoxelTerrainOpDefinition, not an authored GenerationParams tunable."); + "Transport slot only: generation overlays it per room from a UVoxelTerrainOpDefinition " + "selected through the active strate's TerrainOperations pool; it is not the authored " + "strate-generation source."); VF_AddExclusion(Result, TEXT("TerraceStepHeight"), TerrainOpReason); VF_AddExclusion(Result, TEXT("TerraceHardness"), TerrainOpReason); VF_AddExclusion(Result, TEXT("TerraceNoiseDisplacement"), TerrainOpReason); @@ -555,54 +1213,85 @@ void FVoxelStrateCorpus::RebuildSpreads() SchemaError.Reset(); bSchemaValid = true; - TMap Properties; - VF_CollectGenerationProperties(Properties); - - TArray ExpectedNames; - TSet ExpectedNameSet; TMap Indices; + for (const ECaveGeneratorType Archetype : GAllArchetypes) + { + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Struct == nullptr) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("No parameter struct is registered for archetype %s."), + VF_GetStrateArchetypeName(Archetype))); + continue; + } + const FString ParamStructName = Struct->GetName(); + TMap Properties; + VF_CollectProperties(Struct, Properties); + TSet ExpectedNames; + + if (VF_IsTunnelArchetype(Archetype)) + { #define VF_COMPOSER_ADD_LERPF(Name) \ - ExpectedNames.Add(TEXT(#Name)); \ - ExpectedNameSet.Add(TEXT(#Name)); \ - VF_AddSpreadDescriptor(FieldSpreads, Indices, Properties, TEXT(#Name), EVoxelStrateFieldKind::Continuous); + ExpectedNames.Add(TEXT(#Name)); \ + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, 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) + ExpectedNames.Add(TEXT(#Name)); \ + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, 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 + } + else + { + for (const TPair& Pair : Properties) + { + if (VF_IsScalarProperty(Pair.Value)) + { + ExpectedNames.Add(Pair.Key); + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, + Properties, Pair.Key, VF_PropertyKind(Pair.Value)); + } + } + } - for (const TPair& Pair : Properties) - { - if (!ExpectedNameSet.Contains(Pair.Key)) + // Sibling structs keep runtime bounds as ordinary C++ fields, just as + // FStrateGenerationParams does. Include them in the report and mark them excluded even + // though reflection cannot see them. + if (!VF_IsTunnelArchetype(Archetype)) { - bSchemaValid = false; - SchemaError += FString::Printf( - TEXT("Reflected FStrateGenerationParams field '%s' is absent from VF_STRATE_PARAM_FIELDS. "), - *Pair.Key); + ExpectedNames.Add(TEXT("StrateTopWorldZ")); + ExpectedNames.Add(TEXT("StrateBottomWorldZ")); + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, Properties, + TEXT("StrateTopWorldZ"), EVoxelStrateFieldKind::Continuous); + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, Properties, + TEXT("StrateBottomWorldZ"), EVoxelStrateFieldKind::Continuous); } - } - for (const FVoxelStrateFieldExclusion& Exclusion : GetNonTunableFields()) - { - if (!ExpectedNameSet.Contains(Exclusion.FieldName)) + + VF_ValidateStructProperties(Properties, ExpectedNames, ParamStructName, + bSchemaValid, SchemaError); + + if (VF_IsTunnelArchetype(Archetype)) { - bSchemaValid = false; - SchemaError += FString::Printf( - TEXT("Explicit exclusion '%s' is absent from VF_STRATE_PARAM_FIELDS. "), - *Exclusion.FieldName); + for (const FVoxelStrateFieldExclusion& Exclusion : GetNonTunableFields()) + { + if (!ExpectedNames.Contains(Exclusion.FieldName)) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("Explicit exclusion '%s' is absent from %s."), + *Exclusion.FieldName, *ParamStructName)); + } + } } - } - for (const FString& ExpectedName : ExpectedNames) - { - const FVoxelStrateFieldSpread* Spread = FindSpread(ExpectedName); - if (Spread != nullptr && !Spread->bReflected && !Spread->bExcluded) + + for (const FString& ExpectedName : ExpectedNames) { - bSchemaValid = false; - SchemaError += FString::Printf( - TEXT("Field '%s' is neither reflected nor explicitly excluded. "), *ExpectedName); + VF_ValidateFieldDescriptor(FieldSpreads, Archetype, ExpectedName, + bSchemaValid, SchemaError); } } @@ -610,13 +1299,27 @@ void FVoxelStrateCorpus::RebuildSpreads() 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 + const void* Memory = VF_GetParamMemory(Entry.ArchetypeParams, Entry.Archetype); + if (Memory == nullptr) + { + continue; + } + + for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex) + { + const FVoxelStrateFieldSpread& Spread = FieldSpreads[FieldIndex]; + if (Spread.Archetype != Entry.Archetype) + { + continue; + } + + double Value = 0.0; + if (VF_ReadNamedField(Memory, Entry.Archetype, Spread.FieldName, Value) + && FMath::IsFinite(Value)) + { + Values[FieldIndex].Add(Value); + } + } } for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex) @@ -638,7 +1341,7 @@ void FVoxelStrateCorpus::RebuildSpreads() Spread.Max = FMath::Max(Spread.Max, Sample); Sum += Sample; } - Spread.Mean = Sum / (double)Samples.Num(); + Spread.Mean = Sum / static_cast(Samples.Num()); double Variance = 0.0; for (const double Sample : Samples) @@ -646,7 +1349,7 @@ void FVoxelStrateCorpus::RebuildSpreads() const double Delta = Sample - Spread.Mean; Variance += Delta * Delta; } - Spread.StdDev = FMath::Sqrt(FMath::Max(0.0, Variance / (double)Samples.Num())); + Spread.StdDev = FMath::Sqrt(FMath::Max(0.0, Variance / static_cast(Samples.Num()))); } #if WITH_EDITOR @@ -660,12 +1363,20 @@ bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& Sour ECaveGeneratorType Archetype, const FStrateGenerationParams& Params, float Weight) { - if (Archetype != ECaveGeneratorType::TunnelNetwork - && Archetype != ECaveGeneratorType::Underwater) + FVoxelStrateArchetypeParams ArchetypeParams; + ArchetypeParams.TunnelNetworkParams = Params; + return AddEntry(SourcePath, SourceName, Archetype, ArchetypeParams, Weight); +} + +bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, float Weight) +{ + if (!VF_IsSupportedArchetype(Archetype)) { SkippedDefinitions.Add(FString::Printf( - TEXT("%s skipped: archetype %s does not author FStrateGenerationParams."), - *SourcePath, VF_GetStrateArchetypeName(Archetype))); + TEXT("%s skipped: unknown archetype value %d."), + *SourcePath, static_cast(static_cast(Archetype)))); return false; } if (!FMath::IsFinite(Weight) || Weight <= 0.0f) @@ -680,15 +1391,16 @@ bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& Sour Entry.SourcePath = SourcePath; Entry.SourceName = SourceName.IsEmpty() ? SourcePath : SourceName; Entry.Archetype = Archetype; - Entry.Params = Params; + Entry.ArchetypeParams = Params; + Entry.Params = Params.TunnelNetworkParams; 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) + if (static_cast(A.Archetype) != static_cast(B.Archetype)) { - return (uint8)A.Archetype < (uint8)B.Archetype; + return static_cast(A.Archetype) < static_cast(B.Archetype); } return A.SourceName < B.SourceName; }); @@ -715,6 +1427,7 @@ bool FVoxelStrateCorpus::LoadFromDefinitions( }); TSet SeenPaths; + int32 NumDefinitionEntries = 0; for (UVoxelStrateDefinition* Definition : SortedDefinitions) { const FString SourcePath = Definition->GetPathName(); @@ -728,35 +1441,57 @@ bool FVoxelStrateCorpus::LoadFromDefinitions( ? Definition->GetName() : Definition->StrateName.ToString(); - if (Definition->GeneratorType != ECaveGeneratorType::TunnelNetwork - && Definition->GeneratorType != ECaveGeneratorType::Underwater) + if (!VF_IsSupportedArchetype(Definition->GeneratorType)) { SkippedDefinitions.Add(FString::Printf( - TEXT("%s (%s) skipped: %s uses a different authored parameter struct; its ") - TEXT("GenerationParams default is not corpus data."), + TEXT("%s (%s) skipped: unknown GeneratorType value %d."), *SourcePath, *SourceName, - VF_GetStrateArchetypeName(Definition->GeneratorType))); + static_cast(static_cast(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. + FVoxelStrateArchetypeParams Params; + Params.TunnelNetworkParams = Definition->GenerationParams; + Params.SlabParams = Definition->SlabParams; + Params.MazeParams = Definition->MazeParams; + Params.SurfaceParams = Definition->SurfaceParams; + Params.VerticalShaftParams = Definition->VerticalShaftParams; + Params.FloatingIslandParams = Definition->FloatingIslandParams; + + if (AddEntry(SourcePath, SourceName, Definition->GeneratorType, Params, 1.0f)) + { + ++NumDefinitionEntries; + } } - Entries.Sort([](const FVoxelStrateCorpusEntry& A, const FVoxelStrateCorpusEntry& B) + // C++ member initializers in VoxelStrateTypes.h are hand-tuned, known-good starting points. + // Add one member for every exact archetype so an empty group still has a valid parent and so + // the defaults are measured alongside project assets. + FVoxelStrateArchetypeParams Defaults; + int32 NumDefaultEntries = 0; + for (const ECaveGeneratorType Archetype : GAllArchetypes) { - if (A.SourcePath != B.SourcePath) { return A.SourcePath < B.SourcePath; } - return (uint8)A.Archetype < (uint8)B.Archetype; - }); - RebuildSpreads(); + const FString ArchetypeName = VF_GetStrateArchetypeName(Archetype); + const FString DefaultPath = FString::Printf( + TEXT("/VoxelForge/ComposerDefaults/%s"), *ArchetypeName); + const FString DefaultName = FString::Printf(TEXT("Default_%s"), *ArchetypeName); + if (AddEntry(DefaultPath, DefaultName, Archetype, Defaults, 1.0f)) + { + ++NumDefaultEntries; + } + } + RebuildSpreads(); + TSet ArchetypeSet; + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + ArchetypeSet.Add(static_cast(Entry.Archetype)); + } OutReport = FString::Printf( - TEXT("Corpus definitions: %d resolved usable entries, %d skipped, %d fields measured. "), - Entries.Num(), SkippedDefinitions.Num(), FieldSpreads.Num()); + TEXT("Corpus definitions: %d resolved project vectors + %d archetype defaults = %d corpus " + "members; %d exact-archetype groups; %d field descriptors measured. "), + NumDefinitionEntries, NumDefaultEntries, Entries.Num(), ArchetypeSet.Num(), + FieldSpreads.Num()); for (const FString& Skipped : SkippedDefinitions) { OutReport += Skipped; @@ -770,44 +1505,62 @@ bool FVoxelStrateCorpus::LoadFromDefinitions( return IsValid(); } -bool FVoxelStrateCorpus::LoadFromSettings(const UVoxelSettings* Settings, FString& OutReport) +static bool VF_IsProjectPackagePath(const FString& PackagePath) { - Reset(); - if (Settings == nullptr) - { - OutReport = TEXT("Cannot load strate corpus: settings is null."); - return false; - } + return PackagePath == TEXT("/Game") + || PackagePath.StartsWith(TEXT("/Game/"), ESearchCase::IgnoreCase); +} - TArray> References; - for (const TPair>& Pair : Settings->FixedStrates) +static bool VF_IsIgnoredSavedCopy(const FString& PackagePath) +{ + return PackagePath.Contains(TEXT("/Saved/Autosaves"), ESearchCase::IgnoreCase) + || PackagePath.Contains(TEXT("/Saved/Cooked"), ESearchCase::IgnoreCase); +} + +bool FVoxelStrateCorpus::LoadFromAssetRegistry(FString& OutReport) +{ + FAssetRegistryModule& AssetRegistryModule = + FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + + TArray AssetData; + AssetRegistryModule.Get().GetAssetsByClass( + UVoxelStrateDefinition::StaticClass()->GetClassPathName(), AssetData, true); + AssetData.Sort([](const FAssetData& A, const FAssetData& B) { - References.Add(Pair.Value); - } - References.Append(Settings->StratePool); - References.Sort([](const TSoftObjectPtr& A, - const TSoftObjectPtr& B) - { - return A.ToSoftObjectPath().ToString() < B.ToSoftObjectPath().ToString(); + return A.GetObjectPathString() < B.GetObjectPathString(); }); TArray Definitions; - TSet SeenPaths; - FString ResolutionNotes; - for (const TSoftObjectPtr& Reference : References) + TSet SeenObjectPaths; + int32 NumIgnoredSaved = 0; + int32 NumOutsideProject = 0; + int32 NumDuplicateRecords = 0; + int32 NumUnresolved = 0; + for (const FAssetData& Asset : AssetData) { - const FString Path = Reference.ToSoftObjectPath().ToString(); - if (Path.IsEmpty() || SeenPaths.Contains(Path)) + const FString PackagePath = Asset.PackageName.ToString(); + const FString ObjectPath = Asset.GetObjectPathString(); + if (!VF_IsProjectPackagePath(PackagePath)) { + ++NumOutsideProject; continue; } - SeenPaths.Add(Path); + if (VF_IsIgnoredSavedCopy(PackagePath) || VF_IsIgnoredSavedCopy(ObjectPath)) + { + ++NumIgnoredSaved; + continue; + } + if (SeenObjectPaths.Contains(ObjectPath)) + { + ++NumDuplicateRecords; + continue; + } + SeenObjectPaths.Add(ObjectPath); - TSoftObjectPtr LoadReference = Reference; - UVoxelStrateDefinition* Definition = LoadReference.LoadSynchronous(); + UVoxelStrateDefinition* Definition = Cast(Asset.GetAsset()); if (Definition == nullptr) { - ResolutionNotes += FString::Printf(TEXT("%s could not be resolved. "), *Path); + ++NumUnresolved; continue; } Definitions.Add(Definition); @@ -816,13 +1569,97 @@ bool FVoxelStrateCorpus::LoadFromSettings(const UVoxelSettings* Settings, FStrin 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); + TEXT("Asset Registry strate scan: %d records; %d project candidates; %d Saved/Autosaves " + "or Saved/Cooked copies ignored; %d outside project; %d duplicate records; %d unresolved. " + "%s"), + AssetData.Num(), SeenObjectPaths.Num(), NumIgnoredSaved, NumOutsideProject, + NumDuplicateRecords, NumUnresolved, *DefinitionReport); + return bLoaded && NumUnresolved == 0; +} + +bool FVoxelStrateCorpus::LoadFromSettings(const UVoxelSettings* Settings, FString& OutReport) +{ + // Compatibility/audit entry point. It intentionally does not load any soft reference: the + // Asset Registry is the corpus source, while the settings pool is reported as the reason the + // first Tier 4a run saw only one vector. + TArray SettingsPaths; + if (Settings != nullptr) + { + TSet UniquePaths; + for (const TPair>& Pair : Settings->FixedStrates) + { + const FString Path = Pair.Value.ToSoftObjectPath().ToString(); + if (!Path.IsEmpty()) + { + UniquePaths.Add(Path); + } + } + for (const TSoftObjectPtr& Reference : Settings->StratePool) + { + const FString Path = Reference.ToSoftObjectPath().ToString(); + if (!Path.IsEmpty()) + { + UniquePaths.Add(Path); + } + } + for (const FString& Path : UniquePaths) + { + SettingsPaths.Add(Path); + } + SettingsPaths.Sort(); + } + + FString RegistryReport; + const bool bLoaded = LoadFromAssetRegistry(RegistryReport); + OutReport = FString::Printf( + TEXT("Settings audit: %d unique fixed/pool soft paths"), SettingsPaths.Num()); + for (const FString& Path : SettingsPaths) + { + OutReport += FString::Printf(TEXT(" [%s]"), *Path); + } + if (Settings == nullptr) + { + OutReport += TEXT(" [settings object was null]"); + } + OutReport += TEXT("; settings references are diagnostic only. "); + OutReport += RegistryReport; return bLoaded; } +int32 FVoxelStrateCorpus::NumForArchetype(ECaveGeneratorType Archetype) const +{ + int32 Count = 0; + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.Archetype == Archetype) + { + ++Count; + } + } + return Count; +} + +const FVoxelStrateFieldSpread* FVoxelStrateCorpus::FindSpread( + ECaveGeneratorType Archetype, const FString& FieldName) const +{ + for (const FVoxelStrateFieldSpread& Spread : FieldSpreads) + { + if (Spread.Archetype == Archetype && Spread.FieldName == FieldName) + { + return &Spread; + } + } + return nullptr; +} + const FVoxelStrateFieldSpread* FVoxelStrateCorpus::FindSpread(const FString& FieldName) const { + // Compatibility lookup: the original API had one tunnel-family namespace. Prefer + // TunnelNetwork, then fall back to the first matching family for old diagnostics. + if (const FVoxelStrateFieldSpread* Spread = FindSpread(ECaveGeneratorType::TunnelNetwork, FieldName)) + { + return Spread; + } for (const FVoxelStrateFieldSpread& Spread : FieldSpreads) { if (Spread.FieldName == FieldName) @@ -842,7 +1679,7 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const const uint8* Bytes = static_cast(Data); for (SIZE_T ByteIndex = 0; ByteIndex < Size; ++ByteIndex) { - Hash ^= (uint32)Bytes[ByteIndex]; + Hash ^= static_cast(Bytes[ByteIndex]); Hash *= 16777619U; } }; @@ -850,7 +1687,7 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const { for (int32 CharIndex = 0; CharIndex < String.Len(); ++CharIndex) { - const uint32 Code = (uint32)String[CharIndex]; + const uint32 Code = static_cast(String[CharIndex]); HashBytes(&Code, sizeof(Code)); } const uint32 Terminator = 0U; @@ -861,17 +1698,54 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const { HashString(Entry.SourcePath); HashString(Entry.SourceName); - const uint8 Archetype = (uint8)Entry.Archetype; + const uint8 Archetype = static_cast(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 + const void* Memory = VF_GetParamMemory(Entry.ArchetypeParams, Entry.Archetype); + UStruct* Struct = VF_GetParamStruct(Entry.Archetype); + if (Memory == nullptr || Struct == nullptr) + { + continue; + } + + if (VF_IsTunnelArchetype(Entry.Archetype)) + { +#define VF_HASH_FSTRATE_FIELD(Name) \ + HashString(TEXT(#Name)); \ + HashBytes(&Entry.ArchetypeParams.TunnelNetworkParams.Name, \ + sizeof(Entry.ArchetypeParams.TunnelNetworkParams.Name)); + VF_STRATE_PARAM_FIELDS(VF_HASH_FSTRATE_FIELD, VF_HASH_FSTRATE_FIELD) +#undef VF_HASH_FSTRATE_FIELD + } + else + { + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + HashString(Pair.Key); + if (VF_IsScalarProperty(Pair.Value)) + { + const void* ValuePtr = Pair.Value->ContainerPtrToValuePtr(Memory); + HashBytes(ValuePtr, Pair.Value->GetSize()); + } + } + + static const TCHAR* RuntimeFields[] = + { + TEXT("StrateTopWorldZ"), TEXT("StrateBottomWorldZ") + }; + for (const TCHAR* RuntimeField : RuntimeFields) + { + double Value = 0.0; + if (VF_ReadNamedField(Memory, Entry.Archetype, RuntimeField, Value)) + { + HashString(RuntimeField); + HashBytes(&Value, sizeof(Value)); + } + } + } } return Hash; } @@ -905,6 +1779,56 @@ bool VF_AreStrateParamsBitIdentical(const FStrateGenerationParams& A, return true; } +bool VF_AreStrateArchetypeParamsBitIdentical( + const FVoxelStrateArchetypeParams& A, + const FVoxelStrateArchetypeParams& B, + ECaveGeneratorType Archetype) +{ + if (VF_IsTunnelArchetype(Archetype)) + { + return VF_AreStrateParamsBitIdentical(A.TunnelNetworkParams, B.TunnelNetworkParams); + } + + const void* AMemory = VF_GetParamMemory(A, Archetype); + const void* BMemory = VF_GetParamMemory(B, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (AMemory == nullptr || BMemory == nullptr || Struct == nullptr) + { + return false; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + const FProperty* Property = Pair.Value; + const void* AValue = Property->ContainerPtrToValuePtr(AMemory); + const void* BValue = Property->ContainerPtrToValuePtr(BMemory); + if (const FBoolProperty* BoolProperty = CastField(Property)) + { + if (BoolProperty->GetPropertyValue(AValue) != BoolProperty->GetPropertyValue(BValue)) + { + return false; + } + } + else if (FMemory::Memcmp(AValue, BValue, Property->GetSize()) != 0) + { + return false; + } + } + + double ATop = 0.0; + double BTop = 0.0; + double ABottom = 0.0; + double BBottom = 0.0; + return VF_ReadRuntimeField(AMemory, Archetype, TEXT("StrateTopWorldZ"), ATop) + && VF_ReadRuntimeField(BMemory, Archetype, TEXT("StrateTopWorldZ"), BTop) + && VF_ReadRuntimeField(AMemory, Archetype, TEXT("StrateBottomWorldZ"), ABottom) + && VF_ReadRuntimeField(BMemory, Archetype, TEXT("StrateBottomWorldZ"), BBottom) + && FMemory::Memcmp(&ATop, &BTop, sizeof(ATop)) == 0 + && FMemory::Memcmp(&ABottom, &BBottom, sizeof(ABottom)) == 0; +} + FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index) { @@ -915,7 +1839,8 @@ FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpu { const FVoxelStrateRollInfo Repeat = VF_RollInternal(Corpus, Seed, Index); checkf(Repeat.bValid && Repeat.Archetype == Result.Archetype - && VF_AreStrateParamsBitIdentical(Repeat.Params, Result.Params) + && VF_AreStrateArchetypeParamsBitIdentical( + Repeat.ArchetypeParams, Result.ArchetypeParams, Result.Archetype) && Repeat.ParentEntryIndices == Result.ParentEntryIndices && Repeat.ParentWeights == Result.ParentWeights, TEXT("VF_RollStrateParams lost determinism for the same corpus/seed/index.")); diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index 17fbf1d..2c486b8 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -28,9 +28,11 @@ struct VOXELFORGE_API FVoxelStrateFieldExclusion FString Reason; }; -/** Measured statistics for one field across the currently loaded corpus. */ +/** Measured statistics for one field in one archetype's corpus group. */ struct VOXELFORGE_API FVoxelStrateFieldSpread { + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FString ParamStructName; FString FieldName; EVoxelStrateFieldKind Kind = EVoxelStrateFieldKind::Continuous; bool bExcluded = false; @@ -50,12 +52,30 @@ struct VOXELFORGE_API FVoxelStrateFieldSpread double ClampMax = 0.0; }; +/** Native parameter storage for every currently supported strate archetype. */ +struct VOXELFORGE_API FVoxelStrateArchetypeParams +{ + FStrateGenerationParams TunnelNetworkParams; + FSlabGenerationParams SlabParams; + FMazeGenerationParams MazeParams; + FSurfaceGenerationParams SurfaceParams; + FVerticalShaftParams VerticalShaftParams; + FFloatingIslandParams FloatingIslandParams; +}; + /** One known-good authored vector and the archetype that gives it meaning. */ struct VOXELFORGE_API FVoxelStrateCorpusEntry { FString SourcePath; FString SourceName; ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + + // Native storage is selected by Archetype. Keeping every family here makes the corpus + // type-safe without changing any runtime generation parameter struct. + FVoxelStrateArchetypeParams ArchetypeParams; + + // Compatibility view for callers of the original Tier 4a API. It is meaningful for + // TunnelNetwork/Underwater entries and is the same value as ArchetypeParams.TunnelNetworkParams. FStrateGenerationParams Params; // The current asset format has no corpus weight field. Loaded hand-authored entries therefore @@ -71,6 +91,10 @@ struct VOXELFORGE_API FVoxelStrateRollInfo FString FailureReason; ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams ArchetypeParams; + + // Compatibility view for the original tunnel-only API. For a non-tunnel roll, the native + // result is in ArchetypeParams and this member is not the active candidate vector. FStrateGenerationParams Params; TArray ParentEntryIndices; @@ -81,17 +105,22 @@ struct VOXELFORGE_API FVoxelStrateRollInfo /** * 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. + * `LoadFromAssetRegistry` enumerates every project UVoxelStrateDefinition through the Asset + * Registry, explicitly excluding Saved/Autosaves and Saved/Cooked copies. It also adds one + * known-good default vector for every archetype/family in VoxelStrateTypes.h. Parents are selected + * only inside the exact archetype group; no cross-archetype blend is attempted. */ class VOXELFORGE_API FVoxelStrateCorpus { public: void Reset(); + bool LoadFromAssetRegistry(FString& OutReport); + + /** + * Compatibility alias retained for callers compiled against the first Tier 4a pass. The + * settings argument is audited only for diagnostics; it is not a corpus input. + */ bool LoadFromSettings(const UVoxelSettings* Settings, FString& OutReport); bool LoadFromDefinitions(const TArray& Definitions, FString& OutReport); @@ -99,6 +128,9 @@ public: bool AddEntry(const FString& SourcePath, const FString& SourceName, ECaveGeneratorType Archetype, const FStrateGenerationParams& Params, float Weight = 1.0f); + bool AddEntry(const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params, + float Weight = 1.0f); bool IsValid() const { return bSchemaValid && Entries.Num() > 0; } bool IsSchemaValid() const { return bSchemaValid; } @@ -109,6 +141,10 @@ public: const TArray& GetFieldSpreads() const { return FieldSpreads; } const TArray& GetSkippedDefinitions() const { return SkippedDefinitions; } + int32 NumForArchetype(ECaveGeneratorType Archetype) const; + + const FVoxelStrateFieldSpread* FindSpread( + ECaveGeneratorType Archetype, const FString& FieldName) const; const FVoxelStrateFieldSpread* FindSpread(const FString& FieldName) const; /** Stable hash of source paths, archetypes, weights, and every listed scalar field. */ @@ -149,3 +185,8 @@ VOXELFORGE_API FStrateGenerationParams VF_RollStrateParams( VOXELFORGE_API bool VF_AreStrateParamsBitIdentical( const FStrateGenerationParams& A, const FStrateGenerationParams& B); +/** Bitwise comparison of the native vector for one archetype family. */ +VOXELFORGE_API bool VF_AreStrateArchetypeParamsBitIdentical( + const FVoxelStrateArchetypeParams& A, + const FVoxelStrateArchetypeParams& B, + ECaveGeneratorType Archetype);