From 42e1f0ec8cfaebe22b2bdbe6cad5ba8a1749e34b Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sat, 5 Sep 2026 02:55:50 +0200 Subject: [PATCH] feat(composer): Tier 4c -- a season that can actually ship, with a veto page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer could invent, measure and show strates. What did not exist was an ARTEFACT a season could ship. Now it does. VF_ComposeSeason: generate N candidates -> measure -> reject failures -> select a descent sequence -> emit a JSON manifest + a review page. ⭐ THE PROPERTY THAT MATTERS: round-trip bit-identity. The manifest was loaded, every selected stack rebuilt, and 393,216 density samples compared BIT-IDENTICALLY. Re-composing the same seed yields byte-identical JSON. A manifest that cannot reproduce its own world is worthless, and that is the one thing that could not be assumed. Floats serialise as readable values PLUS exact IEEE bits, so a season survives a text round trip. Test season: 24 generated, 19 survived the hard gates, 6 selected. 18 not selected -- 4 vacuous, 1 primordial-law failure, 13 policy non-selections. 4 grounded, 2 outliers. EVERY selected slot passes the primordial law, asserted per slot rather than sampled. Selection policy is explicit, named and PROVISIONAL, because Jahni has not defined "good" yet and said so: "it'll be easier once i'm able to see the strates candidates." So no quality score was invented and presented as settled. The knobs are 75/25 grounded-to-outlier (outlier axis = distance from corpus centroid), adjacent-similarity threshold .20 with penalty 1.0, new-archetype bonus .10, new-recipe bonus .05, boss cadence every 5th non-terminal slot. All of them are his to turn. The veto is a review page in DESCENT ORDER -- the season read top to bottom the way it is played, not the survivor/outlier ordering the candidate sheet uses. Reuses VoxelStratePreview rather than a second renderer. Saved/VoxelForge/SeasonTest/season_0000_seed_9320/season_manifest.json .../review/index.html ⚠️ FIVE GAPS CODEX RAISED, recorded not buried: 1. §4's Tier A is strates PLUS passages; only the strate spine is persisted. Manager-generated passage geometry and content placement are not. 2. Authored fixed strates keep scalar vectors + asset paths, not every asset-level passage/biome/disturbance/content field. 3. Boss slots are MARKERS, not arena or content guarantees. 4. WorldRadiusVoxels == 0 is enforced as specced but contradicts a production bounded world -- Jahni's call, still unset by design. 5. Bit identity is proven for the same build and platform. A frozen season spanning engine versions needs an explicit schema contract. Real risk, since seasons freeze generation for months. Build clean, 28 succeeded / 0 failed, verified by me. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU --- CODEMAP.md | 16 + COMPOSER-NOTES.md | 92 +- .../Tests/VoxelForgeComposerSeasonTest.cpp | 208 ++ .../Private/VoxelSeasonManifest.cpp | 2729 +++++++++++++++++ .../VoxelForge/Private/VoxelStratePreview.cpp | 26 +- .../VoxelForge/Public/VoxelSeasonManifest.h | 269 ++ .../VoxelForge/Public/VoxelStrateComposer.h | 10 + Source/VoxelForge/Public/VoxelStratePreview.h | 6 + 8 files changed, 3351 insertions(+), 5 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp create mode 100644 Source/VoxelForge/Private/VoxelSeasonManifest.cpp create mode 100644 Source/VoxelForge/Public/VoxelSeasonManifest.h diff --git a/CODEMAP.md b/CODEMAP.md index 931f337..2377595 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -523,6 +523,21 @@ the caller centres it on each coarse survivor's `LargestComponentPoint` and the 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. +**`Public/VoxelSeasonManifest.h` + `Private/VoxelSeasonManifest.cpp`** — Tier 4c's offline season +artifact. `VF_ComposeSeason` generates a bounded configurable candidate batch, applies the Tier 2 +non-vacuous / largest-component / primordial-law gates, then selects an ordered Tier A spine with the +explicit provisional grounded/outlier, adjacency, variety, and five-slot boss policy. The JSON stores +the complete six-family parameter vector (float values carry both a readable number and exact IEEE-754 +bits), seed/index/archetype/recipe, bounds, measured metrics, gate facts, candidate audit, and rejection +counts. `VF_LoadVoxelSeasonManifest` plus `VF_RebuildVoxelSeasonStrate` are the round-trip seam; generated +recipe entries rebuild through the mandatory-post builder, while authored `FixedStrates` retain their +absolute slot and native vector. The selected-only descent review reuses `VoxelStratePreview`, and all +composition/review work is editor/build-box code; normal runtime generation is untouched. The focused +season test currently reports **24 generated / 19 hard-gate survivors / 6 selected / 18 unselected or +rejected**, with **4 vacuous**, **1 primordial-law budget**, **13 policy-not-selected**, and **393,216** +round-trip density samples bit-identical. The specified UE 5.7 editor build succeeded and the full +`VoxelForge` namespace passed **28/28 tests** after this seam was added. + ### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp` `UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; **negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`. @@ -590,6 +605,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | ″ | `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. | | `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**. | +| `VoxelForgeComposerSeasonTest.cpp` | `VoxelForge.Composer.Season` | Composes a six-slot offline season from 24 structure candidates; asserts **19 hard-gate survivors**, **6 selected**, deterministic byte-identical JSON, absolute descent order, all selected primordial-law facts, and a selected-only review page with metrics/reasons/boss markers. Loads the JSON, rebuilds every selected stack, and compares **393,216** bounded density samples bit-for-bit. Rejection audit: **4 vacuous**, **1 primordial-law budget**, **13 policy-not-selected**. | | `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 c69d799..160c8fe 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -89,6 +89,64 @@ normal game path.** The editor-only PIE inspection button described below is a d exception: it overlays one already-built slot so the owner can walk a measured candidate; it is not cooked, does not compose a season, and is cleared by RebuildStrates. +**Tier 4c is now the build-box seam:** `VF_ComposeSeason(SeasonSeed, Settings)` generates a bounded +candidate batch, measures every candidate, rejects vacuous/fragmented/primordial-law failures, and +writes the selected Tier A spine as diffable `season_manifest.json`. The default test budget is 24 +candidates at measurement step 8 and a six-slot output; production can raise the candidate count for +the overnight budget without changing the runtime contract. The manifest stores each selected seed, +recipe, complete native parameter-family vector, archetype, bounds, measured metrics, and gate facts. +Floating-point fields carry both a readable value and their exact IEEE-754 bit pattern, so a loader can +rebuild the same stack and the season test can assert bit-identical density rather than assume it. + +The selected-only review page is a descent-ordered `VoxelStratePreview` contact sheet. Each card shows +the depth, recipe, metrics, grounded/outlier/fixed selection reason, and boss-slot marker. Selection is +deliberately provisional: the current readable policy reserves 75% grounded and 25% outlier slots, +penalises near-identical neighbours, rewards new archetypes/recipes, and respects fixed absolute slots. +Jahni's preview and veto remain the decision that turns this artifact into a published season. The +composer and review writer are editor/build-box code only; no normal runtime generation path calls +them, and `WorldRadiusVoxels` remains 0. + +An abbreviated manifest shape is: + +```json +{ + "format": "VoxelForgeSeasonManifest", + "schema_version": 1, + "season": 0, + "seed": 9320, + "candidate_count": 24, + "survivor_count": 19, + "selected_count": 6, + "strates": [ + { + "depth_index": 0, + "seed": 123, + "archetype": "Maze", + "uses_recipe": true, + "recipe": { + "root_polarity": 0, + "root": { "op_class": 0, "param_block": 3 }, + "shape_source": { "op_class": 3, "param_block": 3 }, + "conversion": { "op_class": 8, "param_block": 3 }, + "structural_param_block": 3, + "modifiers": [] + }, + "parameters": { "maze": { "LatticeSpacing": { "value": 18.0, "bits": "0x41900000" } } }, + "measured_metrics": { + "air_fraction": { "kind": "float", "value": 0.5, "bits": "0x3F000000" }, + "largest_component_share": { "kind": "float", "value": 0.9, "bits": "0x3F666666" } + }, + "selection_reason": "grounded; provisional 75/25 policy", + "boss_slot": false + } + ] +} +``` + +The real file is complete; the sample is intentionally abbreviated. `FVoxelSeasonFixedStrate` also +allows a build invocation to provide a full recipe/vector for an absolute slot, while entries adapted +from today's authored `FixedStrates` retain their native vector and source asset path. + ### 3.2 Structure: how a stack gets assembled The current shipping builders, for reference (structural posts included; TunnelNetwork's builder is @@ -759,8 +817,15 @@ reviewable outlier quota instead of assuming survivor promotion preserves surpri ### ⭐ It is TWO things, not one **Tier A — the SPINE. Finite, tiny, actually stored.** Strate slots and passages. ~30 strates plus a few -passages each is roughly 100 entries — kilobytes. **This already exists** as `StrateLayout` plus the -passage list, computed once in `UVoxelStrateManager::Initialize`. +passages each is roughly 100 entries — kilobytes. The runtime manager already computes `StrateLayout` +and its passage list once in `UVoxelStrateManager::Initialize`; Tier 4c now adds the first reviewable +serialized Tier A artifact, `season_manifest.json`, containing the selected ordered strate spine and +the data required to reproduce each selected density field exactly. + +The current Tier 4c manifest deliberately serializes the chosen strates, not the manager's generated +inter-strate passage geometry or the full material/creature/content placement payload. The season test +therefore proves each stored strate's density round trip; passage serialization and the wider content +manifest remain a follow-up before this file can be called the complete world manifest described here. **Tier B — the FIELD. Effectively unbounded in XY, therefore NEVER stored.** Landmarks, rooms, material deposits, build sites. It is a **function**: give it a region, it enumerates what is there. @@ -1038,8 +1103,16 @@ roughness and should stay late in the stack. 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). +- ✅ **Season pipeline (§3.1, §3.5, §4, Tier 4c)** — `VF_ComposeSeason` generates and measures a + bounded candidate batch, rejects vacuous/fragmented/primordial-law failures, and writes a Tier A + JSON spine plus a selected-only descent review page using `VoxelStratePreview`. The explicit policy + is provisional: default 75% grounded / 25% outlier, adjacent-similarity penalty, archetype/recipe + variety bonuses, and fixed absolute slots with boss markers every five non-terminal slots. The + focused artifact run was **24 generated / 19 hard-gate survivors / 6 selected / 18 rejected or + unselected**: 4 vacuous, 1 primordial-law budget failure, and 13 policy-not-selected. The loader + rebuild test compared **393,216 density samples bit-identically**, and the same seed emitted + byte-identical JSON. This closes the offline artifact and veto seam; full passage/content manifest + serialization is still open as noted in §4. ### Tier 5 — the long game - ✅ **Promotion** (good strates rejoin the corpus) — JSON promotable records and season manifests, @@ -1145,6 +1218,17 @@ in §3.3. The StructureRoll preview measured fine blank plan/card counts **6/42 42 fine filled pairs plus 42 contour pairs with 0 refusals. No existing generation/equivalence test failed. +**2026-09-05 — Tier 4c season validation.** The specified UE 5.7 `VoxelMEditor Win64 Development` +build succeeded after adding the season manifest and review seam. The focused +`VoxelForge.Composer.Season` test passed, then the full headless `VoxelForge` namespace reported +**28 succeeded, 0 failed, 0 not run, 0 succeeded-with-warnings**. The season artifact used 24 +generated candidates, 19 hard-gate survivors, and selected 6; it recorded 4 vacuous rejections, 1 +primordial-law budget rejection, and 13 provisional-policy non-selections. Reloading the JSON and +rebuilding every selected stack compared **393,216 density samples bit-identically**; composing the +same seed twice emitted byte-identical JSON. The selected-only descent review was written beside the +manifest. The run also kept all existing equivalence, determinism, box-verdict, and connectivity +tests green. + --- ## 11. Tier 1 status — passages aim at real open space (bounded full-point query; build not run) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp new file mode 100644 index 0000000..9173fdb --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp @@ -0,0 +1,208 @@ +// Tier 4c — offline season composition, manifest determinism, and exact rebuild. + +#if WITH_DEV_AUTOMATION_TESTS && WITH_EDITOR + +#include "Misc/AutomationTest.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +#include "VoxelDensityOpStack.h" +#include "VoxelSeasonManifest.h" +#include "VoxelStrateComposer.h" + +namespace +{ + bool SameDensityBits(float A, float B) + { + return FMemory::Memcmp(&A, &B, sizeof(float)) == 0; + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeComposerSeasonTest, + "VoxelForge.Composer.Season", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters) +{ + (void)Parameters; + + FVoxelStrateCorpus Corpus; + FString CorpusReport; + TestTrue(TEXT("season test loads a valid corpus"), + Corpus.LoadFromAssetRegistry(CorpusReport) && Corpus.IsValid()); + if (!Corpus.IsValid()) + { + AddError(CorpusReport); + return false; + } + + FVoxelSeasonCompositionSettings Settings; + Settings.Corpus = &Corpus; + Settings.CandidateCount = 24; + Settings.SelectedStrateCount = 6; + Settings.StrateHeightInChunks = 4; + Settings.InterStrateGapChunks = 0; + Settings.OriginSpineRadius = 14.0f; + Settings.WorldRadiusVoxels = 0.0f; + Settings.OutputDirectory = FPaths::Combine( + FPaths::ProjectSavedDir(), TEXT("VoxelForge"), TEXT("SeasonTest")); + Settings.bWriteArtifacts = true; + Settings.bWriteReviewPage = true; + Settings.bIncludeCandidateAudit = true; + + const int32 SeasonSeed = 0x2468; + const FVoxelSeasonManifest Manifest = VF_ComposeSeason(SeasonSeed, Settings); + TestTrue(TEXT("composition produces a usable season manifest"), Manifest.IsUsable()); + if (!Manifest.IsUsable()) + { + AddError(Manifest.Error); + return false; + } + TestEqual(TEXT("candidate count is the configured number of attempts"), + Manifest.CandidateCount, Settings.CandidateCount); + TestEqual(TEXT("selected count is the configured spine length"), + Manifest.SelectedCount, Settings.SelectedStrateCount); + TestEqual(TEXT("candidate audit contains every attempt"), + Manifest.CandidateAudit.Num(), Settings.CandidateCount); + TestEqual(TEXT("WorldRadiusVoxels remains zero in the artifact"), + Manifest.WorldRadiusVoxels, 0.0f); + TestEqual(TEXT("selected grounded/outlier counts add to generated slots"), + Manifest.GroundedSelectedCount + Manifest.OutlierSelectedCount, + Manifest.SelectedCount - Manifest.FixedSelectedCount); + + for (int32 Index = 0; Index < Manifest.Strates.Num(); ++Index) + { + const FVoxelSeasonStrate& Strate = Manifest.Strates[Index]; + TestEqual(FString::Printf(TEXT("slot %d keeps descent order"), Index), + Strate.DepthIndex, Index); + TestTrue(FString::Printf(TEXT("slot %d passed non-vacuous gate"), Index), + Strate.bPassedNonVacuous); + TestTrue(FString::Printf(TEXT("slot %d passed largest-component gate"), Index), + Strate.bPassedLargestComponent); + TestTrue(FString::Printf(TEXT("slot %d passed primordial law"), Index), + Strate.bPassedPrimordialLaw + && Strate.PrimordialLawResult == EVoxelConnectivityResult::Connected); + TestTrue(FString::Printf(TEXT("slot %d has zero world radius"), Index), + Manifest.WorldRadiusVoxels == 0.0f); + } + + TestTrue(TEXT("season manifest JSON was emitted"), !Manifest.SerializedJson.IsEmpty()); + TestTrue(TEXT("season manifest file was emitted"), FPaths::FileExists(Manifest.ManifestPath)); + TestTrue(TEXT("selected-season review page was emitted"), + FPaths::FileExists(Manifest.ReviewPagePath)); + FString ReviewHtml; + if (FFileHelper::LoadFileToString(ReviewHtml, *Manifest.ReviewPagePath)) + { + TestTrue(TEXT("review page explains why each selected slot was chosen"), + ReviewHtml.Contains(TEXT("Why selected:"))); + TestTrue(TEXT("review page marks boss slots"), ReviewHtml.Contains(TEXT("Boss slot:"))); + } + else + { + AddError(FString::Printf(TEXT("could not read review page %s"), *Manifest.ReviewPagePath)); + } + + // Compose again with the same inputs. SerializedJson excludes derived filesystem paths, so + // this is the byte-level determinism assertion for the portable artifact itself. + const FVoxelSeasonManifest RepeatManifest = VF_ComposeSeason(SeasonSeed, Settings); + TestTrue(TEXT("repeat composition is usable"), RepeatManifest.IsUsable()); + TestEqual(TEXT("same season seed produces byte-identical JSON"), + RepeatManifest.SerializedJson, Manifest.SerializedJson); + + FVoxelSeasonManifest LoadedManifest; + FString LoadReport; + TestTrue(TEXT("manifest loads from its JSON file"), + VF_LoadVoxelSeasonManifest(Manifest.ManifestPath, LoadedManifest, LoadReport)); + if (!LoadedManifest.IsUsable()) + { + AddError(LoadReport); + return false; + } + TestEqual(TEXT("loaded manifest has the same number of selected strates"), + LoadedManifest.Strates.Num(), Manifest.Strates.Num()); + + int64 ComparedDensitySamples = 0; + bool bAllDensityBitsIdentical = true; + for (int32 Index = 0; Index < Manifest.Strates.Num(); ++Index) + { + const FVoxelSeasonStrate& OriginalStrate = Manifest.Strates[Index]; + const FVoxelSeasonStrate& LoadedStrate = LoadedManifest.Strates[Index]; + TestTrue(FString::Printf(TEXT("slot %d parameter vector survives JSON bit-identically"), Index), + VF_AreStrateArchetypeParamsBitIdentical( + OriginalStrate.Params, LoadedStrate.Params, OriginalStrate.Archetype)); + TestTrue(FString::Printf(TEXT("slot %d recipe survives JSON identically"), Index), + VF_AreStrateStructureRecipesIdentical( + OriginalStrate.Recipe, LoadedStrate.Recipe)); + + FVoxelOpStack OriginalStack; + FVoxelOpContext OriginalContext; + FVoxelOpStack LoadedStack; + FVoxelOpContext LoadedContext; + FString BuildError; + TestTrue(FString::Printf(TEXT("slot %d original stack rebuilds"), Index), + VF_RebuildVoxelSeasonStrate( + OriginalStrate, Manifest.OriginSpineRadius, nullptr, + OriginalStack, OriginalContext, &BuildError)); + TestTrue(FString::Printf(TEXT("slot %d loaded stack rebuilds"), Index), + VF_RebuildVoxelSeasonStrate( + LoadedStrate, LoadedManifest.OriginSpineRadius, nullptr, + LoadedStack, LoadedContext, &BuildError)); + if (OriginalStack.Num() == 0 || LoadedStack.Num() == 0) + { + bAllDensityBitsIdentical = false; + continue; + } + TestEqual(FString::Printf(TEXT("slot %d original/rebuilt stack operation count"), Index), + LoadedStack.Num(), OriginalStack.Num()); + TestEqual(FString::Printf(TEXT("slot %d original context radius"), Index), + OriginalContext.WorldRadiusVoxels, 0.0f); + TestEqual(FString::Printf(TEXT("slot %d loaded context radius"), Index), + LoadedContext.WorldRadiusVoxels, 0.0f); + OriginalStack.PrepareChunk(OriginalContext); + LoadedStack.PrepareChunk(LoadedContext); + + // Compare the complete bounded probe lattice for each selected field at two-voxel + // spacing, not a handful of favorable points. Every sampled scalar must retain its + // exact IEEE-754 bits after JSON load and stack reconstruction. + constexpr int32 ProbeHalfExtent = 32; + constexpr int32 ProbeStep = 2; + for (int32 Z = OriginalStrate.BottomWorldZ; + Z < OriginalStrate.TopWorldZ && bAllDensityBitsIdentical; + Z += ProbeStep) + { + for (int32 Y = -ProbeHalfExtent; + Y < ProbeHalfExtent && bAllDensityBitsIdentical; + Y += ProbeStep) + { + for (int32 X = -ProbeHalfExtent; + X < ProbeHalfExtent; + X += ProbeStep) + { + const float OriginalDensity = OriginalStack.EvalMC( + static_cast(X), static_cast(Y), static_cast(Z)); + const float LoadedDensity = LoadedStack.EvalMC( + static_cast(X), static_cast(Y), static_cast(Z)); + ++ComparedDensitySamples; + if (!SameDensityBits(OriginalDensity, LoadedDensity)) + { + bAllDensityBitsIdentical = false; + AddError(FString::Printf( + TEXT("slot %d density mismatch at (%d,%d,%d): 0x%08x vs 0x%08x"), + Index, X, Y, Z, + *reinterpret_cast(&OriginalDensity), + *reinterpret_cast(&LoadedDensity))); + break; + } + } + } + } + } + AddInfo(FString::Printf(TEXT("round-trip density probe compared %lld bit-identical samples"), + ComparedDensitySamples)); + TestTrue(TEXT("manifest round-trip density field is bit-identical"), + bAllDensityBitsIdentical); + return true; +} + +#endif diff --git a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp new file mode 100644 index 0000000..af3682e --- /dev/null +++ b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp @@ -0,0 +1,2729 @@ +// Offline Tier 4c season composition, manifest persistence, and review artifact. + +#include "VoxelSeasonManifest.h" + +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "HAL/FileManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" +#include "UObject/EnumProperty.h" +#include "UObject/FieldIterator.h" +#include "UObject/UnrealType.h" + +#include "VoxelDensityOpStack.h" +#include "VoxelSettings.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.h" +#include "VoxelStratePreview.h" +#include "VoxelCaveMorphology.h" + +#include + +namespace VoxelSeasonManifestPrivate +{ + enum class EScalarKind : uint8 + { + Float, + Integer, + Boolean, + }; + + struct FScalarValue + { + EScalarKind Kind = EScalarKind::Float; + double Number = 0.0; + bool Boolean = false; + }; + + bool IsTunnelArchetype(ECaveGeneratorType Archetype) + { + return Archetype == ECaveGeneratorType::TunnelNetwork + || Archetype == ECaveGeneratorType::Underwater; + } + + bool IsSupportedArchetype(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + case ECaveGeneratorType::Maze: + case ECaveGeneratorType::SurfaceWorld: + case ECaveGeneratorType::VerticalShafts: + case ECaveGeneratorType::FloatingIslands: + case ECaveGeneratorType::Underwater: + return true; + default: + return false; + } + } + + UStruct* ParamStruct(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return FStrateGenerationParams::StaticStruct(); + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return FSlabGenerationParams::StaticStruct(); + case ECaveGeneratorType::Maze: + return FMazeGenerationParams::StaticStruct(); + case ECaveGeneratorType::SurfaceWorld: + return FSurfaceGenerationParams::StaticStruct(); + case ECaveGeneratorType::VerticalShafts: + return FVerticalShaftParams::StaticStruct(); + case ECaveGeneratorType::FloatingIslands: + return FFloatingIslandParams::StaticStruct(); + default: + return nullptr; + } + } + + const void* ParamMemory(const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return &Params.TunnelNetworkParams; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return &Params.SlabParams; + case ECaveGeneratorType::Maze: + return &Params.MazeParams; + case ECaveGeneratorType::SurfaceWorld: + return &Params.SurfaceParams; + case ECaveGeneratorType::VerticalShafts: + return &Params.VerticalShaftParams; + case ECaveGeneratorType::FloatingIslands: + return &Params.FloatingIslandParams; + default: + return nullptr; + } + } + + void* ParamMemory(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return &Params.TunnelNetworkParams; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return &Params.SlabParams; + case ECaveGeneratorType::Maze: + return &Params.MazeParams; + case ECaveGeneratorType::SurfaceWorld: + return &Params.SurfaceParams; + case ECaveGeneratorType::VerticalShafts: + return &Params.VerticalShaftParams; + case ECaveGeneratorType::FloatingIslands: + return &Params.FloatingIslandParams; + default: + return nullptr; + } + } + + template + double NativeNumber(T Value) + { + if constexpr (std::is_enum::value) + { + using Underlying = typename std::underlying_type::type; + return static_cast(static_cast(Value)); + } + else + { + return static_cast(Value); + } + } + + template + EScalarKind NativeKind() + { + using ValueType = typename std::remove_cv< + typename std::remove_reference::type>::type; + if (std::is_same::value) + { + return EScalarKind::Boolean; + } + if (std::is_integral::value || std::is_enum::value) + { + return EScalarKind::Integer; + } + return EScalarKind::Float; + } + + template + typename std::enable_if::value, void>::type + AssignNative(T& Target, double Value) + { + Target = static_cast(Value); + } + + template + typename std::enable_if::value, void>::type + AssignNative(T& Target, double Value) + { + using Underlying = typename std::underlying_type::type; + Target = static_cast(static_cast(FMath::RoundToInt(Value))); + } + + template + void AssignNativeScalar(T& Target, const FScalarValue& Value) + { + using ValueType = typename std::remove_cv::type; + if constexpr (std::is_same::value) + { + Target = Value.Boolean; + } + else + { + AssignNative(Target, Value.Number); + } + } + + template + bool NativeBoolValue(T Value) + { + using ValueType = typename std::remove_cv::type; + if constexpr (std::is_same::value) + { + return Value; + } + else + { + return NativeNumber(Value) != 0.0; + } + } + + bool IsScalarProperty(const FProperty* Property) + { + return CastField(Property) != nullptr + || CastField(Property) != nullptr + || CastField(Property) != nullptr; + } + + bool ReadProperty(const FProperty* Property, const void* Memory, FScalarValue& OutValue) + { + if (Property == nullptr || Memory == nullptr || !IsScalarProperty(Property)) + { + return false; + } + const void* ValuePtr = Property->ContainerPtrToValuePtr(Memory); + if (const FBoolProperty* Bool = CastField(Property)) + { + OutValue.Kind = EScalarKind::Boolean; + OutValue.Boolean = Bool->GetPropertyValue(ValuePtr); + OutValue.Number = OutValue.Boolean ? 1.0 : 0.0; + return true; + } + + const FNumericProperty* Numeric = CastField(Property); + const FNumericProperty* Underlying = Numeric; + if (const FEnumProperty* Enum = CastField(Property)) + { + Underlying = Enum->GetUnderlyingProperty(); + } + if (Underlying == nullptr) + { + return false; + } + OutValue.Kind = Underlying->IsFloatingPoint() + ? EScalarKind::Float : EScalarKind::Integer; + OutValue.Number = Underlying->IsFloatingPoint() + ? Underlying->GetFloatingPointPropertyValue(ValuePtr) + : static_cast(Underlying->GetSignedIntPropertyValue(ValuePtr)); + return FMath::IsFinite(OutValue.Number); + } + + bool WriteProperty(const FProperty* Property, void* Memory, const FScalarValue& Value) + { + if (Property == nullptr || Memory == nullptr || !IsScalarProperty(Property)) + { + return false; + } + void* ValuePtr = Property->ContainerPtrToValuePtr(Memory); + if (const FBoolProperty* Bool = CastField(Property)) + { + if (Value.Kind != EScalarKind::Boolean) + { + return false; + } + Bool->SetPropertyValue(ValuePtr, Value.Boolean); + return true; + } + + const FNumericProperty* Numeric = CastField(Property); + const FNumericProperty* Underlying = Numeric; + if (const FEnumProperty* Enum = CastField(Property)) + { + Underlying = Enum->GetUnderlyingProperty(); + } + if (Underlying == nullptr || !FMath::IsFinite(Value.Number)) + { + return false; + } + if (Underlying->IsFloatingPoint()) + { + if (Value.Kind != EScalarKind::Float) + { + return false; + } + Underlying->SetFloatingPointPropertyValue(ValuePtr, Value.Number); + } + else + { + if (Value.Kind != EScalarKind::Integer + || FMath::FloorToDouble(Value.Number) != Value.Number) + { + return false; + } + Underlying->SetIntPropertyValue(ValuePtr, static_cast(Value.Number)); + } + return true; + } + + bool ReadTunnelField(const FStrateGenerationParams& Params, + const FString& FieldName, FScalarValue& OutValue) + { +#define VF_SEASON_READ_TUNNEL(Name) \ + if (FieldName == TEXT(#Name)) \ + { \ + OutValue.Kind = NativeKind(); \ + OutValue.Number = NativeNumber(Params.Name); \ + OutValue.Boolean = NativeBoolValue(Params.Name); \ + return FMath::IsFinite(OutValue.Number); \ + } + VF_STRATE_PARAM_FIELDS(VF_SEASON_READ_TUNNEL, VF_SEASON_READ_TUNNEL) +#undef VF_SEASON_READ_TUNNEL + return false; + } + + bool WriteTunnelField(FStrateGenerationParams& Params, + const FString& FieldName, const FScalarValue& Value) + { +#define VF_SEASON_WRITE_TUNNEL(Name) \ + if (FieldName == TEXT(#Name)) \ + { \ + if (Value.Kind != NativeKind()) return false; \ + AssignNativeScalar(Params.Name, Value); \ + return true; \ + } + VF_STRATE_PARAM_FIELDS(VF_SEASON_WRITE_TUNNEL, VF_SEASON_WRITE_TUNNEL) +#undef VF_SEASON_WRITE_TUNNEL + return false; + } + + bool ReadRuntimeField(const void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, FScalarValue& OutValue) + { + if (Memory == nullptr || (FieldName != TEXT("StrateTopWorldZ") + && FieldName != TEXT("StrateBottomWorldZ"))) + { + return false; + } + const bool bTop = FieldName == TEXT("StrateTopWorldZ"); + float Number = 0.0f; + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::Maze: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::SurfaceWorld: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::VerticalShafts: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::FloatingIslands: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + default: + return false; + } + OutValue.Kind = EScalarKind::Float; + OutValue.Number = Number; + return FMath::IsFinite(Number); + } + + bool WriteRuntimeField(void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, const FScalarValue& Value) + { + if (Memory == nullptr || Value.Kind != EScalarKind::Float + || !FMath::IsFinite(Value.Number) + || (FieldName != TEXT("StrateTopWorldZ") + && FieldName != TEXT("StrateBottomWorldZ"))) + { + return false; + } + const bool bTop = FieldName == TEXT("StrateTopWorldZ"); + const float Number = static_cast(Value.Number); + if (!FMath::IsFinite(Number)) + { + return false; + } + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::Maze: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::SurfaceWorld: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::VerticalShafts: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::FloatingIslands: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + default: + return false; + } + } + + bool ReadNamedField(const void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, FScalarValue& OutValue) + { + if (FieldName == TEXT("StrateTopWorldZ") || FieldName == TEXT("StrateBottomWorldZ")) + { + return ReadRuntimeField(Memory, Archetype, FieldName, OutValue); + } + if (Memory == nullptr || !IsSupportedArchetype(Archetype)) + { + return false; + } + if (IsTunnelArchetype(Archetype) + && ReadTunnelField(*static_cast(Memory), + FieldName, OutValue)) + { + return true; + } + UStruct* Struct = ParamStruct(Archetype); + for (TFieldIterator It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It) + { + if ((*It)->GetName() == FieldName) + { + return ReadProperty(*It, Memory, OutValue); + } + } + return false; + } + + bool WriteNamedField(void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, const FScalarValue& Value) + { + if (FieldName == TEXT("StrateTopWorldZ") || FieldName == TEXT("StrateBottomWorldZ")) + { + return WriteRuntimeField(Memory, Archetype, FieldName, Value); + } + if (Memory == nullptr || !IsSupportedArchetype(Archetype)) + { + return false; + } + if (IsTunnelArchetype(Archetype) + && WriteTunnelField(*static_cast(Memory), + FieldName, Value)) + { + return true; + } + UStruct* Struct = ParamStruct(Archetype); + for (TFieldIterator It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It) + { + if ((*It)->GetName() == FieldName) + { + return WriteProperty(*It, Memory, Value); + } + } + return false; + } + + TArray FieldNames(ECaveGeneratorType Archetype) + { + TArray Result; + if (IsTunnelArchetype(Archetype)) + { +#define VF_SEASON_ADD_TUNNEL(Name) Result.AddUnique(TEXT(#Name)); + VF_STRATE_PARAM_FIELDS(VF_SEASON_ADD_TUNNEL, VF_SEASON_ADD_TUNNEL) +#undef VF_SEASON_ADD_TUNNEL + } + else if (UStruct* Struct = ParamStruct(Archetype)) + { + for (TFieldIterator It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It) + { + if (IsScalarProperty(*It)) + { + Result.AddUnique((*It)->GetName()); + } + } + } + Result.Add(TEXT("StrateTopWorldZ")); + Result.Add(TEXT("StrateBottomWorldZ")); + Result.Sort([](const FString& A, const FString& B) { return A < B; }); + return Result; + } + + FString FloatBits(float Value) + { + uint32 Bits = 0; + FMemory::Memcpy(&Bits, &Value, sizeof(Bits)); + return FString::Printf(TEXT("0x%08X"), Bits); + } + + bool ParseFloatBits(const FString& Text, float& OutValue) + { + FString Digits = Text; + if (Digits.StartsWith(TEXT("0x"), ESearchCase::IgnoreCase)) + { + Digits.RightChopInline(2); + } + if (Digits.IsEmpty() || Digits.Len() > 8) + { + return false; + } + uint32 Bits = 0; + for (const TCHAR Ch : Digits) + { + uint32 Nibble = 0; + if (Ch >= TEXT('0') && Ch <= TEXT('9')) Nibble = static_cast(Ch - TEXT('0')); + else if (Ch >= TEXT('a') && Ch <= TEXT('f')) Nibble = static_cast(Ch - TEXT('a') + 10); + else if (Ch >= TEXT('A') && Ch <= TEXT('F')) Nibble = static_cast(Ch - TEXT('A') + 10); + else return false; + Bits = (Bits << 4) | Nibble; + } + FMemory::Memcpy(&OutValue, &Bits, sizeof(OutValue)); + return FMath::IsFinite(OutValue); + } + + TSharedPtr SerializeScalar(const FScalarValue& Value) + { + TSharedPtr Object = MakeShared(); + if (Value.Kind == EScalarKind::Boolean) + { + Object->SetStringField(TEXT("kind"), TEXT("bool")); + Object->SetBoolField(TEXT("value"), Value.Boolean); + return Object; + } + if (Value.Kind == EScalarKind::Integer) + { + Object->SetStringField(TEXT("kind"), TEXT("int")); + Object->SetNumberField(TEXT("value"), Value.Number); + return Object; + } + const float FloatValue = static_cast(Value.Number); + Object->SetStringField(TEXT("kind"), TEXT("float")); + Object->SetNumberField(TEXT("value"), static_cast(FloatValue)); + Object->SetStringField(TEXT("bits"), FloatBits(FloatValue)); + return Object; + } + + bool ReadJsonNumber(const TSharedPtr& Object, + const TCHAR* Name, double& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::Number) + { + return false; + } + OutValue = (*Found)->AsNumber(); + return FMath::IsFinite(OutValue); + } + + bool ReadJsonInt32(const TSharedPtr& Object, + const TCHAR* Name, int32& OutValue) + { + double Number = 0.0; + if (!ReadJsonNumber(Object, Name, Number) + || FMath::FloorToDouble(Number) != Number + || Number < static_cast(TNumericLimits::Lowest()) + || Number > static_cast(TNumericLimits::Max())) + { + return false; + } + OutValue = static_cast(Number); + return true; + } + + bool ReadJsonInt64(const TSharedPtr& Object, + const TCHAR* Name, int64& OutValue) + { + double Number = 0.0; + if (!ReadJsonNumber(Object, Name, Number) + || FMath::FloorToDouble(Number) != Number + || Number < -9223372036854775807.0 + || Number > 9223372036854775807.0) + { + return false; + } + OutValue = static_cast(Number); + return static_cast(OutValue) == Number; + } + + bool ReadJsonString(const TSharedPtr& Object, + const TCHAR* Name, FString& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::String) + { + return false; + } + OutValue = (*Found)->AsString(); + return true; + } + + bool ReadJsonBool(const TSharedPtr& Object, + const TCHAR* Name, bool& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::Boolean) + { + return false; + } + OutValue = (*Found)->AsBool(); + return true; + } + + bool ReadJsonObject(const TSharedPtr& Object, + const TCHAR* Name, TSharedPtr& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::Object) + { + return false; + } + OutValue = (*Found)->AsObject(); + return OutValue.IsValid(); + } + + bool ReadJsonArray(const TSharedPtr& Object, + const TCHAR* Name, + const TArray>*& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::Array) + { + return false; + } + OutValue = &(*Found)->AsArray(); + return true; + } + + bool ReadScalar(const TSharedPtr& Object, + const FString& Name, FScalarValue& OutValue) + { + TSharedPtr Scalar; + if (!ReadJsonObject(Object, *Name, Scalar)) return false; + FString Kind; + if (!ReadJsonString(Scalar, TEXT("kind"), Kind)) return false; + if (Kind == TEXT("bool")) + { + OutValue.Kind = EScalarKind::Boolean; + return ReadJsonBool(Scalar, TEXT("value"), OutValue.Boolean); + } + if (Kind == TEXT("int")) + { + OutValue.Kind = EScalarKind::Integer; + return ReadJsonNumber(Scalar, TEXT("value"), OutValue.Number) + && FMath::FloorToDouble(OutValue.Number) == OutValue.Number; + } + if (Kind != TEXT("float")) return false; + FString Bits; + if (!ReadJsonString(Scalar, TEXT("bits"), Bits)) return false; + float FloatValue = 0.0f; + if (!ParseFloatBits(Bits, FloatValue)) return false; + OutValue.Kind = EScalarKind::Float; + OutValue.Number = FloatValue; + return true; + } + + TSharedPtr SerializeParamsForArchetype( + ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params) + { + TSharedPtr Object = MakeShared(); + const void* Memory = ParamMemory(Params, Archetype); + for (const FString& FieldName : FieldNames(Archetype)) + { + FScalarValue Value; + if (ReadNamedField(Memory, Archetype, FieldName, Value)) + { + Object->SetObjectField(FieldName, SerializeScalar(Value)); + } + } + return Object; + } + + bool DeserializeParamsForArchetype( + ECaveGeneratorType Archetype, const TSharedPtr& Object, + FVoxelStrateArchetypeParams& OutParams) + { + if (!Object.IsValid()) return false; + void* Memory = ParamMemory(OutParams, Archetype); + if (Memory == nullptr) return false; + for (const FString& FieldName : FieldNames(Archetype)) + { + FScalarValue Value; + if (!ReadScalar(Object, FieldName, Value) + || !WriteNamedField(Memory, Archetype, FieldName, Value)) + { + return false; + } + } + return true; + } + + TSharedPtr SerializeAllParams(const FVoxelStrateArchetypeParams& Params) + { + TSharedPtr Object = MakeShared(); + Object->SetObjectField(TEXT("tunnel_network"), + SerializeParamsForArchetype(ECaveGeneratorType::TunnelNetwork, Params)); + Object->SetObjectField(TEXT("slab"), + SerializeParamsForArchetype(ECaveGeneratorType::FlatPlain, Params)); + Object->SetObjectField(TEXT("maze"), + SerializeParamsForArchetype(ECaveGeneratorType::Maze, Params)); + Object->SetObjectField(TEXT("surface"), + SerializeParamsForArchetype(ECaveGeneratorType::SurfaceWorld, Params)); + Object->SetObjectField(TEXT("vertical_shaft"), + SerializeParamsForArchetype(ECaveGeneratorType::VerticalShafts, Params)); + Object->SetObjectField(TEXT("floating_island"), + SerializeParamsForArchetype(ECaveGeneratorType::FloatingIslands, Params)); + return Object; + } + + bool DeserializeAllParams(const TSharedPtr& Object, + FVoxelStrateArchetypeParams& OutParams) + { + if (!Object.IsValid()) return false; + TSharedPtr Tunnel, Slab, Maze, Surface, Vertical, Floating; + if (!ReadJsonObject(Object, TEXT("tunnel_network"), Tunnel) + || !ReadJsonObject(Object, TEXT("slab"), Slab) + || !ReadJsonObject(Object, TEXT("maze"), Maze) + || !ReadJsonObject(Object, TEXT("surface"), Surface) + || !ReadJsonObject(Object, TEXT("vertical_shaft"), Vertical) + || !ReadJsonObject(Object, TEXT("floating_island"), Floating)) + { + return false; + } + OutParams = FVoxelStrateArchetypeParams(); + return DeserializeParamsForArchetype(ECaveGeneratorType::TunnelNetwork, Tunnel, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::FlatPlain, Slab, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::Maze, Maze, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::SurfaceWorld, Surface, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::VerticalShafts, Vertical, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::FloatingIslands, Floating, OutParams); + } + + TSharedPtr 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 DeserializeRecipeEntry(const TSharedPtr& Object, + FVoxelOpRecipeEntry& OutEntry) + { + int32 OpClass = 0; + int32 ParamBlock = 0; + if (!ReadJsonInt32(Object, TEXT("op_class"), OpClass) + || !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 SerializeRecipe(const FVoxelOpStackRecipe& Recipe) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("root_polarity"), static_cast(Recipe.RootPolarity)); + Object->SetObjectField(TEXT("root"), SerializeRecipeEntry(Recipe.Root)); + Object->SetObjectField(TEXT("shape_source"), SerializeRecipeEntry(Recipe.ShapeSource)); + Object->SetObjectField(TEXT("conversion"), SerializeRecipeEntry(Recipe.Conversion)); + Object->SetNumberField(TEXT("structural_param_block"), + static_cast(Recipe.StructuralParamBlock)); + TArray> Modifiers; + for (const FVoxelOpRecipeEntry& Entry : Recipe.Modifiers) + { + Modifiers.Add(MakeShared(SerializeRecipeEntry(Entry))); + } + Object->SetArrayField(TEXT("modifiers"), MoveTemp(Modifiers)); + return Object; + } + + bool DeserializeRecipe(const TSharedPtr& Object, + FVoxelOpStackRecipe& OutRecipe) + { + if (!Object.IsValid()) return false; + int32 RootPolarity = 0; + int32 StructuralBlock = 0; + TSharedPtr Root, Shape, Conversion; + const TArray>* Modifiers = nullptr; + if (!ReadJsonInt32(Object, TEXT("root_polarity"), RootPolarity) + || !ReadJsonObject(Object, TEXT("root"), Root) + || !ReadJsonObject(Object, TEXT("shape_source"), Shape) + || !ReadJsonObject(Object, TEXT("conversion"), Conversion) + || !ReadJsonInt32(Object, TEXT("structural_param_block"), StructuralBlock) + || !ReadJsonArray(Object, TEXT("modifiers"), Modifiers) + || RootPolarity < static_cast(EVoxelStrateRootPolarity::RockCarve) + || RootPolarity > static_cast(EVoxelStrateRootPolarity::VoidFill) + || StructuralBlock < static_cast(EVoxelStrateParamBlock::None) + || StructuralBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland) + || Modifiers->Num() < 4 || Modifiers->Num() > 8 + || !DeserializeRecipeEntry(Root, OutRecipe.Root) + || !DeserializeRecipeEntry(Shape, OutRecipe.ShapeSource) + || !DeserializeRecipeEntry(Conversion, OutRecipe.Conversion)) + { + return false; + } + OutRecipe.RootPolarity = static_cast(RootPolarity); + OutRecipe.StructuralParamBlock = static_cast(StructuralBlock); + OutRecipe.Modifiers.Reset(); + for (const TSharedPtr& Value : *Modifiers) + { + if (!Value.IsValid() || Value->Type != EJson::Object) return false; + FVoxelOpRecipeEntry& Entry = OutRecipe.Modifiers.AddDefaulted_GetRef(); + if (!DeserializeRecipeEntry(Value->AsObject(), Entry)) return false; + } + return true; + } + + void SetMetricFloat(const TSharedPtr& Object, + const TCHAR* Name, float Value) + { + FScalarValue Scalar; + Scalar.Kind = EScalarKind::Float; + Scalar.Number = Value; + Object->SetObjectField(Name, SerializeScalar(Scalar)); + } + + bool ReadMetricFloat(const TSharedPtr& Object, + const TCHAR* Name, float& OutValue) + { + FScalarValue Scalar; + if (!ReadScalar(Object, Name, Scalar) || Scalar.Kind != EScalarKind::Float) + { + return false; + } + OutValue = static_cast(Scalar.Number); + return FMath::IsFinite(OutValue); + } + + TSharedPtr SerializeMetrics(const FVoxelStrateMeasuredMetrics& Metrics) + { + TSharedPtr Object = MakeShared(); + 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)); + SetMetricFloat(Object, TEXT("air_fraction"), Metrics.AirFraction); + Object->SetNumberField(TEXT("num_air_components"), Metrics.NumAirComponents); + SetMetricFloat(Object, 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); + SetMetricFloat(Object, TEXT("walkable_fraction"), Metrics.WalkableFraction); + SetMetricFloat(Object, 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); + SetMetricFloat(Object, TEXT("sampled_min_x"), Metrics.SampledMinX); + SetMetricFloat(Object, TEXT("sampled_max_x"), Metrics.SampledMaxX); + SetMetricFloat(Object, TEXT("sampled_min_y"), Metrics.SampledMinY); + SetMetricFloat(Object, TEXT("sampled_max_y"), Metrics.SampledMaxY); + TSharedPtr Point = MakeShared(); + SetMetricFloat(Point, TEXT("x"), static_cast(Metrics.LargestComponentPoint.X)); + SetMetricFloat(Point, TEXT("y"), static_cast(Metrics.LargestComponentPoint.Y)); + SetMetricFloat(Point, TEXT("z"), static_cast(Metrics.LargestComponentPoint.Z)); + Object->SetObjectField(TEXT("largest_component_point"), Point); + TArray> Components; + for (const int64 Cells : Metrics.AirComponentCells) + { + Components.Add(MakeShared(static_cast(Cells))); + } + Object->SetArrayField(TEXT("air_component_cells"), MoveTemp(Components)); + return Object; + } + + bool DeserializeMetrics(const TSharedPtr& Object, + FVoxelStrateMeasuredMetrics& OutMetrics) + { + if (!Object.IsValid()) return false; + bool bValid = true; + bValid = ReadJsonBool(Object, TEXT("valid"), OutMetrics.bValid) && bValid; + bValid = ReadJsonInt64(Object, TEXT("num_sampled"), OutMetrics.NumSampled) && bValid; + bValid = ReadJsonInt64(Object, TEXT("num_air"), OutMetrics.NumAir) && bValid; + bValid = ReadJsonInt64(Object, TEXT("num_solid"), OutMetrics.NumSolid) && bValid; + bValid = ReadMetricFloat(Object, TEXT("air_fraction"), OutMetrics.AirFraction) && bValid; + bValid = ReadJsonInt32(Object, TEXT("num_air_components"), OutMetrics.NumAirComponents) && bValid; + bValid = ReadMetricFloat(Object, TEXT("largest_component_share"), OutMetrics.LargestComponentShare) && bValid; + bValid = ReadJsonInt64(Object, TEXT("largest_component_cells"), OutMetrics.LargestComponentCells) && bValid; + bValid = ReadJsonInt32(Object, TEXT("num_components_at_least_1_pct"), OutMetrics.NumComponentsAtLeast1Pct) && bValid; + bValid = ReadMetricFloat(Object, TEXT("walkable_fraction"), OutMetrics.WalkableFraction) && bValid; + bValid = ReadMetricFloat(Object, TEXT("median_feature_scale"), OutMetrics.MedianFeatureScale) && bValid; + bValid = ReadJsonInt32(Object, TEXT("median_vertical_clearance"), OutMetrics.MedianVerticalClearance) && bValid; + bValid = ReadJsonInt32(Object, TEXT("resolved_margin_voxels"), OutMetrics.ResolvedMarginVoxels) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_min_z"), OutMetrics.SampledMinZ) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_max_z"), OutMetrics.SampledMaxZ) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_num_x"), OutMetrics.SampledNumX) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_num_y"), OutMetrics.SampledNumY) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_num_z"), OutMetrics.SampledNumZ) && bValid; + bValid = ReadMetricFloat(Object, TEXT("sampled_min_x"), OutMetrics.SampledMinX) && bValid; + bValid = ReadMetricFloat(Object, TEXT("sampled_max_x"), OutMetrics.SampledMaxX) && bValid; + bValid = ReadMetricFloat(Object, TEXT("sampled_min_y"), OutMetrics.SampledMinY) && bValid; + bValid = ReadMetricFloat(Object, TEXT("sampled_max_y"), OutMetrics.SampledMaxY) && bValid; + TSharedPtr Point; + bValid = ReadJsonObject(Object, TEXT("largest_component_point"), Point) && bValid; + if (Point.IsValid()) + { + float X = 0.0f; + float Y = 0.0f; + float Z = 0.0f; + bValid = ReadMetricFloat(Point, TEXT("x"), X) && bValid; + bValid = ReadMetricFloat(Point, TEXT("y"), Y) && bValid; + bValid = ReadMetricFloat(Point, TEXT("z"), Z) && bValid; + OutMetrics.LargestComponentPoint = FVector(X, Y, Z); + } + const TArray>* Components = nullptr; + bValid = ReadJsonArray(Object, TEXT("air_component_cells"), Components) && bValid; + OutMetrics.AirComponentCells.Reset(); + if (Components != nullptr) + { + for (const TSharedPtr& Value : *Components) + { + if (!Value.IsValid() || Value->Type != EJson::Number) + { + bValid = false; + continue; + } + const double Number = Value->AsNumber(); + const int64 Cells = static_cast(Number); + if (!FMath::IsFinite(Number) || Number < 0.0 + || FMath::FloorToDouble(Number) != Number + || static_cast(Cells) != Number) + { + bValid = false; + continue; + } + OutMetrics.AirComponentCells.Add(Cells); + } + } + return bValid; + } + + const TCHAR* ConnectivityName(EVoxelConnectivityResult Result) + { + switch (Result) + { + case EVoxelConnectivityResult::Connected: return TEXT("Connected"); + case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnectedAtThisResolution"); + case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid"); + case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid"); + case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow"); + case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudgetExhausted"); + } + return TEXT("Unknown"); + } + + bool ParseConnectivityName(const FString& Name, EVoxelConnectivityResult& OutResult) + { + const EVoxelConnectivityResult Values[] = { + EVoxelConnectivityResult::Connected, + EVoxelConnectivityResult::NotConnectedAtThisResolution, + EVoxelConnectivityResult::StartCellSolid, + EVoxelConnectivityResult::GoalCellSolid, + EVoxelConnectivityResult::OutOfWindow, + EVoxelConnectivityResult::CoarseLiedBudgetExhausted, + }; + for (const EVoxelConnectivityResult Value : Values) + { + if (Name == ConnectivityName(Value)) + { + OutResult = Value; + return true; + } + } + return false; + } + + const TCHAR* ArchetypeName(ECaveGeneratorType Archetype) + { + return VF_GetStrateArchetypeName(Archetype); + } + + bool ParseArchetype(const FString& Name, ECaveGeneratorType& OutArchetype) + { + const ECaveGeneratorType Values[] = { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::CrystalChamber, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + ECaveGeneratorType::Underwater, + }; + for (const ECaveGeneratorType Value : Values) + { + if (Name == ArchetypeName(Value)) + { + OutArchetype = Value; + return true; + } + } + return false; + } + + TSharedPtr SerializePolicy(const FVoxelSeasonSelectionPolicy& Policy) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("grounded_fraction"), Policy.GroundedFraction); + Object->SetNumberField(TEXT("outlier_fraction"), Policy.OutlierFraction); + Object->SetNumberField(TEXT("adjacent_similarity_distance_threshold"), + Policy.AdjacentSimilarityDistanceThreshold); + Object->SetNumberField(TEXT("adjacent_similarity_penalty_weight"), + Policy.AdjacentSimilarityPenaltyWeight); + Object->SetNumberField(TEXT("new_archetype_bonus"), Policy.NewArchetypeBonus); + Object->SetNumberField(TEXT("new_recipe_bonus"), Policy.NewRecipeBonus); + Object->SetNumberField(TEXT("boss_slot_interval"), Policy.BossSlotInterval); + Object->SetNumberField(TEXT("minimum_largest_component_share"), + Policy.MinimumLargestComponentShare); + Object->SetNumberField(TEXT("feature_scale_normalization_voxels"), + Policy.FeatureScaleNormalizationVoxels); + Object->SetNumberField(TEXT("clearance_normalization_voxels"), + Policy.ClearanceNormalizationVoxels); + return Object; + } + + bool ReadPolicy(const TSharedPtr& Object, + FVoxelSeasonSelectionPolicy& OutPolicy) + { + if (!Object.IsValid()) return false; + double Number = 0.0; + bool bValid = true; + if (ReadJsonNumber(Object, TEXT("grounded_fraction"), Number)) OutPolicy.GroundedFraction = static_cast(Number); else bValid = false; + if (ReadJsonNumber(Object, TEXT("outlier_fraction"), Number)) OutPolicy.OutlierFraction = static_cast(Number); else bValid = false; + if (ReadJsonNumber(Object, TEXT("adjacent_similarity_distance_threshold"), Number)) OutPolicy.AdjacentSimilarityDistanceThreshold = Number; else bValid = false; + if (ReadJsonNumber(Object, TEXT("adjacent_similarity_penalty_weight"), Number)) OutPolicy.AdjacentSimilarityPenaltyWeight = Number; else bValid = false; + if (ReadJsonNumber(Object, TEXT("new_archetype_bonus"), Number)) OutPolicy.NewArchetypeBonus = Number; else bValid = false; + if (ReadJsonNumber(Object, TEXT("new_recipe_bonus"), Number)) OutPolicy.NewRecipeBonus = Number; else bValid = false; + if (ReadJsonInt32(Object, TEXT("boss_slot_interval"), OutPolicy.BossSlotInterval) == false) bValid = false; + if (ReadJsonNumber(Object, TEXT("minimum_largest_component_share"), Number)) OutPolicy.MinimumLargestComponentShare = static_cast(Number); else bValid = false; + if (ReadJsonNumber(Object, TEXT("feature_scale_normalization_voxels"), Number)) OutPolicy.FeatureScaleNormalizationVoxels = static_cast(Number); else bValid = false; + if (ReadJsonNumber(Object, TEXT("clearance_normalization_voxels"), Number)) OutPolicy.ClearanceNormalizationVoxels = static_cast(Number); else bValid = false; + return bValid; + } + + void SetCommonManifestFields(const TSharedPtr& Root, + const FVoxelSeasonManifest& Manifest) + { + Root->SetStringField(TEXT("format"), TEXT("VoxelForgeSeasonManifest")); + Root->SetBoolField(TEXT("valid"), Manifest.bValid); + Root->SetNumberField(TEXT("schema_version"), Manifest.SchemaVersion); + 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("selected_count"), Manifest.SelectedCount); + Root->SetNumberField(TEXT("rejected_count"), Manifest.RejectedCount); + Root->SetNumberField(TEXT("grounded_selected_count"), Manifest.GroundedSelectedCount); + Root->SetNumberField(TEXT("outlier_selected_count"), Manifest.OutlierSelectedCount); + Root->SetNumberField(TEXT("fixed_selected_count"), Manifest.FixedSelectedCount); + Root->SetNumberField(TEXT("total_strates"), Manifest.TotalStrates); + Root->SetNumberField(TEXT("inter_strate_gap_chunks"), Manifest.InterStrateGapChunks); + SetMetricFloat(Root, TEXT("origin_spine_radius"), Manifest.OriginSpineRadius); + SetMetricFloat(Root, TEXT("world_radius_voxels"), Manifest.WorldRadiusVoxels); + Root->SetObjectField(TEXT("selection_policy"), SerializePolicy(Manifest.SelectionPolicy)); + Root->SetStringField(TEXT("policy_note"), Manifest.PolicyNote); + } + + TSharedPtr SerializeStrate(const FVoxelSeasonStrate& Strate) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("depth_index"), Strate.DepthIndex); + Object->SetNumberField(TEXT("candidate_index"), Strate.CandidateIndex); + Object->SetNumberField(TEXT("seed"), Strate.Seed); + Object->SetNumberField(TEXT("height_in_chunks"), Strate.HeightInChunks); + Object->SetNumberField(TEXT("top_world_z"), Strate.TopWorldZ); + Object->SetNumberField(TEXT("bottom_world_z"), Strate.BottomWorldZ); + Object->SetStringField(TEXT("archetype"), ArchetypeName(Strate.Archetype)); + Object->SetNumberField(TEXT("archetype_id"), static_cast(Strate.Archetype)); + Object->SetBoolField(TEXT("uses_recipe"), Strate.bUsesRecipe); + Object->SetObjectField(TEXT("recipe"), SerializeRecipe(Strate.Recipe)); + Object->SetObjectField(TEXT("parameters"), SerializeAllParams(Strate.Params)); + Object->SetObjectField(TEXT("measured_metrics"), SerializeMetrics(Strate.Metrics)); + Object->SetNumberField(TEXT("distance_from_corpus_centroid"), Strate.DistanceFromCorpusCentroid); + Object->SetStringField(TEXT("selection_reason"), Strate.SelectionReason); + Object->SetStringField(TEXT("source_definition_path"), Strate.SourceDefinitionPath); + Object->SetBoolField(TEXT("grounded"), Strate.bGrounded); + Object->SetBoolField(TEXT("outlier"), Strate.bOutlier); + Object->SetBoolField(TEXT("boss_slot"), Strate.bBossSlot); + Object->SetBoolField(TEXT("fixed"), Strate.bFixed); + Object->SetBoolField(TEXT("passed_non_vacuous"), Strate.bPassedNonVacuous); + Object->SetBoolField(TEXT("passed_largest_component"), Strate.bPassedLargestComponent); + Object->SetBoolField(TEXT("passed_primordial_law"), Strate.bPassedPrimordialLaw); + Object->SetStringField(TEXT("primordial_law_result"), ConnectivityName(Strate.PrimordialLawResult)); + return Object; + } + + bool DeserializeStrate(const TSharedPtr& Object, + FVoxelSeasonStrate& OutStrate) + { + if (!Object.IsValid()) return false; + FString ArchetypeText, Reason, SourcePath, LawText; + int32 ArchetypeId = -1; + bool bValid = ReadJsonInt32(Object, TEXT("depth_index"), OutStrate.DepthIndex) + && ReadJsonInt32(Object, TEXT("candidate_index"), OutStrate.CandidateIndex) + && ReadJsonInt32(Object, TEXT("seed"), OutStrate.Seed) + && ReadJsonInt32(Object, TEXT("height_in_chunks"), OutStrate.HeightInChunks) + && ReadJsonInt32(Object, TEXT("top_world_z"), OutStrate.TopWorldZ) + && ReadJsonInt32(Object, TEXT("bottom_world_z"), OutStrate.BottomWorldZ) + && ReadJsonString(Object, TEXT("archetype"), ArchetypeText) + && ReadJsonInt32(Object, TEXT("archetype_id"), ArchetypeId) + && ReadJsonBool(Object, TEXT("uses_recipe"), OutStrate.bUsesRecipe) + && ReadJsonString(Object, TEXT("selection_reason"), Reason) + && ReadJsonString(Object, TEXT("source_definition_path"), SourcePath) + && ReadJsonBool(Object, TEXT("grounded"), OutStrate.bGrounded) + && ReadJsonBool(Object, TEXT("outlier"), OutStrate.bOutlier) + && ReadJsonBool(Object, TEXT("boss_slot"), OutStrate.bBossSlot) + && ReadJsonBool(Object, TEXT("fixed"), OutStrate.bFixed) + && ReadJsonBool(Object, TEXT("passed_non_vacuous"), OutStrate.bPassedNonVacuous) + && ReadJsonBool(Object, TEXT("passed_largest_component"), OutStrate.bPassedLargestComponent) + && ReadJsonBool(Object, TEXT("passed_primordial_law"), OutStrate.bPassedPrimordialLaw) + && ReadJsonString(Object, TEXT("primordial_law_result"), LawText); + double Distance = 0.0; + bValid = ReadJsonNumber(Object, TEXT("distance_from_corpus_centroid"), Distance) && bValid; + OutStrate.DistanceFromCorpusCentroid = Distance; + OutStrate.SelectionReason = Reason; + OutStrate.SourceDefinitionPath = SourcePath; + TSharedPtr Recipe, Params, Metrics; + bValid = ReadJsonObject(Object, TEXT("recipe"), Recipe) && bValid; + bValid = ReadJsonObject(Object, TEXT("parameters"), Params) && bValid; + bValid = ReadJsonObject(Object, TEXT("measured_metrics"), Metrics) && bValid; + bValid = ParseArchetype(ArchetypeText, OutStrate.Archetype) && bValid; + bValid = ArchetypeId == static_cast(OutStrate.Archetype) && bValid; + bValid = ParseConnectivityName(LawText, OutStrate.PrimordialLawResult) && bValid; + if (OutStrate.bUsesRecipe) + { + bValid = DeserializeRecipe(Recipe, OutStrate.Recipe) && bValid; + } + bValid = DeserializeAllParams(Params, OutStrate.Params) && bValid; + bValid = DeserializeMetrics(Metrics, OutStrate.Metrics) && bValid; + return bValid; + } + + bool SaveJson(const FString& FilePath, const TSharedPtr& Root, + FString& OutReport) + { + const FString Directory = FPaths::GetPath(FilePath); + if (!Directory.IsEmpty() && !IFileManager::Get().MakeDirectory(*Directory, true)) + { + OutReport = FString::Printf(TEXT("could not create manifest directory %s"), *Directory); + return false; + } + FString JsonText; + TSharedRef>> Writer = + TJsonWriterFactory>::Create(&JsonText); + if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close() + || !FFileHelper::SaveStringToFile(JsonText, *FilePath)) + { + OutReport = FString::Printf(TEXT("could not write season manifest %s"), *FilePath); + return false; + } + return true; + } + + bool LoadJson(const FString& FilePath, TSharedPtr& OutRoot, + FString& OutReport, FString* OutJsonText = nullptr) + { + FString JsonText; + if (!FFileHelper::LoadFileToString(JsonText, *FilePath)) + { + OutReport = FString::Printf(TEXT("could not read season manifest %s"), *FilePath); + return false; + } + TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonText); + if (!FJsonSerializer::Deserialize(Reader, OutRoot) || !OutRoot.IsValid()) + { + OutReport = FString::Printf(TEXT("invalid JSON in season manifest %s"), *FilePath); + return false; + } + if (OutJsonText != nullptr) + { + *OutJsonText = MoveTemp(JsonText); + } + return true; + } +} + +const TCHAR* VF_GetVoxelSeasonSelectionReasonName(EVoxelSeasonSelectionReason Reason) +{ + switch (Reason) + { + case EVoxelSeasonSelectionReason::Grounded: return TEXT("grounded"); + case EVoxelSeasonSelectionReason::Outlier: return TEXT("outlier"); + case EVoxelSeasonSelectionReason::Fixed: return TEXT("fixed"); + } + return TEXT("unknown"); +} + +FString VF_SerializeVoxelSeasonManifest(const FVoxelSeasonManifest& Manifest) +{ + using namespace VoxelSeasonManifestPrivate; + TSharedPtr Root = MakeShared(); + SetCommonManifestFields(Root, Manifest); + + TArray> Strates; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + Strates.Add(MakeShared(SerializeStrate(Strate))); + } + Root->SetArrayField(TEXT("strates"), MoveTemp(Strates)); + + TArray> Rejections; + for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts) + { + TSharedPtr Object = MakeShared(); + Object->SetStringField(TEXT("reason"), Rejection.Reason); + Object->SetNumberField(TEXT("count"), Rejection.Count); + Rejections.Add(MakeShared(Object)); + } + Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections)); + + if (!Manifest.CandidateAudit.IsEmpty()) + { + TArray> Audit; + for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("candidate_index"), Row.CandidateIndex); + Object->SetNumberField(TEXT("seed"), Row.Seed); + Object->SetStringField(TEXT("archetype"), ArchetypeName(Row.Archetype)); + Object->SetNumberField(TEXT("recipe_hash"), static_cast(Row.RecipeHash)); + Object->SetNumberField(TEXT("distance_from_corpus_centroid"), Row.DistanceFromCorpusCentroid); + Object->SetBoolField(TEXT("passed_hard_gates"), Row.bPassedHardGates); + Object->SetBoolField(TEXT("selected"), Row.bSelected); + Object->SetStringField(TEXT("rejection_reason"), Row.RejectionReason); + Audit.Add(MakeShared(Object)); + } + Root->SetArrayField(TEXT("candidate_audit"), MoveTemp(Audit)); + } + + FString JsonText; + TSharedRef>> Writer = + TJsonWriterFactory>::Create(&JsonText); + if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close()) + { + return FString(); + } + return JsonText; +} + +bool VF_SaveVoxelSeasonManifest(const FString& ManifestPath, + const FVoxelSeasonManifest& Manifest, + FString& OutReport) +{ + if (ManifestPath.IsEmpty()) + { + OutReport = TEXT("season manifest path is empty"); + return false; + } + const FString JsonText = VF_SerializeVoxelSeasonManifest(Manifest); + if (JsonText.IsEmpty()) + { + OutReport = TEXT("could not serialize season manifest"); + return false; + } + const FString Directory = FPaths::GetPath(ManifestPath); + if (!Directory.IsEmpty() && !IFileManager::Get().MakeDirectory(*Directory, true)) + { + OutReport = FString::Printf(TEXT("could not create manifest directory %s"), *Directory); + return false; + } + if (!FFileHelper::SaveStringToFile(JsonText, *ManifestPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + OutReport = FString::Printf(TEXT("could not write season manifest %s"), *ManifestPath); + return false; + } + OutReport = FString::Printf(TEXT("saved season manifest to %s"), *ManifestPath); + return true; +} + +bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, + FVoxelSeasonManifest& OutManifest, + FString& OutReport) +{ + using namespace VoxelSeasonManifestPrivate; + OutManifest = FVoxelSeasonManifest(); + OutReport.Reset(); + if (ManifestPath.IsEmpty()) + { + OutReport = TEXT("season manifest path is empty"); + OutManifest.Error = OutReport; + return false; + } + + TSharedPtr Root; + FString OriginalJson; + if (!LoadJson(ManifestPath, Root, OutReport, &OriginalJson)) + { + OutManifest.Error = OutReport; + return false; + } + + FString Format; + int32 SchemaVersion = 0; + bool bValid = false; + if (!ReadJsonString(Root, TEXT("format"), Format) + || Format != TEXT("VoxelForgeSeasonManifest") + || !ReadJsonBool(Root, TEXT("valid"), bValid) + || !ReadJsonInt32(Root, TEXT("schema_version"), SchemaVersion)) + { + OutReport = TEXT("season manifest header is missing or malformed"); + OutManifest.Error = OutReport; + return false; + } + if (SchemaVersion != FVoxelSeasonManifest::CurrentSchemaVersion) + { + OutReport = FString::Printf(TEXT("unsupported season manifest schema %d (expected %d)"), + SchemaVersion, FVoxelSeasonManifest::CurrentSchemaVersion); + OutManifest.Error = OutReport; + return false; + } + + bool bFieldsValid = true; + OutManifest.bValid = bValid; + OutManifest.SchemaVersion = SchemaVersion; + bFieldsValid = ReadJsonInt32(Root, TEXT("season"), OutManifest.Season) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("seed"), OutManifest.Seed) && bFieldsValid; + double CorpusHash = 0.0; + bFieldsValid = ReadJsonNumber(Root, TEXT("input_corpus_hash"), CorpusHash) && bFieldsValid; + if (CorpusHash < 0.0 || CorpusHash > 4294967295.0 + || FMath::FloorToDouble(CorpusHash) != CorpusHash) + { + bFieldsValid = false; + } + OutManifest.InputCorpusHash = static_cast(CorpusHash); + bFieldsValid = ReadJsonInt32(Root, TEXT("candidate_count"), OutManifest.CandidateCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("survivor_count"), OutManifest.SurvivorCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("selected_count"), OutManifest.SelectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("rejected_count"), OutManifest.RejectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("grounded_selected_count"), OutManifest.GroundedSelectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("outlier_selected_count"), OutManifest.OutlierSelectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("fixed_selected_count"), OutManifest.FixedSelectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("total_strates"), OutManifest.TotalStrates) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("inter_strate_gap_chunks"), OutManifest.InterStrateGapChunks) && bFieldsValid; + bFieldsValid = ReadMetricFloat(Root, TEXT("origin_spine_radius"), OutManifest.OriginSpineRadius) && bFieldsValid; + bFieldsValid = ReadMetricFloat(Root, TEXT("world_radius_voxels"), OutManifest.WorldRadiusVoxels) && bFieldsValid; + TSharedPtr Policy; + bFieldsValid = ReadJsonObject(Root, TEXT("selection_policy"), Policy) && bFieldsValid; + if (Policy.IsValid()) + { + bFieldsValid = ReadPolicy(Policy, OutManifest.SelectionPolicy) && bFieldsValid; + } + bFieldsValid = ReadJsonString(Root, TEXT("policy_note"), OutManifest.PolicyNote) && bFieldsValid; + + const TArray>* StrateValues = nullptr; + const TArray>* RejectionValues = nullptr; + bFieldsValid = ReadJsonArray(Root, TEXT("strates"), StrateValues) && bFieldsValid; + bFieldsValid = ReadJsonArray(Root, TEXT("rejection_counts"), RejectionValues) && bFieldsValid; + if (StrateValues != nullptr) + { + OutManifest.Strates.Reset(); + OutManifest.Strates.Reserve(StrateValues->Num()); + for (const TSharedPtr& Value : *StrateValues) + { + if (!Value.IsValid() || Value->Type != EJson::Object) + { + bFieldsValid = false; + continue; + } + FVoxelSeasonStrate& Strate = OutManifest.Strates.AddDefaulted_GetRef(); + if (!DeserializeStrate(Value->AsObject(), Strate)) + { + bFieldsValid = false; + } + } + } + if (RejectionValues != nullptr) + { + OutManifest.RejectionCounts.Reset(); + OutManifest.RejectionCounts.Reserve(RejectionValues->Num()); + for (const TSharedPtr& Value : *RejectionValues) + { + if (!Value.IsValid() || Value->Type != EJson::Object) + { + bFieldsValid = false; + continue; + } + TSharedPtr Object = Value->AsObject(); + FVoxelSeasonRejectionCount& Row = OutManifest.RejectionCounts.AddDefaulted_GetRef(); + bFieldsValid = ReadJsonString(Object, TEXT("reason"), Row.Reason) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Object, TEXT("count"), Row.Count) && bFieldsValid; + } + } + + const TArray>* AuditValues = nullptr; + if (ReadJsonArray(Root, TEXT("candidate_audit"), AuditValues)) + { + OutManifest.CandidateAudit.Reset(); + OutManifest.CandidateAudit.Reserve(AuditValues->Num()); + for (const TSharedPtr& Value : *AuditValues) + { + if (!Value.IsValid() || Value->Type != EJson::Object) + { + bFieldsValid = false; + continue; + } + TSharedPtr Object = Value->AsObject(); + FVoxelSeasonCandidateAudit& Row = OutManifest.CandidateAudit.AddDefaulted_GetRef(); + FString ArchetypeText; + double Distance = 0.0; + bFieldsValid = ReadJsonInt32(Object, TEXT("candidate_index"), Row.CandidateIndex) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Object, TEXT("seed"), Row.Seed) && bFieldsValid; + bFieldsValid = ReadJsonString(Object, TEXT("archetype"), ArchetypeText) && bFieldsValid; + double RecipeHash = 0.0; + bFieldsValid = ReadJsonNumber(Object, TEXT("recipe_hash"), RecipeHash) && bFieldsValid; + bFieldsValid = RecipeHash >= 0.0 && RecipeHash <= 4294967295.0 + && FMath::FloorToDouble(RecipeHash) == RecipeHash && bFieldsValid; + Row.RecipeHash = static_cast(RecipeHash); + bFieldsValid = ReadJsonNumber(Object, TEXT("distance_from_corpus_centroid"), Distance) && bFieldsValid; + Row.DistanceFromCorpusCentroid = Distance; + bFieldsValid = ReadJsonBool(Object, TEXT("passed_hard_gates"), Row.bPassedHardGates) && bFieldsValid; + bFieldsValid = ReadJsonBool(Object, TEXT("selected"), Row.bSelected) && bFieldsValid; + bFieldsValid = ReadJsonString(Object, TEXT("rejection_reason"), Row.RejectionReason) && bFieldsValid; + bFieldsValid = ParseArchetype(ArchetypeText, Row.Archetype) && bFieldsValid; + } + } + + if (OutManifest.Strates.Num() != OutManifest.SelectedCount + || OutManifest.SelectedCount <= 0 + || OutManifest.WorldRadiusVoxels != 0.0f) + { + bFieldsValid = false; + } + for (int32 Index = 0; Index < OutManifest.Strates.Num(); ++Index) + { + const FVoxelSeasonStrate& Strate = OutManifest.Strates[Index]; + if (Strate.DepthIndex != Index || !Strate.bPassedPrimordialLaw + || Strate.PrimordialLawResult != EVoxelConnectivityResult::Connected) + { + bFieldsValid = false; + break; + } + } + if (!bFieldsValid) + { + OutReport = FString::Printf(TEXT("season manifest %s contains invalid fields"), *ManifestPath); + OutManifest.Error = OutReport; + OutManifest.bValid = false; + return false; + } + OutManifest.ManifestPath = ManifestPath; + OutManifest.SerializedJson = MoveTemp(OriginalJson); + OutReport = FString::Printf(TEXT("loaded season manifest from %s"), *ManifestPath); + return OutManifest.IsUsable(); +} + +namespace VoxelSeasonCompositionPrivate +{ + float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); + float ActiveBottom(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); + float ActiveSeal(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); + void SetRuntimeBounds(FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ); + const TCHAR* RejectionReasonForLaw(EVoxelConnectivityResult Result); + + // These caps are policy/allocator guards, not gameplay limits. They keep a malformed build-box + // request from turning the candidate audit or Z-layout arithmetic into an unbounded allocation. + constexpr int32 MaxCandidateCount = 4096; + constexpr int32 MaxSelectedStrateCount = 256; + constexpr int32 MaxStrateHeightInChunks = 4096; + +#if WITH_EDITOR + struct FSeasonCandidateState + { + FVoxelStrateComposerCandidate Candidate; + FVoxelStrateMeasuredMetrics Metrics; + double DistanceFromCorpusCentroid = 0.0; + bool bPassedHardGates = false; + bool bSelected = false; + bool bUnavailable = false; + bool bOutlierPool = false; + FString RejectionReason; + }; +#endif + + struct FSeasonEvaluation + { + bool bPassedNonVacuous = false; + bool bPassedLargestComponent = false; + bool bPassedPrimordialLaw = false; + EVoxelConnectivityResult PrimordialLawResult = EVoxelConnectivityResult::OutOfWindow; + FVoxelStrateMeasuredMetrics Metrics; + FString FailureReason; + }; + + class FSeasonStackSampler final : public IVoxelStrateDensitySampler + { + public: + explicit FSeasonStackSampler(const FVoxelOpStack& InStack) + : Stack(InStack) + { + } + + float SampleDensity(float WorldX, float WorldY, float WorldZ) const override + { + // FVoxelOpStack::EvalMC is the established MC-facing convention: negative solid, + // positive air. No generation path is called here; this class exists only for the + // offline measurement API. + return Stack.EvalMC(WorldX, WorldY, WorldZ); + } + + private: + const FVoxelOpStack& Stack; + }; + + bool IsFinitePolicy(const FVoxelSeasonSelectionPolicy& Policy) + { + const float FractionSum = Policy.GroundedFraction + Policy.OutlierFraction; + return FMath::IsFinite(Policy.GroundedFraction) + && FMath::IsFinite(Policy.OutlierFraction) + && Policy.GroundedFraction >= 0.0f + && Policy.OutlierFraction >= 0.0f + && FMath::IsFinite(FractionSum) && FractionSum > 0.0f + && FMath::IsFinite(Policy.AdjacentSimilarityDistanceThreshold) + && Policy.AdjacentSimilarityDistanceThreshold >= 0.0 + && FMath::IsFinite(Policy.AdjacentSimilarityPenaltyWeight) + && Policy.AdjacentSimilarityPenaltyWeight >= 0.0 + && FMath::IsFinite(Policy.NewArchetypeBonus) + && FMath::IsFinite(Policy.NewRecipeBonus) + && Policy.BossSlotInterval >= 0 + && FMath::IsFinite(Policy.MinimumLargestComponentShare) + && Policy.MinimumLargestComponentShare >= 0.0f + && Policy.MinimumLargestComponentShare <= 1.0f + && FMath::IsFinite(Policy.FeatureScaleNormalizationVoxels) + && Policy.FeatureScaleNormalizationVoxels > 0.0f + && FMath::IsFinite(Policy.ClearanceNormalizationVoxels) + && Policy.ClearanceNormalizationVoxels > 0.0f; + } + + bool IsValidMeasureSettings(const FVoxelStrateMeasureSettings& Settings) + { + return Settings.SampleStep > 0 + && Settings.RadiusInVoxels > 0 + && Settings.MaxCells > 0 + && Settings.MaxRouteRetries >= 0 + && Settings.HeadroomCells >= 0 + && Settings.InteriorMarginVoxels >= -1 + && FMath::IsFinite(Settings.CenterXY.X) + && FMath::IsFinite(Settings.CenterXY.Y) + && FMath::IsFinite(Settings.CoverMarginVoxels) + && Settings.CoverMarginVoxels >= 0.0f; + } + + bool IsBossSlot(int32 DepthIndex, int32 TotalStrates, int32 Interval) + { + // Slot zero is the surface entry, and the terminal slot is deliberately never a boss. + return Interval > 0 && DepthIndex > 0 && DepthIndex < TotalStrates - 1 + && ((DepthIndex + 1) % Interval) == 0; + } + + uint32 SeasonAttemptHash(int32 SeasonSeed, int32 CandidateIndex) + { + // The attempt index is deliberately folded into the hash. Reordering candidate work on + // the build box therefore cannot accidentally turn an attempt into mutable RNG state. + uint32 Hash = VoxelHash::Mix(static_cast(SeasonSeed) ^ 0x53454153u); + Hash = VoxelHash::Mix(Hash ^ (static_cast(CandidateIndex) * 0x9E3779B9u)); + return VoxelHash::Mix(Hash ^ 0x54494552u); + } + + FString PercentageText(float Fraction) + { + return FString::Printf(TEXT("%.0f%%"), static_cast(Fraction * 100.0f)); + } + + void AddRejection(TMap& Counts, const FString& Reason) + { + const FString Key = Reason.IsEmpty() ? TEXT("unspecified") : Reason; + Counts.FindOrAdd(Key) += 1; + } + + void ExportRejectionCounts(const TMap& Counts, + TArray& OutCounts) + { + TArray Reasons; + Counts.GetKeys(Reasons); + Reasons.Sort([](const FString& A, const FString& B) { return A < B; }); + OutCounts.Reset(); + for (const FString& Reason : Reasons) + { + FVoxelSeasonRejectionCount& Row = OutCounts.AddDefaulted_GetRef(); + Row.Reason = Reason; + Row.Count = Counts.FindChecked(Reason); + } + } + + void CopyDefinitionParams(const UVoxelStrateDefinition& Definition, + FVoxelSeasonFixedStrate& OutFixed) + { + OutFixed = FVoxelSeasonFixedStrate(); + OutFixed.HeightInChunks = Definition.StrateHeightInChunks; + OutFixed.Archetype = Definition.GeneratorType; + OutFixed.SourceDefinitionPath = Definition.GetPathName(); + OutFixed.bUsesRecipe = false; + OutFixed.Params.TunnelNetworkParams = Definition.GenerationParams; + OutFixed.Params.SlabParams = Definition.SlabParams; + OutFixed.Params.MazeParams = Definition.MazeParams; + OutFixed.Params.SurfaceParams = Definition.SurfaceParams; + OutFixed.Params.VerticalShaftParams = Definition.VerticalShaftParams; + OutFixed.Params.FloatingIslandParams = Definition.FloatingIslandParams; + } + + bool ResolveFixedStrates(const FVoxelSeasonCompositionSettings& Settings, + int32 SeasonSeed, + TMap& OutFixed, + FString& OutError) + { + OutFixed = Settings.FixedStrates; + if (Settings.WorldSettings == nullptr) + { + return true; + } + + for (const TPair>& Pair + : Settings.WorldSettings->FixedStrates) + { + if (OutFixed.Contains(Pair.Key)) + { + continue; + } + UVoxelStrateDefinition* Definition = Pair.Value.LoadSynchronous(); + if (Definition == nullptr) + { + OutError = FString::Printf(TEXT("fixed strate slot %d could not load definition %s"), + Pair.Key, *Pair.Value.ToSoftObjectPath().ToString()); + return false; + } + FVoxelSeasonFixedStrate Fixed; + CopyDefinitionParams(*Definition, Fixed); + Fixed.Seed = SeasonSeed; + if (Fixed.SourceDefinitionPath.IsEmpty()) + { + Fixed.SourceDefinitionPath = Pair.Value.ToSoftObjectPath().ToString(); + } + OutFixed.Add(Pair.Key, MoveTemp(Fixed)); + } + return true; + } + +#if WITH_EDITOR + void MakeCandidateStrate(const FVoxelStrateComposerCandidate& Candidate, + int32 CandidateIndex, + int32 HeightInChunks, + int32 TopWorldZ, + int32 BottomWorldZ, + FVoxelSeasonStrate& OutStrate) + { + OutStrate = FVoxelSeasonStrate(); + OutStrate.DepthIndex = INDEX_NONE; + OutStrate.CandidateIndex = CandidateIndex; + OutStrate.Seed = Candidate.Seed; + OutStrate.HeightInChunks = HeightInChunks; + OutStrate.TopWorldZ = TopWorldZ; + OutStrate.BottomWorldZ = BottomWorldZ; + OutStrate.Archetype = Candidate.Archetype; + OutStrate.Recipe = Candidate.Recipe; + OutStrate.Params = Candidate.ArchetypeParams; + OutStrate.bUsesRecipe = true; + SetRuntimeBounds(OutStrate.Params, static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + } +#endif + + void MakeFixedStrate(const FVoxelSeasonFixedStrate& Fixed, + int32 DepthIndex, + int32 TopWorldZ, + int32 BottomWorldZ, + FVoxelSeasonStrate& OutStrate) + { + OutStrate = FVoxelSeasonStrate(); + OutStrate.DepthIndex = DepthIndex; + OutStrate.CandidateIndex = INDEX_NONE; + OutStrate.Seed = Fixed.Seed; + OutStrate.HeightInChunks = Fixed.HeightInChunks; + OutStrate.TopWorldZ = TopWorldZ; + OutStrate.BottomWorldZ = BottomWorldZ; + OutStrate.Archetype = Fixed.Archetype; + OutStrate.Recipe = Fixed.Recipe; + OutStrate.Params = Fixed.Params; + OutStrate.SourceDefinitionPath = Fixed.SourceDefinitionPath; + OutStrate.bFixed = true; + OutStrate.bUsesRecipe = Fixed.bUsesRecipe; + SetRuntimeBounds(OutStrate.Params, static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + } + + bool EvaluateStrate(const FVoxelSeasonStrate& Strate, + const FVoxelSeasonCompositionSettings& Settings, + FSeasonEvaluation& OutEvaluation, + FVoxelStrateSampleGrid* OutGrid = nullptr, + FVoxelStrateMetrics* OutRawMetrics = nullptr) + { + OutEvaluation = FSeasonEvaluation(); + if (OutGrid != nullptr) + { + *OutGrid = FVoxelStrateSampleGrid(); + } + if (OutRawMetrics != nullptr) + { + *OutRawMetrics = FVoxelStrateMetrics(); + } + + FVoxelOpStack Stack; + FVoxelOpContext Context; + FString BuildError; + bool bBuilt = false; + if (Strate.bUsesRecipe) + { + bBuilt = VF_BuildStackFromRecipe(Strate.Recipe, Strate.Params, Strate.Seed, + Settings.OriginSpineRadius, nullptr, Stack, + Context, &BuildError); + } + else + { +#if WITH_EDITOR + const float Seal = ActiveSeal(Strate.Params, Strate.Archetype); + bBuilt = VF_BuildNativeStrateStackForCandidate( + Strate.Archetype, Strate.Params, Strate.Seed, Settings.OriginSpineRadius, + 0.0f, Seal, nullptr, Stack, Context); + if (!bBuilt) + { + BuildError = TEXT("native fixed strate stack could not be materialised"); + } +#else + BuildError = TEXT("native fixed strate validation is editor-only"); +#endif + } + if (!bBuilt) + { + OutEvaluation.FailureReason = BuildError.IsEmpty() + ? TEXT("recipe/native stack build failed") : BuildError; + return false; + } + if (Context.WorldRadiusVoxels != 0.0f) + { + OutEvaluation.FailureReason = TEXT("WorldRadiusVoxels was not zero during composition"); + return false; + } + + const float Seal = Context.EdgeSealThickness; + if (!FMath::IsFinite(Seal) || Seal < 0.0f) + { + OutEvaluation.FailureReason = TEXT("boundary seal thickness is invalid"); + return false; + } + Stack.PrepareChunk(Context); + FSeasonStackSampler Sampler(Stack); + FVoxelStrateMetrics RawMetrics = VF_MeasureStrateWithSampler( + Sampler, Strate.BottomWorldZ, Strate.TopWorldZ, Seal, + Settings.MeasureSettings, OutGrid); + if (OutRawMetrics != nullptr) + { + *OutRawMetrics = RawMetrics; + } + if (!RawMetrics.bValid) + { + OutEvaluation.FailureReason = RawMetrics.RefusalReason.IsEmpty() + ? TEXT("measurement failed") + : FString::Printf(TEXT("measurement failed: %s"), *RawMetrics.RefusalReason); + return false; + } + + OutEvaluation.bPassedNonVacuous = RawMetrics.NumSampled > 0 + && RawMetrics.NumAir > 0 && RawMetrics.NumSolid > 0; + if (!OutEvaluation.bPassedNonVacuous) + { + OutEvaluation.FailureReason = TEXT("vacuous"); + return false; + } + + OutEvaluation.bPassedLargestComponent = + RawMetrics.LargestComponentShare >= Settings.SelectionPolicy.MinimumLargestComponentShare; + if (!OutEvaluation.bPassedLargestComponent) + { + OutEvaluation.FailureReason = FString::Printf( + TEXT("fragmented (largest component %.6f < %.6f)"), + RawMetrics.LargestComponentShare, + Settings.SelectionPolicy.MinimumLargestComponentShare); + return false; + } + + const float InteriorBottom = static_cast(Strate.BottomWorldZ) + + static_cast(RawMetrics.ResolvedMarginVoxels); + const float InteriorTop = static_cast(Strate.TopWorldZ) + - static_cast(RawMetrics.ResolvedMarginVoxels); + if (!FMath::IsFinite(InteriorBottom) || !FMath::IsFinite(InteriorTop) + || InteriorTop - InteriorBottom <= 1.0f) + { + OutEvaluation.FailureReason = TEXT("primordial law failed: no two interior endpoint cells"); + return false; + } + + const FVoxelConnectivityDiagnostics Law = VF_DiagnoseConnectivityWithSampler( + Sampler, Strate.BottomWorldZ, Strate.TopWorldZ, Seal, + FVector(0.0f, 0.0f, InteriorBottom + 0.5f), + FVector(0.0f, 0.0f, InteriorTop - 0.5f), Settings.MeasureSettings); + OutEvaluation.PrimordialLawResult = Law.Result; + OutEvaluation.bPassedPrimordialLaw = Law.bValid + && Law.Result == EVoxelConnectivityResult::Connected + && !Law.bStartSnapped && !Law.bGoalSnapped; + if (!OutEvaluation.bPassedPrimordialLaw) + { + OutEvaluation.FailureReason = FString::Printf( + TEXT("primordial law failed: %s%s"), + RejectionReasonForLaw(Law.Result), + (Law.bStartSnapped || Law.bGoalSnapped) ? TEXT(" (endpoint snapped)") : TEXT("")); + return false; + } + + OutEvaluation.Metrics = VF_SummarizeStrateMetrics(RawMetrics); + if (!OutEvaluation.Metrics.IsUsable()) + { + OutEvaluation.FailureReason = TEXT("measurement summary was not usable"); + return false; + } + return true; + } + + FVoxelStratePromotionPolicy MakeMetricDistancePolicy( + const FVoxelSeasonSelectionPolicy& Policy) + { + FVoxelStratePromotionPolicy Result; + Result.FeatureScaleNormalizationVoxels = Policy.FeatureScaleNormalizationVoxels; + Result.ClearanceNormalizationVoxels = Policy.ClearanceNormalizationVoxels; + return Result; + } + + bool IsBetterScore(double Score, int32 CandidateIndex, + double BestScore, int32 BestCandidateIndex) + { + constexpr double TieEpsilon = 1.0e-12; + if (BestCandidateIndex == INDEX_NONE) + { + return true; + } + if (Score > BestScore + TieEpsilon) + { + return true; + } + return FMath::Abs(Score - BestScore) <= TieEpsilon + && CandidateIndex < BestCandidateIndex; + } + float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Params.TunnelNetworkParams.StrateTopWorldZ; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Params.SlabParams.StrateTopWorldZ; + case ECaveGeneratorType::Maze: + return Params.MazeParams.StrateTopWorldZ; + case ECaveGeneratorType::SurfaceWorld: + return Params.SurfaceParams.StrateTopWorldZ; + case ECaveGeneratorType::VerticalShafts: + return Params.VerticalShaftParams.StrateTopWorldZ; + case ECaveGeneratorType::FloatingIslands: + return Params.FloatingIslandParams.StrateTopWorldZ; + default: + return 0.0f; + } + } + + float ActiveBottom(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Params.TunnelNetworkParams.StrateBottomWorldZ; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Params.SlabParams.StrateBottomWorldZ; + case ECaveGeneratorType::Maze: + return Params.MazeParams.StrateBottomWorldZ; + case ECaveGeneratorType::SurfaceWorld: + return Params.SurfaceParams.StrateBottomWorldZ; + case ECaveGeneratorType::VerticalShafts: + return Params.VerticalShaftParams.StrateBottomWorldZ; + case ECaveGeneratorType::FloatingIslands: + return Params.FloatingIslandParams.StrateBottomWorldZ; + default: + return 0.0f; + } + } + + float ActiveSeal(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; + } + } + + void SetRuntimeBounds(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; + } + + bool SameFloatBits(float A, float B) + { + return FMemory::Memcmp(&A, &B, sizeof(float)) == 0; + } + + const TCHAR* RejectionReasonForLaw(EVoxelConnectivityResult Result) + { + return VoxelSeasonManifestPrivate::ConnectivityName(Result); + } +} + +namespace VoxelSeasonCompositionPrivate +{ +#if WITH_EDITOR + bool WriteReviewPage(const FVoxelSeasonManifest& Manifest, + const FVoxelSeasonCompositionSettings& Settings, + const FString& ReviewDirectory, + FString& OutReviewPath, + FString& OutError); +#endif +} + +#if WITH_EDITOR + +FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, + const FVoxelSeasonCompositionSettings& Settings) +{ + using namespace VoxelSeasonCompositionPrivate; + FVoxelSeasonManifest Manifest; + Manifest.Season = Settings.SeasonNumber != 0 + ? Settings.SeasonNumber + : (Settings.WorldSettings != nullptr ? Settings.WorldSettings->CurrentSeason : 0); + Manifest.Seed = SeasonSeed; + Manifest.SchemaVersion = FVoxelSeasonManifest::CurrentSchemaVersion; + Manifest.SelectionPolicy = Settings.SelectionPolicy; + Manifest.PolicyNote = TEXT( + "PROVISIONAL POLICY: grounded/outlier ratio, adjacency threshold/weight, variety bonuses, " + "boss cadence, and metric normalizers are review knobs. This is not a settled quality score. " + "Jahni reviews the selected descent page and may veto the season before publish."); + + auto Fail = [&](const FString& Reason) -> FVoxelSeasonManifest + { + Manifest.bValid = false; + Manifest.Error = Reason; + return Manifest; + }; + + if (Settings.WorldSettings != nullptr + && Settings.WorldSettings->WorldRadiusVoxels != 0.0f) + { + return Fail(TEXT("WorldRadiusVoxels must remain exactly 0 for season composition.")); + } + if (Settings.WorldRadiusVoxels != 0.0f) + { + return Fail(TEXT("WorldRadiusVoxels must remain exactly 0 for season composition.")); + } + if (Settings.CandidateCount <= 0 || Settings.CandidateCount > MaxCandidateCount) + { + return Fail(FString::Printf(TEXT("CandidateCount must be in [1,%d]."), MaxCandidateCount)); + } + if (Settings.SelectedStrateCount <= 0 + || Settings.SelectedStrateCount > MaxSelectedStrateCount) + { + return Fail(FString::Printf(TEXT("SelectedStrateCount must be in [1,%d]."), + MaxSelectedStrateCount)); + } + if (Settings.StrateHeightInChunks <= 0 + || Settings.StrateHeightInChunks > MaxStrateHeightInChunks) + { + return Fail(FString::Printf(TEXT("StrateHeightInChunks must be in [1,%d]."), + MaxStrateHeightInChunks)); + } + if (Settings.InterStrateGapChunks < 0 + || Settings.InterStrateGapChunks > MaxStrateHeightInChunks) + { + return Fail(TEXT("InterStrateGapChunks is outside the bounded composition range.")); + } + if (!FMath::IsFinite(Settings.OriginSpineRadius) || Settings.OriginSpineRadius < 0.0f) + { + return Fail(TEXT("OriginSpineRadius must be finite and non-negative.")); + } + if (!IsFinitePolicy(Settings.SelectionPolicy)) + { + return Fail(TEXT("SelectionPolicy contains a non-finite or out-of-range knob.")); + } + if (!IsValidMeasureSettings(Settings.MeasureSettings)) + { + return Fail(TEXT("MeasureSettings are invalid for a bounded offline pass.")); + } + + TMap FixedStrates; + FString FixedError; + if (!ResolveFixedStrates(Settings, SeasonSeed, FixedStrates, FixedError)) + { + return Fail(FixedError); + } + for (const TPair& Pair : FixedStrates) + { + if (Pair.Key < 0 || Pair.Key >= Settings.SelectedStrateCount) + { + return Fail(FString::Printf(TEXT("FixedStrates index %d is outside [0,%d)."), + Pair.Key, Settings.SelectedStrateCount)); + } + if (!VoxelSeasonManifestPrivate::IsSupportedArchetype(Pair.Value.Archetype)) + { + return Fail(FString::Printf(TEXT("FixedStrates index %d has an unsupported archetype."), + Pair.Key)); + } + if (Pair.Value.HeightInChunks <= 0 + || Pair.Value.HeightInChunks > MaxStrateHeightInChunks) + { + return Fail(FString::Printf(TEXT("FixedStrates index %d has an invalid height."), + Pair.Key)); + } + } + + FVoxelStrateCorpus OwnedCorpus; + const FVoxelStrateCorpus* Corpus = Settings.Corpus; + if (Corpus == nullptr) + { + FString CorpusReport; + if (!OwnedCorpus.LoadFromAssetRegistry(CorpusReport) || !OwnedCorpus.IsValid()) + { + return Fail(FString::Printf(TEXT("could not load the strate corpus: %s"), + *CorpusReport)); + } + Corpus = &OwnedCorpus; + } + if (Corpus == nullptr || !Corpus->IsValid()) + { + return Fail(TEXT("season composition requires a valid strate corpus.")); + } + + Manifest.InputCorpusHash = Corpus->GetContentsHash(); + Manifest.CandidateCount = Settings.CandidateCount; + Manifest.TotalStrates = Settings.SelectedStrateCount; + Manifest.InterStrateGapChunks = Settings.InterStrateGapChunks; + Manifest.OriginSpineRadius = Settings.OriginSpineRadius; + Manifest.WorldRadiusVoxels = 0.0f; + + TArray Candidates; + Candidates.Reserve(Settings.CandidateCount); + for (int32 CandidateIndex = 0; CandidateIndex < Settings.CandidateCount; ++CandidateIndex) + { + FSeasonCandidateState& State = Candidates.AddDefaulted_GetRef(); + const int32 CandidateSeed = static_cast( + SeasonAttemptHash(SeasonSeed, CandidateIndex)); + State.Candidate = VF_RollStrateCandidate( + *Corpus, CandidateSeed, CandidateIndex, true); + if (!State.Candidate.bValid) + { + State.RejectionReason = State.Candidate.FailureReason.IsEmpty() + ? TEXT("candidate roll failed") : State.Candidate.FailureReason; + continue; + } + + State.DistanceFromCorpusCentroid = VF_DistanceFromStrateCorpusCentroid( + *Corpus, State.Candidate.ArchetypeParams, State.Candidate.Archetype); + if (!FMath::IsFinite(State.DistanceFromCorpusCentroid)) + { + State.DistanceFromCorpusCentroid = 0.0; + } + + FVoxelSeasonStrate Probe; + const int32 ProbeTopWorldZ = 32; + const int32 ProbeBottomWorldZ = ProbeTopWorldZ + - Settings.StrateHeightInChunks * 32; + MakeCandidateStrate(State.Candidate, CandidateIndex, + Settings.StrateHeightInChunks, ProbeTopWorldZ, + ProbeBottomWorldZ, Probe); + FSeasonEvaluation Evaluation; + if (EvaluateStrate(Probe, Settings, Evaluation)) + { + State.Metrics = Evaluation.Metrics; + State.bPassedHardGates = true; + } + else + { + State.RejectionReason = Evaluation.FailureReason; + } + } + + TArray SurvivorIndices; + SurvivorIndices.Reserve(Candidates.Num()); + for (int32 Index = 0; Index < Candidates.Num(); ++Index) + { + if (Candidates[Index].bPassedHardGates) + { + SurvivorIndices.Add(Index); + } + } + Manifest.SurvivorCount = SurvivorIndices.Num(); + + const int32 GeneratedSlotCount = Settings.SelectedStrateCount - FixedStrates.Num(); + if (GeneratedSlotCount < 0) + { + return Fail(TEXT("FixedStrates contains more slots than the requested season.")); + } + if (SurvivorIndices.Num() < GeneratedSlotCount) + { + return Fail(FString::Printf( + TEXT("only %d candidates survived the hard gates, but %d generated slots are required"), + SurvivorIndices.Num(), GeneratedSlotCount)); + } + + const float FractionSum = Settings.SelectionPolicy.GroundedFraction + + Settings.SelectionPolicy.OutlierFraction; + const int32 TargetOutlierCount = FMath::Clamp( + FMath::RoundToInt(static_cast(GeneratedSlotCount) + * Settings.SelectionPolicy.OutlierFraction / FractionSum), + 0, GeneratedSlotCount); + SurvivorIndices.Sort([&Candidates](int32 A, int32 B) + { + const double ADistance = Candidates[A].DistanceFromCorpusCentroid; + const double BDistance = Candidates[B].DistanceFromCorpusCentroid; + if (ADistance != BDistance) + { + return ADistance > BDistance; + } + return Candidates[A].Candidate.Index < Candidates[B].Candidate.Index; + }); + for (int32 Index = 0; Index < FMath::Min(TargetOutlierCount, SurvivorIndices.Num()); ++Index) + { + Candidates[SurvivorIndices[Index]].bOutlierPool = true; + } + + const FVoxelStratePromotionPolicy MetricDistancePolicy = + MakeMetricDistancePolicy(Settings.SelectionPolicy); + TSet UsedArchetypes; + TSet UsedRecipes; + FVoxelStrateMeasuredMetrics PreviousMetrics; + bool bHavePreviousMetrics = false; + int64 CurrentTopChunk = 0; + int32 GeneratedOrdinal = 0; + int32 SelectedGeneratedCount = 0; + int32 AssignedOutlierCount = 0; + int32 FixedSelectedCount = 0; + + auto GetLayoutBounds = [&](int32 HeightInChunks, + int32& OutTopWorldZ, int32& OutBottomWorldZ, + int64& OutBottomChunk) -> bool + { + if (HeightInChunks <= 0) return false; + const int64 TopChunk = CurrentTopChunk; + OutBottomChunk = TopChunk - static_cast(HeightInChunks) + 1; + const int64 TopWorld = (TopChunk + 1) * 32; + const int64 BottomWorld = OutBottomChunk * 32; + if (TopWorld < static_cast(TNumericLimits::Lowest()) + || TopWorld > static_cast(TNumericLimits::Max()) + || BottomWorld < static_cast(TNumericLimits::Lowest()) + || BottomWorld > static_cast(TNumericLimits::Max())) + { + return false; + } + OutTopWorldZ = static_cast(TopWorld); + OutBottomWorldZ = static_cast(BottomWorld); + return OutTopWorldZ > OutBottomWorldZ; + }; + + for (int32 DepthIndex = 0; DepthIndex < Settings.SelectedStrateCount; ++DepthIndex) + { + const FVoxelSeasonFixedStrate* Fixed = FixedStrates.Find(DepthIndex); + const int32 HeightInChunks = Fixed != nullptr + ? Fixed->HeightInChunks : Settings.StrateHeightInChunks; + int32 TopWorldZ = 0; + int32 BottomWorldZ = 0; + int64 BottomChunk = 0; + if (!GetLayoutBounds(HeightInChunks, TopWorldZ, BottomWorldZ, BottomChunk)) + { + return Fail(FString::Printf(TEXT("season Z layout overflowed at slot %d"), DepthIndex)); + } + + if (Fixed != nullptr) + { + FVoxelSeasonStrate Strate; + MakeFixedStrate(*Fixed, DepthIndex, TopWorldZ, BottomWorldZ, Strate); + FSeasonEvaluation Evaluation; + if (!EvaluateStrate(Strate, Settings, Evaluation)) + { + return Fail(FString::Printf(TEXT("fixed slot %d failed validation: %s"), + DepthIndex, *Evaluation.FailureReason)); + } + Strate.Metrics = Evaluation.Metrics; + Strate.bPassedNonVacuous = Evaluation.bPassedNonVacuous; + Strate.bPassedLargestComponent = Evaluation.bPassedLargestComponent; + Strate.bPassedPrimordialLaw = Evaluation.bPassedPrimordialLaw; + Strate.PrimordialLawResult = Evaluation.PrimordialLawResult; + Strate.DistanceFromCorpusCentroid = VF_DistanceFromStrateCorpusCentroid( + *Corpus, Strate.Params, Strate.Archetype); + Strate.bBossSlot = IsBossSlot(DepthIndex, Settings.SelectedStrateCount, + Settings.SelectionPolicy.BossSlotInterval); + Strate.SelectionReason = TEXT("fixed: preserved from the absolute FixedStrates slot"); + if (Strate.bBossSlot) + { + Strate.SelectionReason += TEXT("; boss slot at the provisional five-slot cadence"); + } + Manifest.Strates.Add(MoveTemp(Strate)); + ++FixedSelectedCount; + UsedArchetypes.Add(static_cast(Fixed->Archetype)); + if (Fixed->bUsesRecipe) + { + UsedRecipes.Add(VF_HashStrateStructureRecipe(Fixed->Recipe)); + } + PreviousMetrics = Manifest.Strates.Last().Metrics; + bHavePreviousMetrics = true; + } + else + { + const int32 RemainingGeneratedSlots = GeneratedSlotCount - GeneratedOrdinal; + const int32 DesiredOutliersSoFar = FMath::RoundToInt( + static_cast(GeneratedOrdinal + 1) * TargetOutlierCount + / FMath::Max(1, GeneratedSlotCount)); + const bool bWantOutlier = DesiredOutliersSoFar > + AssignedOutlierCount; + + int32 BestStateIndex = INDEX_NONE; + double BestScore = -TNumericLimits::Max(); + bool bUsedClassFallback = false; + auto FindBest = [&](bool bRequireOutlierClass) -> bool + { + bool bFound = false; + for (int32 StateIndex = 0; StateIndex < Candidates.Num(); ++StateIndex) + { + FSeasonCandidateState& State = Candidates[StateIndex]; + if (!State.bPassedHardGates || State.bSelected || State.bUnavailable + || (bRequireOutlierClass && State.bOutlierPool != bWantOutlier)) + { + continue; + } + const bool bNewArchetype = !UsedArchetypes.Contains( + static_cast(State.Candidate.Archetype)); + const uint32 RecipeHash = VF_HashStrateStructureRecipe(State.Candidate.Recipe); + const bool bNewRecipe = !UsedRecipes.Contains(RecipeHash); + const bool bActualOutlier = State.bOutlierPool; + double Score = bActualOutlier + ? State.DistanceFromCorpusCentroid + : -State.DistanceFromCorpusCentroid; + if (bHavePreviousMetrics && State.Metrics.IsUsable()) + { + const double AdjacentDistance = VF_NormalizedMeasuredMetricDistance( + PreviousMetrics, State.Metrics, MetricDistancePolicy); + if (FMath::IsFinite(AdjacentDistance) + && AdjacentDistance < Settings.SelectionPolicy.AdjacentSimilarityDistanceThreshold) + { + Score -= (Settings.SelectionPolicy.AdjacentSimilarityDistanceThreshold + - AdjacentDistance) + * Settings.SelectionPolicy.AdjacentSimilarityPenaltyWeight; + } + } + if (bNewArchetype) + { + Score += Settings.SelectionPolicy.NewArchetypeBonus; + } + if (bNewRecipe) + { + Score += Settings.SelectionPolicy.NewRecipeBonus; + } + if (!FMath::IsFinite(Score)) + { + continue; + } + if (!bFound || IsBetterScore(Score, State.Candidate.Index, + BestScore, + Candidates[BestStateIndex].Candidate.Index)) + { + BestStateIndex = StateIndex; + BestScore = Score; + bFound = true; + } + } + return bFound; + }; + + if (!FindBest(true)) + { + bUsedClassFallback = FindBest(false); + } + if (BestStateIndex == INDEX_NONE) + { + return Fail(FString::Printf( + TEXT("no validated candidate remains for season slot %d (%d generated slots remain)"), + DepthIndex, RemainingGeneratedSlots)); + } + + bool bPlaced = false; + while (BestStateIndex != INDEX_NONE) + { + FSeasonCandidateState& State = Candidates[BestStateIndex]; + FVoxelSeasonStrate Strate; + MakeCandidateStrate(State.Candidate, State.Candidate.Index, + Settings.StrateHeightInChunks, TopWorldZ, + BottomWorldZ, Strate); + Strate.DepthIndex = DepthIndex; + FSeasonEvaluation Evaluation; + if (!EvaluateStrate(Strate, Settings, Evaluation)) + { + State.bUnavailable = true; + State.RejectionReason = FString::Printf( + TEXT("final placement at depth %d failed: %s"), + DepthIndex, *Evaluation.FailureReason); + BestStateIndex = INDEX_NONE; + if (!FindBest(true)) + { + bUsedClassFallback = FindBest(false); + } + continue; + } + + const bool bActualOutlier = State.bOutlierPool; + const bool bNewArchetype = !UsedArchetypes.Contains( + static_cast(State.Candidate.Archetype)); + const uint32 RecipeHash = VF_HashStrateStructureRecipe(State.Candidate.Recipe); + const bool bNewRecipe = !UsedRecipes.Contains(RecipeHash); + Strate.Metrics = Evaluation.Metrics; + Strate.DistanceFromCorpusCentroid = State.DistanceFromCorpusCentroid; + Strate.bGrounded = !bActualOutlier; + Strate.bOutlier = bActualOutlier; + Strate.bBossSlot = IsBossSlot(DepthIndex, Settings.SelectedStrateCount, + Settings.SelectionPolicy.BossSlotInterval); + Strate.bPassedNonVacuous = Evaluation.bPassedNonVacuous; + Strate.bPassedLargestComponent = Evaluation.bPassedLargestComponent; + Strate.bPassedPrimordialLaw = Evaluation.bPassedPrimordialLaw; + Strate.PrimordialLawResult = Evaluation.PrimordialLawResult; + const FString ClassName = bActualOutlier ? TEXT("outlier") : TEXT("grounded"); + const FString DistanceDirection = bActualOutlier + ? TEXT("farthest surviving corpus-centroid distance") + : TEXT("nearest surviving corpus-centroid distance"); + Strate.SelectionReason = FString::Printf( + TEXT("%s (%s grounded / %s outlier placeholder): %s; " + "adjacency penalty and variety tie-breakers"), + *ClassName, + *PercentageText(Settings.SelectionPolicy.GroundedFraction / FractionSum), + *PercentageText(Settings.SelectionPolicy.OutlierFraction / FractionSum), + *DistanceDirection); + if (bUsedClassFallback) + { + Strate.SelectionReason += TEXT("; requested ratio class was exhausted"); + } + if (bNewArchetype || bNewRecipe) + { + Strate.SelectionReason += TEXT("; unseen "); + if (bNewArchetype) Strate.SelectionReason += TEXT("archetype"); + if (bNewArchetype && bNewRecipe) Strate.SelectionReason += TEXT("/"); + if (bNewRecipe) Strate.SelectionReason += TEXT("recipe"); + Strate.SelectionReason += TEXT(" bonus applied"); + } + if (Strate.bBossSlot) + { + Strate.SelectionReason += TEXT("; boss slot at the provisional five-slot cadence"); + } + + State.Metrics = Evaluation.Metrics; + State.bSelected = true; + State.RejectionReason.Reset(); + Manifest.Strates.Add(MoveTemp(Strate)); + UsedArchetypes.Add(static_cast(State.Candidate.Archetype)); + UsedRecipes.Add(RecipeHash); + PreviousMetrics = Manifest.Strates.Last().Metrics; + bHavePreviousMetrics = true; + ++GeneratedOrdinal; + ++SelectedGeneratedCount; + if (bActualOutlier) + { + ++AssignedOutlierCount; + } + bPlaced = true; + break; + } + if (!bPlaced) + { + return Fail(FString::Printf(TEXT("all remaining candidates failed final placement at slot %d"), + DepthIndex)); + } + } + + CurrentTopChunk = BottomChunk - 1 - static_cast(Settings.InterStrateGapChunks); + } + + if (Manifest.Strates.Num() != Settings.SelectedStrateCount + || SelectedGeneratedCount != GeneratedSlotCount + || FixedSelectedCount != FixedStrates.Num()) + { + return Fail(TEXT("season selector did not fill every absolute slot exactly once")); + } + + TMap RejectionCounts; + if (Settings.bIncludeCandidateAudit) + { + Manifest.CandidateAudit.Reserve(Candidates.Num()); + } + for (const FSeasonCandidateState& State : Candidates) + { + const FString Reason = State.bSelected + ? FString() + : (State.RejectionReason.IsEmpty() + ? TEXT("not selected by provisional policy") : State.RejectionReason); + if (!State.bSelected) + { + AddRejection(RejectionCounts, Reason); + } + if (Settings.bIncludeCandidateAudit) + { + FVoxelSeasonCandidateAudit& Row = Manifest.CandidateAudit.AddDefaulted_GetRef(); + Row.CandidateIndex = State.Candidate.Index; + Row.Seed = State.Candidate.Seed; + Row.Archetype = State.Candidate.Archetype; + Row.RecipeHash = State.Candidate.bValid + ? VF_HashStrateStructureRecipe(State.Candidate.Recipe) : 0; + Row.DistanceFromCorpusCentroid = State.DistanceFromCorpusCentroid; + Row.bPassedHardGates = State.bPassedHardGates; + Row.bSelected = State.bSelected; + Row.RejectionReason = Reason; + } + } + ExportRejectionCounts(RejectionCounts, Manifest.RejectionCounts); + Manifest.SelectedCount = Manifest.Strates.Num(); + Manifest.GroundedSelectedCount = 0; + Manifest.OutlierSelectedCount = 0; + Manifest.FixedSelectedCount = 0; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + if (Strate.bGrounded) ++Manifest.GroundedSelectedCount; + if (Strate.bOutlier) ++Manifest.OutlierSelectedCount; + if (Strate.bFixed) ++Manifest.FixedSelectedCount; + if (!Strate.bPassedNonVacuous || !Strate.bPassedLargestComponent + || !Strate.bPassedPrimordialLaw + || Strate.PrimordialLawResult != EVoxelConnectivityResult::Connected) + { + return Fail(FString::Printf(TEXT("selected slot %d failed the final primordial-law assertion"), + Strate.DepthIndex)); + } + } + Manifest.RejectedCount = FMath::Max(0, Manifest.CandidateCount - SelectedGeneratedCount); + + if (Settings.bWriteArtifacts) + { + const FString BaseDirectory = Settings.OutputDirectory.IsEmpty() + ? FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("VoxelForge"), TEXT("Seasons")) + : Settings.OutputDirectory; + const FString SeasonDirectory = FPaths::Combine( + BaseDirectory, + FString::Printf(TEXT("season_%04d_seed_%d"), Manifest.Season, Manifest.Seed)); + Manifest.ManifestPath = FPaths::Combine(SeasonDirectory, TEXT("season_manifest.json")); + if (Settings.bWriteReviewPage) + { + const FString ReviewDirectory = FPaths::Combine(SeasonDirectory, TEXT("review")); + if (!WriteReviewPage(Manifest, Settings, ReviewDirectory, + Manifest.ReviewPagePath, Manifest.Error)) + { + return Fail(Manifest.Error.IsEmpty() + ? TEXT("could not write selected-season review page") : Manifest.Error); + } + } + } + + Manifest.bValid = true; + Manifest.Error.Reset(); + Manifest.SerializedJson = VF_SerializeVoxelSeasonManifest(Manifest); + if (Manifest.SerializedJson.IsEmpty()) + { + return Fail(TEXT("could not serialize composed season manifest")); + } + if (Settings.bWriteArtifacts) + { + FString SaveReport; + if (!VF_SaveVoxelSeasonManifest(Manifest.ManifestPath, Manifest, SaveReport)) + { + return Fail(SaveReport); + } + } + return Manifest; +} + +#else + +FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, + const FVoxelSeasonCompositionSettings& Settings) +{ + (void)SeasonSeed; + (void)Settings; + FVoxelSeasonManifest Manifest; + Manifest.Error = TEXT("season composition is editor/build-box only"); + return Manifest; +} + +#endif + +FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* WorldSettings) +{ + FVoxelSeasonCompositionSettings Settings; + Settings.WorldSettings = WorldSettings; + if (WorldSettings != nullptr) + { + Settings.SelectedStrateCount = WorldSettings->TotalStrates; + Settings.InterStrateGapChunks = WorldSettings->InterStrateGapChunks; + Settings.OriginSpineRadius = WorldSettings->OriginSpineRadius; + Settings.WorldRadiusVoxels = WorldSettings->WorldRadiusVoxels; + Settings.SeasonNumber = WorldSettings->CurrentSeason; + } + return VF_ComposeSeason(SeasonSeed, Settings); +} + +bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError) +{ + using namespace VoxelSeasonCompositionPrivate; + auto Fail = [&](const FString& Reason) -> bool + { + if (OutError != nullptr) + { + *OutError = Reason; + } + return false; + }; + if (OutError != nullptr) + { + OutError->Reset(); + } + if (!VoxelSeasonManifestPrivate::IsSupportedArchetype(Strate.Archetype) + || Strate.HeightInChunks <= 0 + || !FMath::IsFinite(SpineRadius) || SpineRadius < 0.0f + || Strate.TopWorldZ <= Strate.BottomWorldZ) + { + return Fail(TEXT("Manifest strate has invalid identity, height, bounds, or spine radius.")); + } + + const float StoredTop = static_cast(Strate.TopWorldZ); + const float StoredBottom = static_cast(Strate.BottomWorldZ); + if (!SameFloatBits(ActiveTop(Strate.Params, Strate.Archetype), StoredTop) + || !SameFloatBits(ActiveBottom(Strate.Params, Strate.Archetype), StoredBottom)) + { + return Fail(TEXT("Manifest bounds do not match the active parameter vector.")); + } + + bool bBuilt = false; + if (Strate.bUsesRecipe) + { + bBuilt = VF_BuildStackFromRecipe(Strate.Recipe, Strate.Params, Strate.Seed, + SpineRadius, StrateManager, OutStack, OutContext, + OutError); + } + else + { +#if WITH_EDITOR + const float Seal = ActiveSeal(Strate.Params, Strate.Archetype); + bBuilt = VF_BuildNativeStrateStackForCandidate( + Strate.Archetype, Strate.Params, Strate.Seed, SpineRadius, + 0.0f, Seal, StrateManager, OutStack, OutContext); + if (!bBuilt && OutError != nullptr) + { + *OutError = TEXT("Native manifest strate could not be materialised in the editor build."); + } +#else + bBuilt = false; + if (OutError != nullptr) + { + *OutError = TEXT("Native fixed-strate rebuild is editor/build-box only."); + } +#endif + } + if (!bBuilt) + { + return false; + } + if (OutContext.WorldRadiusVoxels != 0.0f) + { + return Fail(TEXT("Manifest rebuild changed the required WorldRadiusVoxels=0 invariant.")); + } + return true; +} + +bool VF_RebuildVoxelSeasonStrate(const FVoxelSeasonStrate& Strate, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError) +{ + return VF_BuildSeasonStrateStack(Strate, SpineRadius, StrateManager, + OutStack, OutContext, OutError); +} + +namespace VoxelSeasonCompositionPrivate +{ + bool WriteReviewPage(const FVoxelSeasonManifest& Manifest, + const FVoxelSeasonCompositionSettings& Settings, + const FString& ReviewDirectory, + FString& OutReviewPath, + FString& OutError) + { + OutReviewPath.Reset(); + OutError.Reset(); + if (!IFileManager::Get().MakeDirectory(*ReviewDirectory, true)) + { + OutError = FString::Printf(TEXT("could not create season review directory %s"), + *ReviewDirectory); + return false; + } + + TArray Cards; + Cards.Reserve(Manifest.Strates.Num()); + FVoxelStratePreviewWindow CommonWindow; + bool bHaveWindow = false; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + FVoxelStrateSampleGrid Grid; + FVoxelStrateMetrics RawMetrics; + FSeasonEvaluation Evaluation; + if (!EvaluateStrate(Strate, Settings, Evaluation, &Grid, &RawMetrics)) + { + OutError = FString::Printf(TEXT("selected slot %d failed its review re-measure: %s"), + Strate.DepthIndex, *Evaluation.FailureReason); + return false; + } + if (!Grid.IsValid()) + { + OutError = FString::Printf(TEXT("selected slot %d produced no review grid"), + Strate.DepthIndex); + return false; + } + + const FString RecipeString = Strate.bUsesRecipe + ? VF_FormatStrateStructureRecipe(Strate.Recipe) + : FString::Printf(TEXT("native/%s"), VF_GetStrateArchetypeName(Strate.Archetype)); + const FString ArrivalVerdict = FString::Printf( + TEXT("%s%s"), + VoxelSeasonManifestPrivate::ConnectivityName(Evaluation.PrimordialLawResult), + (Evaluation.bPassedPrimordialLaw ? TEXT("") : TEXT(" (failed)"))); + FVoxelStratePreviewCandidate& Card = Cards.AddDefaulted_GetRef(); + if (!VF_WriteStratePreviewCandidate( + ReviewDirectory, Strate.DepthIndex, RecipeString, Grid, RawMetrics, + Settings.MeasureSettings.HeadroomCells, + Strate.DistanceFromCorpusCentroid, ArrivalVerdict, false, TEXT(""), + Card, OutError)) + { + return false; + } + Card.bSeasonOrder = true; + Card.DepthIndex = Strate.DepthIndex; + Card.SelectionReason = Strate.SelectionReason; + Card.bBossSlot = Strate.bBossSlot; + if (!bHaveWindow) + { + CommonWindow = Card.Window; + bHaveWindow = true; + } + } + + if (!bHaveWindow || !CommonWindow.IsValid()) + { + OutError = TEXT("selected season has no valid preview measurement window"); + return false; + } + const FString RunTitle = FString::Printf( + TEXT("VoxelForge Season %d — seed %d — descent review"), Manifest.Season, Manifest.Seed); + if (!VF_WriteStratePreviewIndex(ReviewDirectory, RunTitle, CommonWindow, + Cards, OutReviewPath, OutError)) + { + return false; + } + return true; + } +} diff --git a/Source/VoxelForge/Private/VoxelStratePreview.cpp b/Source/VoxelForge/Private/VoxelStratePreview.cpp index 4f5bc24..f4ce3a3 100644 --- a/Source/VoxelForge/Private/VoxelStratePreview.cpp +++ b/Source/VoxelForge/Private/VoxelStratePreview.cpp @@ -979,6 +979,18 @@ bool VF_WriteStratePreviewIndex( SortedCandidates.Sort([](const FVoxelStratePreviewCandidate& A, const FVoxelStratePreviewCandidate& B) { + if (A.bSeasonOrder != B.bSeasonOrder) + { + return A.bSeasonOrder; + } + if (A.bSeasonOrder && B.bSeasonOrder) + { + if (A.DepthIndex != B.DepthIndex) + { + return A.DepthIndex < B.DepthIndex; + } + return A.CandidateIndex < B.CandidateIndex; + } if (A.bRejected != B.bRejected) { return !A.bRejected; @@ -1071,11 +1083,23 @@ bool VF_WriteStratePreviewIndex( const FString CardClass = Candidate.bRejected ? TEXT("candidate rejected") : TEXT("candidate"); const FString MetricWindow = Candidate.Window.IsValid() ? Candidate.Window.Describe() : WindowDescription; + const int32 DisplayIndex = Candidate.bSeasonOrder && Candidate.DepthIndex != INDEX_NONE + ? Candidate.DepthIndex : Candidate.CandidateIndex; Html += FString::Printf( TEXT("

#%d %s

\n"), - *CardClass, Candidate.CandidateIndex, *StatusClass, *Status); + *CardClass, DisplayIndex, *StatusClass, *Status); Html += FString::Printf(TEXT("
%s
\n"), *VF_HtmlEscape(Candidate.RecipeString)); + if (Candidate.bSeasonOrder) + { + Html += FString::Printf( + TEXT("

Why selected: %s" + "
Boss slot: %s

\n"), + *VF_HtmlEscape(Candidate.SelectionReason.IsEmpty() + ? TEXT("selected by the provisional season policy") + : Candidate.SelectionReason), + Candidate.bBossSlot ? TEXT("yes") : TEXT("no")); + } if (Candidate.bRejected) { Html += FString::Printf(TEXT("

Why rejected: %s

\n"), diff --git a/Source/VoxelForge/Public/VoxelSeasonManifest.h b/Source/VoxelForge/Public/VoxelSeasonManifest.h new file mode 100644 index 0000000..ee7741d --- /dev/null +++ b/Source/VoxelForge/Public/VoxelSeasonManifest.h @@ -0,0 +1,269 @@ +// Offline Tier 4c season composition and the diffable Tier A manifest. +// +// This header deliberately describes an editor/build-box artifact. The normal runtime path still +// receives its existing settings and builds density exactly as before; no runtime code consumes a +// season manifest as a hidden generation fallback. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelStrateComposer.h" +#include "VoxelStrateMeasure.h" + +class UVoxelSettings; + +/** Why the provisional season selector put one surviving candidate in a slot. */ +enum class EVoxelSeasonSelectionReason : uint8 +{ + Grounded, + Outlier, + Fixed, +}; + +/** + * Explicit, reviewable selection knobs for one season. + * + * None of these values is a settled definition of quality. They are the first policy Jahni can + * change after walking the review page. Hard validation remains separate from this policy. + */ +struct VOXELFORGE_API FVoxelSeasonSelectionPolicy +{ + // Placeholder mix only: ordinary strates give an outlier contrast. Revisit after the first + // season review; this is not a learned or final quality score. + float GroundedFraction = 0.75f; + float OutlierFraction = 0.25f; + + // Below this measured distance two neighbours look too similar. The selector subtracts a + // penalty proportional to the gap; provisional because the useful contrast is visual. + double AdjacentSimilarityDistanceThreshold = 0.20; + double AdjacentSimilarityPenaltyWeight = 1.0; + + // Rewards an unseen archetype/recipe in the already chosen prefix. These are variety + // tie-breakers, not claims that one archetype or recipe is better. Provisional review knobs. + double NewArchetypeBonus = 0.10; + double NewRecipeBonus = 0.05; + + // GDD §6 says roughly every five levels, and a boss is intermediary rather than terminal. + // This interval is therefore an explicit provisional layout knob, not a quality score. + int32 BossSlotInterval = 5; + + // Tier 2 hard gate: a surviving air component must retain at least this share. Provisional + // only in its threshold; the existence of the gate is non-negotiable for a shippable season. + float MinimumLargestComponentShare = 0.50f; + + // Normalizers for the two unbounded measured dimensions in the adjacency distance. Fractions + // already live in [0,1]. These physical scales are provisional and visible in the manifest. + float FeatureScaleNormalizationVoxels = 256.0f; + float ClearanceNormalizationVoxels = 64.0f; +}; + +/** A caller-supplied fixed slot for tests/tools that already have a complete manifest record. */ +struct VOXELFORGE_API FVoxelSeasonFixedStrate +{ + int32 Seed = 0; + int32 HeightInChunks = 4; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams Params; + FVoxelOpStackRecipe Recipe; + bool bUsesRecipe = true; + FString SourceDefinitionPath; +}; + +/** Settings for the offline build-box pass. No member is consulted by normal runtime generation. */ +struct VOXELFORGE_API FVoxelSeasonCompositionSettings +{ + // Optional live-world settings. TotalStrates, spine/gap values, and FixedStrates are read + // from it when present. The pointer is borrowed for the duration of VF_ComposeSeason only. + const UVoxelSettings* WorldSettings = nullptr; + + // Optional preloaded corpus. With nullptr the composer loads the project asset corpus on the + // build box, exactly like the existing offline composer tests. + const FVoxelStrateCorpus* Corpus = nullptr; + + // 24 gives a useful minute-scale automation pass at the default coarse measurement window. + // Production can raise this to the overnight budget without changing the policy. + int32 CandidateCount = 24; + int32 SelectedStrateCount = 10; + + // Generated candidates use one stable height. Fixed authored slots may provide their own + // height. World layout bounds are written into every selected parameter family below. + int32 StrateHeightInChunks = 4; + int32 InterStrateGapChunks = 0; + float OriginSpineRadius = 14.0f; + float WorldRadiusVoxels = 0.0f; + + // Coarse measurement is deliberately bounded. Tests may reduce the window; production may + // spend the overnight budget on a finer pass after the selector has been judged. + FVoxelStrateMeasureSettings MeasureSettings; + FVoxelSeasonSelectionPolicy SelectionPolicy; + + // Explicit composed fixed slots take precedence. When empty, the composer adapts the + // existing UVoxelSettings::FixedStrates map, preserving its absolute slot indices. + TMap FixedStrates; + + // Empty means ProjectSavedDir()/VoxelForge/Seasons. Files are never written to the runtime + // content directory by this API. + FString OutputDirectory; + bool bWriteArtifacts = true; + bool bWriteReviewPage = true; + bool bIncludeCandidateAudit = true; + + // Informational only; when WorldSettings is present CurrentSeason supplies the default. + int32 SeasonNumber = 0; + + FVoxelSeasonCompositionSettings() + { + // The existing Tier 4b test uses step 4. Season composition defaults to step 8 so the + // default automation artifact is measured in minutes, while MaxCells remains bounded. + MeasureSettings.SampleStep = 8; + MeasureSettings.RadiusInVoxels = 256; + MeasureSettings.MaxCells = 8000000; + MeasureSettings.MaxRouteRetries = 16; + MeasureSettings.HeadroomCells = 2; + MeasureSettings.InteriorMarginVoxels = -1; + } +}; + +// Friendly aliases for callers that use the task's shorter naming. +using FVoxelSeasonComposerSettings = FVoxelSeasonCompositionSettings; +using FVoxelSeasonComposeSettings = FVoxelSeasonCompositionSettings; + +/** Compact audit row; the full vectors live only for selected strates. */ +struct VOXELFORGE_API FVoxelSeasonCandidateAudit +{ + int32 CandidateIndex = INDEX_NONE; + int32 Seed = 0; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + uint32 RecipeHash = 0; + double DistanceFromCorpusCentroid = 0.0; + bool bPassedHardGates = false; + bool bSelected = false; + FString RejectionReason; +}; + +/** Count of one explicit hard-gate or final-placement rejection reason. */ +struct VOXELFORGE_API FVoxelSeasonRejectionCount +{ + FString Reason; + int32 Count = 0; +}; + +/** One ordered Tier A spine entry. */ +struct VOXELFORGE_API FVoxelSeasonStrate +{ + // DepthIndex is the absolute world-layout slot. CandidateIndex is the offline attempt id. + int32 DepthIndex = INDEX_NONE; + int32 CandidateIndex = INDEX_NONE; + int32 Seed = 0; + int32 HeightInChunks = 0; + int32 TopWorldZ = 0; + int32 BottomWorldZ = 0; + + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelOpStackRecipe Recipe; + FVoxelStrateArchetypeParams Params; + FVoxelStrateMeasuredMetrics Metrics; + + double DistanceFromCorpusCentroid = 0.0; + FString SelectionReason; + FString SourceDefinitionPath; + + bool bGrounded = false; + bool bOutlier = false; + bool bBossSlot = false; + bool bFixed = false; + bool bUsesRecipe = true; + + bool bPassedNonVacuous = false; + bool bPassedLargestComponent = false; + bool bPassedPrimordialLaw = false; + EVoxelConnectivityResult PrimordialLawResult = EVoxelConnectivityResult::OutOfWindow; +}; + +/** + * The stored Tier A season artifact. + * + * Tier B is intentionally absent: its hash-lattice field remains an arithmetic/enumeration API, + * not a second stored table. Strates is ordered from the surface down and is the only finite spine + * persisted by this task. + */ +struct VOXELFORGE_API FVoxelSeasonManifest +{ + static constexpr int32 CurrentSchemaVersion = 1; + + bool bValid = false; + int32 SchemaVersion = CurrentSchemaVersion; + FString Error; + + int32 Season = 0; + int32 Seed = 0; + uint32 InputCorpusHash = 0; + int32 CandidateCount = 0; + int32 SurvivorCount = 0; + int32 SelectedCount = 0; + int32 RejectedCount = 0; + int32 GroundedSelectedCount = 0; + int32 OutlierSelectedCount = 0; + int32 FixedSelectedCount = 0; + int32 TotalStrates = 0; + int32 InterStrateGapChunks = 0; + float OriginSpineRadius = 0.0f; + float WorldRadiusVoxels = 0.0f; + + FVoxelSeasonSelectionPolicy SelectionPolicy; + FString PolicyNote; + + TArray Strates; + TArray CandidateAudit; + TArray RejectionCounts; + + // Derived artifact locations; intentionally not serialized into the portable JSON. + FString ManifestPath; + FString ReviewPagePath; + FString SerializedJson; + + bool IsUsable() const + { + return bValid && Error.IsEmpty() && WorldRadiusVoxels == 0.0f + && Strates.Num() == SelectedCount && SelectedCount > 0; + } +}; + +/** Human-readable enum names used by JSON and the review page. */ +VOXELFORGE_API const TCHAR* VF_GetVoxelSeasonSelectionReasonName( + EVoxelSeasonSelectionReason Reason); + +/** Compose, validate, select, serialize, and (by default) write one season. Editor/build-box only. */ +VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason( + int32 SeasonSeed, const FVoxelSeasonCompositionSettings& Settings); + +/** Convenience overload that adapts the existing runtime settings asset. */ +VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason( + int32 SeasonSeed, const UVoxelSettings* WorldSettings); + +/** Serialize/read the complete Tier A manifest as stable pretty JSON. */ +VOXELFORGE_API FString VF_SerializeVoxelSeasonManifest( + const FVoxelSeasonManifest& Manifest); +VOXELFORGE_API bool VF_SaveVoxelSeasonManifest( + const FString& ManifestPath, const FVoxelSeasonManifest& Manifest, FString& OutReport); +VOXELFORGE_API bool VF_LoadVoxelSeasonManifest( + const FString& ManifestPath, FVoxelSeasonManifest& OutManifest, FString& OutReport); + +/** Rebuild exactly the stack represented by one manifest entry. */ +VOXELFORGE_API bool VF_BuildSeasonStrateStack( + const FVoxelSeasonStrate& Strate, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError = nullptr); + +/** Verbose alias for tests/tools that want the round-trip operation named explicitly. */ +VOXELFORGE_API bool VF_RebuildVoxelSeasonStrate( + const FVoxelSeasonStrate& Strate, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError = nullptr); + diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index c64f665..6bbf2a3 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -18,6 +18,16 @@ class UVoxelSettings; class UVoxelStrateDefinition; class UVoxelStrateManager; +#if WITH_EDITOR +// The complete definitions live in VoxelSeasonManifest.h. Keeping this forward declaration here +// makes the Tier 4c entry point discoverable from the composer header without creating a circular +// include (the manifest header includes this one for the existing vector/recipe types). +struct FVoxelSeasonManifest; +struct FVoxelSeasonCompositionSettings; +VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason( + int32 SeasonSeed, const FVoxelSeasonCompositionSettings& Settings); +#endif + /** The one-bit identity choice at the root of a rolled structure. */ UENUM(BlueprintType) enum class EVoxelStrateRootPolarity : uint8 diff --git a/Source/VoxelForge/Public/VoxelStratePreview.h b/Source/VoxelForge/Public/VoxelStratePreview.h index d3f77c7..2ae89fd 100644 --- a/Source/VoxelForge/Public/VoxelStratePreview.h +++ b/Source/VoxelForge/Public/VoxelStratePreview.h @@ -71,6 +71,12 @@ struct VOXELFORGE_API FVoxelStrateFinePreviewSettings struct VOXELFORGE_API FVoxelStratePreviewCandidate { int32 CandidateIndex = INDEX_NONE; + // Season pages deliberately opt into descent order. The ordinary candidate contact sheet + // keeps its survivor/outlier ordering; a season review must read top-to-bottom like play. + bool bSeasonOrder = false; + int32 DepthIndex = INDEX_NONE; + FString SelectionReason; + bool bBossSlot = false; FVoxelStratePreviewWindow Window; FString RecipeString; FString ArrivalDepartureVerdict;