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:
Fr0zkaandClaude Opus 5 committed 2026-09-05 08:37:01 +02:00
1 parent 088b40216d
commit faa62d419e
21 files changed
+1268 -287

No files matched your search

+30 -8
View File
@@ -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 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 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 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 structure candidate uses `VF_BuildStackFromRecipe`. Custom recipes now classify with that exact
ClassifyTile so no unproven box bound can skip their mesh. The bridge and its editable properties are 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. 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) ### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems: `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) ## 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 > 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 > 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 > **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 ### 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 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**: 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` + - Replicate the **effective seed + strate layout identity** ONCE (at join). For a cooked season the
`UVoxelStrateManager` then generate byte-identical terrain locally. (Today the seed lives on the data layout is the reviewed manifest, not a pool shuffle: peers must load the same season asset and compare
asset / `UVoxelSettings::Seed`; MP must propagate the *host's* effective seed to joiners so their `AVoxelWorld::GetCurrentSeasonContentHash()` before accepting play. For the legacy path the layout remains
generators match — a mismatch = divergent worlds. The strate layout is deterministic from seed, so it deterministic from the effective seed. Every peer then generates terrain locally; geometry never crosses
syncs implicitly once the seed does.) 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]]) - 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. → it is the only thing that must sync. Since carving is a minor feature, this traffic is small.
+25 -14
View File
@@ -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) | | 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) | | Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) |
| Rendering | `VoxelMaterial` (61) | | 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. | | Carving budget | `MaxModifications=0` (97), `MaxBrushRadius=15` (102), `MaxTotalVolume=0` (107). 0 = unlimited. |
### 3.5 World orchestrator — `Public/VoxelWorld.h` + `Private/VoxelWorld.cpp` ### 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`. | | `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`. | | `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. | | `ClearAllModifications` | 726 | Clears diff layer, regenerates. |
| `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. | | `ChangeSeed` | 740 | **Legacy season reset**: new seed everywhere, clear diffs, bump season, reload. Rejected while a cooked season owns the seed. |
| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. | | `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". | | `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)". > **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. | | `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. |
| `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. | | `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. |
| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. | | `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. | | `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, 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. | | **`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. | | **`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. | | **`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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. - `FStrateSlot` (h:84): definition + chunk-Z range + index.
| Method | .cpp line | Role | | 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. | | `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. | | `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. | | `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. | | `GetStrateForChunk` | 466 | Chunk → definition. |
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | | `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. | | `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). | | `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. | | `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). | | `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, 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 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 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 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. 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 / 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. candidate / target-slot values, and no runtime generation hook.
**`Public/VoxelSeasonManifest.h` + `Private/VoxelSeasonManifest.cpp`** — Tier 4c's offline season **`Public/VoxelSeasonManifest.h` + `Private/VoxelSeasonManifest.cpp`** — Tier 4c's offline composer
artifact. `VF_ComposeSeason` generates a bounded configurable candidate batch, applies the Tier 2 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 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 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 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 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 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 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 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** 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 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` ### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp`
`UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; `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. | | `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**. | | `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`. | | `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. | | `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. | | `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. | | `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. | | Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :757. |
| Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. | | Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. |
| Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. | | 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). | | 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). | | Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). |
| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. | | Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. |
+16 -4
View File
@@ -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 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. 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 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 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 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 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. 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 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 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, 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. 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 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 composer, corpus, measurement, selection, promotion, and review writer are editor/build-box code only.
them, and `WorldRadiusVoxels` remains 0. 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: An abbreviated manifest shape is:
```json ```json
{ {
"format": "VoxelForgeSeasonManifest", "format": "VoxelForgeSeasonManifest",
"schema_version": 1, "schema_version": 2,
"content_hash": "0123456789abcdef0123456789abcdef01234567",
"season": 0, "season": 0,
"seed": 9320, "seed": 9320,
"candidate_count": 24, "candidate_count": 24,
@@ -5,10 +5,18 @@
#include "Misc/AutomationTest.h" #include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h" #include "Misc/FileHelper.h"
#include "Misc/Paths.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 "VoxelDensityOpStack.h"
#include "VoxelGenerator.h"
#include "VoxelSeasonAsset.h"
#include "VoxelSeasonManifest.h" #include "VoxelSeasonManifest.h"
#include "VoxelSettings.h"
#include "VoxelStrateComposer.h" #include "VoxelStrateComposer.h"
#include "VoxelStrateDefinition.h"
#include "VoxelStrateManager.h"
namespace namespace
{ {
@@ -121,6 +129,11 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
} }
TestEqual(TEXT("loaded manifest has the same number of selected strates"), TestEqual(TEXT("loaded manifest has the same number of selected strates"),
LoadedManifest.Strates.Num(), Manifest.Strates.Num()); 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; int64 ComparedDensitySamples = 0;
bool bAllDensityBitsIdentical = true; bool bAllDensityBitsIdentical = true;
@@ -202,6 +215,190 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
ComparedDensitySamples)); ComparedDensitySamples));
TestTrue(TEXT("manifest round-trip density field is bit-identical"), TestTrue(TEXT("manifest round-trip density field is bit-identical"),
bAllDensityBitsIdentical); 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; return true;
} }
@@ -4886,6 +4886,7 @@ namespace
uint32 SeedU; uint32 SeedU;
}; };
#if WITH_EDITOR
//============================================================================= //=============================================================================
// RÔLE 2 — COMBINER : RÉGIONS LATÉRALES // RÔLE 2 — COMBINER : RÉGIONS LATÉRALES
//============================================================================= //=============================================================================
@@ -5008,6 +5009,7 @@ namespace
mutable FVoxelStrateRegionPartitionCache PartitionCache; mutable FVoxelStrateRegionPartitionCache PartitionCache;
mutable bool bPrepared = false; mutable bool bPrepared = false;
}; };
#endif
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE. } // ⚠️ 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 // 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; return true;
} }
#if WITH_EDITOR
namespace namespace
{ {
static EVoxelStrateParamBlock VF_RegionParamBlock(ECaveGeneratorType Archetype) static EVoxelStrateParamBlock VF_RegionParamBlock(ECaveGeneratorType Archetype)
@@ -6449,3 +6452,4 @@ bool VF_BuildStrateRegionStack(
OutStack = MoveTemp(Parent); OutStack = MoveTemp(Parent);
return true; return true;
} }
#endif // WITH_EDITOR — lateral regions remain behind the failed shippability gate
+168 -74
View File
@@ -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 // 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. // déréférencer l'asset de settings.
Seed = Settings ? Settings->Seed : 0; Seed = Settings ? Settings->GetEffectiveWorldSeed() : 0;
OriginSpineRadius = Settings ? Settings->OriginSpineRadius : 14.0f; OriginSpineRadius = Settings ? Settings->GetEffectiveOriginSpineRadius() : 14.0f;
WorldRadiusVoxels = Settings ? Settings->WorldRadiusVoxels : 0.0f; WorldRadiusVoxels = Settings ? Settings->GetEffectiveWorldRadiusVoxels() : 0.0f;
EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.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é. // la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté.
thread_local FVoxelOpStack CP_OpStack; thread_local FVoxelOpStack CP_OpStack;
thread_local bool CP_UseOpStack = false; 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 #if WITH_EDITOR
// Editor-only composer override. The candidate recipe is copied into this worker-local // 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 // cache on the same versioned refetch as the native params; no worker reads mutable editor
// state while the world is applying a new candidate. // state while the world is applying a new candidate.
thread_local bool CP_UseComposerRecipe = false;
thread_local bool CP_UseComposerRegions = false; thread_local bool CP_UseComposerRegions = false;
thread_local int32 CP_ComposerSeed = 0; thread_local int32 CP_ComposerSeed = 0;
thread_local FVoxelStrateArchetypeParams CP_ComposerParams;
thread_local FVoxelOpStackRecipe CP_ComposerRecipe;
thread_local FVoxelStrateRegionManifest CP_ComposerRegions; thread_local FVoxelStrateRegionManifest CP_ComposerRegions;
#endif #endif
@@ -763,29 +766,29 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
CP_Version = LayoutVersion; CP_Version = LayoutVersion;
CP_Chunk = ChunkCoord; CP_Chunk = ChunkCoord;
CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
CP_UseCustomRecipe = false;
bool bAllowCustomRecipe = true;
#if WITH_EDITOR #if WITH_EDITOR
CP_UseComposerRecipe = false;
CP_UseComposerRegions = false; CP_UseComposerRegions = false;
ECaveGeneratorType ComposerArchetype = ECaveGeneratorType::TunnelNetwork;
bool bComposerUseRecipe = false;
if (StrateManager->GetComposerRegionOverrideForChunk( if (StrateManager->GetComposerRegionOverrideForChunk(
ChunkCoord, CP_ComposerRegions)) ChunkCoord, CP_ComposerRegions))
{ {
CP_UseComposerRegions = true; CP_UseComposerRegions = true;
bAllowCustomRecipe = false;
CP_ComposerSeed = CP_ComposerRegions.Seed; CP_ComposerSeed = CP_ComposerRegions.Seed;
if (CP_ComposerRegions.Regions.Num() > 0) if (CP_ComposerRegions.Regions.Num() > 0)
{ {
CP_GenType = CP_ComposerRegions.Regions[0].Archetype; 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 #endif
if (bAllowCustomRecipe
&& StrateManager->GetRecipeForChunk(
ChunkCoord, CP_CustomRecipeSeed, CP_GenType,
CP_CustomParams, CP_CustomRecipe))
{
CP_UseCustomRecipe = true;
}
switch (CP_GenType) switch (CP_GenType)
{ {
case ECaveGeneratorType::FlatPlain: 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 // 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. // par chunk (comme le refetch de params juste au-dessus), jamais par voxel.
CP_UseOpStack = StrateManager->UsesOperatorStackForChunk(ChunkCoord); CP_UseOpStack = StrateManager->UsesOperatorStackForChunk(ChunkCoord);
CP_UseOpStack = CP_UseOpStack || CP_UseCustomRecipe;
#if WITH_EDITOR #if WITH_EDITOR
// A composer override is already an explicit, validated stack description. It must // 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 // 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 // disabled; otherwise a multi-region candidate would be installed in the manager but
// silently fall back to the old one-archetype switch. // silently fall back to the old one-archetype switch.
CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions || CP_UseComposerRecipe; CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions;
#endif #endif
if (CP_UseOpStack) if (CP_UseOpStack)
{ {
CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile
bool bBuiltSpecialStack = false;
#if WITH_EDITOR #if WITH_EDITOR
if (CP_UseComposerRegions) if (CP_UseComposerRegions)
{ {
bBuiltSpecialStack = true;
FVoxelOpContext RegionContext; FVoxelOpContext RegionContext;
if (VF_BuildStrateRegionStack( if (VF_BuildStrateRegionStack(
CP_ComposerRegions, OriginSpineRadius, StrateManager, CP_ComposerRegions, OriginSpineRadius, StrateManager,
@@ -847,70 +853,71 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
CP_UseOpStack = false; CP_UseOpStack = false;
} }
} }
else if (CP_UseComposerRecipe) #endif
if (!bBuiltSpecialStack)
{ {
FVoxelOpContext ComposerContext; if (CP_UseCustomRecipe)
if (VF_BuildStackFromRecipe(
CP_ComposerRecipe, CP_ComposerParams, Seed,
OriginSpineRadius, StrateManager, CP_OpStack, ComposerContext, nullptr))
{ {
ComposerContext.ChunkCoord = ChunkCoord; FVoxelOpContext RecipeContext;
ComposerContext.Step = 1; if (VF_BuildStackFromRecipe(
ComposerContext.LayoutVersion = LayoutVersion; CP_CustomRecipe, CP_CustomParams, CP_CustomRecipeSeed,
// The recipe builder supplies the candidate's vertical seal to the OriginSpineRadius, StrateManager, CP_OpStack, RecipeContext, nullptr))
// structural post. The XY edge seal is global and uses live settings. {
ComposerContext.WorldRadiusVoxels = WorldRadiusVoxels; RecipeContext.ChunkCoord = ChunkCoord;
ComposerContext.EdgeSealThickness = EdgeSealThickness; RecipeContext.Step = 1;
CP_OpStack.PrepareChunk(ComposerContext); 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 else
{ {
// The editor action validates before installing the override. Keep this FVoxelOpContext OpCtx;
// fallback safe if a future recipe changes underneath PIE: the native OpCtx.ChunkCoord = ChunkCoord;
// candidate params remain available instead of yielding an uninitialised OpCtx.Seed = (uint32)Seed;
// stack or a hole. OpCtx.LayoutVersion = LayoutVersion;
CP_UseOpStack = false; OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
} OpCtx.EdgeSealThickness = EdgeSealThickness;
}
else
#endif
{
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 // ⚠️ 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 // 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 // d'une pile construite autrement serait un trou. Ici on ne fait que fournir les
// params déjà cherchés juste au-dessus. // params déjà cherchés juste au-dessus.
FVoxelStackParamRefs Refs; FVoxelStackParamRefs Refs;
Refs.Slab = &CP_Slab; Refs.Slab = &CP_Slab;
Refs.Maze = &CP_Maze; Refs.Maze = &CP_Maze;
Refs.Vert = &CP_Vert; Refs.Vert = &CP_Vert;
Refs.Float = &CP_Float; Refs.Float = &CP_Float;
Refs.Tunnel = &CP_Tunnel; Refs.Tunnel = &CP_Tunnel;
// SurfaceWorld : le champ de biomes est fabriqué ICI, du côté qui connaît le // 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`, // 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. // ce qui lui permet de devenir un asset en Phase 3 sans traîner le générateur.
TArray<FSurfaceGenerationParams> PerBiome; TArray<FSurfaceGenerationParams> PerBiome;
if (CP_GenType == ECaveGeneratorType::SurfaceWorld) if (CP_GenType == ECaveGeneratorType::SurfaceWorld)
{ {
Refs.Surface = &CP_Surface; Refs.Surface = &CP_Surface;
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0) if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
{ {
PerBiome = CP_SurfaceBiomeParams; PerBiome = CP_SurfaceBiomeParams;
Refs.SurfaceBiomeParams = &PerBiome; Refs.SurfaceBiomeParams = &PerBiome;
Refs.BiomeField = MakeUnique<FGeneratorBiomeField>( Refs.BiomeField = MakeUnique<FGeneratorBiomeField>(
this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z); this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z);
} }
} }
CP_UseOpStack = VF_BuildOpStackForChunk(CP_GenType, Refs, Seed, OriginSpineRadius, CP_UseOpStack = VF_BuildOpStackForChunk(
StrateManager, CP_OpStack, OpCtx); CP_GenType, Refs, Seed, OriginSpineRadius,
StrateManager, CP_OpStack, OpCtx);
// Le test appelle PrepareChunk, pas la production : c'est exactement la divergence // 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` // 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, // 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 // which is the point: the first op that hoists real per-chunk work must find the
// call already here. // 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; 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 #if WITH_EDITOR
// A multi-region composer override is already a complete parent stack: it owns the lateral // 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 // 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); 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 #endif
bool bCanSolid = true; // "tout le treillis est solide" encore prouvable 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
}
+189 -68
View File
@@ -7,6 +7,7 @@
#include "HAL/FileManager.h" #include "HAL/FileManager.h"
#include "Misc/FileHelper.h" #include "Misc/FileHelper.h"
#include "Misc/Paths.h" #include "Misc/Paths.h"
#include "Misc/SecureHash.h"
#include "Serialization/JsonReader.h" #include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h" #include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h" #include "Serialization/JsonWriter.h"
@@ -1428,56 +1429,85 @@ const TCHAR* VF_GetVoxelSeasonSelectionReasonName(EVoxelSeasonSelectionReason Re
return TEXT("unknown"); return TEXT("unknown");
} }
FString VF_SerializeVoxelSeasonManifest(const FVoxelSeasonManifest& Manifest) namespace
{ {
using namespace VoxelSeasonManifestPrivate; FString SerializeVoxelSeasonManifestInternal(
TSharedPtr<FJsonObject> Root = MakeShared<FJsonObject>(); const FVoxelSeasonManifest& Manifest, bool bIncludeContentHash)
SetCommonManifestFields(Root, Manifest);
TArray<TSharedPtr<FJsonValue>> Strates;
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{ {
Strates.Add(MakeShared<FJsonValueObject>(SerializeStrate(Strate))); using namespace VoxelSeasonManifestPrivate;
} TSharedPtr<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetArrayField(TEXT("strates"), MoveTemp(Strates)); SetCommonManifestFields(Root, Manifest);
TArray<TSharedPtr<FJsonValue>> Rejections; TArray<TSharedPtr<FJsonValue>> Strates;
for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts) for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{ {
TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>(); Strates.Add(MakeShared<FJsonValueObject>(SerializeStrate(Strate)));
Object->SetStringField(TEXT("reason"), Rejection.Reason); }
Object->SetNumberField(TEXT("count"), Rejection.Count); Root->SetArrayField(TEXT("strates"), MoveTemp(Strates));
Rejections.Add(MakeShared<FJsonValueObject>(Object));
}
Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections));
if (!Manifest.CandidateAudit.IsEmpty()) TArray<TSharedPtr<FJsonValue>> Rejections;
{ for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts)
TArray<TSharedPtr<FJsonValue>> Audit;
for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit)
{ {
TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>(); TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>();
Object->SetNumberField(TEXT("candidate_index"), Row.CandidateIndex); Object->SetStringField(TEXT("reason"), Rejection.Reason);
Object->SetNumberField(TEXT("seed"), Row.Seed); Object->SetNumberField(TEXT("count"), Rejection.Count);
Object->SetStringField(TEXT("archetype"), ArchetypeName(Row.Archetype)); Rejections.Add(MakeShared<FJsonValueObject>(Object));
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)); Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections));
}
FString JsonText; if (!Manifest.CandidateAudit.IsEmpty())
TSharedRef<TJsonWriter<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>> Writer = {
TJsonWriterFactory<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>::Create(&JsonText); TArray<TSharedPtr<FJsonValue>> Audit;
if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close()) for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit)
{ {
return FString(); 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, bool VF_SaveVoxelSeasonManifest(const FString& ManifestPath,
@@ -1511,24 +1541,25 @@ bool VF_SaveVoxelSeasonManifest(const FString& ManifestPath,
return true; return true;
} }
bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, bool VF_DeserializeVoxelSeasonManifest(const FString& ManifestJson,
FVoxelSeasonManifest& OutManifest, FVoxelSeasonManifest& OutManifest,
FString& OutReport) FString& OutReport)
{ {
using namespace VoxelSeasonManifestPrivate; using namespace VoxelSeasonManifestPrivate;
OutManifest = FVoxelSeasonManifest(); OutManifest = FVoxelSeasonManifest();
OutReport.Reset(); 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; OutManifest.Error = OutReport;
return false; return false;
} }
TSharedPtr<FJsonObject> Root; TSharedPtr<FJsonObject> Root;
FString OriginalJson; const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(ManifestJson);
if (!LoadJson(ManifestPath, Root, OutReport, &OriginalJson)) if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{ {
OutReport = TEXT("season manifest JSON is malformed");
OutManifest.Error = OutReport; OutManifest.Error = OutReport;
return false; return false;
} }
@@ -1556,6 +1587,8 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath,
bool bFieldsValid = true; bool bFieldsValid = true;
OutManifest.bValid = bValid; OutManifest.bValid = bValid;
OutManifest.SchemaVersion = SchemaVersion; OutManifest.SchemaVersion = SchemaVersion;
bFieldsValid = ReadJsonString(Root, TEXT("content_hash"), OutManifest.ContentHash)
&& bFieldsValid;
bFieldsValid = ReadJsonInt32(Root, TEXT("season"), OutManifest.Season) && bFieldsValid; bFieldsValid = ReadJsonInt32(Root, TEXT("season"), OutManifest.Season) && bFieldsValid;
bFieldsValid = ReadJsonInt32(Root, TEXT("seed"), OutManifest.Seed) && bFieldsValid; bFieldsValid = ReadJsonInt32(Root, TEXT("seed"), OutManifest.Seed) && bFieldsValid;
double CorpusHash = 0.0; double CorpusHash = 0.0;
@@ -1669,7 +1702,7 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath,
const FVoxelSeasonStrate& Strate = OutManifest.Strates[Index]; const FVoxelSeasonStrate& Strate = OutManifest.Strates[Index];
const float StoredTop = static_cast<float>(Strate.TopWorldZ); const float StoredTop = static_cast<float>(Strate.TopWorldZ);
const float StoredBottom = static_cast<float>(Strate.BottomWorldZ); 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) || Strate.PrimordialLawResult != EVoxelConnectivityResult::Connected)
{ {
bFieldsValid = false; bFieldsValid = false;
@@ -1695,17 +1728,99 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath,
} }
if (!bFieldsValid) if (!bFieldsValid)
{ {
OutReport = FString::Printf(TEXT("season manifest %s contains invalid fields"), *ManifestPath); OutReport = TEXT("season manifest contains invalid fields");
OutManifest.Error = OutReport; OutManifest.Error = OutReport;
OutManifest.bValid = false; OutManifest.bValid = false;
return false; return false;
} }
OutManifest.ManifestPath = ManifestPath; const FString ComputedHash = VF_ComputeVoxelSeasonManifestContentHash(OutManifest);
OutManifest.SerializedJson = MoveTemp(OriginalJson); if (ComputedHash.IsEmpty()
OutReport = FString::Printf(TEXT("loaded season manifest from %s"), *ManifestPath); || !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(); 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 namespace VoxelSeasonCompositionPrivate
{ {
float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype);
@@ -1920,7 +2035,10 @@ namespace VoxelSeasonCompositionPrivate
OutStrate.Params = Candidate.ArchetypeParams; OutStrate.Params = Candidate.ArchetypeParams;
OutStrate.Regions = Candidate.Regions; OutStrate.Regions = Candidate.Regions;
OutStrate.bUsesRecipe = true; 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), SetRuntimeBounds(OutStrate.Params, static_cast<float>(TopWorldZ),
static_cast<float>(BottomWorldZ)); static_cast<float>(BottomWorldZ));
if (OutStrate.Regions.IsValid()) if (OutStrate.Regions.IsValid())
@@ -2235,6 +2353,7 @@ namespace VoxelSeasonCompositionPrivate
return VoxelSeasonManifestPrivate::ConnectivityName(Result); return VoxelSeasonManifestPrivate::ConnectivityName(Result);
} }
} }
#endif // WITH_EDITOR — composition, measurement and review helpers never ship
namespace VoxelSeasonCompositionPrivate namespace VoxelSeasonCompositionPrivate
{ {
@@ -2791,6 +2910,11 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed,
Manifest.bValid = true; Manifest.bValid = true;
Manifest.Error.Reset(); 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); Manifest.SerializedJson = VF_SerializeVoxelSeasonManifest(Manifest);
if (Manifest.SerializedJson.IsEmpty()) if (Manifest.SerializedJson.IsEmpty())
{ {
@@ -2807,20 +2931,9 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed,
return Manifest; 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 #endif
#if WITH_EDITOR
FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* WorldSettings) FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* WorldSettings)
{ {
FVoxelSeasonCompositionSettings Settings; FVoxelSeasonCompositionSettings Settings;
@@ -2835,6 +2948,7 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* Wo
} }
return VF_ComposeSeason(SeasonSeed, Settings); return VF_ComposeSeason(SeasonSeed, Settings);
} }
#endif
bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate,
float SpineRadius, float SpineRadius,
@@ -2843,7 +2957,7 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate,
FVoxelOpContext& OutContext, FVoxelOpContext& OutContext,
FString* OutError) FString* OutError)
{ {
using namespace VoxelSeasonCompositionPrivate; using namespace VoxelSeasonRuntimePrivate;
auto Fail = [&](const FString& Reason) -> bool auto Fail = [&](const FString& Reason) -> bool
{ {
if (OutError != nullptr) if (OutError != nullptr)
@@ -2890,8 +3004,12 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate,
bool bBuilt = false; bool bBuilt = false;
if (Strate.bUsesRegions) if (Strate.bUsesRegions)
{ {
#if WITH_EDITOR
bBuilt = VF_BuildStrateRegionStack(Strate.Regions, SpineRadius, StrateManager, bBuilt = VF_BuildStrateRegionStack(Strate.Regions, SpineRadius, StrateManager,
OutStack, OutContext, OutError); OutStack, OutContext, OutError);
#else
return Fail(TEXT("Lateral region manifests are not shippable."));
#endif
} }
else if (Strate.bUsesRecipe) else if (Strate.bUsesRecipe)
{ {
@@ -2902,7 +3020,8 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate,
else else
{ {
#if WITH_EDITOR #if WITH_EDITOR
const float Seal = ActiveSeal(Strate.Params, Strate.Archetype); const float Seal = VoxelSeasonCompositionPrivate::ActiveSeal(
Strate.Params, Strate.Archetype);
bBuilt = VF_BuildNativeStrateStackForCandidate( bBuilt = VF_BuildNativeStrateStackForCandidate(
Strate.Archetype, Strate.Params, Strate.Seed, SpineRadius, Strate.Archetype, Strate.Params, Strate.Seed, SpineRadius,
0.0f, Seal, StrateManager, OutStack, OutContext); 0.0f, Seal, StrateManager, OutStack, OutContext);
@@ -2940,6 +3059,7 @@ bool VF_RebuildVoxelSeasonStrate(const FVoxelSeasonStrate& Strate,
OutStack, OutContext, OutError); OutStack, OutContext, OutError);
} }
#if WITH_EDITOR
namespace VoxelSeasonCompositionPrivate namespace VoxelSeasonCompositionPrivate
{ {
bool WriteReviewPage(const FVoxelSeasonManifest& Manifest, bool WriteReviewPage(const FVoxelSeasonManifest& Manifest,
@@ -3021,3 +3141,4 @@ namespace VoxelSeasonCompositionPrivate
return true; 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 <type_traits>
#include <utility> #include <utility>
#if WITH_EDITOR
namespace namespace
{ {
constexpr float GJitterFraction = 0.15f; 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( FVoxelStrateMeasuredMetrics VF_SummarizeStrateMetrics(
const FVoxelStrateMetrics& Metrics) const FVoxelStrateMetrics& Metrics)
{ {
@@ -3901,22 +3881,6 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const
return Hash; 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) const TCHAR* VF_GetStrateCorpusProvenanceName(EVoxelStrateCorpusProvenance Provenance)
{ {
switch (Provenance) switch (Provenance)
@@ -4400,6 +4364,7 @@ bool FVoxelStrateCorpus::SetMeasuredMetrics(
} }
return false; return false;
} }
#endif // WITH_EDITOR — corpus loading, promotion and parameter/structure roll implementation
void VF_SetStrateArchetypeRuntimeBounds( void VF_SetStrateArchetypeRuntimeBounds(
FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ) FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ)
@@ -4589,11 +4554,13 @@ namespace VoxelStrateRegionPrivate
} }
} }
#if WITH_EDITOR
int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex) int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex)
{ {
return 1 + static_cast<int32>(VoxelHash::Cell( return 1 + static_cast<int32>(VoxelHash::Cell(
StrateIndex, StrateIndex ^ 0x6D, static_cast<uint32>(Seed) ^ 0x524F4C4Cu) % 3u); StrateIndex, StrateIndex ^ 0x6D, static_cast<uint32>(Seed) ^ 0x524F4C4Cu) % 3u);
} }
#endif
uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex) uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex)
{ {
@@ -4753,6 +4720,7 @@ FVoxelStrateRegionBoxProof VF_AnalyzeStrateRegionBox(
return Proof; return Proof;
} }
#if WITH_EDITOR
FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus, FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus,
int32 Seed, int32 Index) int32 Seed, int32 Index)
{ {
@@ -5694,3 +5662,4 @@ bool VF_AreStrateStructureRecipesIdentical(const FVoxelOpStackRecipe& A,
} }
return true; return true;
} }
#endif // WITH_EDITOR — no roller/corpus entry point is compiled into Shipping
+237 -60
View File
@@ -4,10 +4,12 @@
#include "VoxelStrateManager.h" #include "VoxelStrateManager.h"
#include "CoreGlobals.h" // GIsAutomationTesting — the opt-in diagnostic stays quiet under tests #include "CoreGlobals.h" // GIsAutomationTesting — the opt-in diagnostic stays quiet under tests
#include "VoxelSettings.h" #include "VoxelSettings.h"
#include "VoxelSeasonAsset.h"
#include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord #include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord
#include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash #include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash
#include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo #include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo
#include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten) #include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten)
#include "UObject/UObjectGlobals.h"
#if WITH_EDITOR #if WITH_EDITOR
#include "VoxelStrateComposer.h" #include "VoxelStrateComposer.h"
@@ -58,22 +60,46 @@ static float PassageFBM(float X, float Seed)
return (MaxV > 0.0f) ? (Total / MaxV) : 0.0f; return (MaxV > 0.0f) ? (Total / MaxV) : 0.0f;
} }
void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
{ {
if (!Settings) if (!Settings)
{ {
UE_LOG(LogTemp, Error, TEXT("[StrateManager] No settings provided!")); 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(); StrateLayout.Empty();
SeasonStrates.Empty();
ActiveSeasonContentHash.Reset();
#if WITH_EDITOR #if WITH_EDITOR
// A full layout rebuild discards any temporary walk-through candidate. The world calls this // 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. // under FScopedGenerationPause, so no worker can observe the map while it is being cleared.
ComposerOverrides.Reset(); ComposerOverrides.Reset();
#endif #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) // STEP 1: Build shuffled pool (seed-based randomization)
@@ -84,13 +110,16 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
using FLoadedPoolEntry = TPair<FString, UVoxelStrateDefinition*>; using FLoadedPoolEntry = TPair<FString, UVoxelStrateDefinition*>;
TArray<FLoadedPoolEntry> ShuffledPool; TArray<FLoadedPoolEntry> ShuffledPool;
for (const TSoftObjectPtr<UVoxelStrateDefinition>& SoftPtr : Settings->StratePool) if (!bUseSeason)
{ {
// Load the asset (synchronous for now — could be async later) for (const TSoftObjectPtr<UVoxelStrateDefinition>& SoftPtr : Settings->StratePool)
UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous();
if (Def)
{ {
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 // Pre-load fixed strate definitions
TMap<int32, UVoxelStrateDefinition*> LoadedFixed; TMap<int32, UVoxelStrateDefinition*> LoadedFixed;
for (auto& Pair : Settings->FixedStrates) if (!bUseSeason)
{ {
UVoxelStrateDefinition* Def = Pair.Value.LoadSynchronous(); for (auto& Pair : Settings->FixedStrates)
if (Def)
{ {
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; FStrateSlot Slot;
Slot.StrateIndex = i; Slot.StrateIndex = i;
// Pick definition: fixed or from pool if (bUseSeason)
UVoxelStrateDefinition** FixedDef = LoadedFixed.Find(i);
if (FixedDef && *FixedDef)
{ {
Slot.Definition = *FixedDef; const FVoxelSeasonStrate& SeasonStrate = SeasonManifest.Strates[i];
} if (!SeasonStrate.SourceDefinitionPath.IsEmpty())
else if (ShuffledPool.Num() > 0) {
{ TSoftObjectPtr<UVoxelStrateDefinition> SourceDefinition(
// Cycle through the pool (wraps around if more strates than pool entries) FSoftObjectPath(SeasonStrate.SourceDefinitionPath));
Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()].Value; UVoxelStrateDefinition* LoadedDefinition = SourceDefinition.LoadSynchronous();
PoolCursor++; 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 else
{ {
UE_LOG(LogTemp, Warning, TEXT("[StrateManager] No strate definitions available for slot %d!"), i); // Pick definition: fixed or from pool
continue; 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 // Compute Z range from definition's height
Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks; Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks;
Slot.TopChunkZ = CurrentTopZ; Slot.TopChunkZ = CurrentTopZ;
Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1); Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1);
}
// Move the cursor down for the next strate, leaving a solid-bedrock gap of // Move the cursor down for the next strate, leaving a solid-bedrock gap of
// InterStrateGapChunks chunks between this strate and the next. // 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); StrateLayout.Add(Slot);
@@ -187,13 +284,17 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
int32 NumOperatorStackDisabledCaves = 0; int32 NumOperatorStackDisabledCaves = 0;
for (const FStrateSlot& Slot : StrateLayout) 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; continue;
} }
++NumCaveSlots; ++NumCaveSlots;
if (!Slot.Definition->bUseOperatorStack) const bool bUsesStack = bUseSeason
? SeasonStrates[Slot.StrateIndex].bUsesRecipe : Slot.Definition->bUseOperatorStack;
if (!bUsesStack)
{ {
++NumOperatorStackDisabledCaves; ++NumOperatorStackDisabledCaves;
} }
@@ -226,9 +327,13 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
for (const FStrateSlot& Slot : StrateLayout) 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 if (!Slot.Definition
|| Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld || SlotArchetype == ECaveGeneratorType::SurfaceWorld
|| Slot.Definition->bUseOperatorStack) || (bUseSeason ? bSeasonStack : Slot.Definition->bUseOperatorStack))
{ {
continue; continue;
} }
@@ -246,8 +351,13 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
CachedSeed = WorldSeed; CachedSeed = WorldSeed;
bOpenSurfaceEntry = Settings->bOpenSurfaceEntry; bOpenSurfaceEntry = Settings->bOpenSurfaceEntry;
OriginSpineRadius = Settings->OriginSpineRadius; OriginSpineRadius = bUseSeason
InterStrateGapChunks = FMath::Max(0, Settings->InterStrateGapChunks); ? SeasonManifest.OriginSpineRadius : Settings->OriginSpineRadius;
InterStrateGapChunks = LayoutGapChunks;
if (bUseSeason)
{
ActiveSeasonContentHash = SeasonManifest.ContentHash;
}
// Passage shape/count is per-strate now (UVoxelStrateDefinition::PassageConfig). // 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) for (const FStrateSlot& Slot : StrateLayout)
{ {
if (!Slot.Definition) continue; if (!Slot.Definition) continue;
if (bUseSeason && SeasonStrates[Slot.StrateIndex].bUsesRecipe) continue;
for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations) for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations)
{ {
@@ -275,6 +386,7 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
// Generate passages between consecutive strates // Generate passages between consecutive strates
GeneratePassages(); GeneratePassages();
return bUseSeason ? IsUsingSeason() : true;
} }
#if WITH_EDITOR #if WITH_EDITOR
@@ -429,18 +541,40 @@ bool UVoxelStrateManager::GetComposerRegionOverrideForChunk(
return true; 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) if (!Override->bUseRecipe || Override->bUseRegions) return false;
{ OutRecipeSeed = Override->CandidateSeed;
return true; 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; return false;
} }
#endif
//============================================================================= //=============================================================================
// PASSAGE GENERATION // PASSAGE GENERATION
@@ -619,10 +753,12 @@ void UVoxelStrateManager::GeneratePassages()
const bool bUsesRoomSeed = const bool bUsesRoomSeed =
UpperDef->GeneratorType == ECaveGeneratorType::TunnelNetwork UpperDef->GeneratorType == ECaveGeneratorType::TunnelNetwork
|| UpperDef->GeneratorType == ECaveGeneratorType::Underwater; || UpperDef->GeneratorType == ECaveGeneratorType::Underwater;
const int32 UpperQuerySeed = bUsesRoomSeed const int32 UpperQuerySeed = SeasonStrates.IsValidIndex(i)
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed( ? SeasonStrates[i].Seed
static_cast<uint32>(CachedSeed), Upper.StrateIndex)) : (bUsesRoomSeed
: CachedSeed; ? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
static_cast<uint32>(CachedSeed), Upper.StrateIndex))
: CachedSeed);
bAimedUpperAtOpenPoint = VF_SuggestLandingPoint( bAimedUpperAtOpenPoint = VF_SuggestLandingPoint(
UpperDef->GeneratorType, UpperDef->GeneratorType,
@@ -657,10 +793,12 @@ void UVoxelStrateManager::GeneratePassages()
const bool bUsesRoomSeed = const bool bUsesRoomSeed =
LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork
|| LowerDef->GeneratorType == ECaveGeneratorType::Underwater; || LowerDef->GeneratorType == ECaveGeneratorType::Underwater;
const int32 LowerQuerySeed = bUsesRoomSeed const int32 LowerQuerySeed = SeasonStrates.IsValidIndex(i + 1)
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed( ? SeasonStrates[i + 1].Seed
static_cast<uint32>(CachedSeed), Lower.StrateIndex)) : (bUsesRoomSeed
: CachedSeed; ? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
static_cast<uint32>(CachedSeed), Lower.StrateIndex))
: CachedSeed);
const float MaxLateralSnap = MaxLateralSnapFor(*LowerDef); const float MaxLateralSnap = MaxLateralSnapFor(*LowerDef);
bAimedLowerAtOpenPoint = VF_SuggestLandingPoint( bAimedLowerAtOpenPoint = VF_SuggestLandingPoint(
@@ -1145,6 +1283,11 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto
} }
#endif #endif
if (SeasonStrates.IsValidIndex(SlotIdx))
{
return SeasonStrates[SlotIdx].Archetype;
}
return StrateLayout[SlotIdx].Definition->GeneratorType; return StrateLayout[SlotIdx].Definition->GeneratorType;
} }
@@ -1153,6 +1296,13 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; } 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 WITH_EDITOR
if (FindComposerOverride(StrateLayout[SlotIdx].StrateIndex) != nullptr) 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 // Copy the designer-authored slab params from the strate definition, unless an editor
// composer candidate owns this slot. // composer candidate owns this slot.
FSlabGenerationParams Result; FSlabGenerationParams Result;
if (SeasonStrates.IsValidIndex(SlotIdx))
{
Result = SeasonStrates[SlotIdx].Params.SlabParams;
}
#if WITH_EDITOR #if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = else if (const FVoxelStrateComposerSlotOverride* Override =
FindComposerOverride(Slot.StrateIndex)) FindComposerOverride(Slot.StrateIndex))
{ {
Result = (Override->Archetype == ECaveGeneratorType::FlatPlain Result = (Override->Archetype == ECaveGeneratorType::FlatPlain
@@ -1281,7 +1435,8 @@ FMazeGenerationParams UVoxelStrateManager::GetMazeParamsForChunk(const FIntVecto
} }
const FStrateSlot& Slot = StrateLayout[SlotIdx]; 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 WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{ {
@@ -1307,7 +1462,8 @@ FSurfaceGenerationParams UVoxelStrateManager::GetSurfaceParamsForChunk(const FIn
} }
const FStrateSlot& Slot = StrateLayout[SlotIdx]; 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 WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{ {
@@ -1333,7 +1489,8 @@ FVerticalShaftParams UVoxelStrateManager::GetVerticalShaftParamsForChunk(const F
} }
const FStrateSlot& Slot = StrateLayout[SlotIdx]; 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 WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{ {
@@ -1359,7 +1516,8 @@ FFloatingIslandParams UVoxelStrateManager::GetFloatingIslandParamsForChunk(const
} }
const FStrateSlot& Slot = StrateLayout[SlotIdx]; 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 WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) 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. // Pull the relative level from whichever archetype owns water.
float Rel = 0.0f; 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::SurfaceWorld:
case ECaveGeneratorType::Underwater: Rel = Def->GenerationParams.WaterLevelRelative; break; Rel = SeasonStrates.IsValidIndex(SlotIdx)
default: Rel = Def->GenerationParams.WaterLevelRelative; break; ? 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; if (Rel <= 0.0f) return -FLT_MAX;
@@ -1483,6 +1650,16 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto
} }
#endif #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); FStrateGenerationParams BaseParams = BuildParamsFromDefinition(Slot.Definition);
//========================================================================= //=========================================================================
@@ -2,6 +2,8 @@
#include "VoxelStrateMeasure.h" #include "VoxelStrateMeasure.h"
#if WITH_EDITOR
#include "VoxelGenerator.h" #include "VoxelGenerator.h"
#include "VoxelStrateManager.h" #include "VoxelStrateManager.h"
#include "VoxelTypes.h" #include "VoxelTypes.h"
@@ -1579,3 +1581,5 @@ FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler(
Result.bStartSnapped, Result.bGoalSnapped, &Result); Result.bStartSnapped, Result.bGoalSnapped, &Result);
return 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");
}
+37 -8
View File
@@ -423,6 +423,7 @@ void AVoxelWorld::RegenerateAllChunks()
void AVoxelWorld::RebuildStrates() void AVoxelWorld::RebuildStrates()
{ {
bool bLayoutRebuilt = true;
{ {
FScopedGenerationPause Guard(this); FScopedGenerationPause Guard(this);
if (!Guard.Acquired()) if (!Guard.Acquired())
@@ -434,12 +435,20 @@ void AVoxelWorld::RebuildStrates()
if (StrateManager && Settings) if (StrateManager && Settings)
{ {
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings. // 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 (AtmosphereManager) AtmosphereManager->Reset();
if (ContentManager) ContentManager->ClearAll(); 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. // Reload all chunks against the rebuilt strate data.
RegenerateAllChunks(); RegenerateAllChunks();
@@ -831,7 +840,7 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
if (StrateManager) if (StrateManager)
{ {
StrateManager->Initialize(Settings, Settings->Seed); StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed());
} }
if (Generator) if (Generator)
{ {
@@ -959,11 +968,19 @@ void AVoxelWorld::BeginPlay()
Mesher->SkirtCells = Settings->SkirtCells; Mesher->SkirtCells = Settings->SkirtCells;
Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off
// Système de strates — piloté par le pool et les fixed entries dans Settings. // Système de strates — a cooked season takes precedence; otherwise the authored pool path is
if (Settings->StratePool.Num() > 0) // exactly the legacy one.
if (!Settings->Season.IsNull() || Settings->StratePool.Num() > 0)
{ {
StrateManager = NewObject<UVoxelStrateManager>(this); 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); Generator->SetStrateManager(StrateManager);
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Strate system initialized with %d strates"), UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Strate system initialized with %d strates"),
StrateManager->GetNumStrates()); StrateManager->GetNumStrates());
@@ -977,7 +994,8 @@ void AVoxelWorld::BeginPlay()
// Content manager — distance-based decoration grid (no LOD pop) + level-0 water planes. // Content manager — distance-based decoration grid (no LOD pop) + level-0 water planes.
ContentManager = NewObject<UVoxelContentManager>(this); 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. // Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers.
if (bManageAtmosphere && StrateManager) if (bManageAtmosphere && StrateManager)
@@ -2641,6 +2659,12 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed failed — no Settings assigned")); UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed failed — no Settings assigned"));
return; 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 OldSeed = Settings->Seed;
const int32 OldSeason = Settings->CurrentSeason; const int32 OldSeason = Settings->CurrentSeason;
@@ -2702,12 +2726,17 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
int32 AVoxelWorld::GetCurrentSeed() const int32 AVoxelWorld::GetCurrentSeed() const
{ {
return Settings ? Settings->Seed : 0; return Settings ? Settings->GetEffectiveWorldSeed() : 0;
} }
int32 AVoxelWorld::GetCurrentSeason() const 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); bool bAppendStructuralPosts = true);
/** Build the region combiner stack. Creative cores are independent; structural posts are global. */ /** Build the region combiner stack. Creative cores are independent; structural posts are global. */
#if WITH_EDITOR
VOXELFORGE_API bool VF_BuildStrateRegionStack( VOXELFORGE_API bool VF_BuildStrateRegionStack(
const FVoxelStrateRegionManifest& Manifest, const FVoxelStrateRegionManifest& Manifest,
float SpineRadius, float SpineRadius,
@@ -488,3 +489,4 @@ VOXELFORGE_API bool VF_BuildStrateRegionStack(
FVoxelOpStack& OutStack, FVoxelOpStack& OutStack,
FVoxelOpContext& OutContext, FVoxelOpContext& OutContext,
FString* OutError = nullptr); 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;
};
+18 -9
View File
@@ -1,8 +1,4 @@
// Offline Tier 4c season composition and the diffable Tier A manifest. // Offline Tier 4c composition plus the runtime-readable, diffable Tier A manifest.
//
// This header deliberately describes an editor/build-box artifact. The normal runtime path still
// receives its existing settings and builds density exactly as before; no runtime code consumes a
// season manifest as a hidden generation fallback.
#pragma once #pragma once
@@ -195,7 +191,7 @@ struct VOXELFORGE_API FVoxelSeasonStrate
*/ */
struct VOXELFORGE_API FVoxelSeasonManifest struct VOXELFORGE_API FVoxelSeasonManifest
{ {
static constexpr int32 CurrentSchemaVersion = 1; static constexpr int32 CurrentSchemaVersion = 2;
bool bValid = false; bool bValid = false;
int32 SchemaVersion = CurrentSchemaVersion; int32 SchemaVersion = CurrentSchemaVersion;
@@ -204,6 +200,9 @@ struct VOXELFORGE_API FVoxelSeasonManifest
int32 Season = 0; int32 Season = 0;
int32 Seed = 0; int32 Seed = 0;
uint32 InputCorpusHash = 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 CandidateCount = 0;
int32 SurvivorCount = 0; int32 SurvivorCount = 0;
int32 SelectedCount = 0; int32 SelectedCount = 0;
@@ -230,15 +229,17 @@ struct VOXELFORGE_API FVoxelSeasonManifest
bool IsUsable() const 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) || Strates.Num() != SelectedCount || SelectedCount <= 0)
{ {
return false; return false;
} }
for (const FVoxelSeasonStrate& Strate : Strates) for (const FVoxelSeasonStrate& Strate : Strates)
{ {
if (Strate.bUsesRegions // Region composition is intentionally not a shippable season format yet. Keeping
&& (!Strate.Regions.IsValid() || Strate.Regions.RegionCount <= 1)) // 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; return false;
} }
@@ -252,16 +253,24 @@ VOXELFORGE_API const TCHAR* VF_GetVoxelSeasonSelectionReasonName(
EVoxelSeasonSelectionReason Reason); EVoxelSeasonSelectionReason Reason);
/** Compose, validate, select, serialize, and (by default) write one season. Editor/build-box only. */ /** Compose, validate, select, serialize, and (by default) write one season. Editor/build-box only. */
#if WITH_EDITOR
VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason( VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason(
int32 SeasonSeed, const FVoxelSeasonCompositionSettings& Settings); int32 SeasonSeed, const FVoxelSeasonCompositionSettings& Settings);
/** Convenience overload that adapts the existing runtime settings asset. */ /** Convenience overload that adapts the existing runtime settings asset. */
VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason( VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason(
int32 SeasonSeed, const UVoxelSettings* WorldSettings); int32 SeasonSeed, const UVoxelSettings* WorldSettings);
#endif
/** Serialize/read the complete Tier A manifest as stable pretty JSON. */ /** Serialize/read the complete Tier A manifest as stable pretty JSON. */
VOXELFORGE_API FString VF_SerializeVoxelSeasonManifest( VOXELFORGE_API FString VF_SerializeVoxelSeasonManifest(
const FVoxelSeasonManifest& Manifest); 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( VOXELFORGE_API bool VF_SaveVoxelSeasonManifest(
const FString& ManifestPath, const FVoxelSeasonManifest& Manifest, FString& OutReport); const FString& ManifestPath, const FVoxelSeasonManifest& Manifest, FString& OutReport);
VOXELFORGE_API bool VF_LoadVoxelSeasonManifest( VOXELFORGE_API bool VF_LoadVoxelSeasonManifest(
+12
View File
@@ -13,6 +13,7 @@
// IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use. // IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use.
class UMaterialInterface; class UMaterialInterface;
class UVoxelSeasonAsset;
UCLASS(BlueprintType) UCLASS(BlueprintType)
class UVoxelSettings : public UPrimaryDataAsset class UVoxelSettings : public UPrimaryDataAsset
@@ -21,6 +22,12 @@ class UVoxelSettings : public UPrimaryDataAsset
public: 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) // STREAMING (distance de vue)
//========================================================================= //=========================================================================
@@ -350,6 +357,11 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
int32 Seed = 0; 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. // Numéro de saison (incrementé par ChangeSeed). Purement informatif côté gameplay.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
int32 CurrentSeason = 1; int32 CurrentSeason = 1;
@@ -356,7 +356,9 @@ VOXELFORGE_API void VF_SetStrateArchetypeRuntimeBounds(
FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ); FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ);
/** Stable identity helpers for the deterministic lateral partition. */ /** Stable identity helpers for the deterministic lateral partition. */
#if WITH_EDITOR
VOXELFORGE_API int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex); VOXELFORGE_API int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex);
#endif
VOXELFORGE_API uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex); VOXELFORGE_API uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex);
VOXELFORGE_API void VF_RekeyStrateRegionManifest( VOXELFORGE_API void VF_RekeyStrateRegionManifest(
FVoxelStrateRegionManifest& Manifest, int32 Seed, int32 StrateIndex); FVoxelStrateRegionManifest& Manifest, int32 Seed, int32 StrateIndex);
+21 -5
View File
@@ -20,13 +20,12 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "VoxelStrateTypes.h" #include "VoxelStrateTypes.h"
#include "VoxelStrateDefinition.h" #include "VoxelStrateDefinition.h"
#include "VoxelSeasonManifest.h"
#include "VoxelStrateManager.generated.h" #include "VoxelStrateManager.generated.h"
class UVoxelSettings; class UVoxelSettings;
#if WITH_EDITOR #if WITH_EDITOR
struct FVoxelStrateArchetypeParams;
struct FVoxelOpStackRecipe;
struct FVoxelStrateRegionManifest; struct FVoxelStrateRegionManifest;
struct FVoxelStrateComposerSlotOverride; struct FVoxelStrateComposerSlotOverride;
#endif #endif
@@ -148,7 +147,21 @@ public:
* @param Settings - VoxelSettings with pool/fixed strate config * @param Settings - VoxelSettings with pool/fixed strate config
* @param WorldSeed - Seed for randomizing non-fixed strates * @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 #if WITH_EDITOR
/** /**
@@ -172,8 +185,6 @@ public:
bool GetComposerRegionOverrideForChunk( bool GetComposerRegionOverrideForChunk(
const FIntVector& ChunkCoord, FVoxelStrateRegionManifest& OutRegions) const; const FIntVector& ChunkCoord, FVoxelStrateRegionManifest& OutRegions) const;
/** Custom recipes cannot be safely classified by the native ClassifyBox proof yet. */
bool HasComposerRecipeOverride() const;
#endif #endif
//========================================================================= //=========================================================================
@@ -356,6 +367,11 @@ protected:
UPROPERTY() UPROPERTY()
TArray<FStrateSlot> StrateLayout; 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 // Passages connecting consecutive strates
TArray<FVoxelPassage> Passages; TArray<FVoxelPassage> Passages;
+4
View File
@@ -308,6 +308,10 @@ public:
UFUNCTION(BlueprintPure, Category = "Voxel World|Season") UFUNCTION(BlueprintPure, Category = "Voxel World|Season")
int32 GetCurrentSeason() const; 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) // STRATE QUERIES (gameplay integration)
//========================================================================= //=========================================================================