diff --git a/CODEMAP.md b/CODEMAP.md index 2a5b689..931f337 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -445,7 +445,7 @@ 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`** (Tier 4a/4b) — offline +**`Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp`** (Tier 4a/4b + Tier 5) — offline `FVoxelStrateCorpus`, `VF_RollStrateParams`, and `VF_RollStrateStructure`. `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. @@ -477,6 +477,18 @@ it. `RequiredResources` / `ProvidedResources` close the room-state hole that cha could not express. `VoxelForgeComposerStructureRollTest` rolls 64 candidates, measures them through the offline sampler, and brute-forces every uniform box verdict from the novel stack itself. +Tier 5 adds `FVoxelStratePromotableRecord`, `FVoxelStratePromotionPolicy`, and +`FVoxelStratePromotionBatchResult`. `VF_SaveStratePromotedRecords` writes a sorted schema-2 JSON +corpus store containing the complete six-family parameter vector, recipe, archetype, gate evidence, +metrics, season/seed, and input corpus hash; `VF_SaveStrateSeasonManifest` writes one diffable JSON +summary beside it. `FVoxelStrateCorpus::LoadPromotedRecords` re-verifies every record through the +world-specific `IVoxelStratePromotionVerifier` before admission, remeasures project/default members +when that verifier supplies the optional audit, and tags membership `project` / `default` / +`promoted`. `VF_SelectStratePromotions` uses normalized measured-metric distance (threshold 0.20), +the existing corpus contents hash, deterministic RecordId ordering, and a six-record season cap. +The promotion test simulates five seasons and deliberately corrupts one stored metric to prove the +loader replaces it with a fresh measurement. + `EVoxelStrateCorpusFreeSamplingMode`, `VF_RollStrateParamsCorpusFree`, and `VF_ValidateStrateCorpusFreeConstraints` are the offline corpus-free control/experiment. They never read a corpus entry or strate asset: `NaiveUniform` rolls every live scalar independently inside a @@ -506,7 +518,8 @@ pass, emits deterministic centre-Y XZ and data-selected XY slices via `IImageWra and scalar density=0 contour views side by side without blurring the sampled raster. Each image is capped at 512 pixels (including its scale footer). `FVoxelStrateFinePreviewSettings` is a separate caller-selected fine pass: the composer selects only survivors at step 1, radius 64 (a 128×128 XY ROI), and `MaxCells=2,000,000`; -the writer labels every image with its exact ROI and step and records cap refusals in the page. The 64-card +the caller centres it on each coarse survivor's `LargestComponentPoint` and the writer labels it +“largest open space” with its exact ROI and step; cap refusals are recorded in the page. The 64-card sheet remains sorted by survivor status then descending corpus-centroid distance, repeats exact measurement windows, marks rejection reasons, explains the 2D-connectivity limitation, and has no runtime generation hook. @@ -575,7 +588,8 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `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` | 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. Bootstrap follow-up: **1,422 applications / 168 distinct fields**; **38/64 survival**, feature scale **0→376**, walkable **0.000000→0.286766** (no broadening beyond the prior run); **1,356 Mixed + 608 AllSolid + 596 AllAir = 1,204 proved boxes**, **1,602,524 lattice voxels checked, 0 violations**, 146.250 s total in the final full-namespace run. | | ″ | `VoxelForge.Composer.TerrainDetailLiveness` | Fixed 4,096-point `GetDensityAt` lattice, legacy and operator-stack paths; changes one terrain-detail group at a time with an empty terrain-op pool. Proves 9 live groups / 23 fields and 3 dead groups / 11 fields; all 24 rows match. | -| `VoxelForgeComposerStructureRollTest.cpp` | `VoxelForge.Composer.StructureRoll` | Rolls root polarity → legal shape source → polarity-derived conversion → 4–8 declaration-legal modifiers → mandatory structural posts; blends the six native parameter families independently, measures 64 novel stacks, captures the same grid for the deterministic filled/contour XZ/XY preview, runs a separate step-1 radius-64 ROI pass for the 42 survivors, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. Focused run: **42/64 survival (65.6%)**, **64 distinct recipes**, **6,848 bootstrap applications / 107 distinct fields**, **659 proved boxes / 877,129 voxels / 0 violations**, **64 coarse filled + 64 coarse contour pairs**, **42 fine ROI filled + 42 fine contour pairs**, **184.659 s**, **0 refusals**. | +| `VoxelForgeComposerPromotionTest.cpp` | `VoxelForge.Composer.Promotion` | Re-measures the 12 project/default members, simulates five deterministic 24-candidate seasons with normalized measured-metric novelty (`<0.20`), cap 6, cumulative JSON promotion, provenance counts, corpus-hash checks, fresh-load gate verification, spread/survival reporting, and deliberate stale-metric corruption. Final run: **9 promoted**, corpus **4/8/9**, survival **20.8/54.2/54.2/45.8/50.0%**, spread **0.869214→0.916302**, **63.556 s**, 0 failures. | +| `VoxelForgeComposerStructureRollTest.cpp` | `VoxelForge.Composer.StructureRoll` | Rolls root polarity → legal shape source → polarity-derived conversion → 4–8 declaration-legal modifiers → mandatory structural posts; blends the six native parameter families independently, measures 64 novel stacks, captures the same grid for the deterministic filled/contour XZ/XY preview, runs a separate step-1 radius-64 ROI pass for the 42 survivors centred on `LargestComponentPoint`, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. Final run: **42/64 survival (65.6%)**, **64 distinct recipes**, **0 invalid recipes**, **64 coarse filled + 64 coarse contour pairs**, **42 fine filled + 42 fine contour pairs**, blank plan/card **6/42→3/42**, **243.649 s**, **0 refusals**. | | `VoxelForgeComposerCorpusFreeTest.cpp` | `VoxelForge.Composer.CorpusFree` | Shares each structure recipe across today's corpus blend, naive independent uniform rolls, and constraint-sampled rolls. The completed equal-arm run uses **16 candidates per arm** (256×3 and 64×3 were stopped before aggregate output for runtime), step 4 / radius 256 / `MaxCells=8,000,000`, fixed passage-law mouths, and 40 box probes per candidate. Result: **13/16, 7/16, 7/16** survival; survivor walkable means **0.066071, 0.012566, 0.012851** and feature-scale means **92.307693, 64.571426, 31.428572**. Box checks: **196/260,876**, **310/412,610**, **262/348,722** proved/voxels, **0 violations** in every arm. | | `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. | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index d9dcb45..c69d799 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -253,6 +253,50 @@ 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 5 status — promotion is built and measured (2026-09-05) + +Promotion is now a real corpus input, not a claim about a future approval UI. A surviving editor +candidate becomes a `FVoxelStratePromotableRecord`: complete native parameter vector (all six family +blocks), recipe, archetype tag, measured metric summary, season/seed/candidate identity, input corpus +hash, and the three gate results. `VF_SaveStratePromotedRecords` writes a sorted, pretty JSON store +(`schema_version=2`) beside one JSON season manifest per run. `FVoxelStrateCorpus` loads those records +alongside project assets and C++ defaults, tags each member `project`, `default`, or `promoted`, and +prints the provenance counts in the membership report. The stored metric is evidence for review only: +the world-specific verifier rebuilds the recipe and remeasures it on every load, and a failed fresh +non-vacuous, largest-component, or primordial arrival→departure gate cannot enter the corpus. + +The provisional policy is deliberately explicit. It accepts only records that pass all three gates, +requires a normalized measured-metric distance of **at least 0.20** from every usable existing member +and already accepted record, and caps a season at **6** promotions. Distance is Euclidean over +`(air fraction, largest-component share, walkable fraction, median feature scale / 256 voxels, +median vertical clearance / 64 voxels)`. The 0.20 threshold is a review-sized novelty floor: it is +roughly a 20-point move in one bounded fraction, or a smaller combined move across dimensions, while +the physical normalizers make feature/clearance changes comparable. It is provisional, not the +owner's definition of good. One authored asset was measured as a valid but all-air window in this +fixture (`DA_Strate2`), so it is retained as measured-but-vacuous and uses conservative exact-parameter +identity fallback rather than a false metric distance; the other **11/12** base members participate +in the measured novelty space. + +The five-season simulation used 24 structure candidates per season, the fixed sample window +(`step=8`, `radius=256`, `WorldRadiusVoxels=0`), and the same fresh verifier on every reload: + +| Season | Input corpus hash | Corpus after | project/default/promoted | survivors / 24 | survival | measured spread | promoted | rejected near-dup / hash / cap | +|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 1 | `9dbcea85` | 15 | 4 / 8 / 3 | 5 | 20.8% | 0.869214 | 3 | 2 / 0 / 0 | +| 2 | `8b624d80` | 20 | 4 / 8 / 8 | 13 | 54.2% | 0.849043 | 5 | 8 / 0 / 0 | +| 3 | `cfbd74ec` | 20 | 4 / 8 / 8 | 13 | 54.2% | 0.849043 | 0 | 13 / 0 / 0 | +| 4 | `cfbd74ec` | 21 | 4 / 8 / 9 | 11 | 45.8% | 0.916302 | 1 | 10 / 0 / 0 | +| 5 | `c108a276` | 21 | 4 / 8 / 9 | 12 | 50.0% | 0.916302 | 0 | 12 / 0 / 0 | + +The curve does **not** show a monotonic survival improvement: `20.8%, 54.2%, 54.2%, 45.8%, +50.0%`. Measured spread also dips and plateaus, but ends wider: **0.869214 → 0.916302 +(+0.047089)**. This five-season run therefore found no collapse toward a mean, but it is not evidence +that collapse cannot happen at a different seed or policy. The current recommendation is to keep the +novelty floor and add a small, explicitly reviewable outlier quota in a later policy revision—promote +some distant survivors even when their quality score is merely acceptable—rather than tune this run +to look smoother. The owner still needs the preview before defining “good”; promotion is the dilution +mechanism, not that missing judgment. + ### ✅ Tier 4a status — parameter roll built (2026-09-04) `Public/VoxelStrateComposer.h` + `Private/VoxelStrateComposer.cpp` now implement the corpus and @@ -654,6 +698,13 @@ cap, or the writer can be called for an explicitly selected list. Fine filenames card's text include the sample step and exact resolved XY/Z window, so they cannot be mistaken for the coarse render. A refused fine render is shown as a refusal with its reason rather than silently omitted. +The fine ROI is now centred on each survivor's deterministic `LargestComponentPoint` from the coarse +air flood fill and is labelled **“largest open space”** on the page. It is not the middle of the +strate and not the world origin. In the final 42-survivor run, the origin-centred baseline had **6/42** +uniform fine plan projections (and 6/42 complete two-view cards); the largest-open-space pass had +**3/42** in both counts. The reduction is real but not total: a largest open space can itself be +larger than a radius-64 ROI, so a uniform card is not proof that the strate is empty. + The vertical image is an XZ section through the sampled cell nearest the strate-window centre Y. The plan image scans every sampled Z layer, keeps layers containing both air and solid, and chooses the one with the largest number of XY solid/air boundary transitions; ties prefer more mixed cells, more @@ -667,11 +718,11 @@ orange walkable cells, a turquoise scalar contour, and a one-chunk footer scale resampling. It also carries the required caveat: a 2D apparent join is not a 3D connectivity proof; the separate arrival→departure verdict is the connectivity check. -The focused run measured **0.456 s** for the coarse render, **70.271 s** for the 42 fine scalar samples, -and **0.573 s** for their filled/contour rendering. Peak fine `Air` + `Density` capture allocation was -**9,338,880 bytes** and the peak single RGBA raster was **75,776 bytes**. The full StructureRoll test -completed in **184.659 s**, with 64 coarse filled pairs, 64 coarse contour pairs, 42 fine filled pairs, -42 fine contour pairs, and 0 refusals. +The final run measured **0.300 s** for the coarse render, **133.272 s** for the origin-plus-centred +fine scalar samples, and **1.113 s** for their filled/contour rendering. Peak fine `Air` + `Density` +capture allocation was **9,338,880 bytes** and the peak single RGBA raster was **75,776 bytes**. The +full StructureRoll test completed in **243.649 s**, with 64 coarse filled pairs, 64 coarse contour +pairs, 42 fine filled pairs, 42 fine contour pairs, and 0 refusals. ### 3.5 Validation in three layers, and how "good" is ever judged @@ -696,7 +747,10 @@ But *good* never has to be defined, only **collected**: ⚠️ **Known risk: convergence to a comfortable middle.** Promote only what you already liked, keep blending near the corpus, and the system slowly stops surprising you. Same failure mode as the build-vote pool, same counter: keep extrapolating past the corpus, and keep some unrated weird -candidates in circulation. +candidates in circulation. The first five-season promotion measurement did **not** collapse: mean +normalized spread ended 0.047089 wider than it started, but survival was not monotonic. That is one +seed and one provisional policy, not a dispensation from the risk; the next policy should reserve a +reviewable outlier quota instead of assuming survivor promotion preserves surprise. --- @@ -981,13 +1035,21 @@ roughness and should stay late in the stack. - ✅ **Structure roll (§3.2, Tier 4b)** — serialisable recipes, root polarity, declaration-derived resource-safe modifier legality, mandatory posts, 64-candidate measurement, and direct box-verdict brute force are built; the bootstrap follow-up is 42/64 survival with 0 invalid recipes and 0 - violations, plus 64 rendered preview pairs and an index.html contact sheet. + violations, plus 64 rendered preview pairs and an index.html contact sheet. The fine survivor ROI + is centred on `LargestComponentPoint` and labelled largest open space; the final before/after + blank-plan count is 6/42 → 3/42. - ⬜ 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 · -eventually the model. All optional, all compounding. +- ✅ **Promotion** (good strates rejoin the corpus) — JSON promotable records and season manifests, + project/default/promoted provenance, fresh-load re-verification of the three gates, measured + near-duplicate rejection, per-season cap, and a deterministic five-season simulation. The run ended + at 21 corpus members (4 project, 8 default, 9 promoted); spread widened overall but survival was + non-monotonic. The owner still needs to define “good” from the preview; promotion does not invent + that judgment. +- ⬜ Theme and tag draws for materials, creatures, and audio · eventually the model. All optional, + all compounding. --- @@ -1025,7 +1087,10 @@ Game-design questions live in **[GDD.md](GDD.md) §16**. These are the ones that 3. **How theme coherence is drawn** — operators, materials, decorations, creatures and audio pulled as a compatible set. Jahni expects his first big job to be creating themes and tagging content with multiple fitting tags. -4. **Where the corpus lives** and how promotion is recorded between seasons. +4. ✅ **Where the corpus lives** and how promotion is recorded between seasons: the cumulative store is + `Saved/ComposerPromotion/seed_0_step_8_radius_256/promoted_strates.json` in the simulation, with + `season_01_manifest.json` … `season_05_manifest.json` alongside it; the default runtime/editor + location is `Saved/VoxelForge/StrateCorpus/promoted_strates.json`. 5. **Whether op discovery** (promoting good sub-stacks into named reusable units) is ever worth it. Parked: the existing parameter space is already vastly larger than a hundred seasons could explore. **The scarce resource is judgment, not vocabulary.** @@ -1071,6 +1136,17 @@ them is *wrong* — a false verdict leaves no geometry and no collision behind i --- +**2026-09-05 — Tier 5 + fine-ROI validation.** The specified UE 5.7 `VoxelMEditor Win64 +Development` build succeeded. The full headless `VoxelForge` namespace reported **27 succeeded, +0 failed, 0 not run, 0 succeeded-with-warnings** in **613.277 s**. The focused promotion test was +green in **63.556 s** and the focused StructureRoll test was green in **243.649 s**. Promotion ended +with 9 cumulative records, fresh-load stale-metric replacement, `WorldRadiusVoxels=0`, and the table +in §3.3. The StructureRoll preview measured fine blank plan/card counts **6/42 → 3/42** and rendered +42 fine filled pairs plus 42 contour pairs with 0 refusals. No existing generation/equivalence test +failed. + +--- + ## 11. Tier 1 status — passages aim at real open space (bounded full-point query; build not run) **Updated 2026-08-29.** `VF_SuggestLandingPoint` (in `VoxelCaveMorphology`) is a **pure free function** diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp index f3efc8d..ffc1c17 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp @@ -620,30 +620,26 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) VF_GetProjectAssetPaths(UVoxelStrateDefinition::StaticClass()); const TArray ProjectTerrainOpAssets = VF_GetProjectAssetPaths(UVoxelTerrainOpDefinition::StaticClass()); - int32 NumProjectCorpusEntries = 0; - int32 NumDefaultCorpusEntries = 0; + const int32 NumProjectCorpusEntries = Corpus.NumForProvenance( + EVoxelStrateCorpusProvenance::Project); + const int32 NumDefaultCorpusEntries = Corpus.NumForProvenance( + EVoxelStrateCorpusProvenance::Default); + const int32 NumPromotedCorpusEntries = Corpus.NumForProvenance( + EVoxelStrateCorpusProvenance::Promoted); 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_GetStrateCorpusProvenanceName(Entry.Provenance), 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 " + "%d defaults, %d promoted, %d total members. Terrain-op Asset Registry count (not rolled in this " "task; live fields use header defaults instead): %d."), - ProjectStrateAssets.Num(), NumProjectCorpusEntries, NumDefaultCorpusEntries, Corpus.Num(), - ProjectTerrainOpAssets.Num())); + ProjectStrateAssets.Num(), NumProjectCorpusEntries, NumDefaultCorpusEntries, + NumPromotedCorpusEntries, Corpus.Num(), ProjectTerrainOpAssets.Num())); AddInfo(CorpusMembership); for (const ECaveGeneratorType Archetype : { ECaveGeneratorType::TunnelNetwork, @@ -712,11 +708,11 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) FString SpreadTable = TEXT( "CORPUS SPREAD (population stddev; spread jitter = ±15% of max-min; near-zero spread " "uses ±25% of field magnitude with a one-unit floor; clamps are safety only)\n" - "archetype | struct | field | kind | excluded | reflected | authored | op-defaults | samples | min | max | mean | stddev | clamp\n"); + "archetype | struct | field | kind | excluded | reflected | authored | promoted | op-defaults | samples | min | max | mean | stddev | clamp\n"); for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads()) { SpreadTable += FString::Printf( - TEXT("%s | %s | %s | %s | %s | %s | %d | %d | %d | %s | %s | %s | %s | %s\n"), + TEXT("%s | %s | %s | %s | %s | %s | %d | %d | %d | %d | %s | %s | %s | %s | %s\n"), VF_GetStrateArchetypeName(Spread.Archetype), *Spread.ParamStructName, *Spread.FieldName, @@ -724,6 +720,7 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) Spread.bExcluded ? TEXT("yes") : TEXT("no"), Spread.bReflected ? TEXT("yes") : TEXT("no"), Spread.AuthoredSampleCount, + Spread.PromotedSampleCount, Spread.DefaultSeedCount, Spread.SampleCount, *VF_FormatSpreadValue(Spread.Min), diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp new file mode 100644 index 0000000..0144da4 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerPromotionTest.cpp @@ -0,0 +1,714 @@ +// Tier 5 — promotion, corpus persistence, re-verification, and compounding simulation. + +#if WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR + +#include "Misc/AutomationTest.h" +#include "HAL/PlatformTime.h" +#include "Math/RandomStream.h" +#include "Misc/Paths.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelDensityOpStack.h" +#include "VoxelSettings.h" +#include "VoxelStrateComposer.h" +#include "VoxelStrateMeasure.h" + +namespace +{ + constexpr int32 NumSimulatedSeasons = 5; + constexpr int32 CandidatesPerSeason = 24; + constexpr int32 PromotionCapPerSeason = 6; + constexpr float LargestComponentSurvivalThreshold = 0.50f; + constexpr int32 SimulationSampleStep = 8; + constexpr int32 SimulationRadius = 256; + + const TCHAR* 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"); + } + + void VF_SetAllRuntimeBounds(FVoxelStrateArchetypeParams& Params, + float TopWorldZ, float BottomWorldZ) + { + Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ; + Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ; + Params.SlabParams.StrateTopWorldZ = TopWorldZ; + Params.SlabParams.StrateBottomWorldZ = BottomWorldZ; + Params.MazeParams.StrateTopWorldZ = TopWorldZ; + Params.MazeParams.StrateBottomWorldZ = BottomWorldZ; + Params.SurfaceParams.StrateTopWorldZ = TopWorldZ; + Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ; + Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ; + Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ; + Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ; + Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ; + } + + float VF_GetBoundarySeal(const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Params.TunnelNetworkParams.BoundarySealThickness; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Params.SlabParams.BoundarySealThickness; + case ECaveGeneratorType::Maze: + return Params.MazeParams.BoundarySealThickness; + case ECaveGeneratorType::SurfaceWorld: + return Params.SurfaceParams.BoundarySealThickness; + case ECaveGeneratorType::VerticalShafts: + return Params.VerticalShaftParams.BoundarySealThickness; + case ECaveGeneratorType::FloatingIslands: + return Params.FloatingIslandParams.BoundarySealThickness; + default: + return 0.0f; + } + } + + ECaveGeneratorType VF_ArchetypeForRecipe(const FVoxelOpStackRecipe& Recipe) + { + switch (Recipe.StructuralParamBlock) + { + case EVoxelStrateParamBlock::TunnelNetwork: return ECaveGeneratorType::TunnelNetwork; + case EVoxelStrateParamBlock::Slab: return ECaveGeneratorType::FlatPlain; + case EVoxelStrateParamBlock::Maze: return ECaveGeneratorType::Maze; + case EVoxelStrateParamBlock::Surface: return ECaveGeneratorType::SurfaceWorld; + case EVoxelStrateParamBlock::VerticalShaft: return ECaveGeneratorType::VerticalShafts; + case EVoxelStrateParamBlock::FloatingIsland: return ECaveGeneratorType::FloatingIslands; + default: return ECaveGeneratorType::TunnelNetwork; + } + } + + class FStackDensitySampler final : public IVoxelStrateDensitySampler + { + public: + explicit FStackDensitySampler(const FVoxelOpStack& InStack) : Stack(InStack) {} + + float SampleDensity(float WorldX, float WorldY, float WorldZ) const override + { + return Stack.EvalMC(WorldX, WorldY, WorldZ); + } + + private: + const FVoxelOpStack& Stack; + }; + + class FPromotionVerifier final : public IVoxelStratePromotionVerifier + { + public: + FPromotionVerifier(const VoxelForgeTest::FTestWorld& InWorld, + int32 InTopVoxelZ, + int32 InBottomVoxelZ, + const FVector& InArrivalPoint, + const FVector& InDeparturePoint, + const FVoxelStrateMeasureSettings& InMeasureSettings, + float InLargestThreshold) + : World(InWorld) + , TopVoxelZ(InTopVoxelZ) + , BottomVoxelZ(InBottomVoxelZ) + , ArrivalPoint(InArrivalPoint) + , DeparturePoint(InDeparturePoint) + , MeasureSettings(InMeasureSettings) + , LargestThreshold(InLargestThreshold) + { + } + + bool Verify(const FVoxelStratePromotableRecord& Record, + FVoxelStratePromotionVerification& OutVerification) const override + { + ++NumVerifications; + OutVerification = FVoxelStratePromotionVerification(); + + if (Record.Archetype != VF_ArchetypeForRecipe(Record.Recipe)) + { + OutVerification.FailureReason = TEXT("record archetype does not match recipe post block"); + return false; + } + + FVoxelStrateArchetypeParams Params = Record.Params; + VF_SetAllRuntimeBounds(Params, static_cast(TopVoxelZ) + 1.0f, + static_cast(BottomVoxelZ)); + + FVoxelOpStack Stack; + FVoxelOpContext Context; + FString BuildError; + if (!VF_BuildStackFromRecipe( + Record.Recipe, Params, Record.Seed, 14.0f, + World.StrateManager.Get(), Stack, Context, &BuildError)) + { + OutVerification.FailureReason = FString::Printf( + TEXT("recipe rebuild failed: %s"), *BuildError); + return false; + } + if (Context.WorldRadiusVoxels != 0.0f) + { + OutVerification.FailureReason = TEXT("re-verified recipe changed WorldRadiusVoxels"); + return false; + } + + Stack.PrepareChunk(Context); + FStackDensitySampler Sampler(Stack); + const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness, + MeasureSettings, nullptr); + OutVerification.MeasuredMetrics = VF_SummarizeStrateMetrics(Metrics); + OutVerification.bPassedNonVacuous = Metrics.bValid + && Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0; + OutVerification.bPassedLargestComponent = OutVerification.bPassedNonVacuous + && Metrics.LargestComponentShare >= LargestThreshold; + + const FVoxelConnectivityDiagnostics Law = VF_DiagnoseConnectivityWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness, + ArrivalPoint, DeparturePoint, MeasureSettings); + OutVerification.bPassedPrimordialLaw = Law.bValid + && Law.Result == EVoxelConnectivityResult::Connected + && !Law.bStartSnapped && !Law.bGoalSnapped; + + if (!OutVerification.PassedAllGates()) + { + OutVerification.FailureReason = FString::Printf( + TEXT("fresh gates failed: non-vacuous=%s largest=%s law=%s"), + OutVerification.bPassedNonVacuous ? TEXT("yes") : TEXT("no"), + OutVerification.bPassedLargestComponent ? TEXT("yes") : TEXT("no"), + VF_ConnectivityName(Law.Result)); + } + return true; + } + + bool MeasureCorpusEntry(const FVoxelStrateCorpusEntry& Entry, + FVoxelStrateMeasuredMetrics& OutMetrics) const override; + + mutable int32 NumVerifications = 0; + + private: + const VoxelForgeTest::FTestWorld& World; + int32 TopVoxelZ = 0; + int32 BottomVoxelZ = 0; + FVector ArrivalPoint = FVector::ZeroVector; + FVector DeparturePoint = FVector::ZeroVector; + FVoxelStrateMeasureSettings MeasureSettings; + float LargestThreshold = 0.50f; + }; + + bool VF_MeasureNativeCorpusEntry( + const VoxelForgeTest::FTestWorld& World, + const FVoxelStrateCorpusEntry& Entry, + int32 TopVoxelZ, + int32 BottomVoxelZ, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateMeasuredMetrics& OutMetrics) + { + FVoxelStrateArchetypeParams Params = Entry.ArchetypeParams; + VF_SetAllRuntimeBounds(Params, static_cast(TopVoxelZ) + 1.0f, + static_cast(BottomVoxelZ)); + + FVoxelOpStack Stack; + FVoxelOpContext Context; + if (!VF_BuildNativeStrateStackForCandidate( + Entry.Archetype, Params, 1337, 14.0f, 0.0f, + VF_GetBoundarySeal(Params, Entry.Archetype), + World.StrateManager.Get(), Stack, Context) + || Context.WorldRadiusVoxels != 0.0f) + { + return false; + } + + Stack.PrepareChunk(Context); + FStackDensitySampler Sampler(Stack); + const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness, + Settings, nullptr); + OutMetrics = VF_SummarizeStrateMetrics(Metrics); + return Metrics.bValid; + } + + bool FPromotionVerifier::MeasureCorpusEntry( + const FVoxelStrateCorpusEntry& Entry, + FVoxelStrateMeasuredMetrics& OutMetrics) const + { + return VF_MeasureNativeCorpusEntry( + World, Entry, TopVoxelZ, BottomVoxelZ, MeasureSettings, OutMetrics); + } + + FVoxelStratePromotableRecord VF_EvaluateCandidate( + const VoxelForgeTest::FTestWorld& World, + const FVoxelStrateComposerCandidate& Candidate, + int32 Season, + uint32 CorpusHash, + int32 TopVoxelZ, + int32 BottomVoxelZ, + const FVector& ArrivalPoint, + const FVector& DeparturePoint, + const FVoxelStrateMeasureSettings& Settings, + float LargestThreshold, + bool& bOutSurvivor) + { + bOutSurvivor = false; + FVoxelStratePromotableRecord Record = VF_MakeStratePromotableRecord( + Candidate, FVoxelStrateMetrics(), Season, CorpusHash, false, false, false); + if (!Candidate.bValid) + { + return Record; + } + + FVoxelStrateArchetypeParams Params = Candidate.ArchetypeParams; + VF_SetAllRuntimeBounds(Params, static_cast(TopVoxelZ) + 1.0f, + static_cast(BottomVoxelZ)); + Record.Params = Params; + + FVoxelOpStack Stack; + FVoxelOpContext Context; + FString BuildError; + if (!VF_BuildStackFromRecipe( + Candidate.Recipe, Params, Candidate.Seed, 14.0f, + World.StrateManager.Get(), Stack, Context, &BuildError) + || Context.WorldRadiusVoxels != 0.0f) + { + return Record; + } + Stack.PrepareChunk(Context); + FStackDensitySampler Sampler(Stack); + const FVoxelStrateMetrics Metrics = VF_MeasureStrateWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness, + Settings, nullptr); + const bool bNonVacuous = Metrics.bValid + && Metrics.NumSampled > 0 && Metrics.NumAir > 0 && Metrics.NumSolid > 0; + const bool bLargestEnough = bNonVacuous + && Metrics.LargestComponentShare >= LargestThreshold; + const FVoxelConnectivityDiagnostics Law = VF_DiagnoseConnectivityWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, Context.EdgeSealThickness, + ArrivalPoint, DeparturePoint, Settings); + const bool bLawPass = Law.bValid + && Law.Result == EVoxelConnectivityResult::Connected + && !Law.bStartSnapped && !Law.bGoalSnapped; + + Record = VF_MakeStratePromotableRecord( + Candidate, Metrics, Season, CorpusHash, + bNonVacuous, bLargestEnough, bLawPass); + Record.Params = Params; + bOutSurvivor = bNonVacuous && bLargestEnough && bLawPass; + return Record; + } + + double VF_MeanMeasuredSpread(const FVoxelStrateCorpus& Corpus, + const TArray& NewRecords, + const FVoxelStratePromotionPolicy& Policy) + { + TArray Metrics; + for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries()) + { + if (Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.IsUsable()) + { + Metrics.Add(Entry.MeasuredMetrics); + } + } + for (const FVoxelStratePromotableRecord& Record : NewRecords) + { + if (Record.MeasuredMetrics.IsUsable()) + { + Metrics.Add(Record.MeasuredMetrics); + } + } + if (Metrics.Num() < 2) + { + return 0.0; + } + + double Sum = 0.0; + int64 PairCount = 0; + for (int32 A = 0; A < Metrics.Num(); ++A) + { + for (int32 B = A + 1; B < Metrics.Num(); ++B) + { + const double Distance = VF_NormalizedMeasuredMetricDistance( + Metrics[A], Metrics[B], Policy); + if (FMath::IsFinite(Distance) && Distance < TNumericLimits::Max()) + { + Sum += Distance; + ++PairCount; + } + } + } + return PairCount > 0 ? Sum / static_cast(PairCount) : 0.0; + } + + bool VF_PromotionBatchesIdentical( + const FVoxelStratePromotionBatchResult& A, + const FVoxelStratePromotionBatchResult& B) + { + if (A.Promoted.Num() != B.Promoted.Num()) + { + return false; + } + for (int32 Index = 0; Index < A.Promoted.Num(); ++Index) + { + const FVoxelStratePromotableRecord& Left = A.Promoted[Index]; + const FVoxelStratePromotableRecord& Right = B.Promoted[Index]; + if (Left.RecordId != Right.RecordId + || Left.Archetype != Right.Archetype + || !VF_AreStrateStructureRecipesIdentical(Left.Recipe, Right.Recipe) + || !VF_AreStrateArchetypeParamsBitIdentical( + Left.Params, Right.Params, Left.Archetype) + || Left.MeasuredMetrics.AirFraction != Right.MeasuredMetrics.AirFraction + || Left.MeasuredMetrics.LargestComponentShare + != Right.MeasuredMetrics.LargestComponentShare) + { + return false; + } + } + return true; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeComposerPromotionTest, + "VoxelForge.Composer.Promotion", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeComposerPromotionTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + (void)Parameters; + const double TestStartSeconds = FPlatformTime::Seconds(); + + UVoxelSettings* AuthoredSettings = LoadObject( + nullptr, TEXT("/Game/VoxelForge/DA_Settings.DA_Settings")); + if (AuthoredSettings == nullptr) + { + AddError(TEXT("Could not load authored settings; promotion simulation did not run.")); + return false; + } + + FTestWorld World; + World.Build(1337, 2, true); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + TestTrue(TEXT("promotion fixture keeps WorldRadiusVoxels at 0"), + World.Settings->WorldRadiusVoxels == 0.0f); + + constexpr int32 CandidateStrateIndex = FTestWorld::SlotFlatPlain; + int32 TopVoxelZ = 0; + int32 BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange(CandidateStrateIndex, TopVoxelZ, BottomVoxelZ)) + { + AddError(TEXT("promotion fixture has no candidate Z range")); + return false; + } + + 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; + } + } + TestEqual(TEXT("promotion fixture has exactly one arrival mouth"), ArrivalCount, 1); + TestEqual(TEXT("promotion fixture has exactly one departure mouth"), DepartureCount, 1); + if (ArrivalCount != 1 || DepartureCount != 1) + { + return false; + } + + FVoxelStrateMeasureSettings MeasureSettings; + MeasureSettings.SampleStep = SimulationSampleStep; + MeasureSettings.RadiusInVoxels = SimulationRadius; + MeasureSettings.CenterXY = FVector2D::ZeroVector; + MeasureSettings.MaxCells = 8000000; + MeasureSettings.MaxRouteRetries = 16; + MeasureSettings.HeadroomCells = 2; + // Use one fixed window for both authored/default members and candidates. Deriving a margin + // from each member's boundary seal can make a small authored strate have no measurable + // interior, which would make the duplicate metric space member-dependent. + MeasureSettings.InteriorMarginVoxels = 0; + + FPromotionVerifier Verifier( + World, TopVoxelZ, BottomVoxelZ, ArrivalPoint, DeparturePoint, + MeasureSettings, LargestComponentSurvivalThreshold); + + // The fixed path is keyed by the authored corpus hash and simulation constants. Starting with + // an empty JSON object makes reruns independent of an older test artifact without deleting it. + const FString SimulationDirectory = FPaths::ProjectSavedDir() / TEXT("ComposerPromotion") + / FString::Printf(TEXT("seed_%d_step_%d_radius_%d"), + AuthoredSettings->Seed, SimulationSampleStep, SimulationRadius); + const FString PromotedStorePath = SimulationDirectory / TEXT("promoted_strates.json"); + const FString CorruptStorePath = SimulationDirectory / TEXT("corrupt_promoted_strates.json"); + FString StoreReport; + TestTrue(TEXT("promotion simulation can initialise its empty JSON store"), + VF_SaveStratePromotedRecords( + PromotedStorePath, TArray(), StoreReport)); + AddInfo(StoreReport); + + FVoxelStrateCorpus BaseCorpus; + FString BaseReport; + TestTrue(TEXT("promotion simulation loads its base project/default corpus"), + BaseCorpus.LoadFromAssetRegistry(BaseReport, PromotedStorePath, &Verifier)); + AddInfo(BaseReport); + if (!BaseCorpus.IsValid()) + { + AddError(TEXT("base corpus was invalid; promotion simulation did not run.")); + return false; + } + + TMap BaseMetrics; + int32 NumVacuousBaseMetrics = 0; + for (const FVoxelStrateCorpusEntry& Entry : BaseCorpus.GetEntries()) + { + if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted) + { + continue; + } + TestTrue(FString::Printf(TEXT("base member %s is measured before policy selection"), + *Entry.SourcePath), + Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.bValid); + if (Entry.bHasMeasuredMetrics && !Entry.MeasuredMetrics.IsUsable()) + { + ++NumVacuousBaseMetrics; + AddInfo(FString::Printf( + TEXT("base member %s measured but vacuous in the fixed promotion window: " + "air=%lld solid=%lld components=%d"), + *Entry.SourcePath, Entry.MeasuredMetrics.NumAir, + Entry.MeasuredMetrics.NumSolid, Entry.MeasuredMetrics.NumAirComponents)); + } + if (Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.IsUsable()) + { + BaseMetrics.Add(Entry.SourcePath, Entry.MeasuredMetrics); + } + } + TestTrue(TEXT("base project/default members have measured metrics for spread"), + BaseMetrics.Num() > 0); + AddInfo(FString::Printf( + TEXT("Base metric audit for duplicate policy: %d usable members, %d measured-but-vacuous " + "members; vacuous members use exact parameter identity as the conservative fallback."), + BaseMetrics.Num(), NumVacuousBaseMetrics)); + + FVoxelStratePromotionPolicy Policy; + Policy.MinimumNormalizedMeasuredMetricDistance = 0.20; + Policy.MaxPromotionsPerSeason = PromotionCapPerSeason; + Policy.FeatureScaleNormalizationVoxels = 256.0f; + Policy.ClearanceNormalizationVoxels = 64.0f; + + TArray AllPromoted; + FString SeasonTable = TEXT( + "season | input corpus | corpus after | project/default/promoted | survivors/candidates " + "| survival | measured spread | promoted | near-dup/hash/cap\n"); + double FirstSpread = -1.0; + double LastSpread = -1.0; + int32 NumSeasonLoadsWithPromotions = 0; + + for (int32 Season = 1; Season <= NumSimulatedSeasons; ++Season) + { + FVoxelStrateCorpus Corpus; + FString CorpusReport; + const bool bLoaded = Corpus.LoadFromAssetRegistry( + CorpusReport, PromotedStorePath, &Verifier); + AddInfo(FString::Printf(TEXT("Season %d corpus load: %s"), Season, *CorpusReport)); + TestTrue(FString::Printf(TEXT("season %d reloads the cumulative corpus safely"), Season), + bLoaded && Corpus.IsValid()); + if (!bLoaded || !Corpus.IsValid()) + { + return false; + } + + for (const TPair& Pair : BaseMetrics) + { + TestTrue(FString::Printf(TEXT("season %d restores base metric %s"), + Season, *Pair.Key), + Corpus.SetMeasuredMetrics(Pair.Key, Pair.Value)); + } + + if (AllPromoted.Num() > 0) + { + ++NumSeasonLoadsWithPromotions; + TestTrue(FString::Printf(TEXT("season %d re-verifies every stored promotion"), Season), + Verifier.NumVerifications >= AllPromoted.Num()); + } + + const uint32 InputCorpusHash = Corpus.GetContentsHash(); + const uint32 SeasonSeedBits = static_cast(AuthoredSettings->Seed) + ^ (static_cast(Season) * 0x9E3779B9u); + const int32 SeasonSeed = static_cast(SeasonSeedBits); + TArray CandidateRecords; + CandidateRecords.Reserve(CandidatesPerSeason); + int32 NumSurvivors = 0; + for (int32 CandidateIndex = 0; + CandidateIndex < CandidatesPerSeason; + ++CandidateIndex) + { + const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( + Corpus, SeasonSeed, CandidateIndex, true); + bool bSurvivor = false; + CandidateRecords.Add(VF_EvaluateCandidate( + World, Candidate, Season, InputCorpusHash, TopVoxelZ, BottomVoxelZ, + ArrivalPoint, DeparturePoint, MeasureSettings, + LargestComponentSurvivalThreshold, bSurvivor)); + if (bSurvivor) + { + ++NumSurvivors; + } + } + + const FVoxelStratePromotionBatchResult Batch = VF_SelectStratePromotions( + Corpus, CandidateRecords, Policy); + const FVoxelStratePromotionBatchResult RepeatBatch = VF_SelectStratePromotions( + Corpus, CandidateRecords, Policy); + TestTrue(FString::Printf(TEXT("season %d promotion selection is deterministic"), Season), + VF_PromotionBatchesIdentical(Batch, RepeatBatch)); + TestTrue(FString::Printf(TEXT("season %d uses a valid explicit promotion policy"), Season), + Batch.bPolicyValid); + TestTrue(FString::Printf(TEXT("season %d respects its promotion cap"), Season), + Batch.Promoted.Num() <= PromotionCapPerSeason); + + const double Spread = VF_MeanMeasuredSpread(Corpus, Batch.Promoted, Policy); + if (FirstSpread < 0.0) { FirstSpread = Spread; } + LastSpread = Spread; + + const int32 ProjectCount = Corpus.NumForProvenance(EVoxelStrateCorpusProvenance::Project); + const int32 DefaultCount = Corpus.NumForProvenance(EVoxelStrateCorpusProvenance::Default); + const int32 ExistingPromotedCount = Corpus.NumForProvenance( + EVoxelStrateCorpusProvenance::Promoted); + const int32 CorpusAfter = Corpus.Num() + Batch.Promoted.Num(); + const int32 PromotedAfter = ExistingPromotedCount + Batch.Promoted.Num(); + const float SurvivalRate = CandidateRecords.Num() > 0 + ? static_cast(NumSurvivors) / static_cast(CandidateRecords.Num()) + : 0.0f; + SeasonTable += FString::Printf( + TEXT("%d | %08x | %d | %d/%d/%d | %d/%d | %.1f%% | %.6f | %d | %d/%d/%d\n"), + Season, InputCorpusHash, CorpusAfter, ProjectCount, DefaultCount, PromotedAfter, + NumSurvivors, CandidateRecords.Num(), 100.0f * SurvivalRate, Spread, + Batch.Promoted.Num(), Batch.NumNearDuplicateRejected, + Batch.NumCorpusHashRejected, Batch.NumCapRejected); + + FVoxelStrateSeasonManifest Manifest; + Manifest.Season = Season; + Manifest.Seed = SeasonSeed; + Manifest.InputCorpusHash = InputCorpusHash; + Manifest.CandidateCount = CandidateRecords.Num(); + Manifest.SurvivorCount = NumSurvivors; + Manifest.PromotedCount = Batch.Promoted.Num(); + Manifest.CorpusSize = CorpusAfter; + Manifest.ProjectCount = ProjectCount; + Manifest.DefaultCount = DefaultCount; + Manifest.PromotedCorpusCount = PromotedAfter; + Manifest.SurvivalRate = SurvivalRate; + Manifest.MeasuredSpread = Spread; + FString ManifestReport; + const FString ManifestPath = SimulationDirectory + / FString::Printf(TEXT("season_%02d_manifest.json"), Season); + TestTrue(FString::Printf(TEXT("season %d manifest is written beside the store"), Season), + VF_SaveStrateSeasonManifest(ManifestPath, Manifest, ManifestReport)); + AddInfo(ManifestReport); + + AllPromoted.Append(Batch.Promoted); + TestTrue(FString::Printf(TEXT("season %d cumulative promoted store is written"), Season), + VF_SaveStratePromotedRecords(PromotedStorePath, AllPromoted, StoreReport)); + } + + AddInfo(SeasonTable); + AddInfo(FString::Printf( + TEXT("Promotion policy: normalized measured-metric distance < %.2f rejects a near duplicate; " + "dimensions=(air, largest share, walkable, feature/%.0f voxels, clearance/%.0f voxels); " + "cap=%d per season; records=%d; reloads with promotions=%d."), + Policy.MinimumNormalizedMeasuredMetricDistance, + Policy.FeatureScaleNormalizationVoxels, Policy.ClearanceNormalizationVoxels, + Policy.MaxPromotionsPerSeason, AllPromoted.Num(), NumSeasonLoadsWithPromotions)); + AddInfo(FString::Printf( + TEXT("Measured spread is mean pairwise distance in that normalized five-dimensional space: " + "season 1=%.6f, season 5=%.6f, delta=%+.6f."), + FirstSpread, LastSpread, LastSpread - FirstSpread)); + + TestEqual(TEXT("five simulated seasons completed"), NumSeasonLoadsWithPromotions + 1, + NumSimulatedSeasons); + TestTrue(TEXT("promotion compounding produced at least one promoted member"), + AllPromoted.Num() > 0); + TestEqual(TEXT("promotion cap was never exceeded in cumulative output"), + FMath::Min(AllPromoted.Num(), NumSimulatedSeasons * PromotionCapPerSeason), + AllPromoted.Num()); + TestTrue(TEXT("promotion corpus contains project members"), + BaseCorpus.NumForProvenance(EVoxelStrateCorpusProvenance::Project) > 0); + TestTrue(TEXT("promotion corpus contains default members"), + BaseCorpus.NumForProvenance(EVoxelStrateCorpusProvenance::Default) > 0); + + // Deliberately corrupt only the stored metric. The loader must still admit the record only + // after rebuilding it, and the corpus entry must expose the fresh value rather than the JSON. + if (AllPromoted.Num() > 0) + { + FVoxelStratePromotableRecord CorruptRecord = AllPromoted[0]; + const float OriginalAir = CorruptRecord.MeasuredMetrics.AirFraction; + CorruptRecord.MeasuredMetrics.AirFraction = OriginalAir > 0.5f ? 0.01f : 0.99f; + TArray CorruptRecords; + CorruptRecords.Add(CorruptRecord); + FString CorruptReport; + TestTrue(TEXT("corrupt-metric promotion store can be written for the safety test"), + VF_SaveStratePromotedRecords(CorruptStorePath, CorruptRecords, CorruptReport)); + + FVoxelStrateCorpus CorruptCorpus; + FString CorruptLoadReport; + TestTrue(TEXT("corrupt stored metric is reverified before corpus admission"), + CorruptCorpus.LoadFromAssetRegistry( + CorruptLoadReport, CorruptStorePath, &Verifier)); + AddInfo(CorruptLoadReport); + TestTrue(TEXT("corrupt stored metric was detected and replaced"), + CorruptLoadReport.Contains(TEXT("stored metrics replaced for 1 records"))); + + FVoxelStratePromotionVerification ExpectedVerification; + TestTrue(TEXT("safety verifier can independently rebuild the corrupted record"), + Verifier.Verify(CorruptRecord, ExpectedVerification)); + bool bFoundReverifiedEntry = false; + for (const FVoxelStrateCorpusEntry& Entry : CorruptCorpus.GetEntries()) + { + if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted + && Entry.PromotionRecordId == CorruptRecord.RecordId) + { + bFoundReverifiedEntry = true; + AddInfo(FString::Printf( + TEXT("stale metric audit %s: stored/corpus air=%.9g, fresh air=%.9g, " + "corrupted JSON air=%.9g"), + *CorruptRecord.RecordId, + Entry.MeasuredMetrics.AirFraction, + ExpectedVerification.MeasuredMetrics.AirFraction, + CorruptRecord.MeasuredMetrics.AirFraction)); + TestTrue(TEXT("loaded promoted metric equals fresh measurement, not stale JSON"), + FMath::IsNearlyEqual( + Entry.MeasuredMetrics.AirFraction, + ExpectedVerification.MeasuredMetrics.AirFraction)); + } + } + TestTrue(TEXT("corrupt safety test found its promoted corpus entry"), + bFoundReverifiedEntry); + } + + const double TestSeconds = FPlatformTime::Seconds() - TestStartSeconds; + AddInfo(FString::Printf( + TEXT("Promotion total runtime %.3fs; WorldRadiusVoxels=0; JSON store=%s."), + TestSeconds, *PromotedStorePath)); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp index 88d2895..9024469 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp @@ -344,6 +344,10 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) int32 NumFineRendered = 0; int32 NumFineContourRendered = 0; int32 NumFineRefused = 0; + int32 NumFinePlanBlankBeforeCentering = 0; + int32 NumFinePlanBlankAfterCentering = 0; + int32 NumFineCardBlankBeforeCentering = 0; + int32 NumFineCardBlankAfterCentering = 0; int64 TotalFineCells = 0; int64 PeakFineCaptureBytes = 0; int64 PeakFineRasterBytes = 0; @@ -625,11 +629,50 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) if (bCandidateSurvivor) { ++NumFineRequested; + + // Render the old origin-centred pass into the same filenames first. The temporary + // record gives an exact before count from the renderer's content-variation check; the + // corrected capture below overwrites those files. The two grids are sequential, so + // peak retained memory remains one fine capture. + FVoxelStrateSampleGrid OriginFineGrid; + const double OriginFineMeasureStartSeconds = FPlatformTime::Seconds(); + const FVoxelStrateMetrics OriginFineMetrics = VF_MeasureStrateWithSampler( + Sampler, BottomVoxelZ, TopVoxelZ + 1, + Context.EdgeSealThickness, FineMeasureSettings, &OriginFineGrid); + FineMeasureSeconds += FPlatformTime::Seconds() - OriginFineMeasureStartSeconds; + FVoxelStratePreviewCandidate OriginFinePreview; + FString OriginFinePreviewError; + const double OriginFineRenderStartSeconds = FPlatformTime::Seconds(); + const bool bOriginFineWritten = VF_WriteStratePreviewFineCandidate( + PreviewDirectory, CandidateIndex, OriginFineGrid, + FineMeasureSettings.HeadroomCells, + OriginFineMetrics.bValid ? FString() : OriginFineMetrics.RefusalReason, + OriginFinePreview, OriginFinePreviewError); + FineRenderSeconds += FPlatformTime::Seconds() - OriginFineRenderStartSeconds; + TestTrue(FString::Printf(TEXT("candidate %d origin baseline fine render records"), + CandidateIndex), + bOriginFineWritten); + if (OriginFinePreview.bFineRendered && !OriginFinePreview.bFinePlanContentVaries) + { + ++NumFinePlanBlankBeforeCentering; + } + if (OriginFinePreview.bFineRendered && OriginFinePreview.bFineBlank) + { + ++NumFineCardBlankBeforeCentering; + } + + FVoxelStrateMeasureSettings CenteredFineMeasureSettings = FineMeasureSettings; + if (Metrics.bValid && FMath::IsFinite(Metrics.LargestComponentPoint.X) + && FMath::IsFinite(Metrics.LargestComponentPoint.Y)) + { + CenteredFineMeasureSettings.CenterXY = FVector2D( + Metrics.LargestComponentPoint.X, Metrics.LargestComponentPoint.Y); + } FVoxelStrateSampleGrid FineGrid; const double FineMeasureStartSeconds = FPlatformTime::Seconds(); const FVoxelStrateMetrics FineMetrics = VF_MeasureStrateWithSampler( Sampler, BottomVoxelZ, TopVoxelZ + 1, - Context.EdgeSealThickness, FineMeasureSettings, &FineGrid); + Context.EdgeSealThickness, CenteredFineMeasureSettings, &FineGrid); FineMeasureSeconds += FPlatformTime::Seconds() - FineMeasureStartSeconds; const FString FineFailureReason = FineMetrics.bValid @@ -645,6 +688,14 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) TestTrue(FString::Printf(TEXT("candidate %d fine preview records without a second renderer sample"), CandidateIndex), bFineWritten); + if (PreviewCandidate.bFineRendered && !PreviewCandidate.bFinePlanContentVaries) + { + ++NumFinePlanBlankAfterCentering; + } + if (PreviewCandidate.bFineRendered && PreviewCandidate.bFineBlank) + { + ++NumFineCardBlankAfterCentering; + } TestTrue(FString::Printf(TEXT("candidate %d fine capture keeps scalar density"), CandidateIndex), !FineMetrics.bValid || FineGrid.HasScalarDensity()); @@ -768,11 +819,15 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) FirstBoxViolation.IsEmpty() ? TEXT("No violations.") : *FirstBoxViolation)); AddInfo(FString::Printf( TEXT("Preview cost: coarse step=%d 64-card pass render %.3fs; fine ROI requested=%d " - "(survivors=%d), rendered=%d, contour pairs=%d, refused=%d; fine sample %.3fs + " + "(survivors=%d), rendered=%d, contour pairs=%d, refused=%d; " + "blank plan origin=%d, blank plan largest-open-space=%d; blank card origin=%d, " + "blank card largest-open-space=%d; fine sample %.3fs + " "render %.3fs; fine cells sampled=%lld; peak fine Air+Density capture=%lld bytes; " "peak single RGBA raster=%lld bytes; ROI step=%d radius=%d MaxCells=%d."), MeasureSettings.SampleStep, CoarsePreviewSeconds, NumFineRequested, NumSurvivors, NumFineRendered, NumFineContourRendered, NumFineRefused, + NumFinePlanBlankBeforeCentering, NumFinePlanBlankAfterCentering, + NumFineCardBlankBeforeCentering, NumFineCardBlankAfterCentering, FineMeasureSeconds, FineRenderSeconds, TotalFineCells, PeakFineCaptureBytes, PeakFineRasterBytes, FinePreviewSettings.SampleStep, FinePreviewSettings.RadiusInVoxels, @@ -815,7 +870,8 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) "vertical=%dx%d, plan=%dx%d, " "plan Z chosen from each candidate's measured layer with the highest solid/air " "boundary count (first candidate Z=%d); index=%s; first-candidate window=%s; " - "fine previews are linked on survivor cards; image cap=512 px including 20 px " + "fine previews are linked on survivor cards and centred on each largest open " + "space point (never the strate midpoint); image cap=512 px including 20 px " "scale footer."), PreviewCandidates.Num(), NumRenderedPairs, @@ -840,6 +896,15 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) NumFineRequested, NumSurvivors); TestEqual(TEXT("fine preview outcomes account for every requested survivor"), NumFineRendered + NumFineRefused, NumFineRequested); + AddInfo(FString::Printf(TEXT( + "Fine blank baseline: plan projection %d/%d origin-centred versus %d/%d centred on " + "LargestComponentPoint; complete two-projection cards %d/%d versus %d/%d. A blank " + "projection/card means its filled-cell pixels were uniform (scale footer excluded), " + "not that the measurement was refused."), + NumFinePlanBlankBeforeCentering, NumFineRequested, + NumFinePlanBlankAfterCentering, NumFineRequested, + NumFineCardBlankBeforeCentering, NumFineRequested, + NumFineCardBlankAfterCentering, NumFineRequested)); TestEqual(TEXT("every rendered fine preview has a scalar contour pair"), NumFineContourRendered, NumFineRendered); TestTrue(TEXT("structure roller emits 64 distinct structural recipes"), diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 833549e..4f88d6a 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -4,8 +4,15 @@ #include "AssetRegistry/AssetData.h" #include "AssetRegistry/AssetRegistryModule.h" +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "HAL/FileManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" #include "Math/RandomStream.h" #include "Modules/ModuleManager.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" #include "UObject/EnumProperty.h" #include "UObject/FieldIterator.h" #include "UObject/UnrealType.h" @@ -2254,6 +2261,806 @@ namespace } } +bool FVoxelStrateMeasuredMetrics::IsUsable() const +{ + return bValid + && NumSampled > 0 + && NumAir > 0 + && NumSolid > 0 + && NumSampled == NumAir + NumSolid + && NumAirComponents > 0 + && LargestComponentCells > 0 + && AirComponentCells.Num() == NumAirComponents + && FMath::IsFinite(AirFraction) + && FMath::IsFinite(LargestComponentShare) + && FMath::IsFinite(WalkableFraction) + && FMath::IsFinite(MedianFeatureScale) + && FMath::IsFinite(LargestComponentPoint.X) + && FMath::IsFinite(LargestComponentPoint.Y) + && FMath::IsFinite(LargestComponentPoint.Z); +} + +FVoxelStrateMeasuredMetrics VF_SummarizeStrateMetrics( + const FVoxelStrateMetrics& Metrics) +{ + FVoxelStrateMeasuredMetrics Result; + Result.bValid = Metrics.bValid; + Result.NumSampled = Metrics.NumSampled; + Result.NumAir = Metrics.NumAir; + Result.NumSolid = Metrics.NumSolid; + Result.AirFraction = Metrics.AirFraction; + Result.NumAirComponents = Metrics.NumAirComponents; + Result.LargestComponentShare = Metrics.LargestComponentShare; + Result.LargestComponentPoint = Metrics.LargestComponentPoint; + Result.LargestComponentCells = Metrics.LargestComponentCells; + Result.NumComponentsAtLeast1Pct = Metrics.NumComponentsAtLeast1Pct; + Result.WalkableFraction = Metrics.WalkableFraction; + Result.MedianFeatureScale = Metrics.MedianFeatureScale; + Result.MedianVerticalClearance = Metrics.MedianVerticalClearance; + Result.ResolvedMarginVoxels = Metrics.ResolvedMarginVoxels; + Result.SampledMinZ = Metrics.SampledMinZ; + Result.SampledMaxZ = Metrics.SampledMaxZ; + Result.SampledNumX = Metrics.SampledNumX; + Result.SampledNumY = Metrics.SampledNumY; + Result.SampledNumZ = Metrics.SampledNumZ; + Result.SampledMinX = Metrics.SampledMinX; + Result.SampledMaxX = Metrics.SampledMaxX; + Result.SampledMinY = Metrics.SampledMinY; + Result.SampledMaxY = Metrics.SampledMaxY; + Result.AirComponentCells = Metrics.AirComponentCells; + return Result; +} + +namespace +{ + constexpr int32 GPromotionStoreSchemaVersion = 2; + + bool VF_GetJsonValue(const TSharedPtr& Object, + const TCHAR* FieldName, + EJson ExpectedType, + TSharedPtr& OutValue) + { + OutValue.Reset(); + if (!Object.IsValid()) + { + return false; + } + + const TSharedPtr* Found = Object->Values.Find(FieldName); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != ExpectedType) + { + return false; + } + OutValue = *Found; + return true; + } + + bool VF_ReadJsonNumber(const TSharedPtr& Object, + const TCHAR* FieldName, double& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::Number, Value)) + { + return false; + } + OutValue = Value->AsNumber(); + return FMath::IsFinite(OutValue); + } + + bool VF_ReadJsonNumber(const TSharedPtr& Object, + const FString& FieldName, double& OutValue) + { + return VF_ReadJsonNumber(Object, *FieldName, OutValue); + } + + bool VF_ReadJsonInt64(const TSharedPtr& Object, + const TCHAR* FieldName, int64& OutValue) + { + double Number = 0.0; + if (!VF_ReadJsonNumber(Object, FieldName, Number) + || Number < -9223372036854775807.0 + || Number > 9223372036854775807.0) + { + return false; + } + const int64 Integral = static_cast(Number); + if (static_cast(Integral) != Number) + { + return false; + } + OutValue = Integral; + return true; + } + + bool VF_ReadJsonInt32(const TSharedPtr& Object, + const TCHAR* FieldName, int32& OutValue) + { + int64 Value = 0; + if (!VF_ReadJsonInt64(Object, FieldName, Value) + || Value < static_cast(TNumericLimits::Lowest()) + || Value > static_cast(TNumericLimits::Max())) + { + return false; + } + OutValue = static_cast(Value); + return true; + } + + bool VF_ReadJsonFloat(const TSharedPtr& Object, + const TCHAR* FieldName, float& OutValue) + { + double Value = 0.0; + if (!VF_ReadJsonNumber(Object, FieldName, Value) + || Value < -static_cast(TNumericLimits::Max()) + || Value > static_cast(TNumericLimits::Max())) + { + return false; + } + OutValue = static_cast(Value); + return FMath::IsFinite(OutValue); + } + + bool VF_ReadJsonBool(const TSharedPtr& Object, + const TCHAR* FieldName, bool& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::Boolean, Value)) + { + return false; + } + OutValue = Value->AsBool(); + return true; + } + + bool VF_ReadJsonString(const TSharedPtr& Object, + const TCHAR* FieldName, FString& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::String, Value)) + { + return false; + } + OutValue = Value->AsString(); + return true; + } + + bool VF_ReadJsonObject(const TSharedPtr& Object, + const TCHAR* FieldName, + TSharedPtr& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::Object, Value)) + { + return false; + } + OutValue = Value->AsObject(); + return OutValue.IsValid(); + } + + bool VF_ReadJsonArray(const TSharedPtr& Object, + const TCHAR* FieldName, + const TArray>*& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::Array, Value)) + { + return false; + } + OutValue = &Value->AsArray(); + return true; + } + + TSharedPtr VF_SerializeRecipeEntry(const FVoxelOpRecipeEntry& Entry) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("op_class"), static_cast(Entry.OpClass)); + Object->SetNumberField(TEXT("param_block"), static_cast(Entry.ParamBlock)); + return Object; + } + + bool VF_DeserializeRecipeEntry(const TSharedPtr& Object, + FVoxelOpRecipeEntry& OutEntry) + { + int32 OpClass = 0; + int32 ParamBlock = 0; + if (!VF_ReadJsonInt32(Object, TEXT("op_class"), OpClass) + || !VF_ReadJsonInt32(Object, TEXT("param_block"), ParamBlock) + || OpClass < static_cast(EVoxelStrateOpClass::ConstantRockSource) + || OpClass > static_cast(EVoxelStrateOpClass::DensityNoiseFillMod) + || ParamBlock < static_cast(EVoxelStrateParamBlock::None) + || ParamBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland)) + { + return false; + } + OutEntry.OpClass = static_cast(OpClass); + OutEntry.ParamBlock = static_cast(ParamBlock); + return true; + } + + TSharedPtr VF_SerializeRecipe(const FVoxelOpStackRecipe& Recipe) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("root_polarity"), static_cast(Recipe.RootPolarity)); + Object->SetObjectField(TEXT("root"), VF_SerializeRecipeEntry(Recipe.Root)); + Object->SetObjectField(TEXT("shape_source"), VF_SerializeRecipeEntry(Recipe.ShapeSource)); + Object->SetObjectField(TEXT("conversion"), VF_SerializeRecipeEntry(Recipe.Conversion)); + Object->SetNumberField(TEXT("structural_param_block"), + static_cast(Recipe.StructuralParamBlock)); + + TArray> Modifiers; + Modifiers.Reserve(Recipe.Modifiers.Num()); + for (const FVoxelOpRecipeEntry& Modifier : Recipe.Modifiers) + { + Modifiers.Add(MakeShared(VF_SerializeRecipeEntry(Modifier))); + } + Object->SetArrayField(TEXT("modifiers"), MoveTemp(Modifiers)); + return Object; + } + + bool VF_DeserializeRecipe(const TSharedPtr& Object, + FVoxelOpStackRecipe& OutRecipe) + { + int32 RootPolarity = 0; + int32 StructuralParamBlock = 0; + TSharedPtr Root; + TSharedPtr ShapeSource; + TSharedPtr Conversion; + const TArray>* Modifiers = nullptr; + if (!VF_ReadJsonInt32(Object, TEXT("root_polarity"), RootPolarity) + || !VF_ReadJsonObject(Object, TEXT("root"), Root) + || !VF_ReadJsonObject(Object, TEXT("shape_source"), ShapeSource) + || !VF_ReadJsonObject(Object, TEXT("conversion"), Conversion) + || !VF_ReadJsonInt32(Object, TEXT("structural_param_block"), StructuralParamBlock) + || !VF_ReadJsonArray(Object, TEXT("modifiers"), Modifiers) + || RootPolarity < static_cast(EVoxelStrateRootPolarity::RockCarve) + || RootPolarity > static_cast(EVoxelStrateRootPolarity::VoidFill) + || StructuralParamBlock < static_cast(EVoxelStrateParamBlock::None) + || StructuralParamBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland) + || Modifiers->Num() < 4 || Modifiers->Num() > 8 + || !VF_DeserializeRecipeEntry(Root, OutRecipe.Root) + || !VF_DeserializeRecipeEntry(ShapeSource, OutRecipe.ShapeSource) + || !VF_DeserializeRecipeEntry(Conversion, OutRecipe.Conversion)) + { + return false; + } + + OutRecipe.RootPolarity = static_cast(RootPolarity); + OutRecipe.StructuralParamBlock = static_cast(StructuralParamBlock); + OutRecipe.Modifiers.Reset(); + OutRecipe.Modifiers.Reserve(Modifiers->Num()); + for (const TSharedPtr& Value : *Modifiers) + { + if (!Value.IsValid() || Value->Type != EJson::Object) + { + return false; + } + FVoxelOpRecipeEntry& Entry = OutRecipe.Modifiers.AddDefaulted_GetRef(); + if (!VF_DeserializeRecipeEntry(Value->AsObject(), Entry)) + { + return false; + } + } + return true; + } + + TSharedPtr VF_SerializeArchetypeParams( + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params) + { + TSharedPtr Object = MakeShared(); + const void* Memory = VF_GetParamMemory(Params, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) + { + return Object; + } + + if (VF_IsTunnelArchetype(Archetype)) + { +#define VF_JSON_WRITE_TUNNEL_LERPF(Name) \ + do { \ + const FString FieldName(TEXT(#Name)); \ + if (!VF_IsExcludedForArchetype(Archetype, FieldName)) { \ + double Value = 0.0; \ + if (VF_ReadNamedField(Memory, Archetype, FieldName, Value)) \ + { Object->SetNumberField(FieldName, Value); } \ + } \ + } while (false); +#define VF_JSON_WRITE_TUNNEL_SNAPF(Name) VF_JSON_WRITE_TUNNEL_LERPF(Name) + VF_STRATE_PARAM_FIELDS(VF_JSON_WRITE_TUNNEL_LERPF, VF_JSON_WRITE_TUNNEL_SNAPF) +#undef VF_JSON_WRITE_TUNNEL_LERPF +#undef VF_JSON_WRITE_TUNNEL_SNAPF + return Object; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + TArray Names; + for (const TPair& Pair : Properties) + { + Names.Add(Pair.Key); + } + Names.Sort([](const FString& A, const FString& B) { return A < B; }); + for (const FString& FieldName : Names) + { + if (VF_IsRuntimeField(FieldName) || VF_IsExcludedForArchetype(Archetype, FieldName)) + { + continue; + } + FProperty* const* PropertyPtr = Properties.Find(FieldName); + if (PropertyPtr == nullptr || *PropertyPtr == nullptr + || !VF_IsScalarProperty(*PropertyPtr)) + { + continue; + } + double Value = 0.0; + if (VF_ReadPropertyValue(*PropertyPtr, Memory, Value)) + { + Object->SetNumberField(FieldName, Value); + } + } + return Object; + } + + TSharedPtr VF_SerializeAllArchetypeParams( + const FVoxelStrateArchetypeParams& Params) + { + // A recipe may read more than the active archetype's family (for example a tunnel + // modifier can be selected while the shape source belongs to another family). Persist + // the complete native parameter vector so re-verification measures the same recipe. + TSharedPtr Object = MakeShared(); + Object->SetObjectField(TEXT("tunnel_network"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::TunnelNetwork, Params)); + Object->SetObjectField(TEXT("slab"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::FlatPlain, Params)); + Object->SetObjectField(TEXT("maze"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::Maze, Params)); + Object->SetObjectField(TEXT("surface"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::SurfaceWorld, Params)); + Object->SetObjectField(TEXT("vertical_shaft"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::VerticalShafts, Params)); + Object->SetObjectField(TEXT("floating_island"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::FloatingIslands, Params)); + return Object; + } + + void VF_ResetParamFamily(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + Params.TunnelNetworkParams = FStrateGenerationParams(); + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Params.SlabParams = FSlabGenerationParams(); + break; + case ECaveGeneratorType::Maze: + Params.MazeParams = FMazeGenerationParams(); + break; + case ECaveGeneratorType::SurfaceWorld: + Params.SurfaceParams = FSurfaceGenerationParams(); + break; + case ECaveGeneratorType::VerticalShafts: + Params.VerticalShaftParams = FVerticalShaftParams(); + break; + case ECaveGeneratorType::FloatingIslands: + Params.FloatingIslandParams = FFloatingIslandParams(); + break; + default: + break; + } + } + + bool VF_DeserializeArchetypeParams( + ECaveGeneratorType Archetype, + const TSharedPtr& Object, + FVoxelStrateArchetypeParams& OutParams) + { + if (!Object.IsValid() || !VF_IsSupportedArchetype(Archetype)) + { + return false; + } + + // Reset only the family being read. The promoted record stores all families, and each + // successive call must preserve the values already decoded for the other families. + VF_ResetParamFamily(OutParams, Archetype); + void* Memory = VF_GetParamMemory(OutParams, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) + { + return false; + } + + bool bValid = true; + if (VF_IsTunnelArchetype(Archetype)) + { +#define VF_JSON_READ_TUNNEL_LERPF(Name) \ + do { \ + const FString FieldName(TEXT(#Name)); \ + if (!VF_IsExcludedForArchetype(Archetype, FieldName)) { \ + double Value = 0.0; \ + const bool bFieldValid = VF_ReadJsonNumber(Object, FieldName, Value) \ + && VF_WriteNamedField(Memory, Archetype, FieldName, Value); \ + bValid = bFieldValid && bValid; \ + } \ + } while (false); +#define VF_JSON_READ_TUNNEL_SNAPF(Name) VF_JSON_READ_TUNNEL_LERPF(Name) + VF_STRATE_PARAM_FIELDS(VF_JSON_READ_TUNNEL_LERPF, VF_JSON_READ_TUNNEL_SNAPF) +#undef VF_JSON_READ_TUNNEL_LERPF +#undef VF_JSON_READ_TUNNEL_SNAPF + } + else + { + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + const FString& FieldName = Pair.Key; + if (VF_IsRuntimeField(FieldName) || VF_IsExcludedForArchetype(Archetype, FieldName) + || Pair.Value == nullptr || !VF_IsScalarProperty(Pair.Value)) + { + continue; + } + double Value = 0.0; + const bool bFieldValid = VF_ReadJsonNumber(Object, FieldName, Value) + && VF_WritePropertyValue(Pair.Value, Memory, Value); + bValid = bFieldValid && bValid; + } + } + + VF_ResetExcludedFields(OutParams, Archetype); + return bValid; + } + + bool VF_DeserializeAllArchetypeParams( + const TSharedPtr& Object, + FVoxelStrateArchetypeParams& OutParams) + { + if (!Object.IsValid()) + { + return false; + } + + TSharedPtr Tunnel; + TSharedPtr Slab; + TSharedPtr Maze; + TSharedPtr Surface; + TSharedPtr Vertical; + TSharedPtr Floating; + if (!VF_ReadJsonObject(Object, TEXT("tunnel_network"), Tunnel) + || !VF_ReadJsonObject(Object, TEXT("slab"), Slab) + || !VF_ReadJsonObject(Object, TEXT("maze"), Maze) + || !VF_ReadJsonObject(Object, TEXT("surface"), Surface) + || !VF_ReadJsonObject(Object, TEXT("vertical_shaft"), Vertical) + || !VF_ReadJsonObject(Object, TEXT("floating_island"), Floating)) + { + return false; + } + + OutParams = FVoxelStrateArchetypeParams(); + bool bValid = true; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::TunnelNetwork, Tunnel, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::FlatPlain, Slab, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::Maze, Maze, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::SurfaceWorld, Surface, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::VerticalShafts, Vertical, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::FloatingIslands, Floating, OutParams) && bValid; + return bValid; + } + + void VF_SetJsonMetricNumbers(const TSharedPtr& Object, + const FVoxelStrateMeasuredMetrics& Metrics) + { + Object->SetBoolField(TEXT("valid"), Metrics.bValid); + Object->SetNumberField(TEXT("num_sampled"), static_cast(Metrics.NumSampled)); + Object->SetNumberField(TEXT("num_air"), static_cast(Metrics.NumAir)); + Object->SetNumberField(TEXT("num_solid"), static_cast(Metrics.NumSolid)); + Object->SetNumberField(TEXT("air_fraction"), Metrics.AirFraction); + Object->SetNumberField(TEXT("num_air_components"), Metrics.NumAirComponents); + Object->SetNumberField(TEXT("largest_component_share"), Metrics.LargestComponentShare); + Object->SetNumberField(TEXT("largest_component_cells"), + static_cast(Metrics.LargestComponentCells)); + Object->SetNumberField(TEXT("num_components_at_least_1_pct"), + Metrics.NumComponentsAtLeast1Pct); + Object->SetNumberField(TEXT("walkable_fraction"), Metrics.WalkableFraction); + Object->SetNumberField(TEXT("median_feature_scale"), Metrics.MedianFeatureScale); + Object->SetNumberField(TEXT("median_vertical_clearance"), Metrics.MedianVerticalClearance); + Object->SetNumberField(TEXT("resolved_margin_voxels"), Metrics.ResolvedMarginVoxels); + Object->SetNumberField(TEXT("sampled_min_z"), Metrics.SampledMinZ); + Object->SetNumberField(TEXT("sampled_max_z"), Metrics.SampledMaxZ); + Object->SetNumberField(TEXT("sampled_num_x"), Metrics.SampledNumX); + Object->SetNumberField(TEXT("sampled_num_y"), Metrics.SampledNumY); + Object->SetNumberField(TEXT("sampled_num_z"), Metrics.SampledNumZ); + Object->SetNumberField(TEXT("sampled_min_x"), Metrics.SampledMinX); + Object->SetNumberField(TEXT("sampled_max_x"), Metrics.SampledMaxX); + Object->SetNumberField(TEXT("sampled_min_y"), Metrics.SampledMinY); + Object->SetNumberField(TEXT("sampled_max_y"), Metrics.SampledMaxY); + + TSharedPtr Point = MakeShared(); + Point->SetNumberField(TEXT("x"), Metrics.LargestComponentPoint.X); + Point->SetNumberField(TEXT("y"), Metrics.LargestComponentPoint.Y); + Point->SetNumberField(TEXT("z"), Metrics.LargestComponentPoint.Z); + Object->SetObjectField(TEXT("largest_component_point"), Point); + + TArray> Components; + Components.Reserve(Metrics.AirComponentCells.Num()); + for (const int64 Cells : Metrics.AirComponentCells) + { + Components.Add(MakeShared(static_cast(Cells))); + } + Object->SetArrayField(TEXT("air_component_cells"), MoveTemp(Components)); + } + + TSharedPtr VF_SerializeMetrics(const FVoxelStrateMeasuredMetrics& Metrics) + { + TSharedPtr Object = MakeShared(); + VF_SetJsonMetricNumbers(Object, Metrics); + return Object; + } + + bool VF_DeserializeMetrics(const TSharedPtr& Object, + FVoxelStrateMeasuredMetrics& OutMetrics) + { + if (!Object.IsValid()) + { + return false; + } + + bool bValid = true; + bValid = VF_ReadJsonBool(Object, TEXT("valid"), OutMetrics.bValid) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("num_sampled"), OutMetrics.NumSampled) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("num_air"), OutMetrics.NumAir) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("num_solid"), OutMetrics.NumSolid) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("air_fraction"), OutMetrics.AirFraction) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("num_air_components"), OutMetrics.NumAirComponents) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("largest_component_share"), + OutMetrics.LargestComponentShare) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("largest_component_cells"), + OutMetrics.LargestComponentCells) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("num_components_at_least_1_pct"), + OutMetrics.NumComponentsAtLeast1Pct) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("walkable_fraction"), OutMetrics.WalkableFraction) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("median_feature_scale"), + OutMetrics.MedianFeatureScale) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("median_vertical_clearance"), + OutMetrics.MedianVerticalClearance) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("resolved_margin_voxels"), + OutMetrics.ResolvedMarginVoxels) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_min_z"), OutMetrics.SampledMinZ) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_max_z"), OutMetrics.SampledMaxZ) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_num_x"), OutMetrics.SampledNumX) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_num_y"), OutMetrics.SampledNumY) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_num_z"), OutMetrics.SampledNumZ) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("sampled_min_x"), OutMetrics.SampledMinX) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("sampled_max_x"), OutMetrics.SampledMaxX) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("sampled_min_y"), OutMetrics.SampledMinY) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("sampled_max_y"), OutMetrics.SampledMaxY) && bValid; + + TSharedPtr Point; + bValid = VF_ReadJsonObject(Object, TEXT("largest_component_point"), Point) && bValid; + if (Point.IsValid()) + { + bValid = VF_ReadJsonNumber(Point, TEXT("x"), OutMetrics.LargestComponentPoint.X) && bValid; + bValid = VF_ReadJsonNumber(Point, TEXT("y"), OutMetrics.LargestComponentPoint.Y) && bValid; + bValid = VF_ReadJsonNumber(Point, TEXT("z"), OutMetrics.LargestComponentPoint.Z) && bValid; + } + + const TArray>* Components = nullptr; + bValid = VF_ReadJsonArray(Object, TEXT("air_component_cells"), Components) && bValid; + OutMetrics.AirComponentCells.Reset(); + if (Components != nullptr) + { + OutMetrics.AirComponentCells.Reserve(Components->Num()); + for (const TSharedPtr& Value : *Components) + { + if (!Value.IsValid() || Value->Type != EJson::Number + || !FMath::IsFinite(Value->AsNumber())) + { + bValid = false; + continue; + } + const double Number = Value->AsNumber(); + const int64 Cells = static_cast(Number); + if (Number < 0.0 || static_cast(Cells) != Number) + { + bValid = false; + continue; + } + OutMetrics.AirComponentCells.Add(Cells); + } + } + return bValid; + } + + TSharedPtr VF_SerializePromotableRecord( + const FVoxelStratePromotableRecord& Record) + { + TSharedPtr Object = MakeShared(); + Object->SetStringField(TEXT("record_id"), Record.RecordId); + Object->SetNumberField(TEXT("season"), Record.Season); + Object->SetNumberField(TEXT("seed"), Record.Seed); + Object->SetNumberField(TEXT("candidate_index"), Record.CandidateIndex); + Object->SetNumberField(TEXT("input_corpus_hash"), + static_cast(Record.InputCorpusHash)); + Object->SetStringField(TEXT("archetype"), VF_GetStrateArchetypeName(Record.Archetype)); + Object->SetObjectField(TEXT("parameters"), + VF_SerializeAllArchetypeParams(Record.Params)); + Object->SetObjectField(TEXT("recipe"), VF_SerializeRecipe(Record.Recipe)); + Object->SetObjectField(TEXT("measured_metrics"), VF_SerializeMetrics(Record.MeasuredMetrics)); + Object->SetBoolField(TEXT("passed_non_vacuous"), Record.bPassedNonVacuous); + Object->SetBoolField(TEXT("passed_largest_component"), Record.bPassedLargestComponent); + Object->SetBoolField(TEXT("passed_primordial_law"), Record.bPassedPrimordialLaw); + return Object; + } + + bool VF_DeserializePromotableRecord( + const TSharedPtr& Object, + FVoxelStratePromotableRecord& OutRecord) + { + if (!Object.IsValid() + || !VF_ReadJsonString(Object, TEXT("record_id"), OutRecord.RecordId) + || OutRecord.RecordId.IsEmpty() + || !VF_ReadJsonInt32(Object, TEXT("season"), OutRecord.Season) + || !VF_ReadJsonInt32(Object, TEXT("seed"), OutRecord.Seed) + || !VF_ReadJsonInt32(Object, TEXT("candidate_index"), OutRecord.CandidateIndex)) + { + return false; + } + + int64 CorpusHash = 0; + FString ArchetypeName; + TSharedPtr Params; + TSharedPtr Recipe; + TSharedPtr Metrics; + if (!VF_ReadJsonInt64(Object, TEXT("input_corpus_hash"), CorpusHash) + || CorpusHash <= 0 || CorpusHash > static_cast(TNumericLimits::Max()) + || !VF_ReadJsonString(Object, TEXT("archetype"), ArchetypeName) + || !VF_ReadJsonObject(Object, TEXT("parameters"), Params) + || !VF_ReadJsonObject(Object, TEXT("recipe"), Recipe) + || !VF_ReadJsonObject(Object, TEXT("measured_metrics"), Metrics) + || !VF_ReadJsonBool(Object, TEXT("passed_non_vacuous"), OutRecord.bPassedNonVacuous) + || !VF_ReadJsonBool(Object, TEXT("passed_largest_component"), OutRecord.bPassedLargestComponent) + || !VF_ReadJsonBool(Object, TEXT("passed_primordial_law"), OutRecord.bPassedPrimordialLaw)) + { + return false; + } + + bool bArchetypeFound = false; + for (const ECaveGeneratorType Archetype : GAllArchetypes) + { + if (ArchetypeName == VF_GetStrateArchetypeName(Archetype)) + { + OutRecord.Archetype = Archetype; + bArchetypeFound = true; + break; + } + } + if (!bArchetypeFound + || !VF_DeserializeAllArchetypeParams(Params, OutRecord.Params) + || !VF_DeserializeRecipe(Recipe, OutRecord.Recipe) + || !VF_DeserializeMetrics(Metrics, OutRecord.MeasuredMetrics)) + { + return false; + } + OutRecord.InputCorpusHash = static_cast(CorpusHash); + return true; + } + + bool VF_MeasuredMetricsExactlyEqual(const FVoxelStrateMeasuredMetrics& A, + const FVoxelStrateMeasuredMetrics& B) + { + return A.bValid == B.bValid + && A.NumSampled == B.NumSampled + && A.NumAir == B.NumAir + && A.NumSolid == B.NumSolid + && A.AirFraction == B.AirFraction + && A.NumAirComponents == B.NumAirComponents + && A.LargestComponentShare == B.LargestComponentShare + && A.LargestComponentPoint == B.LargestComponentPoint + && A.LargestComponentCells == B.LargestComponentCells + && A.NumComponentsAtLeast1Pct == B.NumComponentsAtLeast1Pct + && A.WalkableFraction == B.WalkableFraction + && A.MedianFeatureScale == B.MedianFeatureScale + && A.MedianVerticalClearance == B.MedianVerticalClearance + && A.ResolvedMarginVoxels == B.ResolvedMarginVoxels + && A.SampledMinZ == B.SampledMinZ + && A.SampledMaxZ == B.SampledMaxZ + && A.SampledNumX == B.SampledNumX + && A.SampledNumY == B.SampledNumY + && A.SampledNumZ == B.SampledNumZ + && A.SampledMinX == B.SampledMinX + && A.SampledMaxX == B.SampledMaxX + && A.SampledMinY == B.SampledMinY + && A.SampledMaxY == B.SampledMaxY + && A.AirComponentCells == B.AirComponentCells; + } + + bool VF_SaveJsonObject(const FString& FilePath, + const TSharedPtr& Object, + FString& OutError) + { + const FString Directory = FPaths::GetPath(FilePath); + if (!Directory.IsEmpty() && !IFileManager::Get().MakeDirectory(*Directory, true)) + { + OutError = FString::Printf(TEXT("could not create JSON directory %s"), *Directory); + return false; + } + + FString JsonText; + TSharedRef>> Writer = + TJsonWriterFactory>::Create(&JsonText); + if (!FJsonSerializer::Serialize(Object.ToSharedRef(), Writer) || !Writer->Close()) + { + OutError = FString::Printf(TEXT("could not serialise JSON for %s"), *FilePath); + return false; + } + if (!FFileHelper::SaveStringToFile(JsonText, *FilePath)) + { + OutError = FString::Printf(TEXT("could not write JSON file %s"), *FilePath); + return false; + } + return true; + } + + bool VF_LoadJsonObject(const FString& FilePath, + TSharedPtr& OutObject, + FString& OutError) + { + FString JsonText; + if (!FFileHelper::LoadFileToString(JsonText, *FilePath)) + { + OutError = FString::Printf(TEXT("could not read JSON file %s"), *FilePath); + return false; + } + + TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonText); + if (!FJsonSerializer::Deserialize(Reader, OutObject) || !OutObject.IsValid()) + { + OutError = FString::Printf(TEXT("invalid JSON in %s"), *FilePath); + return false; + } + return true; + } + + FString VF_PromotionSortKey(const FVoxelStratePromotableRecord& Record) + { + if (!Record.RecordId.IsEmpty()) + { + return Record.RecordId; + } + return FString::Printf(TEXT("season_%08d_seed_%08d_candidate_%08d_archetype_%d_recipe_%08x"), + Record.Season, Record.Seed, Record.CandidateIndex, + static_cast(static_cast(Record.Archetype)), + VF_HashStrateStructureRecipe(Record.Recipe)); + } + + bool VF_MetricsAreFinite(const FVoxelStrateMeasuredMetrics& Metrics) + { + return FMath::IsFinite(Metrics.AirFraction) + && FMath::IsFinite(Metrics.LargestComponentShare) + && FMath::IsFinite(Metrics.WalkableFraction) + && FMath::IsFinite(Metrics.MedianFeatureScale) + && FMath::IsFinite(Metrics.LargestComponentPoint.X) + && FMath::IsFinite(Metrics.LargestComponentPoint.Y) + && FMath::IsFinite(Metrics.LargestComponentPoint.Z); + } +} + const TArray& FVoxelStrateCorpus::GetNonTunableFields() { static const TArray Exclusions = [] @@ -2412,7 +3219,32 @@ void FVoxelStrateCorpus::RebuildSpreads() for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex) { - FieldSpreads[FieldIndex].AuthoredSampleCount = Values[FieldIndex].Num(); + int32 AuthoredSampleCount = 0; + int32 PromotedSampleCount = 0; + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.Archetype != FieldSpreads[FieldIndex].Archetype) + { + continue; + } + double Value = 0.0; + const void* Memory = VF_GetParamMemory(Entry.ArchetypeParams, Entry.Archetype); + if (Memory != nullptr + && VF_ReadNamedField(Memory, Entry.Archetype, FieldSpreads[FieldIndex].FieldName, Value) + && FMath::IsFinite(Value)) + { + if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted) + { + ++PromotedSampleCount; + } + else + { + ++AuthoredSampleCount; + } + } + } + FieldSpreads[FieldIndex].AuthoredSampleCount = AuthoredSampleCount; + FieldSpreads[FieldIndex].PromotedSampleCount = PromotedSampleCount; } VF_AddTerrainOperationDefaultSamples(Values, FieldSpreads); @@ -2420,7 +3252,8 @@ void FVoxelStrateCorpus::RebuildSpreads() { FVoxelStrateFieldSpread& Spread = FieldSpreads[FieldIndex]; const TArray& Samples = Values[FieldIndex]; - Spread.DefaultSeedCount = Samples.Num() - Spread.AuthoredSampleCount; + Spread.DefaultSeedCount = Samples.Num() - Spread.AuthoredSampleCount + - Spread.PromotedSampleCount; Spread.SampleCount = Samples.Num(); if (Samples.Num() == 0) { @@ -2466,6 +3299,24 @@ bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& Sour bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& SourceName, ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params, float Weight) +{ + return AddEntryInternal(SourcePath, SourceName, Archetype, Params, Weight, + EVoxelStrateCorpusProvenance::Project, + nullptr, nullptr, FString(), INDEX_NONE, 0, INDEX_NONE, 0, true); +} + +bool FVoxelStrateCorpus::AddEntryInternal( + const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params, + float Weight, EVoxelStrateCorpusProvenance Provenance, + const FVoxelOpStackRecipe* Recipe, + const FVoxelStrateMeasuredMetrics* Metrics, + const FString& PromotionRecordId, + int32 PromotionSeason, + int32 PromotionSeed, + int32 PromotionCandidateIndex, + uint32 PromotionInputCorpusHash, + bool bRebuild) { if (!VF_IsSupportedArchetype(Archetype)) { @@ -2489,6 +3340,22 @@ bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& Sour Entry.ArchetypeParams = Params; Entry.Params = Params.TunnelNetworkParams; Entry.Weight = Weight; + Entry.Provenance = Provenance; + Entry.bHasRecipe = Recipe != nullptr; + if (Recipe != nullptr) + { + Entry.Recipe = *Recipe; + } + Entry.bHasMeasuredMetrics = Metrics != nullptr; + if (Metrics != nullptr) + { + Entry.MeasuredMetrics = *Metrics; + } + Entry.PromotionRecordId = PromotionRecordId; + Entry.PromotionSeason = PromotionSeason; + Entry.PromotionSeed = PromotionSeed; + Entry.PromotionCandidateIndex = PromotionCandidateIndex; + Entry.PromotionInputCorpusHash = PromotionInputCorpusHash; Entries.Sort([](const FVoxelStrateCorpusEntry& A, const FVoxelStrateCorpusEntry& B) { @@ -2499,7 +3366,10 @@ bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& Sour } return A.SourceName < B.SourceName; }); - RebuildSpreads(); + if (bRebuild) + { + RebuildSpreads(); + } return true; } @@ -2570,7 +3440,9 @@ bool FVoxelStrateCorpus::LoadFromDefinitions( 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)) + if (AddEntryInternal(DefaultPath, DefaultName, Archetype, Defaults, 1.0f, + EVoxelStrateCorpusProvenance::Default, + nullptr, nullptr, FString(), INDEX_NONE, 0, INDEX_NONE, 0, true)) { ++NumDefaultEntries; } @@ -2612,7 +3484,10 @@ static bool VF_IsIgnoredSavedCopy(const FString& PackagePath) || PackagePath.Contains(TEXT("/Saved/Cooked"), ESearchCase::IgnoreCase); } -bool FVoxelStrateCorpus::LoadFromAssetRegistry(FString& OutReport) +bool FVoxelStrateCorpus::LoadFromAssetRegistry( + FString& OutReport, + const FString& PromotedStorePath, + const IVoxelStratePromotionVerifier* PromotionVerifier) { FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); @@ -2663,13 +3538,60 @@ bool FVoxelStrateCorpus::LoadFromAssetRegistry(FString& OutReport) FString DefinitionReport; const bool bLoaded = LoadFromDefinitions(Definitions, DefinitionReport); + int32 NumBaseMetricsMeasured = 0; + int32 NumBaseMetricsUnusable = 0; + int32 NumBaseMetricsUnavailable = 0; + if (PromotionVerifier != nullptr) + { + for (FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted) + { + continue; + } + + FVoxelStrateMeasuredMetrics Metrics; + if (PromotionVerifier->MeasureCorpusEntry(Entry, Metrics) + && Metrics.bValid) + { + Entry.MeasuredMetrics = Metrics; + Entry.bHasMeasuredMetrics = true; + ++NumBaseMetricsMeasured; + if (!Metrics.IsUsable()) + { + ++NumBaseMetricsUnusable; + } + } + else + { + Entry.bHasMeasuredMetrics = false; + ++NumBaseMetricsUnavailable; + } + } + } OutReport = FString::Printf( 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"), + "%s Base metric audit: %d measured (%d usable, %d vacuous), %d unavailable."), AssetData.Num(), SeenObjectPaths.Num(), NumIgnoredSaved, NumOutsideProject, - NumDuplicateRecords, NumUnresolved, *DefinitionReport); - return bLoaded && NumUnresolved == 0; + NumDuplicateRecords, NumUnresolved, *DefinitionReport, + NumBaseMetricsMeasured, NumBaseMetricsMeasured - NumBaseMetricsUnusable, + NumBaseMetricsUnusable, NumBaseMetricsUnavailable); + + const FString ResolvedPromotedStorePath = PromotedStorePath.IsEmpty() + ? FPaths::ProjectSavedDir() / TEXT("VoxelForge") / TEXT("StrateCorpus") + / TEXT("promoted_strates.json") + : PromotedStorePath; + FString PromotedReport; + const bool bPromotedLoaded = LoadPromotedRecords( + ResolvedPromotedStorePath, PromotionVerifier, PromotedReport); + OutReport += FString::Printf( + TEXT(" Promotion store: %s Membership provenance: project=%d default=%d promoted=%d."), + *PromotedReport, + NumForProvenance(EVoxelStrateCorpusProvenance::Project), + NumForProvenance(EVoxelStrateCorpusProvenance::Default), + NumForProvenance(EVoxelStrateCorpusProvenance::Promoted)); + return bLoaded && NumUnresolved == 0 && bPromotedLoaded; } bool FVoxelStrateCorpus::LoadFromSettings(const UVoxelSettings* Settings, FString& OutReport) @@ -2734,6 +3656,19 @@ int32 FVoxelStrateCorpus::NumForArchetype(ECaveGeneratorType Archetype) const return Count; } +int32 FVoxelStrateCorpus::NumForProvenance(EVoxelStrateCorpusProvenance Provenance) const +{ + int32 Count = 0; + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.Provenance == Provenance) + { + ++Count; + } + } + return Count; +} + const FVoxelStrateFieldSpread* FVoxelStrateCorpus::FindSpread( ECaveGeneratorType Archetype, const FString& FieldName) const { @@ -2796,6 +3731,19 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const const uint8 Archetype = static_cast(Entry.Archetype); HashBytes(&Archetype, sizeof(Archetype)); HashBytes(&Entry.Weight, sizeof(Entry.Weight)); + const bool bPromotedEntry = Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted; + + // Preserve the Tier 4 hash byte-for-byte for the existing project/default corpus. A + // promoted member is a new input, however, and its recipe must participate in the hash so + // a season can be reproduced from exactly the same cumulative corpus contents. + if (bPromotedEntry) + { + const uint8 PromotedMarker = 1; + HashBytes(&PromotedMarker, sizeof(PromotedMarker)); + const uint32 RecipeHash = Entry.bHasRecipe + ? VF_HashStrateStructureRecipe(Entry.Recipe) : 0U; + HashBytes(&RecipeHash, sizeof(RecipeHash)); + } const void* Memory = VF_GetParamMemory(Entry.ArchetypeParams, Entry.Archetype); UStruct* Struct = VF_GetParamStruct(Entry.Archetype); @@ -2807,9 +3755,11 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const if (VF_IsTunnelArchetype(Entry.Archetype)) { #define VF_HASH_FSTRATE_FIELD(Name) \ - HashString(TEXT(#Name)); \ - HashBytes(&Entry.ArchetypeParams.TunnelNetworkParams.Name, \ - sizeof(Entry.ArchetypeParams.TunnelNetworkParams.Name)); + if (!bPromotedEntry || !VF_IsRuntimeField(TEXT(#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 } @@ -2834,13 +3784,79 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const for (const TCHAR* RuntimeField : RuntimeFields) { double Value = 0.0; - if (VF_ReadNamedField(Memory, Entry.Archetype, RuntimeField, Value)) + if (!bPromotedEntry + && VF_ReadNamedField(Memory, Entry.Archetype, RuntimeField, Value)) { HashString(RuntimeField); HashBytes(&Value, sizeof(Value)); } } } + + if (bPromotedEntry) + { + // Promoted recipes carry all six native families. Include the complete vector in the + // cumulative corpus identity, while omitting runtime Z bounds so a saved record and + // its reloaded form hash identically. + const ECaveGeneratorType Families[] = + { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + }; + for (const ECaveGeneratorType Family : Families) + { + const uint8 FamilyCode = static_cast(Family); + HashBytes(&FamilyCode, sizeof(FamilyCode)); + const void* FamilyMemory = VF_GetParamMemory(Entry.ArchetypeParams, Family); + UStruct* FamilyStruct = VF_GetParamStruct(Family); + if (FamilyMemory == nullptr || FamilyStruct == nullptr) + { + continue; + } + + if (VF_IsTunnelArchetype(Family)) + { +#define VF_HASH_PROMOTED_TUNNEL_FIELD(Name) \ + if (!VF_IsRuntimeField(TEXT(#Name))) { \ + HashString(TEXT(#Name)); \ + HashBytes(&Entry.ArchetypeParams.TunnelNetworkParams.Name, \ + sizeof(Entry.ArchetypeParams.TunnelNetworkParams.Name)); \ + } + VF_STRATE_PARAM_FIELDS(VF_HASH_PROMOTED_TUNNEL_FIELD, VF_HASH_PROMOTED_TUNNEL_FIELD) +#undef VF_HASH_PROMOTED_TUNNEL_FIELD + } + else + { + TMap FamilyProperties; + VF_CollectProperties(FamilyStruct, FamilyProperties); + TArray FamilyNames; + for (const TPair& Pair : FamilyProperties) + { + FamilyNames.Add(Pair.Key); + } + FamilyNames.Sort([](const FString& A, const FString& B) + { + return A < B; + }); + for (const FString& FieldName : FamilyNames) + { + const FProperty* const* PropertyPtr = FamilyProperties.Find(FieldName); + if (PropertyPtr == nullptr || *PropertyPtr == nullptr + || !VF_IsScalarProperty(*PropertyPtr)) + { + continue; + } + HashString(FieldName); + const void* ValuePtr = (*PropertyPtr)->ContainerPtrToValuePtr(FamilyMemory); + HashBytes(ValuePtr, (*PropertyPtr)->GetSize()); + } + } + } + } } return Hash; } @@ -2861,6 +3877,17 @@ const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype) return TEXT("Unknown"); } +const TCHAR* VF_GetStrateCorpusProvenanceName(EVoxelStrateCorpusProvenance Provenance) +{ + switch (Provenance) + { + case EVoxelStrateCorpusProvenance::Project: return TEXT("project"); + case EVoxelStrateCorpusProvenance::Default: return TEXT("default"); + case EVoxelStrateCorpusProvenance::Promoted: return TEXT("promoted"); + } + return TEXT("unknown"); +} + bool VF_AreStrateParamsBitIdentical(const FStrateGenerationParams& A, const FStrateGenerationParams& B) { @@ -2965,6 +3992,375 @@ double VF_DistanceFromStrateCorpusCentroid( return FMath::Sqrt(FMath::Max(0.0, DistanceSquared)); } +double VF_NormalizedMeasuredMetricDistance( + const FVoxelStrateMeasuredMetrics& A, + const FVoxelStrateMeasuredMetrics& B, + const FVoxelStratePromotionPolicy& Policy) +{ + if (!A.IsUsable() || !B.IsUsable() + || !FMath::IsFinite(Policy.FeatureScaleNormalizationVoxels) + || !FMath::IsFinite(Policy.ClearanceNormalizationVoxels) + || Policy.FeatureScaleNormalizationVoxels <= 0.0f + || Policy.ClearanceNormalizationVoxels <= 0.0f) + { + return TNumericLimits::Max(); + } + + const double FeatureDelta = static_cast(A.MedianFeatureScale - B.MedianFeatureScale) + / static_cast(Policy.FeatureScaleNormalizationVoxels); + const double ClearanceDelta = static_cast(A.MedianVerticalClearance + - B.MedianVerticalClearance) + / static_cast(Policy.ClearanceNormalizationVoxels); + const double AirDelta = static_cast(A.AirFraction - B.AirFraction); + const double LargestDelta = static_cast(A.LargestComponentShare + - B.LargestComponentShare); + const double WalkableDelta = static_cast(A.WalkableFraction - B.WalkableFraction); + const double DistanceSquared = AirDelta * AirDelta + + LargestDelta * LargestDelta + + WalkableDelta * WalkableDelta + + FeatureDelta * FeatureDelta + + ClearanceDelta * ClearanceDelta; + return FMath::Sqrt(FMath::Max(0.0, DistanceSquared)); +} + +FVoxelStratePromotionBatchResult VF_SelectStratePromotions( + const FVoxelStrateCorpus& Corpus, + const TArray& Candidates, + const FVoxelStratePromotionPolicy& Policy) +{ + FVoxelStratePromotionBatchResult Result; + Result.NumCandidates = Candidates.Num(); + Result.MinimumAcceptedDistance = 0.0; + + if (!FMath::IsFinite(Policy.MinimumNormalizedMeasuredMetricDistance) + || Policy.MinimumNormalizedMeasuredMetricDistance <= 0.0 + || Policy.MaxPromotionsPerSeason < 0 + || !FMath::IsFinite(Policy.FeatureScaleNormalizationVoxels) + || !FMath::IsFinite(Policy.ClearanceNormalizationVoxels) + || Policy.FeatureScaleNormalizationVoxels <= 0.0f + || Policy.ClearanceNormalizationVoxels <= 0.0f) + { + return Result; + } + Result.bPolicyValid = true; + + const uint32 ExpectedCorpusHash = Corpus.GetContentsHash(); + TArray Ordered = Candidates; + Ordered.Sort([](const FVoxelStratePromotableRecord& A, + const FVoxelStratePromotableRecord& B) + { + const FString AKey = VF_PromotionSortKey(A); + const FString BKey = VF_PromotionSortKey(B); + if (AKey != BKey) + { + return AKey < BKey; + } + if (A.InputCorpusHash != B.InputCorpusHash) + { + return A.InputCorpusHash < B.InputCorpusHash; + } + return A.CandidateIndex < B.CandidateIndex; + }); + + double MinimumDistance = TNumericLimits::Max(); + for (const FVoxelStratePromotableRecord& Candidate : Ordered) + { + if (Candidate.InputCorpusHash != ExpectedCorpusHash) + { + ++Result.NumCorpusHashRejected; + continue; + } + if (!Candidate.bPassedNonVacuous + || !Candidate.bPassedLargestComponent + || !Candidate.bPassedPrimordialLaw + || !Candidate.MeasuredMetrics.IsUsable() + || !VF_MetricsAreFinite(Candidate.MeasuredMetrics) + || !VF_IsSupportedArchetype(Candidate.Archetype) + || Candidate.RecordId.IsEmpty()) + { + ++Result.NumValidationRejected; + continue; + } + + bool bNearDuplicate = false; + for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries()) + { + if (Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.IsUsable()) + { + const double Distance = VF_NormalizedMeasuredMetricDistance( + Candidate.MeasuredMetrics, Entry.MeasuredMetrics, Policy); + if (Distance < Policy.MinimumNormalizedMeasuredMetricDistance) + { + bNearDuplicate = true; + break; + } + } + else if (Entry.Archetype == Candidate.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + Entry.ArchetypeParams, Candidate.Params, Candidate.Archetype)) + { + bNearDuplicate = true; + break; + } + } + if (!bNearDuplicate) + { + for (const FVoxelStratePromotableRecord& Accepted : Result.Promoted) + { + const double Distance = VF_NormalizedMeasuredMetricDistance( + Candidate.MeasuredMetrics, Accepted.MeasuredMetrics, Policy); + if (Distance < Policy.MinimumNormalizedMeasuredMetricDistance + || (Candidate.Archetype == Accepted.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + Candidate.Params, Accepted.Params, Candidate.Archetype))) + { + bNearDuplicate = true; + break; + } + MinimumDistance = FMath::Min(MinimumDistance, Distance); + } + } + if (bNearDuplicate) + { + ++Result.NumNearDuplicateRejected; + continue; + } + if (Result.Promoted.Num() >= Policy.MaxPromotionsPerSeason) + { + ++Result.NumCapRejected; + continue; + } + + Result.Promoted.Add(Candidate); + // A first accepted record has no pairwise distance. Keep the public value at zero until + // the batch contains a meaningful comparison. + if (MinimumDistance < TNumericLimits::Max()) + { + Result.MinimumAcceptedDistance = MinimumDistance; + } + } + return Result; +} + +bool VF_SaveStratePromotedRecords( + const FString& StorePath, + const TArray& Records, + FString& OutReport) +{ + if (StorePath.IsEmpty()) + { + OutReport = TEXT("promotion store path is empty"); + return false; + } + + TArray SortedRecords = Records; + SortedRecords.Sort([](const FVoxelStratePromotableRecord& A, + const FVoxelStratePromotableRecord& B) + { + return VF_PromotionSortKey(A) < VF_PromotionSortKey(B); + }); + + TSet RecordIds; + TArray> JsonRecords; + JsonRecords.Reserve(SortedRecords.Num()); + for (const FVoxelStratePromotableRecord& Record : SortedRecords) + { + if (Record.RecordId.IsEmpty() || RecordIds.Contains(Record.RecordId)) + { + OutReport = FString::Printf( + TEXT("promotion store contains an empty or duplicate record id (%s)"), + *Record.RecordId); + return false; + } + RecordIds.Add(Record.RecordId); + JsonRecords.Add(MakeShared(VF_SerializePromotableRecord(Record))); + } + + TSharedPtr Root = MakeShared(); + Root->SetStringField(TEXT("format"), TEXT("VoxelForgePromotedStrates")); + Root->SetNumberField(TEXT("schema_version"), GPromotionStoreSchemaVersion); + Root->SetNumberField(TEXT("record_count"), SortedRecords.Num()); + Root->SetArrayField(TEXT("records"), MoveTemp(JsonRecords)); + + FString Error; + if (!VF_SaveJsonObject(StorePath, Root, Error)) + { + OutReport = Error; + return false; + } + OutReport = FString::Printf(TEXT("saved %d promoted records to %s"), + SortedRecords.Num(), *StorePath); + return true; +} + +bool VF_SaveStrateSeasonManifest( + const FString& ManifestPath, + const FVoxelStrateSeasonManifest& Manifest, + FString& OutReport) +{ + if (ManifestPath.IsEmpty()) + { + OutReport = TEXT("season manifest path is empty"); + return false; + } + + TSharedPtr Root = MakeShared(); + Root->SetStringField(TEXT("format"), TEXT("VoxelForgeSeasonManifest")); + Root->SetNumberField(TEXT("schema_version"), GPromotionStoreSchemaVersion); + Root->SetNumberField(TEXT("season"), Manifest.Season); + Root->SetNumberField(TEXT("seed"), Manifest.Seed); + Root->SetNumberField(TEXT("input_corpus_hash"), + static_cast(Manifest.InputCorpusHash)); + Root->SetNumberField(TEXT("candidate_count"), Manifest.CandidateCount); + Root->SetNumberField(TEXT("survivor_count"), Manifest.SurvivorCount); + Root->SetNumberField(TEXT("promoted_count"), Manifest.PromotedCount); + Root->SetNumberField(TEXT("corpus_size"), Manifest.CorpusSize); + Root->SetNumberField(TEXT("project_count"), Manifest.ProjectCount); + Root->SetNumberField(TEXT("default_count"), Manifest.DefaultCount); + Root->SetNumberField(TEXT("promoted_corpus_count"), Manifest.PromotedCorpusCount); + Root->SetNumberField(TEXT("survival_rate"), Manifest.SurvivalRate); + Root->SetNumberField(TEXT("measured_spread"), Manifest.MeasuredSpread); + + FString Error; + if (!VF_SaveJsonObject(ManifestPath, Root, Error)) + { + OutReport = Error; + return false; + } + OutReport = FString::Printf(TEXT("saved season %d manifest to %s"), + Manifest.Season, *ManifestPath); + return true; +} + +bool FVoxelStrateCorpus::LoadPromotedRecords( + const FString& StorePath, + const IVoxelStratePromotionVerifier* PromotionVerifier, + FString& OutReport) +{ + if (StorePath.IsEmpty()) + { + OutReport = TEXT("no promotion store path supplied; 0 promoted members loaded"); + return false; + } + if (!IFileManager::Get().FileExists(*StorePath)) + { + OutReport = FString::Printf(TEXT("%s is absent; 0 promoted members loaded"), *StorePath); + return true; + } + + TSharedPtr Root; + FString JsonError; + if (!VF_LoadJsonObject(StorePath, Root, JsonError)) + { + OutReport = JsonError; + return false; + } + + int32 SchemaVersion = 0; + int32 StoredRecordCount = 0; + const TArray>* JsonRecords = nullptr; + if (!VF_ReadJsonInt32(Root, TEXT("schema_version"), SchemaVersion) + || SchemaVersion != GPromotionStoreSchemaVersion + || !VF_ReadJsonInt32(Root, TEXT("record_count"), StoredRecordCount) + || !VF_ReadJsonArray(Root, TEXT("records"), JsonRecords)) + { + OutReport = FString::Printf(TEXT("%s has unsupported or malformed promotion-store schema"), + *StorePath); + return false; + } + if (StoredRecordCount != JsonRecords->Num()) + { + OutReport = FString::Printf( + TEXT("%s has record_count=%d but contains %d records"), + *StorePath, StoredRecordCount, JsonRecords->Num()); + return false; + } + + if (PromotionVerifier == nullptr) + { + // A stored metric is evidence for a human, never an admission ticket. Without a + // world-specific verifier there is no safe way to establish the primordial law, so this + // compatibility call intentionally leaves the base corpus unchanged. + OutReport = FString::Printf( + TEXT("%s contains %d records; verifier was null, so all promoted records were skipped"), + *StorePath, JsonRecords->Num()); + return true; + } + + int32 NumLoaded = 0; + int32 NumMalformed = 0; + int32 NumRejected = 0; + int32 NumMetricReplaced = 0; + TSet SeenRecordIds; + for (const TSharedPtr& JsonValue : *JsonRecords) + { + if (!JsonValue.IsValid() || JsonValue->Type != EJson::Object) + { + ++NumMalformed; + continue; + } + + FVoxelStratePromotableRecord Record; + if (!VF_DeserializePromotableRecord(JsonValue->AsObject(), Record) + || SeenRecordIds.Contains(Record.RecordId)) + { + ++NumMalformed; + continue; + } + SeenRecordIds.Add(Record.RecordId); + + FVoxelStratePromotionVerification Verification; + if (!PromotionVerifier->Verify(Record, Verification) + || !Verification.PassedAllGates()) + { + ++NumRejected; + continue; + } + + if (!VF_MeasuredMetricsExactlyEqual(Record.MeasuredMetrics, + Verification.MeasuredMetrics)) + { + ++NumMetricReplaced; + } + const FString SourcePath = FString::Printf( + TEXT("/VoxelForge/Promoted/%s"), *Record.RecordId); + if (!AddEntryInternal(SourcePath, Record.RecordId, Record.Archetype, Record.Params, 1.0f, + EVoxelStrateCorpusProvenance::Promoted, + &Record.Recipe, &Verification.MeasuredMetrics, + Record.RecordId, Record.Season, Record.Seed, + Record.CandidateIndex, Record.InputCorpusHash, false)) + { + ++NumRejected; + continue; + } + ++NumLoaded; + } + + RebuildSpreads(); + OutReport = FString::Printf( + TEXT("%s: %d records re-verified; %d promoted members loaded; %d rejected by fresh gates; " + "%d malformed/duplicate; stored metrics replaced for %d records"), + *StorePath, NumLoaded + NumRejected, NumLoaded, NumRejected, NumMalformed, + NumMetricReplaced); + return true; +} + +bool FVoxelStrateCorpus::SetMeasuredMetrics( + const FString& SourcePath, + const FVoxelStrateMeasuredMetrics& Metrics) +{ + for (FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.SourcePath == SourcePath) + { + Entry.MeasuredMetrics = Metrics; + Entry.bHasMeasuredMetrics = Metrics.bValid; + return true; + } + } + return false; +} + FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index) { @@ -3558,6 +4954,34 @@ FVoxelStrateComposerCandidate VF_RollStrateCandidate( Candidate.bValid = bAllBlocksValid; return Candidate; } + +FVoxelStratePromotableRecord VF_MakeStratePromotableRecord( + const FVoxelStrateComposerCandidate& Candidate, + const FVoxelStrateMetrics& Metrics, + int32 Season, + uint32 InputCorpusHash, + bool bPassedNonVacuous, + bool bPassedLargestComponent, + bool bPassedPrimordialLaw) +{ + FVoxelStratePromotableRecord Record; + Record.Season = Season; + Record.Seed = Candidate.Seed; + Record.CandidateIndex = Candidate.Index; + Record.InputCorpusHash = InputCorpusHash; + Record.Archetype = Candidate.Archetype; + Record.Params = Candidate.ArchetypeParams; + Record.Recipe = Candidate.Recipe; + Record.MeasuredMetrics = VF_SummarizeStrateMetrics(Metrics); + Record.bPassedNonVacuous = bPassedNonVacuous; + Record.bPassedLargestComponent = bPassedLargestComponent; + Record.bPassedPrimordialLaw = bPassedPrimordialLaw; + Record.RecordId = FString::Printf( + TEXT("season_%04d_seed_%d_candidate_%04d_corpus_%08x_recipe_%08x"), + Season, Candidate.Seed, Candidate.Index, InputCorpusHash, + VF_HashStrateStructureRecipe(Candidate.Recipe)); + return Record; +} #endif FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe) diff --git a/Source/VoxelForge/Private/VoxelStratePreview.cpp b/Source/VoxelForge/Private/VoxelStratePreview.cpp index f0c387c..4f5bc24 100644 --- a/Source/VoxelForge/Private/VoxelStratePreview.cpp +++ b/Source/VoxelForge/Private/VoxelStratePreview.cpp @@ -177,6 +177,28 @@ namespace return bAnyWalkable ? GWalkableColor : bAnyAir ? GAirColor : GSolidColor; } + bool VF_HasContentVariation(const TArray& Pixels, + int32 Width, + int32 ContentHeight) + { + if (Width <= 0 || ContentHeight <= 0 || Pixels.Num() < Width * ContentHeight) + { + return false; + } + const FColor First = Pixels[0]; + for (int32 Y = 0; Y < ContentHeight; ++Y) + { + for (int32 X = 0; X < Width; ++X) + { + if (Pixels[Y * Width + X] != First) + { + return true; + } + } + } + return false; + } + FPlanSliceChoice VF_ChoosePlanSlice(const FVoxelStrateSampleGrid& Grid, int32 HeadroomCells) { @@ -864,6 +886,8 @@ bool VF_WriteStratePreviewFineCandidate( CandidateIndex); return false; } + InOutCandidate.bFineVerticalContentVaries = VF_HasContentVariation( + Pixels, Width, Height - GPreviewScaleBarFooterPixels); const bool bHasContour = FineGrid.HasScalarDensity(); if (bHasContour) @@ -896,6 +920,8 @@ bool VF_WriteStratePreviewFineCandidate( CandidateIndex); return false; } + InOutCandidate.bFinePlanContentVaries = VF_HasContentVariation( + Pixels, Width, Height - GPreviewScaleBarFooterPixels); if (bHasContour) { int32 ContourWidth = 0; @@ -914,6 +940,8 @@ bool VF_WriteStratePreviewFineCandidate( InOutCandidate.bFineRendered = true; InOutCandidate.bFineContourRendered = bHasContour; + InOutCandidate.bFineBlank = !InOutCandidate.bFineVerticalContentVaries + && !InOutCandidate.bFinePlanContentVaries; InOutCandidate.FinePlanSliceCellZ = Plan.CellZ; InOutCandidate.FinePlanSliceWorldZ = VF_CellWorldCoordinate( FineGrid.MinZ, FineGrid.MaxZ, FineGrid.SampleStep, Plan.CellZ); @@ -1021,8 +1049,10 @@ bool VF_WriteStratePreviewIndex( "air" "walkable cell" "density=0 contour\n" - "

Fine ROI: the separate step-1 pass is requested only " - "for selected candidates (this run selects coarse survivors). Its exact window is printed " + "

Fine ROI — largest open space: the separate step-1 pass " + "is requested only for selected candidates (this run selects coarse survivors). Its XY " + "window is centred on the deterministic LargestComponentPoint from the coarse " + "air flood fill, not on the strate midpoint or the world origin. Its exact window is printed " "on the card. If its cell cap is exceeded, no fine allocation is made and the refusal is " "shown on that card.

\n" "

Ordering: survivors first, then each group by descending distance from " @@ -1115,7 +1145,7 @@ bool VF_WriteStratePreviewIndex( Html += FString::Printf(TEXT("

No coarse image: %s
\n"), *VF_HtmlEscape(Candidate.RenderFailureReason)); } - Html += TEXT("\n

FINE ROI — filled cells and density=0 contour

\n"); + Html += TEXT("
\n

FINE ROI — largest open space; filled cells and density=0 contour

\n"); if (!Candidate.bFineRequested) { Html += TEXT("

Fine ROI not requested for this candidate; it did not pass the coarse survivor screen.

\n"); @@ -1132,7 +1162,7 @@ bool VF_WriteStratePreviewIndex( { const FString FineWindowDescription = Candidate.FineWindow.Describe(); Html += FString::Printf( - TEXT("

Exact fine ROI window: %s; slice Y=%d voxels.

\n" + TEXT("

Largest-open-space fine ROI; exact window: %s; slice Y=%d voxels.

\n" "
\"candidate
" "FINE ROI · filled cells · step=%d
\n"), *VF_HtmlEscape(FineWindowDescription), Candidate.FineVerticalSliceWorldY, @@ -1154,7 +1184,7 @@ bool VF_WriteStratePreviewIndex( } Html += FString::Printf( - TEXT("

Exact fine ROI window: %s; selected Z=%d voxels.

\n" + TEXT("

Largest-open-space fine ROI; exact window: %s; selected Z=%d voxels.

\n" "
\"candidate
" "FINE ROI · filled cells · step=%d
\n"), *VF_HtmlEscape(FineWindowDescription), Candidate.FinePlanSliceWorldZ, diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index 31a8a7b..c64f665 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -10,6 +10,7 @@ #include "CoreMinimal.h" #include "VoxelStrateTypes.h" #include "VoxelDensityOpStack.h" +#include "VoxelStrateMeasure.h" #include "VoxelStrateComposer.generated.h" @@ -142,6 +143,7 @@ struct VOXELFORGE_API FVoxelStrateFieldSpread // Actual authored corpus samples and synthetic per-operation default samples are kept // separate in the report. SampleCount is their effective total used for the statistics. int32 AuthoredSampleCount = 0; + int32 PromotedSampleCount = 0; int32 DefaultSeedCount = 0; int32 SampleCount = 0; double Min = 0.0; @@ -157,6 +159,54 @@ struct VOXELFORGE_API FVoxelStrateFieldSpread double ClampMax = 0.0; }; +/** Where a member of the offline corpus came from. */ +enum class EVoxelStrateCorpusProvenance : uint8 +{ + Project, + Default, + Promoted, +}; + +/** The finite, diffable metric summary stored with a promoted strate. */ +struct VOXELFORGE_API FVoxelStrateMeasuredMetrics +{ + bool bValid = false; + + int64 NumSampled = 0; + int64 NumAir = 0; + int64 NumSolid = 0; + float AirFraction = 0.0f; + int32 NumAirComponents = 0; + float LargestComponentShare = 0.0f; + FVector LargestComponentPoint = FVector::ZeroVector; + int64 LargestComponentCells = 0; + int32 NumComponentsAtLeast1Pct = 0; + float WalkableFraction = 0.0f; + float MedianFeatureScale = 0.0f; + int32 MedianVerticalClearance = 0; + + int32 ResolvedMarginVoxels = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + int32 SampledNumX = 0; + int32 SampledNumY = 0; + int32 SampledNumZ = 0; + float SampledMinX = 0.0f; + float SampledMaxX = 0.0f; + float SampledMinY = 0.0f; + float SampledMaxY = 0.0f; + + // This is the component-size audit trail, not a retained density grid. It is bounded by the + // number of discovered air components and is useful when reviewing a promoted record. + TArray AirComponentCells; + + bool IsUsable() const; +}; + +/** Copy the measured facts that are safe and useful to persist; no sampler or grid is retained. */ +VOXELFORGE_API FVoxelStrateMeasuredMetrics VF_SummarizeStrateMetrics( + const FVoxelStrateMetrics& Metrics); + /** Native parameter storage for every currently supported strate archetype. */ struct VOXELFORGE_API FVoxelStrateArchetypeParams { @@ -187,6 +237,129 @@ struct VOXELFORGE_API FVoxelStrateCorpusEntry // use 1.0. The field remains explicit so a future offline corpus can assign weights without // changing the roll algorithm. float Weight = 1.0f; + + EVoxelStrateCorpusProvenance Provenance = EVoxelStrateCorpusProvenance::Project; + + // Promoted entries retain their recipe and re-measured facts so the next policy decision can + // compare measured geometry, not only parameter-space distance. Project/default entries leave + // these fields unset unless an offline simulation explicitly measures them. + bool bHasRecipe = false; + FVoxelOpStackRecipe Recipe; + bool bHasMeasuredMetrics = false; + FVoxelStrateMeasuredMetrics MeasuredMetrics; + + FString PromotionRecordId; + int32 PromotionSeason = INDEX_NONE; + int32 PromotionSeed = 0; + int32 PromotionCandidateIndex = INDEX_NONE; + uint32 PromotionInputCorpusHash = 0; +}; + +/** A candidate record that is eligible to be considered for corpus promotion. */ +struct VOXELFORGE_API FVoxelStratePromotableRecord +{ + FString RecordId; + int32 Season = 0; + int32 Seed = 0; + int32 CandidateIndex = INDEX_NONE; + uint32 InputCorpusHash = 0; + + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams Params; + FVoxelOpStackRecipe Recipe; + FVoxelStrateMeasuredMetrics MeasuredMetrics; + + // These are recorded evidence for review. The loader deliberately ignores the stored metric + // and gate values and asks its verifier to rebuild/re-measure the recipe instead. + bool bPassedNonVacuous = false; + bool bPassedLargestComponent = false; + bool bPassedPrimordialLaw = false; +}; + +/** Fresh validation output used by the safe promoted-record loader. */ +struct VOXELFORGE_API FVoxelStratePromotionVerification +{ + bool bPassedNonVacuous = false; + bool bPassedLargestComponent = false; + bool bPassedPrimordialLaw = false; + FVoxelStrateMeasuredMetrics MeasuredMetrics; + FString FailureReason; + + bool PassedAllGates() const + { + return bPassedNonVacuous && bPassedLargestComponent && bPassedPrimordialLaw + && MeasuredMetrics.IsUsable(); + } +}; + +/** + * World-specific re-measurement seam for promoted records. + * + * A corpus store cannot know the active fixture, mouths, or validation window. Its caller must + * provide this verifier; a null verifier means promoted records are skipped rather than trusted. + */ +class VOXELFORGE_API IVoxelStratePromotionVerifier +{ +public: + virtual ~IVoxelStratePromotionVerifier() = default; + virtual bool Verify(const FVoxelStratePromotableRecord& Record, + FVoxelStratePromotionVerification& OutVerification) const = 0; + + // Optional companion audit for project/default members. A policy pass can then compare a + // candidate against the measured corpus rather than falling back to parameter identity. A + // world-specific verifier may return false when that fixture cannot measure the member. + virtual bool MeasureCorpusEntry(const FVoxelStrateCorpusEntry& Entry, + FVoxelStrateMeasuredMetrics& OutMetrics) const + { + (void)Entry; + (void)OutMetrics; + return false; + } +}; + +/** Explicit, provisional Tier 5 policy. The owner can change these after seeing the preview. */ +struct VOXELFORGE_API FVoxelStratePromotionPolicy +{ + // Five normalized measured dimensions are compared with Euclidean distance. 0.20 is large + // enough to reject close copies while still admitting genuinely different geometry; it is a + // provisional review threshold, not a claim that this is the owner's final definition of good. + double MinimumNormalizedMeasuredMetricDistance = 0.20; + int32 MaxPromotionsPerSeason = 6; + + // Physical normalization for the two unbounded-in-principle metric dimensions. Air fraction, + // largest share, and walkable fraction already live in [0,1]. + float FeatureScaleNormalizationVoxels = 256.0f; + float ClearanceNormalizationVoxels = 64.0f; +}; + +/** Counters and accepted records from one deterministic policy pass. */ +struct VOXELFORGE_API FVoxelStratePromotionBatchResult +{ + bool bPolicyValid = false; + TArray Promoted; + int32 NumCandidates = 0; + int32 NumValidationRejected = 0; + int32 NumCorpusHashRejected = 0; + int32 NumNearDuplicateRejected = 0; + int32 NumCapRejected = 0; + double MinimumAcceptedDistance = 0.0; +}; + +/** One line of the diffable season manifest written beside the cumulative record store. */ +struct VOXELFORGE_API FVoxelStrateSeasonManifest +{ + int32 Season = 0; + int32 Seed = 0; + uint32 InputCorpusHash = 0; + int32 CandidateCount = 0; + int32 SurvivorCount = 0; + int32 PromotedCount = 0; + int32 CorpusSize = 0; + int32 ProjectCount = 0; + int32 DefaultCount = 0; + int32 PromotedCorpusCount = 0; + float SurvivalRate = 0.0f; + double MeasuredSpread = 0.0; }; /** Result of one deterministic roll, including provenance for the offline report. */ @@ -243,15 +416,20 @@ struct VOXELFORGE_API FVoxelStrateComposerCandidate * * `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. + * known-good default vector for every archetype/family in VoxelStrateTypes.h, then optionally + * loads the cumulative promoted-record JSON store. Promoted records enter only after the caller's + * world-specific verifier rebuilds and remeasures them; with no verifier they are skipped safely. + * 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); + bool LoadFromAssetRegistry( + FString& OutReport, + const FString& PromotedStorePath = FString(), + const IVoxelStratePromotionVerifier* PromotionVerifier = nullptr); /** * Compatibility alias retained for callers compiled against the first Tier 4a pass. The @@ -268,6 +446,15 @@ public: ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params, float Weight = 1.0f); + /** Load cumulative promoted records, re-verifying every record before it enters Entries. */ + bool LoadPromotedRecords(const FString& StorePath, + const IVoxelStratePromotionVerifier* PromotionVerifier, + FString& OutReport); + + /** Attach an offline measurement to an existing member without changing corpus identity. */ + bool SetMeasuredMetrics(const FString& SourcePath, + const FVoxelStrateMeasuredMetrics& Metrics); + bool IsValid() const { return bSchemaValid && Entries.Num() > 0; } bool IsSchemaValid() const { return bSchemaValid; } const FString& GetSchemaError() const { return SchemaError; } @@ -278,6 +465,7 @@ public: const TArray& GetSkippedDefinitions() const { return SkippedDefinitions; } int32 NumForArchetype(ECaveGeneratorType Archetype) const; + int32 NumForProvenance(EVoxelStrateCorpusProvenance Provenance) const; const FVoxelStrateFieldSpread* FindSpread( ECaveGeneratorType Archetype, const FString& FieldName) const; @@ -296,6 +484,18 @@ public: bool AreClampMetadataAvailableAtRuntime() const { return false; } private: + bool AddEntryInternal(const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, + float Weight, EVoxelStrateCorpusProvenance Provenance, + const FVoxelOpStackRecipe* Recipe, + const FVoxelStrateMeasuredMetrics* Metrics, + const FString& PromotionRecordId, + int32 PromotionSeason, + int32 PromotionSeed, + int32 PromotionCandidateIndex, + uint32 PromotionInputCorpusHash, + bool bRebuild); void RebuildSpreads(); TArray Entries; @@ -308,6 +508,8 @@ private: /** Human-readable name used by the offline report. */ VOXELFORGE_API const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype); +VOXELFORGE_API const TCHAR* VF_GetStrateCorpusProvenanceName( + EVoxelStrateCorpusProvenance Provenance); /** Full provenance result. Pure with respect to the corpus: no global or retained RNG state. */ VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailed( @@ -349,6 +551,30 @@ VOXELFORGE_API bool VF_ValidateStrateCorpusFreeConstraints( float StrateHeightInVoxels, FString& OutViolation); +/** Normalized Euclidean distance in the five policy metric dimensions. */ +VOXELFORGE_API double VF_NormalizedMeasuredMetricDistance( + const FVoxelStrateMeasuredMetrics& A, + const FVoxelStrateMeasuredMetrics& B, + const FVoxelStratePromotionPolicy& Policy); + +/** Select deterministic, validated, varied records for one season. */ +VOXELFORGE_API FVoxelStratePromotionBatchResult VF_SelectStratePromotions( + const FVoxelStrateCorpus& Corpus, + const TArray& Candidates, + const FVoxelStratePromotionPolicy& Policy); + +/** Write/readable cumulative JSON store; records are sorted by RecordId before writing. */ +VOXELFORGE_API bool VF_SaveStratePromotedRecords( + const FString& StorePath, + const TArray& Records, + FString& OutReport); + +/** Write the matching season summary beside the promoted-record store. */ +VOXELFORGE_API bool VF_SaveStrateSeasonManifest( + const FString& ManifestPath, + const FVoxelStrateSeasonManifest& Manifest, + FString& OutReport); + #if WITH_EDITOR /** * Roll one candidate for the editor walk-through path. Parameter rolls call @@ -373,6 +599,16 @@ VOXELFORGE_API bool VF_BuildNativeStrateStackForCandidate( const UVoxelStrateManager* StrateManager, FVoxelOpStack& OutStack, FVoxelOpContext& OutContext); + +/** Build the persisted record from the exact editor candidate and its just-measured gate facts. */ +VOXELFORGE_API FVoxelStratePromotableRecord VF_MakeStratePromotableRecord( + const FVoxelStrateComposerCandidate& Candidate, + const FVoxelStrateMetrics& Metrics, + int32 Season, + uint32 InputCorpusHash, + bool bPassedNonVacuous, + bool bPassedLargestComponent, + bool bPassedPrimordialLaw); #endif /** Stable compact representation and equality/hash helpers for manifests and reports. */ diff --git a/Source/VoxelForge/Public/VoxelStratePreview.h b/Source/VoxelForge/Public/VoxelStratePreview.h index 35ae823..d3f77c7 100644 --- a/Source/VoxelForge/Public/VoxelStratePreview.h +++ b/Source/VoxelForge/Public/VoxelStratePreview.h @@ -39,7 +39,8 @@ struct VOXELFORGE_API FVoxelStrateFinePreviewSettings // The default is deliberately one voxel: the pass exists to expose detail that step 4 cannot. int32 SampleStep = 1; // radius=64 gives a 128x128 XY ROI and stays below the default two-million-cell cap for the - // four-chunk fixture while retaining the same origin-centred frame as the coarse sweep. + // four-chunk fixture. The caller may re-centre it on the deterministic largest open space + // point found by the coarse air flood fill; it is not inherently the strate midpoint. int32 RadiusInVoxels = 64; int32 MaxCells = 2000000; FVector2D CenterXY = FVector2D::ZeroVector; @@ -96,6 +97,9 @@ struct VOXELFORGE_API FVoxelStratePreviewCandidate bool bFineRequested = false; bool bFineRendered = false; bool bFineContourRendered = false; + bool bFineVerticalContentVaries = false; + bool bFinePlanContentVaries = false; + bool bFineBlank = false; float AirFraction = 0.0f; float LargestComponentShare = 0.0f; diff --git a/Source/VoxelForge/VoxelForge.Build.cs b/Source/VoxelForge/VoxelForge.Build.cs index af6c5f8..ede695e 100644 --- a/Source/VoxelForge/VoxelForge.Build.cs +++ b/Source/VoxelForge/VoxelForge.Build.cs @@ -79,6 +79,7 @@ public class VoxelForge : ModuleRules // Private dependencies - only used in our .cpp files, not exposed in headers PrivateDependencyModuleNames.AddRange(new string[] { + "Json", // Diffable Tier 5 promoted-record and season-manifest stores "ImageWrapper", // PNG encode for the biome-map preview bake (BakeBiomePreview) "RHI", // Texture3D create + RHIUpdateTexture3D for the density volume (mini-sun shadows) "RenderCore", // ENQUEUE_RENDER_COMMAND for the volume upload