From faa62d419e13def21828bf9c6d99512fac6ece52 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sat, 5 Sep 2026 08:37:01 +0200 Subject: [PATCH] feat(runtime): the composed world can BE the game world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jahni: "can you not implement those settings? as in, have them be live in game?" This is that. The composer stops being a lab tool. THE ARCHITECTURAL LINE, which is the whole design (COMPOSER-NOTES §3.1): COMPOSITION stays editor-only, EVALUATION becomes runtime. Rolling, measuring, preview, selection, promotion, the corpus -- none of it reaches a player's machine. What crosses into the runtime is exactly two things: reading a season manifest, and materialising a recipe into an op stack. UVoxelSeasonAsset a cookable UPrimaryDataAsset holding the manifest, so a season ships in a packaged game. The JSON stays the reviewable, git-diffable artefact. UVoxelSettings::Season optional. Unset => behaviour is exactly as today. GetEffective*() season seed / spine radius / world radius / season number override the authored values when assigned. The test deliberately sets DIFFERENT authored values, so "the season is authoritative" is proved rather than coincidental. ⭐ MULTIPLAYER DETERMINISM, PROVED: two independent runtime managers built the same season and compared 393,216 density samples BIT-IDENTICALLY. This is the property the whole multiplayer model rests on -- replicate seed + layout + diff, never geometry. ⭐ AND A STALE SEASON IS DETECTABLE, NOT SILENT. Initialize() now returns bool and an assigned-but-invalid season NEVER falls back to the authored pool. Falling back would hand a client a world that looks fine and is not the season -- silent divergence, the worst failure mode in a shared world. GetCurrentSeasonContentHash() is exposed BlueprintPure to compare at join. HasComposerRecipeOverride() is gone. It force-classified every custom recipe as Mixed, which was correct while box verdicts were only proved for hand-written stacks -- but 8e346a3 made EffectOverBox correct in ISOLATION, proved on a stack no builder produces. Keeping the force would have cost tile skipping on every composed world forever. VF_RollStrateRegionCount moved behind WITH_EDITOR while the partition-seed helpers stayed runtime: rolling out, evaluation in. Lateral regions remain gated off. ⚠️ Sol hit its usage limit mid-task, so the edits arrived unvalidated. I built and ran them myself rather than assume -- and they did not compile: VoxelForgeComposerSeasonTest.cpp was missing UObject/Package.h, which every other test picks up transitively through the shared fixture it does not use. One include; every NewObject/GetTransientPackage in the file was failing. Editor build clean. SHIPPING BUILD SUCCEEDS -- the invariant that proves no composer roller is linked into a packaged game. 30 succeeded / 0 failed. ⚠️ UNVERIFIED: whether it looks right on screen. Nothing headless can tell us that; it needs Jahni to assign a season asset and press play. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU --- ARCHITECTURE.md | 38 ++- CODEMAP.md | 39 ++- COMPOSER-NOTES.md | 20 +- .../Tests/VoxelForgeComposerSeasonTest.cpp | 197 ++++++++++++ .../Private/VoxelDensityOpStack.cpp | 4 + Source/VoxelForge/Private/VoxelGenerator.cpp | 242 +++++++++----- .../VoxelForge/Private/VoxelSeasonAsset.cpp | 135 ++++++++ .../Private/VoxelSeasonManifest.cpp | 257 +++++++++++---- Source/VoxelForge/Private/VoxelSettings.cpp | 51 +++ .../Private/VoxelStrateComposer.cpp | 43 +-- .../VoxelForge/Private/VoxelStrateManager.cpp | 297 ++++++++++++++---- .../VoxelForge/Private/VoxelStrateMeasure.cpp | 4 + .../VoxelForge/Private/VoxelStrateRecipe.cpp | 38 +++ Source/VoxelForge/Private/VoxelWorld.cpp | 45 ++- .../VoxelForge/Public/VoxelDensityOpStack.h | 2 + Source/VoxelForge/Public/VoxelSeasonAsset.h | 72 +++++ .../VoxelForge/Public/VoxelSeasonManifest.h | 27 +- Source/VoxelForge/Public/VoxelSettings.h | 12 + .../VoxelForge/Public/VoxelStrateComposer.h | 2 + Source/VoxelForge/Public/VoxelStrateManager.h | 26 +- Source/VoxelForge/Public/VoxelWorld.h | 4 + 21 files changed, 1268 insertions(+), 287 deletions(-) create mode 100644 Source/VoxelForge/Private/VoxelSeasonAsset.cpp create mode 100644 Source/VoxelForge/Private/VoxelSettings.cpp create mode 100644 Source/VoxelForge/Private/VoxelStrateRecipe.cpp create mode 100644 Source/VoxelForge/Public/VoxelSeasonAsset.h diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e70c263..bb723c8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -164,10 +164,32 @@ pauses and drains active generation, installs one temporary override on an exist 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 +structure candidate uses `VF_BuildStackFromRecipe`. Custom recipes now classify with that exact +materialised stack; slot/gap crossings, diffs, and disturbances still fail closed to `Mixed`, and +accepted uniform verdicts are brute-force checked by `VoxelForge.Composer.Season`. The bridge and its editable properties are WITH_EDITOR/WITH_EDITORONLY_DATA only and do not exist in a shipping build. +### 8.4a Cooked seasons — composition stays offline, evaluation ships + +`UVoxelSeasonAsset` is a `UPrimaryDataAsset` imported from the diffable schema-2 +`season_manifest.json`. The asset embeds the reviewed JSON, independently copies its seed, season, +spine/radius and SHA-1 content hash, and retains cook references to authored fixed definitions. +Runtime parses and verifies both hash copies before accepting the season. An assigned invalid season +fails closed; it never falls back to an unrelated authored pool. + +When `UVoxelSettings::Season` is assigned, `UVoxelStrateManager::Initialize` keeps its existing +half-built-layout/`GeneratePassages` ordering but feeds it the manifest's exact ordered slots, bounds, +archetypes, vectors, per-strate seeds and recipes. `UVoxelGenerator` copies an immutable recipe/vector +on the existing `(owner, chunk, layout version)` refetch and materialises `FVoxelOpStack` locally. +No rolling, corpus load, measurement, selection, promotion, or review code is compiled for Shipping. +When Season is unset, the authored fixed/pool shuffle path is unchanged. + +Schema 2 is deliberately density-focused. A fixed slot may retain an authored definition path as its +content/visual/passage bag; a generated slot has deterministic empty/default wider content. Because +passage configuration is not stored, generated slots set `Connections=0` instead of inventing the +UObject default passage that was never measured; the origin spine remains. Lateral-region manifests +are rejected while `VF_LateralRegionsAreShippable()` remains false (measured cross-seam law 9/16). + ### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW) `UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems: @@ -836,7 +858,7 @@ generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colou ## 9. Multiplayer model (listen-server first, dedicated-friendly) -> **Status: DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a +> **Status: transport is DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a > single `GetPlayerPosition()` center; a local diff layer). This section locks in the invariants so the > streaming / AI / carve systems are built network-aware from the start instead of retrofitted. Target > **now = listen server** (the host is a player AND the authority); **dedicated server = future / out of @@ -845,11 +867,11 @@ generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colou ### 9.1 The core invariant — determinism means you NEVER replicate geometry The world is a pure function of **(seed, strate layout)** (§8.4). So terrain is reconstructed identically on every peer from a tiny amount of shared state — it is **never streamed as geometry over the wire**: -- Replicate the **effective seed + strate layout** ONCE (at join). Every client's `UVoxelGenerator` + - `UVoxelStrateManager` then generate byte-identical terrain locally. (Today the seed lives on the data - asset / `UVoxelSettings::Seed`; MP must propagate the *host's* effective seed to joiners so their - generators match — a mismatch = divergent worlds. The strate layout is deterministic from seed, so it - syncs implicitly once the seed does.) +- Replicate the **effective seed + strate layout identity** ONCE (at join). For a cooked season the + layout is the reviewed manifest, not a pool shuffle: peers must load the same season asset and compare + `AVoxelWorld::GetCurrentSeasonContentHash()` before accepting play. For the legacy path the layout remains + deterministic from the effective seed. Every peer then generates terrain locally; geometry never crosses + the wire. The transport/RPC that performs this join handshake remains out of tree. - The **diff layer is the ONLY non-deterministic terrain state** (§3.9, [[voxelforge-difflayer-threading]]) → it is the only thing that must sync. Since carving is a minor feature, this traffic is small. diff --git a/CODEMAP.md b/CODEMAP.md index 8a94420..ec658eb 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -222,7 +222,7 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). | World bounds | `WorldRadiusVoxels=8192` and `EdgeSealThickness=64` (actor-space XY radial shell; radius 0 = true legacy no-op) | | Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) | | Rendering | `VoxelMaterial` (61) | -| Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) | +| Strates | Optional cooked `Season`; effective seed/spine/radius/season accessors read it when assigned. Otherwise `Seed`, `CurrentSeason=1`, `StratePool`, `FixedStrates`, and `TotalStrates=10` retain the authored path. | | Carving budget | `MaxModifications=0` (97), `MaxBrushRadius=15` (102), `MaxTotalVolume=0` (107). 0 = unlimited. | ### 3.5 World orchestrator — `Public/VoxelWorld.h` + `Private/VoxelWorld.cpp` @@ -278,8 +278,8 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** | `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → `ApplyModification`. | | `ApplyModification` | ~1581 | Single funnel for all brushes: DiffLayer → **sync-remesh the brush-centre level-0 tile** (`SyncRemeshTile`, instant hole; skipped if that tile is mid-gen — would race a stale in-flight result) → `RemeshDirtyChunks(..., excludeCenter)` for the neighbours → `RemoveDecorationsInSphere`. | | `ClearAllModifications` | 726 | Clears diff layer, regenerates. | -| `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. | -| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. | +| `ChangeSeed` | 740 | **Legacy season reset**: new seed everywhere, clear diffs, bump season, reload. Rejected while a cooked season owns the seed. | +| `GetCurrentSeed` / `GetCurrentSeason` / `GetCurrentSeasonContentHash` | — | Effective cooked-or-legacy identity; the hash is the join-time stale-season comparison value. | | `RemeshDirtyChunks` | 798 | Queue loaded level-0 dirty tiles onto `DirtyRemeshQueue` (async re-mesh, no pop) + `MarkDirtyVoxelBox` the volume. Optional `ExcludeTile` = the sync'd centre. Drained FIRST in the submit loop at **BackgroundHigh** (ahead of streaming/band) so a dig never waits behind streaming; in-flight tiles stay QUEUED (not dropped) so a stale pre-carve result is corrected once it lands — fixes "hole shows up a beat late / not until I move". | > **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_ClassifyTile` (T1.d) / `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)". @@ -297,14 +297,14 @@ This is **where terrain shape lives.** | `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. | | `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. | | `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. | -| `InitializeSettings` | 211 | Copies seed, spine radius, and global XY world-bound settings from `UVoxelSettings`; radius 0 preserves the legacy unbounded field. | -| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. Its `CP_*` per-chunk state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; every key component is an integer compare and a different generator/world cannot inherit the previous owner's cached params or op stack. | +| `InitializeSettings` | 211 | Copies the effective seed, spine radius, and global XY world-bound settings from `UVoxelSettings` (the season owns all three when assigned); radius 0 preserves the unbounded field. | +| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, materialises a cooked-season recipe on the existing per-chunk refetch when present, dispatches, then adds disturbances/diff. Its `CP_*` state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; workers copy immutable recipe/vector data and never read editor composer state. | | **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. | | **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. | | `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. | | `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. | | `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. | -| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): first applies the shared radial XY edge proof (global outer-shell tiles → AllSolid, including gaps/out-of-layout), then proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. Its diagnostic-only not-op-stack bail attribution distinguishes a tile wholly inside the disabled slot, a boundary tile, and an unresolved layout; the classifier's conditions/returns are unchanged. §8.10. | +| `ClassifyTile` | — | **T1.d trivial-tile reject.** Cooked/editor recipes now build the exact recipe stack and call its proved `ClassifyBox`; a tile crossing a slot/gap, any diff, or any disturbance returns `Mixed`. This replaces the old global `HasComposerRecipeOverride()` force-to-Mixed. Native cave/surface/gap proofs and all §8.10 caches remain unchanged. | | `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. | | `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. | | `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. | @@ -425,7 +425,7 @@ Maps depth→strate at runtime; owns passages. - `FStrateSlot` (h:84): definition + chunk-Z range + index. | Method | .cpp line | Role | |--------|-----------|------| -| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + pool sorted by soft-asset path, then unchanged Fisher-Yates shuffle), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. | +| `Initialize` | 10 | Keeps the existing sequencing through `GeneratePassages`, but selects one slot source: a validated cooked season (exact ordered bounds/vector/recipe) or the unchanged authored fixed+sorted/shuffled pool. Assigned-invalid seasons fail closed. Authored source definitions are duplicated as content bags; generated slots use deterministic C++ defaults and zero auto-passages because schema 2 does not store passage configuration. | | `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. After XY placement, the destination's pure `VF_SuggestLandingPoint` may move the lower mouth within an archetype-specific lattice budget, then the result is clamped outside the destination seal bands and interpolated through the control points; unsupported/no-footing answers warn and preserve the old random reach. | | `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. | | `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. | @@ -435,6 +435,7 @@ Maps depth→strate at runtime; owns passages. | `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. | +| `GetRecipeForChunk` | — | Runtime immutable recipe/vector/strate-seed copy for cooked seasons; editor slot overrides use the same worker hand-off. | | `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). | @@ -491,7 +492,8 @@ class IDs, an ordered modifier ID list, and one native parameter-family ID per e represented in the manifest; `VF_BuildStackFromRecipe` appends spine, vertical seal, passage carve, and XY edge seal in the fixed order. The roller derives modifier legality from the op channel and resource declarations, rolls 4–8 unique modifiers, and validates the recipe before materialising -it. `RequiredResources` / `ProvidedResources` close the room-state hole that channel masks alone +it. **Rolling is editor-only; `VF_BuildStackFromRecipe` is runtime evaluation and is used by cooked +seasons.** `RequiredResources` / `ProvidedResources` close the room-state hole that channel masks alone 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. @@ -543,20 +545,28 @@ descending corpus-centroid distance. The `VoxelForge.Composer.Showcase` test add alphabetized one-card-per-archetype PIE hand-off page with the Part A survivor distributions, exact seed / candidate / target-slot values, and 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 +**`Public/VoxelSeasonManifest.h` + `Private/VoxelSeasonManifest.cpp`** — Tier 4c's offline composer +and runtime-readable schema-2 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 +counts. A canonical SHA-1 `content_hash` covers the complete payload. `VF_DeserializeVoxelSeasonManifest` +verifies it in memory; `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 +composition/review work is editor/build-box code; parsing and recipe materialisation are runtime. Lateral +region manifests remain rejected because their cross-seam law is only 9/16. 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. +`VoxelForge` namespace passed **28/28 tests** after the original seam was added; current results must be +re-recorded after the owner build. + +**`Public/VoxelSeasonAsset.h` + `Private/VoxelSeasonAsset.cpp`** — `UVoxelSeasonAsset : +UPrimaryDataAsset`, the cookable carrier. Its editor button imports the reviewable JSON, stores the exact +text plus independently copied metadata/hash, and retains soft references to any authored definitions. +`UVoxelSettings::Season` is the only activation switch. ### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp` `UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; @@ -626,7 +636,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `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**. | | `VoxelForgeComposerShowcaseTest.cpp` | `VoxelForge.Composer.Showcase` | Exhausts the bounded parameter-roll set (seeds **0, 7331**, indices **0–63**), excludes multi-region rolls while the lateral gate is off, and measures every missing-family candidate in all six interior target slots at step 4 / radius 256 / `MaxCells=8,000,000`. Hard gates are non-vacuous, largest air share ≥ **0.50**, and exact unsnapped arrival→departure connectivity; selection score is floor-area fraction + clearance tie-break + projected-surface tie-break. It asserts roll/manifest determinism, density sign, zero `WorldRadiusVoxels`, bit-identical metric reruns, and writes one alphabetized card per archetype to `Saved/VoxelForge/Showcase/index.html`, with step-1 radius-64 filled/contour plan + vertical ROI images centred on `LargestComponentPoint`. | -| `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**. | +| `VoxelForgeComposerSeasonTest.cpp` | `VoxelForge.Composer.Season` | Existing compose/review/393,216-sample round trip plus schema hash tamper rejection, two independent runtime manager/generator instances, season-authoritative seed/spine/radius, unset-season regression, and brute-force verification of every sampled non-Mixed recipe tile verdict (up to 64). | | `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. | @@ -683,6 +693,7 @@ Stage order (negative=solid throughout). Each stage's anchor: | Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :757. | | Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. | | Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. | +| Publish/use an offline season | Create `UVoxelSeasonAsset`, set `SourceManifestJson`, press `ImportSeasonManifestJson`, then assign it to `UVoxelSettings::Season`. | | Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (expands `VF_STRATE_PARAM_FIELDS`, StrateTypes.h — new fields go in that list). | | Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). | | Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index d656a6b..e481edb 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -83,7 +83,8 @@ thousand candidate strates, measure them all, keep the best, validate at any res a model over the candidates at zero runtime cost. **And Jahni reviews and vetoes the season before it 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 +**What ships is small:** the season's seed plus the chosen strates' parameter vectors and structure +recipes. Clients generate 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 @@ -98,20 +99,31 @@ recipe, complete native parameter-family vector, archetype, bounds, measured met 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. +Publishing is explicit: keep the JSON as the reviewed/diffable source, create a +`UVoxelSeasonAsset`, point `SourceManifestJson` at it, press `ImportSeasonManifestJson`, and assign the +asset to `UVoxelSettings::Season`. The primary data asset is what cooks. Schema 2 embeds a canonical +SHA-1 `content_hash`; runtime recomputes it and also compares the asset's separately imported metadata, +so a stale or edited season fails closed instead of silently falling back to the authored pool. + 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. +composer, corpus, measurement, selection, promotion, and review writer are editor/build-box code only. +Runtime contains only manifest parsing/verification and recipe evaluation, and `WorldRadiusVoxels` +remains 0. Generated schema-2 slots have no passage/content/visual record: they therefore use +deterministic empty/default wider content and zero auto-passages (the origin spine remains). Fixed slots +may retain an authored definition path as that wider-content bag. Lateral regions remain unshippable at +the measured 9/16 cross-seam law result. An abbreviated manifest shape is: ```json { "format": "VoxelForgeSeasonManifest", - "schema_version": 1, + "schema_version": 2, + "content_hash": "0123456789abcdef0123456789abcdef01234567", "season": 0, "seed": 9320, "candidate_count": 24, diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp index 9173fdb..2384724 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp @@ -5,10 +5,18 @@ #include "Misc/AutomationTest.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" +#include "UObject/Package.h" // GetTransientPackage() — this file does not use the shared + // fixture, which is where the other tests pick it up implicitly. +#include "UObject/StrongObjectPtr.h" #include "VoxelDensityOpStack.h" +#include "VoxelGenerator.h" +#include "VoxelSeasonAsset.h" #include "VoxelSeasonManifest.h" +#include "VoxelSettings.h" #include "VoxelStrateComposer.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.h" namespace { @@ -121,6 +129,11 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters) } TestEqual(TEXT("loaded manifest has the same number of selected strates"), LoadedManifest.Strates.Num(), Manifest.Strates.Num()); + TestEqual(TEXT("loaded manifest content hash matches the composed payload"), + LoadedManifest.ContentHash, Manifest.ContentHash); + TestEqual(TEXT("manifest recomputes the hash it was composed from"), + VF_ComputeVoxelSeasonManifestContentHash(LoadedManifest), + LoadedManifest.ContentHash); int64 ComparedDensitySamples = 0; bool bAllDensityBitsIdentical = true; @@ -202,6 +215,190 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters) ComparedDensitySamples)); TestTrue(TEXT("manifest round-trip density field is bit-identical"), bAllDensityBitsIdentical); + + // Runtime seam: embed the reviewed JSON in a cookable primary asset, then let two independent + // managers/generators materialise the complete selected layout. The authored settings seed and + // radius are deliberately different so the test proves the season is authoritative. + TStrongObjectPtr SeasonAsset( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + FString AssetReport; + TestTrue(TEXT("cookable season asset accepts the reviewed manifest"), + SeasonAsset->SetManifestJson(Manifest.SerializedJson, AssetReport)); + + TStrongObjectPtr RuntimeSettings( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + RuntimeSettings->Seed = Manifest.Seed + 17; + RuntimeSettings->OriginSpineRadius = Manifest.OriginSpineRadius + 31.0f; + RuntimeSettings->WorldRadiusVoxels = 4096.0f; + RuntimeSettings->Season = TSoftObjectPtr(SeasonAsset.Get()); + + TStrongObjectPtr ManagerA( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TStrongObjectPtr ManagerB( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TestTrue(TEXT("first independent season manager initializes"), + ManagerA->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed())); + TestTrue(TEXT("second independent season manager initializes"), + ManagerB->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed())); + TestEqual(TEXT("season overrides the authored settings seed"), + RuntimeSettings->GetEffectiveWorldSeed(), Manifest.Seed); + TestEqual(TEXT("season keeps WorldRadiusVoxels zero"), + RuntimeSettings->GetEffectiveWorldRadiusVoxels(), 0.0f); + TestEqual(TEXT("season owns the origin spine radius used by recipe stacks"), + RuntimeSettings->GetEffectiveOriginSpineRadius(), Manifest.OriginSpineRadius); + TestEqual(TEXT("first manager loads every season slot"), + ManagerA->GetNumStrates(), Manifest.Strates.Num()); + TestEqual(TEXT("second manager loads every season slot"), + ManagerB->GetNumStrates(), Manifest.Strates.Num()); + TestEqual(TEXT("first manager exposes the reviewed content hash"), + ManagerA->GetSeasonContentHash(), Manifest.ContentHash); + TestEqual(TEXT("second manager exposes the reviewed content hash"), + ManagerB->GetSeasonContentHash(), Manifest.ContentHash); + + TStrongObjectPtr GeneratorA( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TStrongObjectPtr GeneratorB( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + GeneratorA->InitializeSettings(RuntimeSettings.Get()); + GeneratorB->InitializeSettings(RuntimeSettings.Get()); + GeneratorA->SetStrateManager(ManagerA.Get()); + GeneratorB->SetStrateManager(ManagerB.Get()); + + int64 ManagerDensitySamples = 0; + bool bManagersBitIdentical = true; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + for (int32 Z = Strate.BottomWorldZ; + Z < Strate.TopWorldZ && bManagersBitIdentical; Z += 2) + { + for (int32 Y = -32; Y < 32 && bManagersBitIdentical; Y += 2) + { + for (int32 X = -32; X < 32; X += 2) + { + const float A = GeneratorA->GetDensityAt((float)X, (float)Y, (float)Z); + const float B = GeneratorB->GetDensityAt((float)X, (float)Y, (float)Z); + ++ManagerDensitySamples; + if (!SameDensityBits(A, B)) + { + bManagersBitIdentical = false; + AddError(FString::Printf( + TEXT("independent season managers diverged at (%d,%d,%d): 0x%08x vs 0x%08x"), + X, Y, Z, *reinterpret_cast(&A), + *reinterpret_cast(&B))); + break; + } + } + } + } + } + AddInfo(FString::Printf( + TEXT("two independent runtime season managers compared %lld bit-identical density samples"), + ManagerDensitySamples)); + TestTrue(TEXT("two independent runtime season managers produce a bit-identical field"), + bManagersBitIdentical); + + // The former HasComposerRecipeOverride force made every one of these tiles Mixed (zero proved + // tiles). The new path asks the exact recipe stack and brute-forces every verdict it accepts. + int32 NumClassified = 0; + int32 NumMixed = 0; + int32 NumAllSolid = 0; + int32 NumAllAir = 0; + int32 NumVerdictsBruteForced = 0; + int32 NumFalseVerdicts = 0; + constexpr int32 TileCells = 16; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + for (int32 ZBand = 1; ZBand <= 3; ++ZBand) + { + const int32 Z = Strate.BottomWorldZ + + (Strate.TopWorldZ - Strate.BottomWorldZ) * ZBand / 4; + for (int32 Y = -128; Y <= 128; Y += 32) + { + for (int32 X = -128; X <= 128; X += 32) + { + const FIntVector Origin(X, Y, Z); + const EVoxelTileClass Verdict = GeneratorA->ClassifyTile( + Origin, 1, TileCells); + ++NumClassified; + if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; } + if (Verdict == EVoxelTileClass::AllSolid) ++NumAllSolid; + else ++NumAllAir; + + if (NumVerdictsBruteForced >= 64) continue; + ++NumVerdictsBruteForced; + const bool bSolid = Verdict == EVoxelTileClass::AllSolid; + bool bBad = false; + for (int32 GZ = -1; GZ <= TileCells + 1 && !bBad; ++GZ) + for (int32 GY = -1; GY <= TileCells + 1 && !bBad; ++GY) + for (int32 GX = -1; GX <= TileCells + 1; ++GX) + { + const float D = GeneratorA->GetDensityAt( + (float)(X + GX), (float)(Y + GY), (float)(Z + GZ)); + if (bSolid ? D >= 0.0f : D < 0.0f) + { + bBad = true; + ++NumFalseVerdicts; + AddError(FString::Printf( + TEXT("composed recipe false %s tile at (%d,%d,%d), sample (%d,%d,%d)=%.9g"), + bSolid ? TEXT("AllSolid") : TEXT("AllAir"), X, Y, Z, + X + GX, Y + GY, Z + GZ, D)); + break; + } + } + } + } + } + } + AddInfo(FString::Printf( + TEXT("composed recipe tile classification: before force=%d Mixed/0 proved; after=%d Mixed, %d AllSolid, %d AllAir; %d accepted verdicts brute-forced, %d violations"), + NumClassified, NumMixed, NumAllSolid, NumAllAir, + NumVerdictsBruteForced, NumFalseVerdicts)); + TestTrue(TEXT("composed recipe classification proves at least one uniform tile"), + NumAllSolid + NumAllAir > 0); + TestEqual(TEXT("composed recipe classification has no false uniform verdict"), + NumFalseVerdicts, 0); + + FString TamperedJson = Manifest.SerializedJson; + const FString OriginalSeedField = FString::Printf(TEXT("\"seed\": %d"), Manifest.Seed); + const FString TamperedSeedField = FString::Printf(TEXT("\"seed\": %d"), Manifest.Seed + 1); + TamperedJson = TamperedJson.Replace(*OriginalSeedField, *TamperedSeedField, + ESearchCase::CaseSensitive); + FVoxelSeasonManifest TamperedManifest; + FString TamperReport; + TestFalse(TEXT("stale/edited season JSON is rejected by its content hash"), + VF_DeserializeVoxelSeasonManifest( + TamperedJson, TamperedManifest, TamperReport)); + TestTrue(TEXT("stale season failure names the content hash"), + TamperReport.Contains(TEXT("content hash"))); + + // Explicit unset regression gate: no season means the authored pointer, seed and radius still + // flow through the original pool/fixed path. + TStrongObjectPtr LegacySettings( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TStrongObjectPtr LegacyDefinition( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + LegacySettings->Seed = 77123; + LegacySettings->OriginSpineRadius = 23.0f; + LegacySettings->WorldRadiusVoxels = 987.0f; + LegacySettings->TotalStrates = 1; + LegacyDefinition->StrateHeightInChunks = 7; + LegacySettings->StratePool.Add( + TSoftObjectPtr(LegacyDefinition.Get())); + TStrongObjectPtr LegacyManager( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TestTrue(TEXT("legacy manager still initializes with Season unset"), + LegacyManager->Initialize(LegacySettings.Get(), LegacySettings->Seed)); + TestTrue(TEXT("season unset remains the authored-pool path"), + LegacySettings->Season.IsNull() + && LegacyManager->GetNumStrates() == 1 + && LegacyManager->GetLayout()[0].Definition == LegacyDefinition.Get() + && LegacyManager->GetLayout()[0].HeightInChunks == 7 + && LegacyManager->GetWorldSeed() == LegacySettings->Seed + && LegacySettings->GetEffectiveWorldSeed() == LegacySettings->Seed + && LegacySettings->GetEffectiveOriginSpineRadius() + == LegacySettings->OriginSpineRadius + && LegacySettings->GetEffectiveWorldRadiusVoxels() + == LegacySettings->WorldRadiusVoxels); return true; } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index ae5170e..cb3326c 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -4886,6 +4886,7 @@ namespace uint32 SeedU; }; +#if WITH_EDITOR //============================================================================= // RÔLE 2 — COMBINER : RÉGIONS LATÉRALES //============================================================================= @@ -5008,6 +5009,7 @@ namespace mutable FVoxelStrateRegionPartitionCache PartitionCache; mutable bool bPrepared = false; }; +#endif } // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE. // Même piège que dans VoxelHeightOpStack.cpp : s'ancrer sur une bannière située plus bas @@ -6144,6 +6146,7 @@ bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, return true; } +#if WITH_EDITOR namespace { static EVoxelStrateParamBlock VF_RegionParamBlock(ECaveGeneratorType Archetype) @@ -6449,3 +6452,4 @@ bool VF_BuildStrateRegionStack( OutStack = MoveTemp(Parent); return true; } +#endif // WITH_EDITOR — lateral regions remain behind the failed shippability gate diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 1fe96b2..05d3e44 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -659,9 +659,9 @@ void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) { // Les paramètres globaux sont copiés ici une seule fois : le chemin voxel ne doit pas // déréférencer l'asset de settings. - Seed = Settings ? Settings->Seed : 0; - OriginSpineRadius = Settings ? Settings->OriginSpineRadius : 14.0f; - WorldRadiusVoxels = Settings ? Settings->WorldRadiusVoxels : 0.0f; + Seed = Settings ? Settings->GetEffectiveWorldSeed() : 0; + OriginSpineRadius = Settings ? Settings->GetEffectiveOriginSpineRadius() : 14.0f; + WorldRadiusVoxels = Settings ? Settings->GetEffectiveWorldRadiusVoxels() : 0.0f; EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.0f; } @@ -736,15 +736,18 @@ 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; + // Cooked-season recipes and editor candidate recipes share this immutable worker-local + // hand-off. Recipe materialisation is runtime; rolling/selection remains editor-only. + thread_local bool CP_UseCustomRecipe = false; + thread_local int32 CP_CustomRecipeSeed = 0; + thread_local FVoxelStrateArchetypeParams CP_CustomParams; + thread_local FVoxelOpStackRecipe CP_CustomRecipe; #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 bool CP_UseComposerRegions = false; thread_local int32 CP_ComposerSeed = 0; - thread_local FVoxelStrateArchetypeParams CP_ComposerParams; - thread_local FVoxelOpStackRecipe CP_ComposerRecipe; thread_local FVoxelStrateRegionManifest CP_ComposerRegions; #endif @@ -763,29 +766,29 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_Version = LayoutVersion; CP_Chunk = ChunkCoord; CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); + CP_UseCustomRecipe = false; + bool bAllowCustomRecipe = true; #if WITH_EDITOR - CP_UseComposerRecipe = false; CP_UseComposerRegions = false; - ECaveGeneratorType ComposerArchetype = ECaveGeneratorType::TunnelNetwork; - bool bComposerUseRecipe = false; if (StrateManager->GetComposerRegionOverrideForChunk( ChunkCoord, CP_ComposerRegions)) { CP_UseComposerRegions = true; + bAllowCustomRecipe = false; CP_ComposerSeed = CP_ComposerRegions.Seed; if (CP_ComposerRegions.Regions.Num() > 0) { CP_GenType = CP_ComposerRegions.Regions[0].Archetype; } } - else if (StrateManager->GetComposerOverrideForChunk( - ChunkCoord, CP_ComposerSeed, ComposerArchetype, CP_ComposerParams, - bComposerUseRecipe, CP_ComposerRecipe)) - { - CP_GenType = ComposerArchetype; - CP_UseComposerRecipe = bComposerUseRecipe; - } #endif + if (bAllowCustomRecipe + && StrateManager->GetRecipeForChunk( + ChunkCoord, CP_CustomRecipeSeed, CP_GenType, + CP_CustomParams, CP_CustomRecipe)) + { + CP_UseCustomRecipe = true; + } switch (CP_GenType) { case ECaveGeneratorType::FlatPlain: @@ -817,19 +820,22 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // Une seule branche ajoutée au chemin densité, et elle est FROIDE : la construction est // par chunk (comme le refetch de params juste au-dessus), jamais par voxel. CP_UseOpStack = StrateManager->UsesOperatorStackForChunk(ChunkCoord); + CP_UseOpStack = CP_UseOpStack || CP_UseCustomRecipe; #if WITH_EDITOR // A composer override is already an explicit, validated stack description. It must // be evaluated even when the authored definition had the normal operator-stack opt-in // disabled; otherwise a multi-region candidate would be installed in the manager but // silently fall back to the old one-archetype switch. - CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions || CP_UseComposerRecipe; + CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions; #endif if (CP_UseOpStack) { CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile + bool bBuiltSpecialStack = false; #if WITH_EDITOR if (CP_UseComposerRegions) { + bBuiltSpecialStack = true; FVoxelOpContext RegionContext; if (VF_BuildStrateRegionStack( CP_ComposerRegions, OriginSpineRadius, StrateManager, @@ -847,70 +853,71 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_UseOpStack = false; } } - else if (CP_UseComposerRecipe) +#endif + if (!bBuiltSpecialStack) { - FVoxelOpContext ComposerContext; - if (VF_BuildStackFromRecipe( - CP_ComposerRecipe, CP_ComposerParams, Seed, - OriginSpineRadius, StrateManager, CP_OpStack, ComposerContext, nullptr)) + if (CP_UseCustomRecipe) { - 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); + FVoxelOpContext RecipeContext; + if (VF_BuildStackFromRecipe( + CP_CustomRecipe, CP_CustomParams, CP_CustomRecipeSeed, + OriginSpineRadius, StrateManager, CP_OpStack, RecipeContext, nullptr)) + { + RecipeContext.ChunkCoord = ChunkCoord; + RecipeContext.Step = 1; + RecipeContext.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. + RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels; + RecipeContext.EdgeSealThickness = EdgeSealThickness; + CP_OpStack.PrepareChunk(RecipeContext); + } + else + { + // Import/load validates recipes before a season becomes active. Keep + // the fallback hole-safe if a future schema changes underneath it. + CP_UseOpStack = false; + } } 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; - OpCtx.LayoutVersion = LayoutVersion; - OpCtx.WorldRadiusVoxels = WorldRadiusVoxels; - OpCtx.EdgeSealThickness = EdgeSealThickness; + FVoxelOpContext OpCtx; + OpCtx.ChunkCoord = ChunkCoord; + OpCtx.Seed = (uint32)Seed; + OpCtx.LayoutVersion = LayoutVersion; + OpCtx.WorldRadiusVoxels = WorldRadiusVoxels; + OpCtx.EdgeSealThickness = EdgeSealThickness; // ⚠️ LE MAPPING VIT DANS `VF_BuildOpStackForChunk` (haut de ce fichier) ET NULLE // PART AILLEURS — `ClassifyTile` appelle la MÊME fabrique. Un verdict de tuile issu // d'une pile construite autrement serait un trou. Ici on ne fait que fournir les // params déjà cherchés juste au-dessus. - FVoxelStackParamRefs Refs; - Refs.Slab = &CP_Slab; - Refs.Maze = &CP_Maze; - Refs.Vert = &CP_Vert; - Refs.Float = &CP_Float; - Refs.Tunnel = &CP_Tunnel; + FVoxelStackParamRefs Refs; + Refs.Slab = &CP_Slab; + Refs.Maze = &CP_Maze; + Refs.Vert = &CP_Vert; + Refs.Float = &CP_Float; + Refs.Tunnel = &CP_Tunnel; // SurfaceWorld : le champ de biomes est fabriqué ICI, du côté qui connaît le // générateur, et TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField`, // ce qui lui permet de devenir un asset en Phase 3 sans traîner le générateur. - TArray PerBiome; - if (CP_GenType == ECaveGeneratorType::SurfaceWorld) - { - Refs.Surface = &CP_Surface; - if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0) - { - PerBiome = CP_SurfaceBiomeParams; - Refs.SurfaceBiomeParams = &PerBiome; - Refs.BiomeField = MakeUnique( - this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z); - } - } + TArray PerBiome; + if (CP_GenType == ECaveGeneratorType::SurfaceWorld) + { + Refs.Surface = &CP_Surface; + if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0) + { + PerBiome = CP_SurfaceBiomeParams; + Refs.SurfaceBiomeParams = &PerBiome; + Refs.BiomeField = MakeUnique( + this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z); + } + } - CP_UseOpStack = VF_BuildOpStackForChunk(CP_GenType, Refs, Seed, OriginSpineRadius, - StrateManager, CP_OpStack, OpCtx); + CP_UseOpStack = VF_BuildOpStackForChunk( + CP_GenType, Refs, Seed, OriginSpineRadius, + StrateManager, CP_OpStack, OpCtx); // Le test appelle PrepareChunk, pas la production : c'est exactement la divergence // qui rend un opérateur vert en test et faux en jeu. Les sept `PrepareChunk` @@ -922,7 +929,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // makes an op green in test and wrong in game. All seven bodies are empty today, // 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); } + if (CP_UseOpStack) { CP_OpStack.PrepareChunk(OpCtx); } + } } } } @@ -2935,6 +2943,99 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (!StrateManager) return EVoxelTileClass::Mixed; + // A selected recipe must be classified by the exact same materialised stack that supplies + // density. Search every touched chunk-Z first: the minimum corner may be in a gap or adjacent + // slot, and letting that fall through to the native factory would prove the wrong field. + int32 RecipeSeed = 0; + ECaveGeneratorType RecipeArchetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams RecipeParams; + FVoxelOpStackRecipe Recipe; + bool bTouchesRecipe = false; + const int32 RecipeMinChunkZ = FloorDivC(MinZ, CHUNK_SIZE); + const int32 RecipeMaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE); + for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ) + { + if (StrateManager->GetRecipeForChunk( + FIntVector(0, 0, CZ), RecipeSeed, RecipeArchetype, + RecipeParams, Recipe)) + { + bTouchesRecipe = true; + break; + } + } + if (bTouchesRecipe) + { + int32 RecipeTopChunkZ = 0; + int32 RecipeBottomChunkZ = 0; + if (!StrateManager->GetStrateChunkZBounds( + RecipeMinChunkZ, RecipeTopChunkZ, RecipeBottomChunkZ) + || RecipeMinChunkZ < RecipeBottomChunkZ + || RecipeMaxChunkZ > RecipeTopChunkZ) + { + return EVoxelTileClass::Mixed; + } + + const int32 RecipeMinChunkX = FloorDivC(MinX, CHUNK_SIZE); + const int32 RecipeMaxChunkX = FloorDivC(MaxX, CHUNK_SIZE); + const int32 RecipeMinChunkY = FloorDivC(MinY, CHUNK_SIZE); + const int32 RecipeMaxChunkY = FloorDivC(MaxY, CHUNK_SIZE); + for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ) + { + int32 TouchedTop = 0; + int32 TouchedBottom = 0; + if (!StrateManager->GetStrateChunkZBounds(CZ, TouchedTop, TouchedBottom) + || TouchedTop != RecipeTopChunkZ || TouchedBottom != RecipeBottomChunkZ) + { + return EVoxelTileClass::Mixed; + } + for (int32 CY = RecipeMinChunkY; CY <= RecipeMaxChunkY; ++CY) + { + for (int32 CX = RecipeMinChunkX; CX <= RecipeMaxChunkX; ++CX) + { + int32 TouchedSeed = 0; + ECaveGeneratorType TouchedArchetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams TouchedParams; + FVoxelOpStackRecipe TouchedRecipe; + const FIntVector TouchedChunk(CX, CY, CZ); + if (!StrateManager->GetRecipeForChunk( + TouchedChunk, TouchedSeed, TouchedArchetype, + TouchedParams, TouchedRecipe) + || TouchedSeed != RecipeSeed + || TouchedArchetype != RecipeArchetype) + { + return EVoxelTileClass::Mixed; + } + + const FStrateDisturbanceParams Disturbances = + StrateManager->GetDisturbanceParamsForChunk(TouchedChunk); + if (Disturbances.ChasmDensity > 0.0f + || Disturbances.BridgeDensity > 0.0f + || Disturbances.RidgeDensity > 0.0f) + { + return EVoxelTileClass::Mixed; + } + } + } + } + + FVoxelOpStack RecipeStack; + FVoxelOpContext RecipeContext; + if (!VF_BuildStackFromRecipe( + Recipe, RecipeParams, RecipeSeed, OriginSpineRadius, + StrateManager, RecipeStack, RecipeContext, nullptr)) + { + return EVoxelTileClass::Mixed; + } + RecipeContext.ChunkCoord = FIntVector( + RecipeMinChunkX, RecipeMinChunkY, RecipeMinChunkZ); + RecipeContext.Step = Step; + RecipeContext.LayoutVersion = StrateManager->GetLayoutVersion(); + RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels; + RecipeContext.EdgeSealThickness = EdgeSealThickness; + RecipeStack.PrepareChunk(RecipeContext); + return RecipeStack.ClassifyBox(TileVoxelBox, RecipeContext); + } + #if WITH_EDITOR // A multi-region composer override is already a complete parent stack: it owns the lateral // blend and the four global structural posts. Classify it as one field only when the complete @@ -3024,13 +3125,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in return RegionStack.ClassifyBox(TileVoxelBox, RegionContext); } - // 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 diff --git a/Source/VoxelForge/Private/VoxelSeasonAsset.cpp b/Source/VoxelForge/Private/VoxelSeasonAsset.cpp new file mode 100644 index 0000000..aa198a4 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelSeasonAsset.cpp @@ -0,0 +1,135 @@ +#include "VoxelSeasonAsset.h" + +#include "VoxelSeasonManifest.h" +#include "VoxelDensityOpStack.h" + +#if WITH_EDITOR +#include "Misc/FileHelper.h" +#endif + +namespace +{ + bool ValidateRuntimeRecipes(const FVoxelSeasonManifest& Manifest, FString& OutReport) + { + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + if (!Strate.bUsesRecipe) continue; + FVoxelOpStack ValidationStack; + FVoxelOpContext ValidationContext; + FString BuildError; + if (!VF_BuildSeasonStrateStack( + Strate, Manifest.OriginSpineRadius, nullptr, + ValidationStack, ValidationContext, &BuildError)) + { + OutReport = FString::Printf( + TEXT("season slot %d recipe is not materialisable: %s"), + Strate.DepthIndex, *BuildError); + return false; + } + } + return true; + } +} + +FPrimaryAssetId UVoxelSeasonAsset::GetPrimaryAssetId() const +{ + return FPrimaryAssetId(TEXT("VoxelSeason"), GetFName()); +} + +bool UVoxelSeasonAsset::LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const +{ + if (ManifestJson.IsEmpty()) + { + OutReport = TEXT("season asset contains no imported manifest JSON"); + OutManifest = FVoxelSeasonManifest(); + return false; + } + + if (!VF_DeserializeVoxelSeasonManifest(ManifestJson, OutManifest, OutReport)) + { + return false; + } + if (!OutManifest.ContentHash.Equals(ManifestContentHash, ESearchCase::IgnoreCase) + || OutManifest.SchemaVersion != SchemaVersion + || OutManifest.Season != SeasonNumber + || OutManifest.Seed != SeasonSeed + || FMemory::Memcmp(&OutManifest.OriginSpineRadius, + &OriginSpineRadius, sizeof(float)) != 0 + || FMemory::Memcmp(&OutManifest.WorldRadiusVoxels, + &WorldRadiusVoxels, sizeof(float)) != 0) + { + OutReport = TEXT("season asset metadata does not match its embedded manifest; re-import the JSON"); + OutManifest.bValid = false; + OutManifest.Error = OutReport; + return false; + } + if (!ValidateRuntimeRecipes(OutManifest, OutReport)) + { + OutManifest.bValid = false; + OutManifest.Error = OutReport; + return false; + } + return true; +} + +bool UVoxelSeasonAsset::SetManifestJson(const FString& InManifestJson, FString& OutReport) +{ + FVoxelSeasonManifest Parsed; + if (!VF_DeserializeVoxelSeasonManifest(InManifestJson, Parsed, OutReport)) + { + return false; + } + if (!ValidateRuntimeRecipes(Parsed, OutReport)) + { + return false; + } + + Modify(); + ManifestJson = InManifestJson; + SchemaVersion = Parsed.SchemaVersion; + SeasonNumber = Parsed.Season; + SeasonSeed = Parsed.Seed; + OriginSpineRadius = Parsed.OriginSpineRadius; + WorldRadiusVoxels = Parsed.WorldRadiusVoxels; + ManifestContentHash = Parsed.ContentHash; + ReferencedDefinitions.Reset(); + TSet SeenDefinitionPaths; + for (const FVoxelSeasonStrate& Strate : Parsed.Strates) + { + if (!Strate.SourceDefinitionPath.IsEmpty() + && !SeenDefinitionPaths.Contains(Strate.SourceDefinitionPath)) + { + SeenDefinitionPaths.Add(Strate.SourceDefinitionPath); + ReferencedDefinitions.Add(TSoftObjectPtr( + FSoftObjectPath(Strate.SourceDefinitionPath))); + } + } + MarkPackageDirty(); + OutReport = FString::Printf( + TEXT("imported season %d seed %d (%d strates, hash %s)"), + SeasonNumber, SeasonSeed, Parsed.Strates.Num(), *ManifestContentHash); + return true; +} + +void UVoxelSeasonAsset::ImportSeasonManifestJson() +{ +#if WITH_EDITOR + FString JsonText; + FString Report; + if (SourceManifestJson.FilePath.IsEmpty() + || !FFileHelper::LoadFileToString(JsonText, *SourceManifestJson.FilePath)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Could not read manifest JSON '%s'."), + *SourceManifestJson.FilePath); + return; + } + if (!SetManifestJson(JsonText, Report)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Import failed: %s"), *Report); + return; + } + UE_LOG(LogTemp, Log, TEXT("[VoxelSeasonAsset] %s"), *Report); +#else + UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] JSON import is editor-only.")); +#endif +} diff --git a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp index 61a4ae8..0b2f37f 100644 --- a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp +++ b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp @@ -7,6 +7,7 @@ #include "HAL/FileManager.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" +#include "Misc/SecureHash.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.h" #include "Serialization/JsonWriter.h" @@ -1428,56 +1429,85 @@ const TCHAR* VF_GetVoxelSeasonSelectionReasonName(EVoxelSeasonSelectionReason Re return TEXT("unknown"); } -FString VF_SerializeVoxelSeasonManifest(const FVoxelSeasonManifest& Manifest) +namespace { - using namespace VoxelSeasonManifestPrivate; - TSharedPtr Root = MakeShared(); - SetCommonManifestFields(Root, Manifest); - - TArray> Strates; - for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + FString SerializeVoxelSeasonManifestInternal( + const FVoxelSeasonManifest& Manifest, bool bIncludeContentHash) { - Strates.Add(MakeShared(SerializeStrate(Strate))); - } - Root->SetArrayField(TEXT("strates"), MoveTemp(Strates)); + using namespace VoxelSeasonManifestPrivate; + TSharedPtr Root = MakeShared(); + SetCommonManifestFields(Root, Manifest); - 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)); + TArray> Strates; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + Strates.Add(MakeShared(SerializeStrate(Strate))); + } + Root->SetArrayField(TEXT("strates"), MoveTemp(Strates)); - if (!Manifest.CandidateAudit.IsEmpty()) - { - TArray> Audit; - for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit) + TArray> Rejections; + for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts) { 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)); + Object->SetStringField(TEXT("reason"), Rejection.Reason); + Object->SetNumberField(TEXT("count"), Rejection.Count); + Rejections.Add(MakeShared(Object)); } - Root->SetArrayField(TEXT("candidate_audit"), MoveTemp(Audit)); - } + Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections)); - FString JsonText; - TSharedRef>> Writer = - TJsonWriterFactory>::Create(&JsonText); - if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close()) - { - return FString(); + 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)); + } + + if (bIncludeContentHash) + { + const FString Payload = SerializeVoxelSeasonManifestInternal(Manifest, false); + if (Payload.IsEmpty()) return FString(); + FTCHARToUTF8 Utf8(*Payload); + uint8 Digest[FSHA1::DigestSize]; + FSHA1::HashBuffer(Utf8.Get(), Utf8.Length(), Digest); + Root->SetStringField(TEXT("content_hash"), BytesToHex(Digest, FSHA1::DigestSize)); + } + + FString JsonText; + TSharedRef>> Writer = + TJsonWriterFactory>::Create(&JsonText); + if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close()) + { + return FString(); + } + return JsonText; } - return JsonText; +} + +FString VF_ComputeVoxelSeasonManifestContentHash(const FVoxelSeasonManifest& Manifest) +{ + const FString Payload = SerializeVoxelSeasonManifestInternal(Manifest, false); + if (Payload.IsEmpty()) return FString(); + FTCHARToUTF8 Utf8(*Payload); + uint8 Digest[FSHA1::DigestSize]; + FSHA1::HashBuffer(Utf8.Get(), Utf8.Length(), Digest); + return BytesToHex(Digest, FSHA1::DigestSize); +} + +FString VF_SerializeVoxelSeasonManifest(const FVoxelSeasonManifest& Manifest) +{ + return SerializeVoxelSeasonManifestInternal(Manifest, true); } bool VF_SaveVoxelSeasonManifest(const FString& ManifestPath, @@ -1511,24 +1541,25 @@ bool VF_SaveVoxelSeasonManifest(const FString& ManifestPath, return true; } -bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, - FVoxelSeasonManifest& OutManifest, - FString& OutReport) +bool VF_DeserializeVoxelSeasonManifest(const FString& ManifestJson, + FVoxelSeasonManifest& OutManifest, + FString& OutReport) { using namespace VoxelSeasonManifestPrivate; OutManifest = FVoxelSeasonManifest(); OutReport.Reset(); - if (ManifestPath.IsEmpty()) + if (ManifestJson.IsEmpty()) { - OutReport = TEXT("season manifest path is empty"); + OutReport = TEXT("season manifest JSON is empty"); OutManifest.Error = OutReport; return false; } TSharedPtr Root; - FString OriginalJson; - if (!LoadJson(ManifestPath, Root, OutReport, &OriginalJson)) + const TSharedRef> Reader = TJsonReaderFactory<>::Create(ManifestJson); + if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid()) { + OutReport = TEXT("season manifest JSON is malformed"); OutManifest.Error = OutReport; return false; } @@ -1556,6 +1587,8 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, bool bFieldsValid = true; OutManifest.bValid = bValid; OutManifest.SchemaVersion = SchemaVersion; + bFieldsValid = ReadJsonString(Root, TEXT("content_hash"), OutManifest.ContentHash) + && bFieldsValid; bFieldsValid = ReadJsonInt32(Root, TEXT("season"), OutManifest.Season) && bFieldsValid; bFieldsValid = ReadJsonInt32(Root, TEXT("seed"), OutManifest.Seed) && bFieldsValid; double CorpusHash = 0.0; @@ -1669,7 +1702,7 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, const FVoxelSeasonStrate& Strate = OutManifest.Strates[Index]; const float StoredTop = static_cast(Strate.TopWorldZ); const float StoredBottom = static_cast(Strate.BottomWorldZ); - if (Strate.DepthIndex != Index || !Strate.bPassedPrimordialLaw + if (Strate.DepthIndex != Index || Strate.bUsesRegions || !Strate.bPassedPrimordialLaw || Strate.PrimordialLawResult != EVoxelConnectivityResult::Connected) { bFieldsValid = false; @@ -1695,17 +1728,99 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, } if (!bFieldsValid) { - OutReport = FString::Printf(TEXT("season manifest %s contains invalid fields"), *ManifestPath); + OutReport = TEXT("season manifest contains invalid fields"); 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); + const FString ComputedHash = VF_ComputeVoxelSeasonManifestContentHash(OutManifest); + if (ComputedHash.IsEmpty() + || !ComputedHash.Equals(OutManifest.ContentHash, ESearchCase::IgnoreCase)) + { + OutReport = FString::Printf( + TEXT("season manifest content hash mismatch (stored %s, computed %s)"), + *OutManifest.ContentHash, *ComputedHash); + OutManifest.Error = OutReport; + OutManifest.bValid = false; + return false; + } + OutManifest.SerializedJson = ManifestJson; + OutReport = FString::Printf(TEXT("loaded season manifest hash %s"), *OutManifest.ContentHash); return OutManifest.IsUsable(); } +bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, + FVoxelSeasonManifest& OutManifest, + FString& OutReport) +{ + FString JsonText; + if (ManifestPath.IsEmpty()) + { + OutReport = TEXT("season manifest path is empty"); + OutManifest = FVoxelSeasonManifest(); + OutManifest.Error = OutReport; + return false; + } + if (!FFileHelper::LoadFileToString(JsonText, *ManifestPath)) + { + OutReport = FString::Printf(TEXT("could not read season manifest %s"), *ManifestPath); + OutManifest = FVoxelSeasonManifest(); + OutManifest.Error = OutReport; + return false; + } + if (!VF_DeserializeVoxelSeasonManifest(JsonText, OutManifest, OutReport)) + { + OutReport = FString::Printf(TEXT("season manifest %s: %s"), *ManifestPath, *OutReport); + OutManifest.Error = OutReport; + return false; + } + OutManifest.ManifestPath = ManifestPath; + OutReport = FString::Printf(TEXT("loaded season manifest from %s (hash %s)"), + *ManifestPath, *OutManifest.ContentHash); + return true; +} + +namespace VoxelSeasonRuntimePrivate +{ + 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; + } + 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; + } + return 0.0f; + } + + bool SameFloatBits(float A, float B) + { + return FMemory::Memcmp(&A, &B, sizeof(float)) == 0; + } +} + +#if WITH_EDITOR namespace VoxelSeasonCompositionPrivate { float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); @@ -1920,7 +2035,10 @@ namespace VoxelSeasonCompositionPrivate OutStrate.Params = Candidate.ArchetypeParams; OutStrate.Regions = Candidate.Regions; OutStrate.bUsesRecipe = true; - OutStrate.bUsesRegions = Candidate.Regions.RegionCount > 1; + // Lateral composition remains a laboratory feature until its cross-seam law is perfect. + // The season artifact must therefore stay on the proved single-stack runtime contract. + OutStrate.bUsesRegions = VF_LateralRegionsAreShippable() + && Candidate.Regions.RegionCount > 1; SetRuntimeBounds(OutStrate.Params, static_cast(TopWorldZ), static_cast(BottomWorldZ)); if (OutStrate.Regions.IsValid()) @@ -2235,6 +2353,7 @@ namespace VoxelSeasonCompositionPrivate return VoxelSeasonManifestPrivate::ConnectivityName(Result); } } +#endif // WITH_EDITOR — composition, measurement and review helpers never ship namespace VoxelSeasonCompositionPrivate { @@ -2791,6 +2910,11 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, Manifest.bValid = true; Manifest.Error.Reset(); + Manifest.ContentHash = VF_ComputeVoxelSeasonManifestContentHash(Manifest); + if (Manifest.ContentHash.IsEmpty()) + { + return Fail(TEXT("could not hash composed season manifest")); + } Manifest.SerializedJson = VF_SerializeVoxelSeasonManifest(Manifest); if (Manifest.SerializedJson.IsEmpty()) { @@ -2807,20 +2931,9 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, 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 +#if WITH_EDITOR FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* WorldSettings) { FVoxelSeasonCompositionSettings Settings; @@ -2835,6 +2948,7 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* Wo } return VF_ComposeSeason(SeasonSeed, Settings); } +#endif bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, float SpineRadius, @@ -2843,7 +2957,7 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, FVoxelOpContext& OutContext, FString* OutError) { - using namespace VoxelSeasonCompositionPrivate; + using namespace VoxelSeasonRuntimePrivate; auto Fail = [&](const FString& Reason) -> bool { if (OutError != nullptr) @@ -2890,8 +3004,12 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, bool bBuilt = false; if (Strate.bUsesRegions) { +#if WITH_EDITOR bBuilt = VF_BuildStrateRegionStack(Strate.Regions, SpineRadius, StrateManager, OutStack, OutContext, OutError); +#else + return Fail(TEXT("Lateral region manifests are not shippable.")); +#endif } else if (Strate.bUsesRecipe) { @@ -2902,7 +3020,8 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, else { #if WITH_EDITOR - const float Seal = ActiveSeal(Strate.Params, Strate.Archetype); + const float Seal = VoxelSeasonCompositionPrivate::ActiveSeal( + Strate.Params, Strate.Archetype); bBuilt = VF_BuildNativeStrateStackForCandidate( Strate.Archetype, Strate.Params, Strate.Seed, SpineRadius, 0.0f, Seal, StrateManager, OutStack, OutContext); @@ -2940,6 +3059,7 @@ bool VF_RebuildVoxelSeasonStrate(const FVoxelSeasonStrate& Strate, OutStack, OutContext, OutError); } +#if WITH_EDITOR namespace VoxelSeasonCompositionPrivate { bool WriteReviewPage(const FVoxelSeasonManifest& Manifest, @@ -3021,3 +3141,4 @@ namespace VoxelSeasonCompositionPrivate return true; } } +#endif // WITH_EDITOR — review rendering never ships diff --git a/Source/VoxelForge/Private/VoxelSettings.cpp b/Source/VoxelForge/Private/VoxelSettings.cpp new file mode 100644 index 0000000..4319d38 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelSettings.cpp @@ -0,0 +1,51 @@ +#include "VoxelSettings.h" + +#include "VoxelSeasonAsset.h" + +int32 UVoxelSettings::GetEffectiveWorldSeed() const +{ + if (!Season.IsNull()) + { + if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous()) + { + return Asset->GetSeasonSeed(); + } + } + return Seed; +} + +float UVoxelSettings::GetEffectiveOriginSpineRadius() const +{ + if (!Season.IsNull()) + { + if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous()) + { + return Asset->GetOriginSpineRadius(); + } + } + return OriginSpineRadius; +} + +float UVoxelSettings::GetEffectiveWorldRadiusVoxels() const +{ + if (!Season.IsNull()) + { + if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous()) + { + return Asset->GetWorldRadiusVoxels(); + } + } + return WorldRadiusVoxels; +} + +int32 UVoxelSettings::GetEffectiveSeasonNumber() const +{ + if (!Season.IsNull()) + { + if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous()) + { + return Asset->GetSeasonNumber(); + } + } + return CurrentSeason; +} diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 51c91b3..50135dc 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -28,6 +28,7 @@ #include #include +#if WITH_EDITOR namespace { constexpr float GJitterFraction = 0.15f; @@ -2262,27 +2263,6 @@ namespace } } -bool FVoxelStrateMeasuredMetrics::IsUsable() const -{ - return bValid - && NumSampled > 0 - && NumAir > 0 - && NumSolid > 0 - && NumSampled == NumAir + NumSolid - && NumAirComponents > 0 - && LargestComponentCells > 0 - && AirComponentCells.Num() == NumAirComponents - && FMath::IsFinite(AirFraction) - && FMath::IsFinite(LargestComponentShare) - && FMath::IsFinite(WalkableFraction) - && FMath::IsFinite(WalkableFloorAreaFraction) - && FMath::IsFinite(LargestWalkableSurfaceShare) - && FMath::IsFinite(MedianFeatureScale) - && FMath::IsFinite(LargestComponentPoint.X) - && FMath::IsFinite(LargestComponentPoint.Y) - && FMath::IsFinite(LargestComponentPoint.Z); -} - FVoxelStrateMeasuredMetrics VF_SummarizeStrateMetrics( const FVoxelStrateMetrics& Metrics) { @@ -3901,22 +3881,6 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const return Hash; } -const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype) -{ - switch (Archetype) - { - case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork"); - case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain"); - case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber"); - case ECaveGeneratorType::Maze: return TEXT("Maze"); - case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld"); - case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts"); - case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands"); - case ECaveGeneratorType::Underwater: return TEXT("Underwater"); - } - return TEXT("Unknown"); -} - const TCHAR* VF_GetStrateCorpusProvenanceName(EVoxelStrateCorpusProvenance Provenance) { switch (Provenance) @@ -4400,6 +4364,7 @@ bool FVoxelStrateCorpus::SetMeasuredMetrics( } return false; } +#endif // WITH_EDITOR — corpus loading, promotion and parameter/structure roll implementation void VF_SetStrateArchetypeRuntimeBounds( FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ) @@ -4589,11 +4554,13 @@ namespace VoxelStrateRegionPrivate } } +#if WITH_EDITOR int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex) { return 1 + static_cast(VoxelHash::Cell( StrateIndex, StrateIndex ^ 0x6D, static_cast(Seed) ^ 0x524F4C4Cu) % 3u); } +#endif uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex) { @@ -4753,6 +4720,7 @@ FVoxelStrateRegionBoxProof VF_AnalyzeStrateRegionBox( return Proof; } +#if WITH_EDITOR FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index) { @@ -5694,3 +5662,4 @@ bool VF_AreStrateStructureRecipesIdentical(const FVoxelOpStackRecipe& A, } return true; } +#endif // WITH_EDITOR — no roller/corpus entry point is compiled into Shipping diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 691f5f1..a1d85a5 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -4,10 +4,12 @@ #include "VoxelStrateManager.h" #include "CoreGlobals.h" // GIsAutomationTesting — the opt-in diagnostic stays quiet under tests #include "VoxelSettings.h" +#include "VoxelSeasonAsset.h" #include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord #include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash #include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo #include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten) +#include "UObject/UObjectGlobals.h" #if WITH_EDITOR #include "VoxelStrateComposer.h" @@ -58,22 +60,46 @@ static float PassageFBM(float X, float Seed) return (MaxV > 0.0f) ? (Total / MaxV) : 0.0f; } -void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) +bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) { if (!Settings) { UE_LOG(LogTemp, Error, TEXT("[StrateManager] No settings provided!")); - return; + return false; + } + + FVoxelSeasonManifest SeasonManifest; + bool bUseSeason = false; + if (!Settings->Season.IsNull()) + { + UVoxelSeasonAsset* SeasonAsset = Settings->Season.LoadSynchronous(); + FString SeasonReport; + if (SeasonAsset == nullptr + || !SeasonAsset->LoadManifest(SeasonManifest, SeasonReport)) + { + UE_LOG(LogTemp, Error, + TEXT("[StrateManager] Season asset is assigned but unusable: %s"), + SeasonAsset ? *SeasonReport : *Settings->Season.ToString()); + return false; // Fail closed: falling back to the authored pool would diverge peers. + } + bUseSeason = true; + WorldSeed = SeasonManifest.Seed; } StrateLayout.Empty(); + SeasonStrates.Empty(); + ActiveSeasonContentHash.Reset(); #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; + const int32 TotalStrates = bUseSeason + ? SeasonManifest.Strates.Num() : Settings->TotalStrates; + const int32 LayoutGapChunks = bUseSeason + ? SeasonManifest.InterStrateGapChunks + : FMath::Max(0, Settings->InterStrateGapChunks); //========================================================================= // STEP 1: Build shuffled pool (seed-based randomization) @@ -84,13 +110,16 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) using FLoadedPoolEntry = TPair; TArray ShuffledPool; - for (const TSoftObjectPtr& SoftPtr : Settings->StratePool) + if (!bUseSeason) { - // Load the asset (synchronous for now — could be async later) - UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous(); - if (Def) + for (const TSoftObjectPtr& SoftPtr : Settings->StratePool) { - ShuffledPool.Emplace(SoftPtr.ToString(), Def); + // Load the asset (synchronous for now — could be async later) + UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous(); + if (Def) + { + ShuffledPool.Emplace(SoftPtr.ToString(), Def); + } } } @@ -125,12 +154,15 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) // Pre-load fixed strate definitions TMap LoadedFixed; - for (auto& Pair : Settings->FixedStrates) + if (!bUseSeason) { - UVoxelStrateDefinition* Def = Pair.Value.LoadSynchronous(); - if (Def) + for (auto& Pair : Settings->FixedStrates) { - LoadedFixed.Add(Pair.Key, Def); + UVoxelStrateDefinition* Def = Pair.Value.LoadSynchronous(); + if (Def) + { + LoadedFixed.Add(Pair.Key, Def); + } } } @@ -142,32 +174,97 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) FStrateSlot Slot; Slot.StrateIndex = i; - // Pick definition: fixed or from pool - UVoxelStrateDefinition** FixedDef = LoadedFixed.Find(i); - if (FixedDef && *FixedDef) + if (bUseSeason) { - Slot.Definition = *FixedDef; - } - else if (ShuffledPool.Num() > 0) - { - // Cycle through the pool (wraps around if more strates than pool entries) - Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()].Value; - PoolCursor++; + const FVoxelSeasonStrate& SeasonStrate = SeasonManifest.Strates[i]; + if (!SeasonStrate.SourceDefinitionPath.IsEmpty()) + { + TSoftObjectPtr SourceDefinition( + FSoftObjectPath(SeasonStrate.SourceDefinitionPath)); + UVoxelStrateDefinition* LoadedDefinition = SourceDefinition.LoadSynchronous(); + if (LoadedDefinition == nullptr) + { + UE_LOG(LogTemp, Error, + TEXT("[StrateManager] Season slot %d could not load authored definition '%s'."), + i, *SeasonStrate.SourceDefinitionPath); + StrateLayout.Empty(); + SeasonStrates.Empty(); + return false; + } + // Keep the authored content/passage/visual bag without mutating the cooked asset, + // then make the manifest's density identity authoritative below. + Slot.Definition = DuplicateObject(LoadedDefinition, this); + } + else + { + // The current density manifest has no wider content record. Keep all consumers + // supplied with a stable definition object, but do not guess an authored theme. + Slot.Definition = NewObject(this, NAME_None, RF_Transient); + Slot.Definition->StrateName = FText::FromString( + FString::Printf(TEXT("Season %d Strate %d"), SeasonManifest.Season, i)); + Slot.Definition->TransitionType = EVoxelStrateTransition::Hard; + // Passage configuration is not part of schema v2. Inventing the UObject default + // here would add an unreviewed tunnel to every generated boundary and make the + // runtime field differ from the field that passed composition. The origin spine + // remains the guaranteed connection until a later schema explicitly stores these. + Slot.Definition->PassageConfig.Connections = 0; + } + + Slot.Definition->GeneratorType = SeasonStrate.Archetype; + Slot.Definition->bUseOperatorStack = SeasonStrate.bUsesRecipe; + Slot.Definition->StrateHeightInChunks = SeasonStrate.HeightInChunks; + Slot.Definition->GenerationParams = SeasonStrate.Params.TunnelNetworkParams; + Slot.Definition->SlabParams = SeasonStrate.Params.SlabParams; + Slot.Definition->MazeParams = SeasonStrate.Params.MazeParams; + Slot.Definition->SurfaceParams = SeasonStrate.Params.SurfaceParams; + Slot.Definition->VerticalShaftParams = SeasonStrate.Params.VerticalShaftParams; + Slot.Definition->FloatingIslandParams = SeasonStrate.Params.FloatingIslandParams; + + Slot.HeightInChunks = SeasonStrate.HeightInChunks; + Slot.TopChunkZ = SeasonStrate.TopWorldZ / CHUNK_SIZE - 1; + Slot.BottomChunkZ = SeasonStrate.BottomWorldZ / CHUNK_SIZE; + if (SeasonStrate.TopWorldZ % CHUNK_SIZE != 0 + || SeasonStrate.BottomWorldZ % CHUNK_SIZE != 0 + || Slot.TopChunkZ != CurrentTopZ + || Slot.BottomChunkZ != CurrentTopZ - (Slot.HeightInChunks - 1)) + { + UE_LOG(LogTemp, Error, + TEXT("[StrateManager] Season slot %d bounds do not describe the declared stacked layout."), i); + StrateLayout.Empty(); + SeasonStrates.Empty(); + return false; + } + SeasonStrates.Add(SeasonStrate); } else { - UE_LOG(LogTemp, Warning, TEXT("[StrateManager] No strate definitions available for slot %d!"), i); - continue; - } + // Pick definition: fixed or from pool + UVoxelStrateDefinition** FixedDef = LoadedFixed.Find(i); + if (FixedDef && *FixedDef) + { + Slot.Definition = *FixedDef; + } + else if (ShuffledPool.Num() > 0) + { + // Cycle through the pool (wraps around if more strates than pool entries) + Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()].Value; + PoolCursor++; + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[StrateManager] No strate definitions available for slot %d!"), i); + continue; + } - // Compute Z range from definition's height - Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks; - Slot.TopChunkZ = CurrentTopZ; - Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1); + // Compute Z range from definition's height + Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks; + Slot.TopChunkZ = CurrentTopZ; + Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1); + } // Move the cursor down for the next strate, leaving a solid-bedrock gap of // InterStrateGapChunks chunks between this strate and the next. - CurrentTopZ = Slot.BottomChunkZ - 1 - FMath::Max(0, Settings->InterStrateGapChunks); + CurrentTopZ = Slot.BottomChunkZ - 1 - LayoutGapChunks; StrateLayout.Add(Slot); @@ -187,13 +284,17 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) int32 NumOperatorStackDisabledCaves = 0; for (const FStrateSlot& Slot : StrateLayout) { - if (!Slot.Definition || Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld) + const ECaveGeneratorType SlotArchetype = bUseSeason + ? SeasonStrates[Slot.StrateIndex].Archetype : Slot.Definition->GeneratorType; + if (!Slot.Definition || SlotArchetype == ECaveGeneratorType::SurfaceWorld) { continue; } ++NumCaveSlots; - if (!Slot.Definition->bUseOperatorStack) + const bool bUsesStack = bUseSeason + ? SeasonStrates[Slot.StrateIndex].bUsesRecipe : Slot.Definition->bUseOperatorStack; + if (!bUsesStack) { ++NumOperatorStackDisabledCaves; } @@ -226,9 +327,13 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) for (const FStrateSlot& Slot : StrateLayout) { + const bool bSeasonStack = bUseSeason + && SeasonStrates[Slot.StrateIndex].bUsesRecipe; + const ECaveGeneratorType SlotArchetype = bUseSeason + ? SeasonStrates[Slot.StrateIndex].Archetype : Slot.Definition->GeneratorType; if (!Slot.Definition - || Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld - || Slot.Definition->bUseOperatorStack) + || SlotArchetype == ECaveGeneratorType::SurfaceWorld + || (bUseSeason ? bSeasonStack : Slot.Definition->bUseOperatorStack)) { continue; } @@ -246,8 +351,13 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) CachedSeed = WorldSeed; bOpenSurfaceEntry = Settings->bOpenSurfaceEntry; - OriginSpineRadius = Settings->OriginSpineRadius; - InterStrateGapChunks = FMath::Max(0, Settings->InterStrateGapChunks); + OriginSpineRadius = bUseSeason + ? SeasonManifest.OriginSpineRadius : Settings->OriginSpineRadius; + InterStrateGapChunks = LayoutGapChunks; + if (bUseSeason) + { + ActiveSeasonContentHash = SeasonManifest.ContentHash; + } // Passage shape/count is per-strate now (UVoxelStrateDefinition::PassageConfig). //========================================================================= @@ -260,6 +370,7 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) for (const FStrateSlot& Slot : StrateLayout) { if (!Slot.Definition) continue; + if (bUseSeason && SeasonStrates[Slot.StrateIndex].bUsesRecipe) continue; for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations) { @@ -275,6 +386,7 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) // Generate passages between consecutive strates GeneratePassages(); + return bUseSeason ? IsUsingSeason() : true; } #if WITH_EDITOR @@ -429,18 +541,40 @@ bool UVoxelStrateManager::GetComposerRegionOverrideForChunk( return true; } -bool UVoxelStrateManager::HasComposerRecipeOverride() const +#endif + +bool UVoxelStrateManager::GetRecipeForChunk( + const FIntVector& ChunkCoord, int32& OutRecipeSeed, + ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams, + FVoxelOpStackRecipe& OutRecipe) const { - for (const TPair>& Pair : ComposerOverrides) + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0) return false; + +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(StrateLayout[SlotIdx].StrateIndex)) { - if (Pair.Value.IsValid() && Pair.Value->bUseRecipe) - { - return true; - } + if (!Override->bUseRecipe || Override->bUseRegions) return false; + OutRecipeSeed = Override->CandidateSeed; + OutArchetype = Override->Archetype; + OutParams = Override->Params; + OutRecipe = Override->Recipe; + return true; + } +#endif + if (SeasonStrates.IsValidIndex(SlotIdx)) + { + const FVoxelSeasonStrate& Strate = SeasonStrates[SlotIdx]; + if (!Strate.bUsesRecipe || Strate.bUsesRegions) return false; + OutRecipeSeed = Strate.Seed; + OutArchetype = Strate.Archetype; + OutParams = Strate.Params; + OutRecipe = Strate.Recipe; + return true; } return false; } -#endif //============================================================================= // PASSAGE GENERATION @@ -619,10 +753,12 @@ void UVoxelStrateManager::GeneratePassages() const bool bUsesRoomSeed = UpperDef->GeneratorType == ECaveGeneratorType::TunnelNetwork || UpperDef->GeneratorType == ECaveGeneratorType::Underwater; - const int32 UpperQuerySeed = bUsesRoomSeed - ? static_cast(VoxelCaveMorphology::MakeStrateSeed( - static_cast(CachedSeed), Upper.StrateIndex)) - : CachedSeed; + const int32 UpperQuerySeed = SeasonStrates.IsValidIndex(i) + ? SeasonStrates[i].Seed + : (bUsesRoomSeed + ? static_cast(VoxelCaveMorphology::MakeStrateSeed( + static_cast(CachedSeed), Upper.StrateIndex)) + : CachedSeed); bAimedUpperAtOpenPoint = VF_SuggestLandingPoint( UpperDef->GeneratorType, @@ -657,10 +793,12 @@ void UVoxelStrateManager::GeneratePassages() const bool bUsesRoomSeed = LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork || LowerDef->GeneratorType == ECaveGeneratorType::Underwater; - const int32 LowerQuerySeed = bUsesRoomSeed - ? static_cast(VoxelCaveMorphology::MakeStrateSeed( - static_cast(CachedSeed), Lower.StrateIndex)) - : CachedSeed; + const int32 LowerQuerySeed = SeasonStrates.IsValidIndex(i + 1) + ? SeasonStrates[i + 1].Seed + : (bUsesRoomSeed + ? static_cast(VoxelCaveMorphology::MakeStrateSeed( + static_cast(CachedSeed), Lower.StrateIndex)) + : CachedSeed); const float MaxLateralSnap = MaxLateralSnapFor(*LowerDef); bAimedLowerAtOpenPoint = VF_SuggestLandingPoint( @@ -1145,6 +1283,11 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto } #endif + if (SeasonStrates.IsValidIndex(SlotIdx)) + { + return SeasonStrates[SlotIdx].Archetype; + } + return StrateLayout[SlotIdx].Definition->GeneratorType; } @@ -1153,6 +1296,13 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; } + if (SeasonStrates.IsValidIndex(SlotIdx)) + { + // A recipe is an explicit stack opt-in. Native fixed entries retain the authored/switch + // route while still reading their season parameter vector. + return SeasonStrates[SlotIdx].bUsesRecipe; + } + #if WITH_EDITOR if (FindComposerOverride(StrateLayout[SlotIdx].StrateIndex) != nullptr) { @@ -1240,8 +1390,12 @@ FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVecto // Copy the designer-authored slab params from the strate definition, unless an editor // composer candidate owns this slot. FSlabGenerationParams Result; + if (SeasonStrates.IsValidIndex(SlotIdx)) + { + Result = SeasonStrates[SlotIdx].Params.SlabParams; + } #if WITH_EDITOR - if (const FVoxelStrateComposerSlotOverride* Override = + else if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) { Result = (Override->Archetype == ECaveGeneratorType::FlatPlain @@ -1281,7 +1435,8 @@ FMazeGenerationParams UVoxelStrateManager::GetMazeParamsForChunk(const FIntVecto } const FStrateSlot& Slot = StrateLayout[SlotIdx]; - FMazeGenerationParams Result = Slot.Definition->MazeParams; + FMazeGenerationParams Result = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.MazeParams : Slot.Definition->MazeParams; #if WITH_EDITOR if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) { @@ -1307,7 +1462,8 @@ FSurfaceGenerationParams UVoxelStrateManager::GetSurfaceParamsForChunk(const FIn } const FStrateSlot& Slot = StrateLayout[SlotIdx]; - FSurfaceGenerationParams Result = Slot.Definition->SurfaceParams; + FSurfaceGenerationParams Result = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.SurfaceParams : Slot.Definition->SurfaceParams; #if WITH_EDITOR if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) { @@ -1333,7 +1489,8 @@ FVerticalShaftParams UVoxelStrateManager::GetVerticalShaftParamsForChunk(const F } const FStrateSlot& Slot = StrateLayout[SlotIdx]; - FVerticalShaftParams Result = Slot.Definition->VerticalShaftParams; + FVerticalShaftParams Result = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.VerticalShaftParams : Slot.Definition->VerticalShaftParams; #if WITH_EDITOR if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) { @@ -1359,7 +1516,8 @@ FFloatingIslandParams UVoxelStrateManager::GetFloatingIslandParamsForChunk(const } const FStrateSlot& Slot = StrateLayout[SlotIdx]; - FFloatingIslandParams Result = Slot.Definition->FloatingIslandParams; + FFloatingIslandParams Result = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.FloatingIslandParams : Slot.Definition->FloatingIslandParams; #if WITH_EDITOR if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) { @@ -1440,11 +1598,20 @@ float UVoxelStrateManager::GetWaterLevelWorldZForChunk(const FIntVector& ChunkCo // Pull the relative level from whichever archetype owns water. float Rel = 0.0f; - switch (Def->GeneratorType) + const ECaveGeneratorType Archetype = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Archetype : Def->GeneratorType; + switch (Archetype) { - case ECaveGeneratorType::SurfaceWorld: Rel = Def->SurfaceParams.WaterLevelRelative; break; - case ECaveGeneratorType::Underwater: Rel = Def->GenerationParams.WaterLevelRelative; break; - default: Rel = Def->GenerationParams.WaterLevelRelative; break; + case ECaveGeneratorType::SurfaceWorld: + Rel = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.SurfaceParams.WaterLevelRelative + : Def->SurfaceParams.WaterLevelRelative; + break; + default: + Rel = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.TunnelNetworkParams.WaterLevelRelative + : Def->GenerationParams.WaterLevelRelative; + break; } if (Rel <= 0.0f) return -FLT_MAX; @@ -1483,6 +1650,16 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto } #endif + if (SeasonStrates.IsValidIndex(SlotIdx)) + { + FStrateGenerationParams Result = SeasonStrates[SlotIdx].Params.TunnelNetworkParams; + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + // Season vectors are already final, measured slot records. Vertical structural posts own + // their boundary; blending them with another selected recipe would describe neither one. + return Result; + } + FStrateGenerationParams BaseParams = BuildParamsFromDefinition(Slot.Definition); //========================================================================= diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 8b72fbf..c80e5f4 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -2,6 +2,8 @@ #include "VoxelStrateMeasure.h" +#if WITH_EDITOR + #include "VoxelGenerator.h" #include "VoxelStrateManager.h" #include "VoxelTypes.h" @@ -1579,3 +1581,5 @@ FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler( Result.bStartSnapped, Result.bGoalSnapped, &Result); return Result; } + +#endif // WITH_EDITOR — measurement never ships diff --git a/Source/VoxelForge/Private/VoxelStrateRecipe.cpp b/Source/VoxelForge/Private/VoxelStrateRecipe.cpp new file mode 100644 index 0000000..66109a8 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelStrateRecipe.cpp @@ -0,0 +1,38 @@ +#include "VoxelStrateComposer.h" + +bool FVoxelStrateMeasuredMetrics::IsUsable() const +{ + return bValid + && NumSampled > 0 + && NumAir > 0 + && NumSolid > 0 + && NumSampled == NumAir + NumSolid + && NumAirComponents > 0 + && LargestComponentCells > 0 + && AirComponentCells.Num() == NumAirComponents + && FMath::IsFinite(AirFraction) + && FMath::IsFinite(LargestComponentShare) + && FMath::IsFinite(WalkableFraction) + && FMath::IsFinite(WalkableFloorAreaFraction) + && FMath::IsFinite(LargestWalkableSurfaceShare) + && FMath::IsFinite(MedianFeatureScale) + && FMath::IsFinite(LargestComponentPoint.X) + && FMath::IsFinite(LargestComponentPoint.Y) + && FMath::IsFinite(LargestComponentPoint.Z); +} + +const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype) +{ + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork"); + case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain"); + case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber"); + case ECaveGeneratorType::Maze: return TEXT("Maze"); + case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld"); + case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts"); + case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands"); + case ECaveGeneratorType::Underwater: return TEXT("Underwater"); + } + return TEXT("Unknown"); +} diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index de42fc6..2b13f6b 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -423,6 +423,7 @@ void AVoxelWorld::RegenerateAllChunks() void AVoxelWorld::RebuildStrates() { + bool bLayoutRebuilt = true; { FScopedGenerationPause Guard(this); if (!Guard.Acquired()) @@ -434,12 +435,20 @@ void AVoxelWorld::RebuildStrates() if (StrateManager && Settings) { // Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings. - StrateManager->Initialize(Settings, Settings->Seed); + bLayoutRebuilt = StrateManager->Initialize( + Settings, Settings->GetEffectiveWorldSeed()); } if (AtmosphereManager) AtmosphereManager->Reset(); if (ContentManager) ContentManager->ClearAll(); } + if (!bLayoutRebuilt) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelWorld] RebuildStrates refused an invalid season; loaded tiles were not regenerated.")); + return; + } + // Reload all chunks against the rebuilt strate data. RegenerateAllChunks(); @@ -831,7 +840,7 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject) if (StrateManager) { - StrateManager->Initialize(Settings, Settings->Seed); + StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed()); } if (Generator) { @@ -959,11 +968,19 @@ void AVoxelWorld::BeginPlay() Mesher->SkirtCells = Settings->SkirtCells; Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off - // Système de strates — piloté par le pool et les fixed entries dans Settings. - if (Settings->StratePool.Num() > 0) + // Système de strates — a cooked season takes precedence; otherwise the authored pool path is + // exactly the legacy one. + if (!Settings->Season.IsNull() || Settings->StratePool.Num() > 0) { StrateManager = NewObject(this); - StrateManager->Initialize(Settings, Settings->Seed); + if (!StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed())) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelWorld] World generation disabled because its assigned season is invalid or incomplete.")); + bShuttingDown.store(true, std::memory_order_release); + SetActorTickEnabled(false); + return; + } Generator->SetStrateManager(StrateManager); UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Strate system initialized with %d strates"), StrateManager->GetNumStrates()); @@ -977,7 +994,8 @@ void AVoxelWorld::BeginPlay() // Content manager — distance-based decoration grid (no LOD pop) + level-0 water planes. ContentManager = NewObject(this); - ContentManager->Initialize(this, StrateManager, Generator, Settings, Settings->Seed); + ContentManager->Initialize( + this, StrateManager, Generator, Settings, Settings->GetEffectiveWorldSeed()); // Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers. if (bManageAtmosphere && StrateManager) @@ -2641,6 +2659,12 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed) UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed failed — no Settings assigned")); return; } + if (!Settings->Season.IsNull()) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelWorld] ChangeSeed rejected: a cooked Season owns the seed. Assign another season asset instead.")); + return; + } const int32 OldSeed = Settings->Seed; const int32 OldSeason = Settings->CurrentSeason; @@ -2702,12 +2726,17 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed) int32 AVoxelWorld::GetCurrentSeed() const { - return Settings ? Settings->Seed : 0; + return Settings ? Settings->GetEffectiveWorldSeed() : 0; } int32 AVoxelWorld::GetCurrentSeason() const { - return Settings ? Settings->CurrentSeason : 0; + return Settings ? Settings->GetEffectiveSeasonNumber() : 0; +} + +FString AVoxelWorld::GetCurrentSeasonContentHash() const +{ + return StrateManager ? StrateManager->GetSeasonContentHash() : FString(); } //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index c54fec1..913b333 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -481,6 +481,7 @@ VOXELFORGE_API bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, bool bAppendStructuralPosts = true); /** Build the region combiner stack. Creative cores are independent; structural posts are global. */ +#if WITH_EDITOR VOXELFORGE_API bool VF_BuildStrateRegionStack( const FVoxelStrateRegionManifest& Manifest, float SpineRadius, @@ -488,3 +489,4 @@ VOXELFORGE_API bool VF_BuildStrateRegionStack( FVoxelOpStack& OutStack, FVoxelOpContext& OutContext, FString* OutError = nullptr); +#endif diff --git a/Source/VoxelForge/Public/VoxelSeasonAsset.h b/Source/VoxelForge/Public/VoxelSeasonAsset.h new file mode 100644 index 0000000..049173b --- /dev/null +++ b/Source/VoxelForge/Public/VoxelSeasonAsset.h @@ -0,0 +1,72 @@ +// Cookable runtime carrier for one reviewed season manifest. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "VoxelSeasonAsset.generated.h" + +struct FVoxelSeasonManifest; +class UVoxelStrateDefinition; + +/** + * The JSON remains the reviewed/diffable source artifact. This asset embeds that exact text so + * Unreal's cooker can carry it into a packaged build, together with an independently stored digest + * used to detect an accidentally stale or edited asset. + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelSeasonAsset : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + virtual FPrimaryAssetId GetPrimaryAssetId() const override; + + /** Parse and validate the embedded manifest, including both content-hash copies. */ + bool LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const; + + /** Runtime-safe import core used by the editor button and automation. */ + bool SetManifestJson(const FString& InManifestJson, FString& OutReport); + + int32 GetSeasonNumber() const { return SeasonNumber; } + int32 GetSeasonSeed() const { return SeasonSeed; } + float GetOriginSpineRadius() const { return OriginSpineRadius; } + float GetWorldRadiusVoxels() const { return WorldRadiusVoxels; } + const FString& GetManifestContentHash() const { return ManifestContentHash; } + + /** Create the asset in Content Browser, choose a JSON file, then press this button. */ + UFUNCTION(CallInEditor, Category = "Season") + void ImportSeasonManifestJson(); + +#if WITH_EDITORONLY_DATA + UPROPERTY(EditAnywhere, Category = "Season|Import", meta = (FilePathFilter = "json")) + FFilePath SourceManifestJson; +#endif + +private: + UPROPERTY(VisibleAnywhere, Category = "Season") + int32 SchemaVersion = 0; + + UPROPERTY(VisibleAnywhere, Category = "Season") + int32 SeasonNumber = 0; + + UPROPERTY(VisibleAnywhere, Category = "Season") + int32 SeasonSeed = 0; + + UPROPERTY(VisibleAnywhere, Category = "Season") + float OriginSpineRadius = 0.0f; + + UPROPERTY(VisibleAnywhere, Category = "Season") + float WorldRadiusVoxels = 0.0f; + + UPROPERTY(VisibleAnywhere, Category = "Season") + FString ManifestContentHash; + + UPROPERTY(VisibleAnywhere, Category = "Season", meta = (MultiLine = true)) + FString ManifestJson; + + // Import-derived cook references for fixed/authored content bags named by the JSON. Generated + // strates legitimately have no source definition and use deterministic C++ defaults at runtime. + UPROPERTY(VisibleAnywhere, Category = "Season") + TArray> ReferencedDefinitions; +}; diff --git a/Source/VoxelForge/Public/VoxelSeasonManifest.h b/Source/VoxelForge/Public/VoxelSeasonManifest.h index 0ffd791..9d77500 100644 --- a/Source/VoxelForge/Public/VoxelSeasonManifest.h +++ b/Source/VoxelForge/Public/VoxelSeasonManifest.h @@ -1,8 +1,4 @@ -// 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. +// Offline Tier 4c composition plus the runtime-readable, diffable Tier A manifest. #pragma once @@ -195,7 +191,7 @@ struct VOXELFORGE_API FVoxelSeasonStrate */ struct VOXELFORGE_API FVoxelSeasonManifest { - static constexpr int32 CurrentSchemaVersion = 1; + static constexpr int32 CurrentSchemaVersion = 2; bool bValid = false; int32 SchemaVersion = CurrentSchemaVersion; @@ -204,6 +200,9 @@ struct VOXELFORGE_API FVoxelSeasonManifest int32 Season = 0; int32 Seed = 0; uint32 InputCorpusHash = 0; + // SHA-1 of the canonical manifest JSON with this field omitted. This detects stale cooked + // season assets without requiring the editor-only input corpus on a player's machine. + FString ContentHash; int32 CandidateCount = 0; int32 SurvivorCount = 0; int32 SelectedCount = 0; @@ -230,15 +229,17 @@ struct VOXELFORGE_API FVoxelSeasonManifest bool IsUsable() const { - if (!bValid || !Error.IsEmpty() || WorldRadiusVoxels != 0.0f + if (!bValid || !Error.IsEmpty() || ContentHash.Len() != 40 || WorldRadiusVoxels != 0.0f || Strates.Num() != SelectedCount || SelectedCount <= 0) { return false; } for (const FVoxelSeasonStrate& Strate : Strates) { - if (Strate.bUsesRegions - && (!Strate.Regions.IsValid() || Strate.Regions.RegionCount <= 1)) + // Region composition is intentionally not a shippable season format yet. Keeping + // this rejection in the portable manifest contract prevents an editor experiment + // from becoming packaged terrain merely because its density evaluator exists. + if (Strate.bUsesRegions) { return false; } @@ -252,16 +253,24 @@ VOXELFORGE_API const TCHAR* VF_GetVoxelSeasonSelectionReasonName( EVoxelSeasonSelectionReason Reason); /** Compose, validate, select, serialize, and (by default) write one season. Editor/build-box only. */ +#if WITH_EDITOR 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); +#endif /** Serialize/read the complete Tier A manifest as stable pretty JSON. */ VOXELFORGE_API FString VF_SerializeVoxelSeasonManifest( const FVoxelSeasonManifest& Manifest); +/** Parse an in-memory/cooked JSON manifest and verify its embedded content hash. */ +VOXELFORGE_API bool VF_DeserializeVoxelSeasonManifest( + const FString& ManifestJson, FVoxelSeasonManifest& OutManifest, FString& OutReport); +/** Stable SHA-1 over the canonical manifest payload (the content_hash field itself is omitted). */ +VOXELFORGE_API FString VF_ComputeVoxelSeasonManifestContentHash( + const FVoxelSeasonManifest& Manifest); VOXELFORGE_API bool VF_SaveVoxelSeasonManifest( const FString& ManifestPath, const FVoxelSeasonManifest& Manifest, FString& OutReport); VOXELFORGE_API bool VF_LoadVoxelSeasonManifest( diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 4a20f5b..5f58313 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -13,6 +13,7 @@ // IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use. class UMaterialInterface; +class UVoxelSeasonAsset; UCLASS(BlueprintType) class UVoxelSettings : public UPrimaryDataAsset @@ -21,6 +22,12 @@ class UVoxelSettings : public UPrimaryDataAsset public: + /** Season seed/radius replace the mutable authored values only when Season is assigned. */ + int32 GetEffectiveWorldSeed() const; + float GetEffectiveOriginSpineRadius() const; + float GetEffectiveWorldRadiusVoxels() const; + int32 GetEffectiveSeasonNumber() const; + //========================================================================= // STREAMING (distance de vue) //========================================================================= @@ -350,6 +357,11 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") int32 Seed = 0; + // Optional reviewed/cooked season. When set, its manifest owns layout, seed, bounds and + // density recipes; the authored StratePool/FixedStrates path remains untouched when unset. + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Voxel|Strates") + TSoftObjectPtr Season; + // Numéro de saison (incrementé par ChangeSeed). Purement informatif côté gameplay. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") int32 CurrentSeason = 1; diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index b0919d7..b51de0c 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -356,7 +356,9 @@ VOXELFORGE_API void VF_SetStrateArchetypeRuntimeBounds( FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ); /** Stable identity helpers for the deterministic lateral partition. */ +#if WITH_EDITOR VOXELFORGE_API int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex); +#endif VOXELFORGE_API uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex); VOXELFORGE_API void VF_RekeyStrateRegionManifest( FVoxelStrateRegionManifest& Manifest, int32 Seed, int32 StrateIndex); diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index dee1c0b..cb65729 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -20,13 +20,12 @@ #include "CoreMinimal.h" #include "VoxelStrateTypes.h" #include "VoxelStrateDefinition.h" +#include "VoxelSeasonManifest.h" #include "VoxelStrateManager.generated.h" class UVoxelSettings; #if WITH_EDITOR -struct FVoxelStrateArchetypeParams; -struct FVoxelOpStackRecipe; struct FVoxelStrateRegionManifest; struct FVoxelStrateComposerSlotOverride; #endif @@ -148,7 +147,21 @@ public: * @param Settings - VoxelSettings with pool/fixed strate config * @param WorldSeed - Seed for randomizing non-fixed strates */ - void Initialize(UVoxelSettings* Settings, int32 WorldSeed); + /** Returns false without a usable layout; an assigned invalid/stale season never falls back. */ + bool Initialize(UVoxelSettings* Settings, int32 WorldSeed); + + /** + * Copy the immutable recipe payload for this chunk. In packaged builds this comes only from + * the cooked season; editor candidate overrides use the same read-only worker hand-off. + */ + bool GetRecipeForChunk( + const FIntVector& ChunkCoord, int32& OutRecipeSeed, + ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams, + FVoxelOpStackRecipe& OutRecipe) const; + + bool IsUsingSeason() const { return !ActiveSeasonContentHash.IsEmpty(); } + const FString& GetSeasonContentHash() const { return ActiveSeasonContentHash; } + int32 GetWorldSeed() const { return CachedSeed; } #if WITH_EDITOR /** @@ -172,8 +185,6 @@ public: bool GetComposerRegionOverrideForChunk( const FIntVector& ChunkCoord, FVoxelStrateRegionManifest& OutRegions) const; - /** Custom recipes cannot be safely classified by the native ClassifyBox proof yet. */ - bool HasComposerRecipeOverride() const; #endif //========================================================================= @@ -356,6 +367,11 @@ protected: UPROPERTY() TArray StrateLayout; + // Parallel to StrateLayout only while a cooked season is active. Plain immutable data is + // copied into worker-local caches on the existing layout-versioned refetch path. + TArray SeasonStrates; + FString ActiveSeasonContentHash; + // Passages connecting consecutive strates TArray Passages; diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 6098b01..8c74486 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -308,6 +308,10 @@ public: UFUNCTION(BlueprintPure, Category = "Voxel World|Season") int32 GetCurrentSeason() const; + /** Reviewed manifest digest to replicate/compare at join; empty on the legacy authored path. */ + UFUNCTION(BlueprintPure, Category = "Voxel World|Season") + FString GetCurrentSeasonContentHash() const; + //========================================================================= // STRATE QUERIES (gameplay integration) //=========================================================================