diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dceb946..d77f987 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -118,6 +118,15 @@ Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`. **If you add a connectivity rule with longer edges, the COLLECT region must still cover the max edge reach, and decisions must not depend on the stored window.** +The editor composer walk-through preserves this invariant by avoiding a layout rebuild. AVoxelWorld::ApplyComposerCandidate +pauses and drains active generation, installs one temporary override on an existing slot, increments +the manager's layout/passages version, and then reuses RegenerateAllChunks to bump GenerationEpoch. +The slot's Z range and passages therefore remain stable; worker tasks copy the immutable override during +their versioned refetch. A parameter candidate uses the production native stack mapping, while a +structure candidate uses VF_BuildStackFromRecipe; custom recipes conservatively return Mixed from +ClassifyTile so no unproven box bound can skip their mesh. The bridge and its editable properties are +WITH_EDITOR/WITH_EDITORONLY_DATA only and do not exist in a shipping build. + ### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW) `UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems: @@ -658,6 +667,14 @@ driven by `EditorBrush*` props. ### 8.11 Live tuning & debug (`AVoxelWorld`, CallInEditor / PIE) - `RebuildStrates` — re-reads ALL of `VoxelSettings` and rebuilds layout/gap/passages/spine + regenerates. Use after changing those (plain `RegenerateAllChunks` keeps the old layout/passages). +- ApplyComposerCandidate — editor-only PIE walk-through for one offline candidate. Set + ComposerSeed, ComposerCandidateIndex, ComposerTargetStrateIndex (0 = topmost), and + bComposerRollStructure in Live Edit|Composer, then click Apply Composer Candidate. It uses the + shared composer roll, overlays density on the existing slot, pauses/drains workers, bumps the + manager version, and reuses RegenerateAllChunks for the epoch/re-stream. It does not rebuild + passages or change the authoritative Settings->Seed; RebuildStrates removes the overlay. The + action logs recipe, archetype, air/largest-component/walkable/feature metrics, and an eight-point + bit-level density verdict. The button itself still requires owner verification in PIE. - `ValidateDeterminism` (F2) — one-click §8.4 regression test: samples chunk-boundary points under two different thread_local cache alignments (left-chunk warm vs right-chunk warm) + a repeat pass; every delta must be EXACTLY 0. Run it after any hot-path refactor that claims diff --git a/CODEMAP.md b/CODEMAP.md index 0527671..3c1030c 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -413,13 +413,14 @@ Maps depth→strate at runtime; owns passages. | `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. | | `FindSlotIndexForChunkZ` | 427 | Z → layout index. | | `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. | -| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. | +| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize` and by the editor composer override). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so live changes never serve stale data. | | `GetStrateForChunk` | 466 | Chunk → definition. | | `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | | `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list — **now all 8 of 8** (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater). **That list is written down here and nowhere else.** With every archetype ported the flag is now the *only* thing that decides the path, so ticking the box is no longer a no-op anywhere — it is a real switch onto the operator stack for that strate. | | `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | | `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. | | `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). | +| `SetComposerOverrideForStrate` / `GetComposerOverrideForChunk` | editor-only | Temporary density-only candidate overlay for one existing slot. Copies immutable params/recipe into the worker refetch path, leaves layout/content/passages intact, and bumps `PassagesVersion` to invalidate generator memos. | | `BuildParamsFromDefinition` (static) | 1399 | Returns the definition's base `GenerationParams` only. Terrain-op assets are passed separately to `BuildChunkCache`, which applies one selected op per room and pre-bakes Column/Pit/Chimney features. | **`Public/VoxelTerrainOpDefinition.h` + `.cpp`** — `UVoxelTerrainOpDefinition : UPrimaryDataAsset` @@ -462,7 +463,8 @@ selection → `FStrateGenerationParams::Lerp` or reflected native-family blend field range jitter (or the season-zero ±25% own-magnitude bootstrap for near-zero ranges) → editor-reflection clamps → ordered-pair repair. Bool `bTunnelsFlowTowardOrigin` inherits from the dominant parent; integer/enum SNAP fields are not -jittered. This API is not called by runtime generation. The settings audit found **one unique path**, +jittered. Normal runtime generation never calls this API; the editor-only AVoxelWorld bridge does so +only from its PIE button. The settings audit found **one unique path**, `/Game/VoxelForge/DA_Strate3`, which explains the old one-vector corpus; the project assets were never in that pool. @@ -475,6 +477,13 @@ it. `RequiredResources` / `ProvidedResources` close the room-state hole that cha could not express. `VoxelForgeComposerStructureRollTest` rolls 64 candidates, measures them through the offline sampler, and brute-forces every uniform box verdict from the novel stack itself. +Public/VoxelWorld.h + Private/VoxelWorld.cpp — ApplyComposerCandidate exposes the four +Live Edit|Composer properties and the CallInEditor button. It preflights the candidate, pauses +generation before installing the manager override, and calls the existing epoch-aware full reset. +The post-apply check compares eight densities against the shared native/recipe stack where the live +world has no extra diff, disturbance, or biome context. It reports SKIPPED rather than comparing +unlike fields. + **`Public/VoxelStratePreview.h` + `Private/VoxelStratePreview.cpp`** — editor/automation-only PNG renderer and self-contained contact sheet. It consumes `FVoxelStrateSampleGrid` from the measurement pass, emits deterministic centre-Y XZ and data-selected XY slices via `IImageWrapper`, and shows filled-cell diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index b0f3894..f14e5f1 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -84,7 +84,10 @@ a model over the candidates at zero runtime cost. **And Jahni reviews and vetoes publishes** — the system does the work, he keeps the veto. **What ships is small:** the season's seed plus the chosen strates' parameter vectors. Clients generate -terrain from those, deterministically, exactly as today. **No runtime composition anywhere.** +terrain from those, deterministically, exactly as today. **No composition is shipped or run in the +normal game path.** The editor-only PIE inspection button described below is a deliberate debugging +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. ### 3.2 Structure: how a stack gets assembled @@ -194,6 +197,26 @@ through the offline Tier 2 sampler; no runtime generation path calls this API. T test took **76.466 s**. The complete `VoxelForge` namespace then passed **24/24 tests (0 failed, 0 not run)** in **339.797 s**. +### 3.2a Editor walk-through hand-off (not a runtime composer) + +AVoxelWorld::ApplyComposerCandidate is the one editor-only bridge from this offline API to a live +PIE world. It calls VF_RollStrateCandidate(Corpus, ComposerSeed, ComposerCandidateIndex, +bComposerRollStructure). That wrapper delegates to the exact parameter/recipe calls exercised by +the offline composer tests, then installs the result +as a temporary density override on ComposerTargetStrateIndex. The layout slot's authored definition, +height, content, and generated passages are intentionally unchanged; this avoids calling +GeneratePassages while Initialize is half-built. The action pauses/drains generation, bumps the +manager layout version, and reuses RegenerateAllChunks so GenerationEpoch invalidates stale mesh +results and the normal streamer rebuilds the world. + +The Details-panel properties are ComposerSeed, ComposerCandidateIndex, ComposerTargetStrateIndex, +and bComposerRollStructure, under Live Edit|Composer. The button is Apply Composer Candidate; it +is usable only during PIE. RebuildStrates clears the temporary override. A post-apply eight-point +density check compares the live generator with the same prepared candidate stack bit-for-bit when +no diff edits, disturbance post, or surface-biome context makes the standalone oracle incomplete. +The log line carries the recipe/archetype and the coarse metrics so it can be matched to a +contact-sheet row. This hand-off is editor-only and is not part of the shipped composer contract. + ### 3.3 Parameters: the range problem **Verified facts, not remembered:** `FStrateGenerationParams` has **73 scalar fields** (+1 bool) — not diff --git a/REVIEW_FINDINGS.md b/REVIEW_FINDINGS.md index 75da76c..309e294 100644 --- a/REVIEW_FINDINGS.md +++ b/REVIEW_FINDINGS.md @@ -90,7 +90,10 @@ Legend: ✅ verified against code · ◻️ checklist box. like Box/Capsule already did (one diff-layer + remesh dispatch). *(2026-07-04)* - [x] **`VoxelContentManager.cpp`** — duplicated drain/reset loops → `DrainDecoResults()` + `ResetGridBuildState(FDecoGrid&)`. *(2026-07-04)* -- [ ] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — two overlapping passage-shape enums, both in +- [x] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — ✅ **CLOSED 2026-09-04 as WONTFIX**, enacting + the 2026-08-16 judgement below, which recommended closure and was then left open by oversight. No + code change; the two enums stay. + Two overlapping passage-shape enums, both in active use (11 refs). Consider consolidating to one. *(judgment call, not dead)* **JUDGED 2026-08-16 — LEAVE THEM. Recommendation: close this item rather than act on it.** They read as duplicates from the index and are not: `EVoxelPassageType` (`VoxelStrateTypes.h` ~42) is diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp index b599132..f3efc8d 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp @@ -816,8 +816,18 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) const double RollAndMeasureStartSeconds = FPlatformTime::Seconds(); for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex) { +#if WITH_EDITOR + // The PIE hand-off and this test deliberately cross the same seam. The wrapper only + // packages the exact parameter-roll call below; it must not grow a second RNG path. + const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( + Corpus, AuthoredSettings->Seed, CandidateIndex, false); + const FVoxelStrateRollInfo& Roll = Candidate.ParameterRoll; + TestTrue(FString::Printf(TEXT("candidate %d hand-off roll is valid"), CandidateIndex), + Candidate.bValid && Roll.bValid); +#else const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailed( Corpus, AuthoredSettings->Seed, CandidateIndex); +#endif TestTrue(FString::Printf(TEXT("candidate %d roll is valid"), CandidateIndex), Roll.bValid); TotalBootstrapJitterFields += Roll.BootstrapJitterFieldCount; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp index 2ee3c85..88d2895 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerStructureRollTest.cpp @@ -367,8 +367,17 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) const double RollAndMeasureStartSeconds = FPlatformTime::Seconds(); for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex) { +#if WITH_EDITOR + // Keep the editor hand-off and the structure test on the same recipe/block roll seam. + const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( + Corpus, AuthoredSettings->Seed, CandidateIndex, true); + TestTrue(FString::Printf(TEXT("candidate %d hand-off roll is valid"), CandidateIndex), + Candidate.bValid); + const FVoxelOpStackRecipe Recipe = Candidate.Recipe; +#else const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure( AuthoredSettings->Seed, CandidateIndex); +#endif const FVoxelOpStackRecipe RepeatRecipe = VF_RollStrateStructure( AuthoredSettings->Seed, CandidateIndex); TestTrue(FString::Printf(TEXT("candidate %d recipe reroll is identical"), CandidateIndex), @@ -404,11 +413,24 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters) FVoxelStrateArchetypeParams CandidateParams; bool bParameterRollsValid = true; double CandidateDistanceSquared = 0.0; - for (const FBlockSpec& Spec : GBlockSpecs) + for (int32 BlockIndex = 0; BlockIndex < UE_ARRAY_COUNT(GBlockSpecs); ++BlockIndex) { +#if WITH_EDITOR + const FBlockSpec& Spec = GBlockSpecs[BlockIndex]; + if (!Candidate.StructureBlockRolls.IsValidIndex(BlockIndex)) + { + bParameterRollsValid = false; + AddError(FString::Printf(TEXT("candidate %d missing parameter block %d"), + CandidateIndex, static_cast(Spec.Block))); + continue; + } + const FVoxelStrateRollInfo& BlockRoll = Candidate.StructureBlockRolls[BlockIndex]; +#else + const FBlockSpec& Spec = GBlockSpecs[BlockIndex]; const FVoxelStrateRollInfo BlockRoll = VF_RollStrateParamsDetailedForArchetype( Corpus, Spec.Archetype, AuthoredSettings->Seed ^ static_cast(Spec.SeedSalt), CandidateIndex); +#endif if (!BlockRoll.bValid) { bParameterRollsValid = false; diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 869514c..3cc4f5f 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -18,6 +18,10 @@ #include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it #include "VoxelStats.h" +#if WITH_EDITOR +#include "VoxelStrateComposer.h" +#endif + #include //============================================================================= @@ -589,6 +593,63 @@ namespace } } +#if WITH_EDITOR +bool VF_BuildNativeStrateStackForCandidate( + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, + int32 InSeed, + float SpineRadius, + float InWorldRadiusVoxels, + float InEdgeSealThickness, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext) +{ + FVoxelStackParamRefs Refs; + switch (Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Refs.Slab = &Params.SlabParams; + break; + case ECaveGeneratorType::Maze: + Refs.Maze = &Params.MazeParams; + break; + case ECaveGeneratorType::SurfaceWorld: + // The live surface path can additionally carry biome-specific parameter arrays and a + // generator-owned biome field. The editor sanity check deliberately handles the exact + // no-biome/native case only; the caller reports a biome target as unavailable rather than + // comparing unlike fields. + Refs.Surface = &Params.SurfaceParams; + break; + case ECaveGeneratorType::VerticalShafts: + Refs.Vert = &Params.VerticalShaftParams; + break; + case ECaveGeneratorType::FloatingIslands: + Refs.Float = &Params.FloatingIslandParams; + break; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + Refs.Tunnel = &Params.TunnelNetworkParams; + break; + default: + return false; + } + + OutContext = FVoxelOpContext(); + OutContext.Seed = static_cast(InSeed); + OutContext.LayoutVersion = StrateManager != nullptr ? StrateManager->GetLayoutVersion() : 0; + OutContext.WorldRadiusVoxels = InWorldRadiusVoxels; + OutContext.EdgeSealThickness = InEdgeSealThickness; + if (!VF_BuildOpStackForChunk(Archetype, Refs, InSeed, SpineRadius, StrateManager, + OutStack, OutContext)) + { + return false; + } + return true; +} +#endif + UVoxelGenerator::UVoxelGenerator() : DensityCacheOwnerId(GNextDensityCacheOwnerId.fetch_add(1, std::memory_order_relaxed) + 1) { @@ -675,6 +736,15 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté. thread_local FVoxelOpStack CP_OpStack; thread_local bool CP_UseOpStack = false; +#if WITH_EDITOR + // Editor-only composer override. The candidate recipe is copied into this worker-local + // cache on the same versioned refetch as the native params; no worker reads mutable editor + // state while the world is applying a new candidate. + thread_local bool CP_UseComposerRecipe = false; + thread_local int32 CP_ComposerSeed = 0; + thread_local FVoxelStrateArchetypeParams CP_ComposerParams; + thread_local FVoxelOpStackRecipe CP_ComposerRecipe; +#endif const uint32 LayoutVersion = StrateManager->GetLayoutVersion(); const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId; @@ -691,6 +761,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_Version = LayoutVersion; CP_Chunk = ChunkCoord; CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); +#if WITH_EDITOR + CP_UseComposerRecipe = false; + ECaveGeneratorType ComposerArchetype = ECaveGeneratorType::TunnelNetwork; + bool bComposerUseRecipe = false; + if (StrateManager->GetComposerOverrideForChunk( + ChunkCoord, CP_ComposerSeed, ComposerArchetype, CP_ComposerParams, + bComposerUseRecipe, CP_ComposerRecipe)) + { + CP_GenType = ComposerArchetype; + CP_UseComposerRecipe = bComposerUseRecipe; + } +#endif switch (CP_GenType) { case ECaveGeneratorType::FlatPlain: @@ -725,6 +807,35 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co if (CP_UseOpStack) { CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile +#if WITH_EDITOR + if (CP_UseComposerRecipe) + { + FVoxelOpContext ComposerContext; + if (VF_BuildStackFromRecipe( + CP_ComposerRecipe, CP_ComposerParams, Seed, + OriginSpineRadius, StrateManager, CP_OpStack, ComposerContext, nullptr)) + { + ComposerContext.ChunkCoord = ChunkCoord; + ComposerContext.Step = 1; + ComposerContext.LayoutVersion = LayoutVersion; + // The recipe builder supplies the candidate's vertical seal to the + // structural post. The XY edge seal is global and uses live settings. + ComposerContext.WorldRadiusVoxels = WorldRadiusVoxels; + ComposerContext.EdgeSealThickness = EdgeSealThickness; + CP_OpStack.PrepareChunk(ComposerContext); + } + else + { + // The editor action validates before installing the override. Keep this + // fallback safe if a future recipe changes underneath PIE: the native + // candidate params remain available instead of yielding an uninitialised + // stack or a hole. + CP_UseOpStack = false; + } + } + else +#endif + { FVoxelOpContext OpCtx; OpCtx.ChunkCoord = ChunkCoord; OpCtx.Seed = (uint32)Seed; @@ -773,6 +884,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // which is the point: the first op that hoists real per-chunk work must find the // call already here. if (CP_UseOpStack) { CP_OpStack.PrepareChunk(OpCtx); } + } } } @@ -2784,6 +2896,16 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (!StrateManager) return EVoxelTileClass::Mixed; +#if WITH_EDITOR + // A custom recipe is deliberately not fed to the native ClassifyBox proof yet. Returning + // Mixed keeps the live walk-through hole-safe: GenerateMesh samples the exact recipe stack, + // while no optimistic AllAir/AllSolid result can skip a candidate tile. + if (StrateManager->HasComposerRecipeOverride()) + { + return EVoxelTileClass::Mixed; + } +#endif + bool bCanSolid = true; // "tout le treillis est solide" encore prouvable bool bCanAir = true; // "tout le treillis est air" encore prouvable diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 19fef09..328ac37 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -2270,6 +2270,131 @@ FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index) return Recipe; } +#if WITH_EDITOR +namespace +{ + struct FComposerStructureBlockSpec + { + EVoxelStrateParamBlock Block; + ECaveGeneratorType Archetype; + uint32 SeedSalt; + }; + + // Keep these salts in the composer implementation so the editor action and the offline + // structure test cannot silently acquire different block streams. + static const FComposerStructureBlockSpec GComposerStructureBlocks[] = + { + { EVoxelStrateParamBlock::TunnelNetwork, ECaveGeneratorType::TunnelNetwork, 0x1001u }, + { EVoxelStrateParamBlock::Slab, ECaveGeneratorType::FlatPlain, 0x1003u }, + { EVoxelStrateParamBlock::Maze, ECaveGeneratorType::Maze, 0x1005u }, + { EVoxelStrateParamBlock::Surface, ECaveGeneratorType::SurfaceWorld, 0x1007u }, + { EVoxelStrateParamBlock::VerticalShaft, ECaveGeneratorType::VerticalShafts, 0x1009u }, + { EVoxelStrateParamBlock::FloatingIsland, ECaveGeneratorType::FloatingIslands, 0x100Bu }, + }; + + void VF_CopyComposerBlock(FVoxelStrateArchetypeParams& OutParams, + const FVoxelStrateRollInfo& Roll, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams; + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutParams.SlabParams = Roll.ArchetypeParams.SlabParams; + break; + case ECaveGeneratorType::Maze: + OutParams.MazeParams = Roll.ArchetypeParams.MazeParams; + break; + case ECaveGeneratorType::SurfaceWorld: + OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams; + break; + case ECaveGeneratorType::VerticalShafts: + OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams; + break; + case ECaveGeneratorType::FloatingIslands: + OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams; + break; + default: + break; + } + } + + ECaveGeneratorType VF_ComposerArchetypeForRecipe(const FVoxelOpStackRecipe& Recipe) + { + switch (Recipe.StructuralParamBlock) + { + case EVoxelStrateParamBlock::TunnelNetwork: return ECaveGeneratorType::TunnelNetwork; + case EVoxelStrateParamBlock::Slab: return ECaveGeneratorType::FlatPlain; + case EVoxelStrateParamBlock::Maze: return ECaveGeneratorType::Maze; + case EVoxelStrateParamBlock::Surface: return ECaveGeneratorType::SurfaceWorld; + case EVoxelStrateParamBlock::VerticalShaft: return ECaveGeneratorType::VerticalShafts; + case EVoxelStrateParamBlock::FloatingIsland: return ECaveGeneratorType::FloatingIslands; + default: return ECaveGeneratorType::TunnelNetwork; + } + } +} + +FVoxelStrateComposerCandidate VF_RollStrateCandidate( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index, bool bRollStructure) +{ + FVoxelStrateComposerCandidate Candidate; + Candidate.Seed = Seed; + Candidate.Index = Index; + Candidate.bStructureRoll = bRollStructure; + + if (!bRollStructure) + { + // This is the exact Tier 4a call used by the parameter-roll measurement test. + Candidate.ParameterRoll = VF_RollStrateParamsDetailed(Corpus, Seed, Index); + if (!Candidate.ParameterRoll.bValid) + { + Candidate.FailureReason = Candidate.ParameterRoll.FailureReason; + return Candidate; + } + + Candidate.Archetype = Candidate.ParameterRoll.Archetype; + Candidate.ArchetypeParams = Candidate.ParameterRoll.ArchetypeParams; + Candidate.bValid = true; + return Candidate; + } + + // This is the exact Tier 4b structure call plus the six independent native-family rolls used + // to materialise every structure candidate in the offline test. + Candidate.Recipe = VF_RollStrateStructure(Seed, Index); + Candidate.Archetype = VF_ComposerArchetypeForRecipe(Candidate.Recipe); + Candidate.StructureBlockRolls.Reserve(UE_ARRAY_COUNT(GComposerStructureBlocks)); + + bool bAllBlocksValid = true; + for (const FComposerStructureBlockSpec& Spec : GComposerStructureBlocks) + { + const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailedForArchetype( + Corpus, Spec.Archetype, Seed ^ static_cast(Spec.SeedSalt), Index); + Candidate.StructureBlockRolls.Add(Roll); + if (!Roll.bValid) + { + bAllBlocksValid = false; + if (Candidate.FailureReason.IsEmpty()) + { + Candidate.FailureReason = FString::Printf( + TEXT("parameter block %d (%s) failed: %s"), + static_cast(Spec.Block), + VF_GetStrateArchetypeName(Spec.Archetype), + *Roll.FailureReason); + } + continue; + } + VF_CopyComposerBlock(Candidate.ArchetypeParams, Roll, Spec.Archetype); + } + + Candidate.bValid = bAllBlocksValid; + return Candidate; +} +#endif + FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe) { FString Modifiers; diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index e11d410..edd71ae 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -9,6 +9,36 @@ #include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo #include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten) +#if WITH_EDITOR +#include "VoxelStrateComposer.h" + +struct FVoxelStrateComposerSlotOverride +{ + int32 CandidateSeed = 0; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams Params; + bool bUseRecipe = false; + FVoxelOpStackRecipe Recipe; +}; + +static void VF_SetComposerRuntimeBounds( + 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; +} +#endif + // Fractal Brownian Motion (layered Perlin) along a 1D parameter, ~[-1,1]. // Independent octaves at increasing frequency / decreasing amplitude give an organic, // non-repeating wander — the key to a worm that SQUIRMS instead of zig-zagging (1D) or @@ -35,6 +65,11 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) } StrateLayout.Empty(); +#if WITH_EDITOR + // A full layout rebuild discards any temporary walk-through candidate. The world calls this + // under FScopedGenerationPause, so no worker can observe the map while it is being cleared. + ComposerOverrides.Reset(); +#endif const int32 TotalStrates = Settings->TotalStrates; @@ -240,6 +275,125 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) GeneratePassages(); } +#if WITH_EDITOR +const FVoxelStrateComposerSlotOverride* UVoxelStrateManager::FindComposerOverride( + int32 StrateIndex) const +{ + const TSharedPtr* Found = ComposerOverrides.Find(StrateIndex); + return Found != nullptr ? Found->Get() : nullptr; +} + +bool UVoxelStrateManager::SetComposerOverrideForStrate( + int32 StrateIndex, int32 CandidateSeed, ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, bool bUseRecipe, + const FVoxelOpStackRecipe* Recipe, FString& OutError) +{ + OutError.Reset(); + + const FStrateSlot* TargetSlot = nullptr; + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.StrateIndex == StrateIndex) + { + TargetSlot = &Slot; + break; + } + } + if (TargetSlot == nullptr || TargetSlot->Definition == nullptr) + { + OutError = FString::Printf(TEXT("Strate index %d is not present in the live layout."), StrateIndex); + return false; + } + + 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: + break; + default: + OutError = TEXT("The composer returned an unsupported strate archetype."); + return false; + } + + if (bUseRecipe && Recipe == nullptr) + { + OutError = TEXT("A structure candidate did not provide a recipe."); + return false; + } + + TSharedPtr Override = + MakeShared(); + Override->CandidateSeed = CandidateSeed; + Override->Archetype = Archetype; + Override->Params = Params; + VF_SetComposerRuntimeBounds( + Override->Params, + (float)(TargetSlot->TopChunkZ + 1) * CHUNK_SIZE, + (float)TargetSlot->BottomChunkZ * CHUNK_SIZE); + Override->bUseRecipe = bUseRecipe; + if (Recipe != nullptr) + { + Override->Recipe = *Recipe; + } + + // Only one slot is overridden at a time. Keeping this map small also makes the worker-side + // copy-on-chunk-refetch cheap. Passage geometry is deliberately not regenerated: the layout + // and its passages are unchanged, and this is the same manager state used by the offline + // candidate measurement harness. + ComposerOverrides.Reset(); + ComposerOverrides.Add(StrateIndex, MoveTemp(Override)); + + // This is both the passage-shortlist invalidation counter and the cache key used by the + // generator's per-chunk params/stack memos. The world has already paused all readers. + ++PassagesVersion; + return true; +} + +bool UVoxelStrateManager::GetComposerOverrideForChunk( + const FIntVector& ChunkCoord, int32& OutCandidateSeed, + ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams, + bool& bOutUseRecipe, FVoxelOpStackRecipe& OutRecipe) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0) + { + return false; + } + + const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(StrateLayout[SlotIdx].StrateIndex); + if (Override == nullptr) + { + return false; + } + + OutCandidateSeed = Override->CandidateSeed; + OutArchetype = Override->Archetype; + OutParams = Override->Params; + bOutUseRecipe = Override->bUseRecipe; + OutRecipe = Override->Recipe; + return true; +} + +bool UVoxelStrateManager::HasComposerRecipeOverride() const +{ + for (const TPair>& Pair : ComposerOverrides) + { + if (Pair.Value.IsValid() && Pair.Value->bUseRecipe) + { + return true; + } + } + return false; +} +#endif + //============================================================================= // PASSAGE GENERATION //============================================================================= @@ -935,6 +1089,14 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto return ECaveGeneratorType::TunnelNetwork; } +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(StrateLayout[SlotIdx].StrateIndex)) + { + return Override->Archetype; + } +#endif + return StrateLayout[SlotIdx].Definition->GeneratorType; } @@ -943,6 +1105,15 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; } +#if WITH_EDITOR + if (FindComposerOverride(StrateLayout[SlotIdx].StrateIndex) != nullptr) + { + // Temporary composer candidates are measured through the stack path. Do not inherit an + // unrelated asset flag from the slot being replaced. + return true; + } +#endif + const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition; if (!Def->bUseOperatorStack) { return false; } @@ -1018,8 +1189,23 @@ FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVecto const FStrateSlot& Slot = StrateLayout[SlotIdx]; - // Copy the designer-authored slab params from the strate definition. - FSlabGenerationParams Result = Slot.Definition->SlabParams; + // Copy the designer-authored slab params from the strate definition, unless an editor + // composer candidate owns this slot. + FSlabGenerationParams Result; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(Slot.StrateIndex)) + { + Result = (Override->Archetype == ECaveGeneratorType::FlatPlain + || Override->Archetype == ECaveGeneratorType::CrystalChamber) + ? Override->Params.SlabParams + : Slot.Definition->SlabParams; + } + else +#endif + { + Result = Slot.Definition->SlabParams; + } // Fill in the runtime Z bounds (voxel coordinates, same convention as // FStrateGenerationParams::StrateTopWorldZ / StrateBottomWorldZ). @@ -1034,32 +1220,111 @@ FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVecto // PER-ARCHETYPE PARAM GETTERS //============================================================================= // Each mirrors GetSlabParamsForChunk: copy designer params, fill runtime Z bounds. -// No cross-boundary blending — archetypes meet at Hard boundaries. A macro keeps -// the boilerplate (slot lookup + fallback + Z bounds) in one place. +// No cross-boundary blending — archetypes meet at Hard boundaries. -#define VF_ARCHETYPE_PARAMS_GETTER(FnName, StructType, DefMember) \ -StructType UVoxelStrateManager::FnName(const FIntVector& ChunkCoord) const \ -{ \ - int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); \ - if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) \ - { \ - StructType Empty; \ - Empty.BaseDensity = -1.0f; \ - return Empty; \ - } \ - const FStrateSlot& Slot = StrateLayout[SlotIdx]; \ - StructType Result = Slot.Definition->DefMember; \ - Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; \ - Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; \ - return Result; \ +FMazeGenerationParams UVoxelStrateManager::GetMazeParamsForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FMazeGenerationParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FMazeGenerationParams Result = Slot.Definition->MazeParams; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) + { + if (Override->Archetype == ECaveGeneratorType::Maze) + { + Result = Override->Params.MazeParams; + } + } +#endif + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + return Result; } -VF_ARCHETYPE_PARAMS_GETTER(GetMazeParamsForChunk, FMazeGenerationParams, MazeParams) -VF_ARCHETYPE_PARAMS_GETTER(GetSurfaceParamsForChunk, FSurfaceGenerationParams, SurfaceParams) -VF_ARCHETYPE_PARAMS_GETTER(GetVerticalShaftParamsForChunk, FVerticalShaftParams, VerticalShaftParams) -VF_ARCHETYPE_PARAMS_GETTER(GetFloatingIslandParamsForChunk, FFloatingIslandParams, FloatingIslandParams) +FSurfaceGenerationParams UVoxelStrateManager::GetSurfaceParamsForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FSurfaceGenerationParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } -#undef VF_ARCHETYPE_PARAMS_GETTER + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FSurfaceGenerationParams Result = Slot.Definition->SurfaceParams; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) + { + if (Override->Archetype == ECaveGeneratorType::SurfaceWorld) + { + Result = Override->Params.SurfaceParams; + } + } +#endif + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + return Result; +} + +FVerticalShaftParams UVoxelStrateManager::GetVerticalShaftParamsForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FVerticalShaftParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FVerticalShaftParams Result = Slot.Definition->VerticalShaftParams; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) + { + if (Override->Archetype == ECaveGeneratorType::VerticalShafts) + { + Result = Override->Params.VerticalShaftParams; + } + } +#endif + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + return Result; +} + +FFloatingIslandParams UVoxelStrateManager::GetFloatingIslandParamsForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FFloatingIslandParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FFloatingIslandParams Result = Slot.Definition->FloatingIslandParams; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) + { + if (Override->Archetype == ECaveGeneratorType::FloatingIslands) + { + Result = Override->Params.FloatingIslandParams; + } + } +#endif + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + return Result; +} FBiomeContext UVoxelStrateManager::GetBiomeContextForChunk(const FIntVector& ChunkCoord) const { @@ -1156,6 +1421,20 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto } const FStrateSlot& Slot = StrateLayout[SlotIdx]; + +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(Slot.StrateIndex)) + { + FStrateGenerationParams Result = Override->Params.TunnelNetworkParams; + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + // A candidate is a hard replacement of this slot. Do not blend its rolled vector with an + // authored neighbour at a boundary; this is also how the offline fixture measures it. + return Result; + } +#endif + FStrateGenerationParams BaseParams = BuildParamsFromDefinition(Slot.Definition); //========================================================================= diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index d27e9ae..fdb707e 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -33,6 +33,11 @@ #include "Misc/Paths.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) +#if WITH_EDITOR +#include "VoxelStrateComposer.h" +#include "VoxelStrateMeasure.h" +#endif + AVoxelWorld::AVoxelWorld() { PrimaryActorTick.bCanEverTick = true; @@ -127,6 +132,228 @@ private: bool bAcquired = false; }; +#if WITH_EDITOR +namespace +{ + void VF_SetComposerRuntimeBounds(FVoxelStrateArchetypeParams& Params, + float TopWorldZ, float BottomWorldZ) + { + Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ; + Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ; + Params.SlabParams.StrateTopWorldZ = TopWorldZ; + Params.SlabParams.StrateBottomWorldZ = BottomWorldZ; + Params.MazeParams.StrateTopWorldZ = TopWorldZ; + Params.MazeParams.StrateBottomWorldZ = BottomWorldZ; + Params.SurfaceParams.StrateTopWorldZ = TopWorldZ; + Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ; + Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ; + Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ; + Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ; + Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ; + } + + float VF_ComposerBoundarySeal(const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + 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; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + default: + return Params.TunnelNetworkParams.BoundarySealThickness; + } + } + + class FComposerStackDensitySampler final : public IVoxelStrateDensitySampler + { + public: + explicit FComposerStackDensitySampler(const FVoxelOpStack& InStack) + : Stack(InStack) {} + + float SampleDensity(float WorldX, float WorldY, float WorldZ) const override + { + return Stack.EvalMC(WorldX, WorldY, WorldZ); + } + + private: + const FVoxelOpStack& Stack; + }; + + bool VF_BuildComposerReferenceStack( + const FVoxelStrateComposerCandidate& Candidate, + const UVoxelGenerator& Generator, + const UVoxelStrateManager& StrateManager, + int32 TargetTopChunkZ, + int32 TargetBottomChunkZ, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString& OutError) + { + const float TopWorldZ = (float)(TargetTopChunkZ + 1) * CHUNK_SIZE; + const float BottomWorldZ = (float)TargetBottomChunkZ * CHUNK_SIZE; + FVoxelStrateArchetypeParams Params = Candidate.ArchetypeParams; + VF_SetComposerRuntimeBounds(Params, TopWorldZ, BottomWorldZ); + + bool bBuilt = false; + if (Candidate.bStructureRoll) + { + bBuilt = VF_BuildStackFromRecipe( + Candidate.Recipe, Params, Generator.Seed, Generator.OriginSpineRadius, + &StrateManager, OutStack, OutContext, &OutError); + } + else + { + bBuilt = VF_BuildNativeStrateStackForCandidate( + Candidate.Archetype, Params, Generator.Seed, Generator.OriginSpineRadius, + Generator.WorldRadiusVoxels, Generator.EdgeSealThickness, &StrateManager, + OutStack, OutContext); + if (!bBuilt && OutError.IsEmpty()) + { + OutError = TEXT("The production native candidate stack could not be materialised."); + } + } + if (!bBuilt) + { + return false; + } + + // The recipe builder deliberately defaults the world-edge context to 0 for offline + // measurements. A live candidate must use the actual world's edge law, while its + // vertical bounds and structural seal remain candidate-owned. + const int32 MidChunkZ = TargetBottomChunkZ + + (TargetTopChunkZ - TargetBottomChunkZ) / 2; + OutContext.ChunkCoord = FIntVector(0, 0, MidChunkZ); + OutContext.Step = 1; + OutContext.LayoutVersion = StrateManager.GetLayoutVersion(); + OutContext.WorldRadiusVoxels = Generator.WorldRadiusVoxels; + OutContext.EdgeSealThickness = Generator.EdgeSealThickness; + OutStack.PrepareChunk(OutContext); + return true; + } + + bool VF_ComposerReferenceIsComparable( + const AVoxelWorld& World, + const FStrateSlot& TargetSlot, + ECaveGeneratorType Archetype, + const FIntVector& RepresentativeChunk, + FString& OutReason) + { + OutReason.Reset(); + if (World.DiffLayer && World.DiffLayer->HasAnyMods()) + { + OutReason = TEXT("diff-layer-modifications-present"); + return false; + } + + const FStrateDisturbanceParams Disturbances = + World.StrateManager->GetDisturbanceParamsForChunk(RepresentativeChunk); + if (Disturbances.ChasmDensity > 0.0f + || Disturbances.BridgeDensity > 0.0f + || Disturbances.RidgeDensity > 0.0f) + { + OutReason = TEXT("live-disturbance-post-present"); + return false; + } + + // The production SurfaceWorld stack may include a generator-owned biome field and + // per-biome params. The standalone candidate stack intentionally does not invent that + // context; fall back to the live metric and mark the density check as unavailable. + if (Archetype == ECaveGeneratorType::SurfaceWorld + && TargetSlot.Definition != nullptr + && TargetSlot.Definition->Biomes.Num() > 0) + { + OutReason = TEXT("surface-biome-context-present"); + return false; + } + return true; + } + + FVoxelStrateMeasureSettings VF_ComposerMeasureSettings() + { + FVoxelStrateMeasureSettings Settings; + Settings.SampleStep = 4; + Settings.RadiusInVoxels = 256; + Settings.CenterXY = FVector2D::ZeroVector; + Settings.MaxCells = 8000000; + Settings.MaxRouteRetries = 16; + Settings.HeadroomCells = 2; + Settings.InteriorMarginVoxels = -1; + return Settings; + } + + bool VF_ComposerDensitySanityCheck( + const UVoxelGenerator& Generator, + const FVoxelOpStack& ReferenceStack, + int32 TargetTopChunkZ, + int32 TargetBottomChunkZ, + float& OutMaxDelta, + int32& OutMismatches, + int32& OutSamples) + { + // Keep all probes inside one representative chunk column where possible. This exercises + // both the live thread-local refetch and the prepared reference stack without making the + // check depend on a particular room being present at a hand-picked coordinate. + static const FIntVector Offsets[] = + { + FIntVector(1, 1, -15), + FIntVector(7, 13, -7), + FIntVector(17, 3, 1), + FIntVector(29, 27, 9), + FIntVector(3, 23, 17), + FIntVector(19, 19, 25), + FIntVector(11, 29, 31), + FIntVector(27, 5, -23), + }; + + const int32 MidChunkZ = TargetBottomChunkZ + + (TargetTopChunkZ - TargetBottomChunkZ) / 2; + const int32 BottomWorldZ = TargetBottomChunkZ * CHUNK_SIZE; + const int32 TopWorldZExclusive = (TargetTopChunkZ + 1) * CHUNK_SIZE; + const int32 MidWorldZ = MidChunkZ * CHUNK_SIZE + CHUNK_SIZE / 2; + + OutMaxDelta = 0.0f; + OutMismatches = 0; + OutSamples = 0; + for (const FIntVector& Offset : Offsets) + { + const float X = (float)Offset.X; + const float Y = (float)Offset.Y; + const float Z = (float)FMath::Clamp( + MidWorldZ + Offset.Z, BottomWorldZ + 1, TopWorldZExclusive - 1); + const float Live = Generator.GetDensityAt(X, Y, Z); + const float Reference = ReferenceStack.EvalMC(X, Y, Z); + + uint32 LiveBits = 0; + uint32 ReferenceBits = 0; + FMemory::Memcpy(&LiveBits, &Live, sizeof(LiveBits)); + FMemory::Memcpy(&ReferenceBits, &Reference, sizeof(ReferenceBits)); + const bool bBitEqual = LiveBits == ReferenceBits; + if (!bBitEqual) + { + ++OutMismatches; + } + + const float Delta = (FMath::IsFinite(Live) && FMath::IsFinite(Reference)) + ? FMath::Abs(Live - Reference) : FLT_MAX; + OutMaxDelta = FMath::Max(OutMaxDelta, Delta); + ++OutSamples; + } + return OutMismatches == 0; + } +} +#endif + //============================================================================= // LIVE EDIT — regenerate all chunks when params change in the Details panel //============================================================================= @@ -270,6 +497,210 @@ void AVoxelWorld::ValidateDeterminism() } #if WITH_EDITOR +void AVoxelWorld::ApplyComposerCandidate() +{ + UWorld* World = GetWorld(); + if (World == nullptr || !World->IsPlayInEditor()) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelWorld] ApplyComposerCandidate: run this button from a PIE world.")); + return; + } + if (bShuttingDown.load(std::memory_order_acquire) + || bGenerationPaused.load(std::memory_order_acquire)) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelWorld] ApplyComposerCandidate: generation is already stopping or paused.")); + return; + } + if (!Settings || !Generator || !StrateManager) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelWorld] ApplyComposerCandidate: BeginPlay has not initialized Settings, Generator, and StrateManager.")); + return; + } + + const TArray& Layout = StrateManager->GetLayout(); + const FStrateSlot* TargetSlot = nullptr; + for (const FStrateSlot& Slot : Layout) + { + if (Slot.StrateIndex == ComposerTargetStrateIndex) + { + TargetSlot = &Slot; + break; + } + } + if (TargetSlot == nullptr || TargetSlot->Definition == nullptr) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelWorld] ApplyComposerCandidate: ComposerTargetStrateIndex=%d is not a live layout slot (layout has %d slots)."), + ComposerTargetStrateIndex, Layout.Num()); + return; + } + + FVoxelStrateCorpus Corpus; + FString CorpusReport; + if (!Corpus.LoadFromAssetRegistry(CorpusReport)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelWorld] ApplyComposerCandidate: composer corpus is unavailable: %s"), + *CorpusReport); + return; + } + + const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( + Corpus, ComposerSeed, ComposerCandidateIndex, bComposerRollStructure); + if (!Candidate.bValid) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d roll failed: %s"), + ComposerSeed, ComposerCandidateIndex, *Candidate.FailureReason); + return; + } + + const int32 TargetTopChunkZ = TargetSlot->TopChunkZ; + const int32 TargetBottomChunkZ = TargetSlot->BottomChunkZ; + const float TargetTopWorldZ = (float)(TargetTopChunkZ + 1) * CHUNK_SIZE; + const float TargetBottomWorldZ = (float)TargetBottomChunkZ * CHUNK_SIZE; + const FIntVector RepresentativeChunk( + 0, 0, TargetBottomChunkZ + (TargetTopChunkZ - TargetBottomChunkZ) / 2); + + // Materialise before mutating the live manager. A bad recipe or unsupported native family + // therefore leaves the running world untouched. + FVoxelStrateArchetypeParams PreflightParams = Candidate.ArchetypeParams; + VF_SetComposerRuntimeBounds(PreflightParams, TargetTopWorldZ, TargetBottomWorldZ); + FVoxelOpStack PreflightStack; + FVoxelOpContext PreflightContext; + FString PreflightError; + bool bPreflightBuilt = false; + if (Candidate.bStructureRoll) + { + bPreflightBuilt = VF_BuildStackFromRecipe( + Candidate.Recipe, PreflightParams, Generator->Seed, Generator->OriginSpineRadius, + StrateManager, PreflightStack, PreflightContext, &PreflightError); + } + else + { + bPreflightBuilt = VF_BuildNativeStrateStackForCandidate( + Candidate.Archetype, PreflightParams, Generator->Seed, Generator->OriginSpineRadius, + Generator->WorldRadiusVoxels, Generator->EdgeSealThickness, StrateManager, + PreflightStack, PreflightContext); + if (!bPreflightBuilt) + { + PreflightError = TEXT("The production native candidate stack could not be materialised."); + } + } + if (!bPreflightBuilt) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d was not applied: %s"), + ComposerSeed, ComposerCandidateIndex, *PreflightError); + return; + } + + FString ApplyError; + { + // The manager override is a density-only replacement. It does not call Initialize or + // GeneratePassages while the layout is half-built: the existing slot Z span, content + // definition, and passage geometry remain stable. The pause drains current workers before + // the map is changed, and the subsequent RegenerateAllChunks supplies the epoch bump. + FScopedGenerationPause Guard(this); + if (!Guard.Acquired()) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelWorld] ApplyComposerCandidate: generation pause timed out; no mutation applied.")); + return; + } + + if (!StrateManager->SetComposerOverrideForStrate( + ComposerTargetStrateIndex, Candidate.Seed, Candidate.Archetype, + Candidate.ArchetypeParams, Candidate.bStructureRoll, + Candidate.bStructureRoll ? &Candidate.Recipe : nullptr, ApplyError)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d was not applied: %s"), + ComposerSeed, ComposerCandidateIndex, *ApplyError); + return; + } + + // These are the same game-thread-owned side systems reset by RebuildStrates. The full + // chunk reset below also resets the density volume and preserves DiffLayer edits. + if (AtmosphereManager) { AtmosphereManager->Reset(); } + if (ContentManager) { ContentManager->ClearAll(); } + } + + // Reuse the established seed/live-edit invalidation path: GenerationEpoch increments here, + // stale ProcessQueue results are dropped, and the normal Tick submits a fresh stream around + // the player. No async worker can observe the half-applied manager state. + RegenerateAllChunks(); + + FVoxelOpStack ReferenceStack; + FVoxelOpContext ReferenceContext; + FString ReferenceError; + const bool bReferenceBuilt = VF_BuildComposerReferenceStack( + Candidate, *Generator, *StrateManager, TargetTopChunkZ, TargetBottomChunkZ, + ReferenceStack, ReferenceContext, ReferenceError); + + FString ComparableReason; + const bool bReferenceComparable = bReferenceBuilt + && VF_ComposerReferenceIsComparable( + *this, *TargetSlot, Candidate.Archetype, RepresentativeChunk, ComparableReason); + + const FVoxelStrateMeasureSettings MeasureSettings = VF_ComposerMeasureSettings(); + FVoxelStrateMetrics Metrics; + FString MetricSource; + if (bReferenceComparable) + { + FComposerStackDensitySampler Sampler(ReferenceStack); + Metrics = VF_MeasureStrateWithSampler( + Sampler, (int32)TargetBottomWorldZ, (int32)TargetTopWorldZ, + VF_ComposerBoundarySeal(PreflightParams, Candidate.Archetype), MeasureSettings); + MetricSource = TEXT("candidate-stack"); + } + else + { + // A live diff, disturbance post, or surface biome context makes a standalone stack an + // unlike oracle. Keep the owner's metric useful by measuring the actual applied generator; + // the log records why the bit-level oracle was skipped. + Metrics = VF_MeasureStrate( + *Generator, *StrateManager, ComposerTargetStrateIndex, MeasureSettings); + MetricSource = TEXT("live-generator"); + } + + float MaxDensityDelta = 0.0f; + int32 DensityMismatches = 0; + int32 DensitySamples = 0; + bool bDensityMatch = false; + if (bReferenceComparable) + { + bDensityMatch = VF_ComposerDensitySanityCheck( + *Generator, ReferenceStack, TargetTopChunkZ, TargetBottomChunkZ, + MaxDensityDelta, DensityMismatches, DensitySamples); + } + + const FString RecipeText = Candidate.bStructureRoll + ? VF_FormatStrateStructureRecipe(Candidate.Recipe) + : TEXT("parameter-roll"); + const FString DeterminismText = !bReferenceBuilt + ? FString::Printf(TEXT("UNAVAILABLE(%s)"), *ReferenceError) + : !bReferenceComparable + ? FString::Printf(TEXT("SKIPPED(%s)"), *ComparableReason) + : FString::Printf(TEXT("%s/%d"), bDensityMatch ? TEXT("PASS") : TEXT("FAIL"), + DensitySamples); + const float AirFraction = Metrics.bValid ? Metrics.AirFraction : -1.0f; + const float LargestShare = Metrics.bValid ? Metrics.LargestComponentShare : -1.0f; + const float WalkableFraction = Metrics.bValid ? Metrics.WalkableFraction : -1.0f; + const float FeatureScale = Metrics.bValid ? Metrics.MedianFeatureScale : -1.0f; + + UE_LOG(LogTemp, Log, + TEXT("[VoxelWorld] ComposerCandidate applied seed=%d index=%d target=%d recipe=%s archetype=%s metrics=%s air=%.6f largest=%.6f walkable=%.6f feature=%.3f components=%d determinism=%s max_delta=%.9g mismatches=%d epoch=%u"), + Candidate.Seed, Candidate.Index, ComposerTargetStrateIndex, *RecipeText, + VF_GetStrateArchetypeName(Candidate.Archetype), *MetricSource, + AirFraction, LargestShare, WalkableFraction, FeatureScale, + Metrics.bValid ? Metrics.NumAirComponents : -1, + *DeterminismText, MaxDensityDelta, DensityMismatches, GenerationEpoch); +} + void AVoxelWorld::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) { Super::PostEditChangeProperty(PropertyChangedEvent); diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index 047e6f6..31e98d4 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -15,6 +15,7 @@ class UVoxelSettings; class UVoxelStrateDefinition; +class UVoxelStrateManager; /** The one-bit identity choice at the root of a rolled structure. */ UENUM(BlueprintType) @@ -211,6 +212,32 @@ struct VOXELFORGE_API FVoxelStrateRollInfo TArray BootstrapJitterFieldNames; }; +#if WITH_EDITOR +/** + * One candidate materialised by the same deterministic calls used by the offline composer tests. + * This is an editor-only hand-off object: it is never a reflected asset and never part of the + * shipping generation contract. + */ +struct VOXELFORGE_API FVoxelStrateComposerCandidate +{ + bool bValid = false; + FString FailureReason; + + int32 Seed = 0; + int32 Index = 0; + bool bStructureRoll = false; + + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams ArchetypeParams; + FVoxelOpStackRecipe Recipe; + + // Kept so automation/editor callers can audit the exact six independent structure blocks + // without re-rolling them through a second implementation. + FVoxelStrateRollInfo ParameterRoll; + TArray StructureBlockRolls; +}; +#endif + /** * Known-good strate vectors plus their measured field spread. * @@ -293,6 +320,32 @@ VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailedForArchetype( /** Pure structure roll: no retained RNG state and no corpus/runtime dependency. */ VOXELFORGE_API FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index); +#if WITH_EDITOR +/** + * Roll one candidate for the editor walk-through path. Parameter rolls call + * VF_RollStrateParamsDetailed; structure rolls call VF_RollStrateStructure and the exact six + * VF_RollStrateParamsDetailedForArchetype block rolls used by the structure test. + */ +VOXELFORGE_API FVoxelStrateComposerCandidate VF_RollStrateCandidate( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index, bool bRollStructure); + +/** + * Build a parameter-roll candidate through the same archetype-to-stack mapping used by production + * GetDensityAt/ClassifyTile. This is editor-only so the offline composer remains absent from a + * shipping build; the result is used only for the post-apply density sanity check. + */ +VOXELFORGE_API bool VF_BuildNativeStrateStackForCandidate( + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, + int32 Seed, + float SpineRadius, + float WorldRadiusVoxels, + float EdgeSealThickness, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext); +#endif + /** Stable compact representation and equality/hash helpers for manifests and reports. */ VOXELFORGE_API FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe); VOXELFORGE_API uint32 VF_HashStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe); diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index 4083497..f9a7da3 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -24,6 +24,12 @@ class UVoxelSettings; +#if WITH_EDITOR +struct FVoxelStrateArchetypeParams; +struct FVoxelOpStackRecipe; +struct FVoxelStrateComposerSlotOverride; +#endif + /** * FVoxelPassage — A navigable connection between two strates. * @@ -143,6 +149,27 @@ public: */ void Initialize(UVoxelSettings* Settings, int32 WorldSeed); +#if WITH_EDITOR + /** + * Install an editor-only candidate over one already-built slot. The slot's definition, + * dimensions, content, and passages remain intact; only the density archetype/parameters + * (or the materialised recipe) are replaced. The caller must hold the world's generation pause. + */ + bool SetComposerOverrideForStrate( + int32 StrateIndex, int32 CandidateSeed, ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, bool bUseRecipe, + const FVoxelOpStackRecipe* Recipe, FString& OutError); + + /** Read the immutable editor override for a chunk into worker-local storage. */ + bool GetComposerOverrideForChunk( + const FIntVector& ChunkCoord, int32& OutCandidateSeed, + ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams, + bool& bOutUseRecipe, FVoxelOpStackRecipe& OutRecipe) const; + + /** Custom recipes cannot be safely classified by the native ClassifyBox proof yet. */ + bool HasComposerRecipeOverride() const; +#endif + //========================================================================= // QUERIES //========================================================================= @@ -326,9 +353,9 @@ protected: // Passages connecting consecutive strates TArray Passages; - // Bumped every time Passages is rebuilt (GeneratePassages). EvaluateModifierSDF keeps a - // thread_local per-chunk shortlist of nearby passages and uses this to invalidate it when - // the passage set changes — so stale indices are never read after a rebuild. + // Bumped every time Passages is rebuilt (GeneratePassages), and for an editor composer + // override. EvaluateModifierSDF and all generator thread_local memos use this to invalidate + // cached passage/strate data after either kind of live change. uint32 PassagesVersion = 0; // How many chunks at strate boundaries are blended (transition zone) @@ -360,6 +387,11 @@ protected: // Returns INDEX into StrateLayout, or -1 if not found. int32 FindSlotIndexForChunkZ(int32 ChunkZ) const; +#if WITH_EDITOR + const FVoxelStrateComposerSlotOverride* FindComposerOverride(int32 StrateIndex) const; + TMap> ComposerOverrides; +#endif + // Build FStrateGenerationParams from a strate definition: // copies base GenerationParams, then applies all referenced terrain op assets. // This is the single point where terrain op data assets are merged into params. diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index ee71991..6098b01 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -452,6 +452,30 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit") bool bLiveEditStrates = false; +#if WITH_EDITORONLY_DATA + /** Seed passed to the deterministic offline composer roll. This does not change Settings->Seed. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer") + int32 ComposerSeed = 0; + + /** Candidate row passed to VF_RollStrateCandidate (the offline tests use the same index). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer", meta = (ClampMin = "0")) + int32 ComposerCandidateIndex = 0; + + /** Existing layout slot to replace; 0 is the topmost strate. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer", meta = (ClampMin = "0")) + int32 ComposerTargetStrateIndex = 0; + + /** False rolls one native parameter vector; true rolls the structure recipe and its six blocks. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer") + bool bComposerRollStructure = false; +#endif + +#if WITH_EDITOR + /** Roll and apply the selected offline composer candidate to one live PIE slot. */ + UFUNCTION(CallInEditor, Category = "Live Edit|Composer") + void ApplyComposerCandidate(); +#endif + /** Click to force-regenerate all chunks right now (useful during PIE). */ UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit") void RegenerateAllChunks();