feat(runtime): the composed world can BE the game world
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
1 parent
088b40216d
commit
faa62d419e
21 files changed
+1268
-287
No files matched your search
+30
-8
@@ -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.
|
||||
|
||||
|
||||
+25
-14
@@ -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. |
|
||||
|
||||
+16
-4
@@ -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,
|
||||
|
||||
@@ -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<UVoxelSeasonAsset> SeasonAsset(
|
||||
NewObject<UVoxelSeasonAsset>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
FString AssetReport;
|
||||
TestTrue(TEXT("cookable season asset accepts the reviewed manifest"),
|
||||
SeasonAsset->SetManifestJson(Manifest.SerializedJson, AssetReport));
|
||||
|
||||
TStrongObjectPtr<UVoxelSettings> RuntimeSettings(
|
||||
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
RuntimeSettings->Seed = Manifest.Seed + 17;
|
||||
RuntimeSettings->OriginSpineRadius = Manifest.OriginSpineRadius + 31.0f;
|
||||
RuntimeSettings->WorldRadiusVoxels = 4096.0f;
|
||||
RuntimeSettings->Season = TSoftObjectPtr<UVoxelSeasonAsset>(SeasonAsset.Get());
|
||||
|
||||
TStrongObjectPtr<UVoxelStrateManager> ManagerA(
|
||||
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
TStrongObjectPtr<UVoxelStrateManager> ManagerB(
|
||||
NewObject<UVoxelStrateManager>(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<UVoxelGenerator> GeneratorA(
|
||||
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
TStrongObjectPtr<UVoxelGenerator> GeneratorB(
|
||||
NewObject<UVoxelGenerator>(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<const uint32*>(&A),
|
||||
*reinterpret_cast<const uint32*>(&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<UVoxelSettings> LegacySettings(
|
||||
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
TStrongObjectPtr<UVoxelStrateDefinition> LegacyDefinition(
|
||||
NewObject<UVoxelStrateDefinition>(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<UVoxelStrateDefinition>(LegacyDefinition.Get()));
|
||||
TStrongObjectPtr<UVoxelStrateManager> LegacyManager(
|
||||
NewObject<UVoxelStrateManager>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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<FSurfaceGenerationParams> 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<FGeneratorBiomeField>(
|
||||
this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z);
|
||||
}
|
||||
}
|
||||
TArray<FSurfaceGenerationParams> 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<FGeneratorBiomeField>(
|
||||
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
|
||||
|
||||
@@ -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<FString> SeenDefinitionPaths;
|
||||
for (const FVoxelSeasonStrate& Strate : Parsed.Strates)
|
||||
{
|
||||
if (!Strate.SourceDefinitionPath.IsEmpty()
|
||||
&& !SeenDefinitionPaths.Contains(Strate.SourceDefinitionPath))
|
||||
{
|
||||
SeenDefinitionPaths.Add(Strate.SourceDefinitionPath);
|
||||
ReferencedDefinitions.Add(TSoftObjectPtr<UVoxelStrateDefinition>(
|
||||
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
|
||||
}
|
||||
@@ -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<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
SetCommonManifestFields(Root, Manifest);
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Strates;
|
||||
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
|
||||
FString SerializeVoxelSeasonManifestInternal(
|
||||
const FVoxelSeasonManifest& Manifest, bool bIncludeContentHash)
|
||||
{
|
||||
Strates.Add(MakeShared<FJsonValueObject>(SerializeStrate(Strate)));
|
||||
}
|
||||
Root->SetArrayField(TEXT("strates"), MoveTemp(Strates));
|
||||
using namespace VoxelSeasonManifestPrivate;
|
||||
TSharedPtr<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
SetCommonManifestFields(Root, Manifest);
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Rejections;
|
||||
for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>();
|
||||
Object->SetStringField(TEXT("reason"), Rejection.Reason);
|
||||
Object->SetNumberField(TEXT("count"), Rejection.Count);
|
||||
Rejections.Add(MakeShared<FJsonValueObject>(Object));
|
||||
}
|
||||
Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections));
|
||||
TArray<TSharedPtr<FJsonValue>> Strates;
|
||||
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
|
||||
{
|
||||
Strates.Add(MakeShared<FJsonValueObject>(SerializeStrate(Strate)));
|
||||
}
|
||||
Root->SetArrayField(TEXT("strates"), MoveTemp(Strates));
|
||||
|
||||
if (!Manifest.CandidateAudit.IsEmpty())
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> Audit;
|
||||
for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit)
|
||||
TArray<TSharedPtr<FJsonValue>> Rejections;
|
||||
for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>();
|
||||
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<double>(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<FJsonValueObject>(Object));
|
||||
Object->SetStringField(TEXT("reason"), Rejection.Reason);
|
||||
Object->SetNumberField(TEXT("count"), Rejection.Count);
|
||||
Rejections.Add(MakeShared<FJsonValueObject>(Object));
|
||||
}
|
||||
Root->SetArrayField(TEXT("candidate_audit"), MoveTemp(Audit));
|
||||
}
|
||||
Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections));
|
||||
|
||||
FString JsonText;
|
||||
TSharedRef<TJsonWriter<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>> Writer =
|
||||
TJsonWriterFactory<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>::Create(&JsonText);
|
||||
if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close())
|
||||
{
|
||||
return FString();
|
||||
if (!Manifest.CandidateAudit.IsEmpty())
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> Audit;
|
||||
for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit)
|
||||
{
|
||||
TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>();
|
||||
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<double>(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<FJsonValueObject>(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<TJsonWriter<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>> Writer =
|
||||
TJsonWriterFactory<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>::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<FJsonObject> Root;
|
||||
FString OriginalJson;
|
||||
if (!LoadJson(ManifestPath, Root, OutReport, &OriginalJson))
|
||||
const TSharedRef<TJsonReader<>> 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<float>(Strate.TopWorldZ);
|
||||
const float StoredBottom = static_cast<float>(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<float>(TopWorldZ),
|
||||
static_cast<float>(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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
#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<int32>(VoxelHash::Cell(
|
||||
StrateIndex, StrateIndex ^ 0x6D, static_cast<uint32>(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
|
||||
@@ -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<FString, UVoxelStrateDefinition*>;
|
||||
TArray<FLoadedPoolEntry> ShuffledPool;
|
||||
for (const TSoftObjectPtr<UVoxelStrateDefinition>& SoftPtr : Settings->StratePool)
|
||||
if (!bUseSeason)
|
||||
{
|
||||
// Load the asset (synchronous for now — could be async later)
|
||||
UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous();
|
||||
if (Def)
|
||||
for (const TSoftObjectPtr<UVoxelStrateDefinition>& 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<int32, UVoxelStrateDefinition*> 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<UVoxelStrateDefinition> 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<UVoxelStrateDefinition>(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<UVoxelStrateDefinition>(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<int32, TSharedPtr<FVoxelStrateComposerSlotOverride>>& 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<int32>(VoxelCaveMorphology::MakeStrateSeed(
|
||||
static_cast<uint32>(CachedSeed), Upper.StrateIndex))
|
||||
: CachedSeed;
|
||||
const int32 UpperQuerySeed = SeasonStrates.IsValidIndex(i)
|
||||
? SeasonStrates[i].Seed
|
||||
: (bUsesRoomSeed
|
||||
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
|
||||
static_cast<uint32>(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<int32>(VoxelCaveMorphology::MakeStrateSeed(
|
||||
static_cast<uint32>(CachedSeed), Lower.StrateIndex))
|
||||
: CachedSeed;
|
||||
const int32 LowerQuerySeed = SeasonStrates.IsValidIndex(i + 1)
|
||||
? SeasonStrates[i + 1].Seed
|
||||
: (bUsesRoomSeed
|
||||
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
|
||||
static_cast<uint32>(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);
|
||||
|
||||
//=========================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<UVoxelStrateManager>(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<UVoxelContentManager>(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();
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -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<TSoftObjectPtr<UVoxelStrateDefinition>> ReferencedDefinitions;
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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<UVoxelSeasonAsset> Season;
|
||||
|
||||
// Numéro de saison (incrementé par ChangeSeed). Purement informatif côté gameplay.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
|
||||
int32 CurrentSeason = 1;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<FStrateSlot> 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<FVoxelSeasonStrate> SeasonStrates;
|
||||
FString ActiveSeasonContentHash;
|
||||
|
||||
// Passages connecting consecutive strates
|
||||
TArray<FVoxelPassage> Passages;
|
||||
|
||||
|
||||
@@ -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)
|
||||
//=========================================================================
|
||||
|
||||
Reference in new issue
Block a user