diff --git a/CODEMAP.md b/CODEMAP.md index dd13107..e66ba3e 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -378,7 +378,7 @@ context. It never touches a generator, operator stack, manager, cache, or mutabl | `EVoxelNoiseType` | 99 | FBM/Ridged/Mixed/Cellular. | | `ECaveGeneratorType` | 146 | TunnelNetwork / FlatPlain / CrystalChamber. | | `EVoxelStrateTransition` | 183 | Gradient / Hard / Interleaved boundary blends. | -| **`FStrateGenerationParams`** | ~350 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, all terrain-op transport fields, boundary seal). `Lerp()` static blends two sets at boundaries — it expands the **`VF_STRATE_PARAM_FIELDS` X-macro** (defined just above the struct): **adding a field to the struct? add it to that list** or blends silently reset it to default. | +| **`FStrateGenerationParams`** | ~350 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, live terrain-detail fields, dead terrain-op transport slots, boundary seal). `Lerp()` static blends two sets at boundaries — it expands the **`VF_STRATE_PARAM_FIELDS` X-macro** (defined just above the struct): **adding a field to the struct? add it to that list** or blends silently reset it to default. | | `FStrateTerrainOpEntry` | 965 | Soft-ptr to a terrain op + Weight + Probability. | | **`FSlabGenerationParams`** | 1019 | Floor/ceiling heights, roughness, columns, seal — for slab generators. | | **`FPlacementProfile`** | ~1747 | **Shared placement vocabulary** for every scatter primitive (`FStrateDecoration`, `FStrateLandmark`, coming `FStrateSetPiece`): spawn (ActorClass/InstancedMesh), Filter gates (surface/slope/overhang/water/RequiredBiome + **F7 awareness `Conditions[]`** — `FTerrainCondition` relief/moisture/biome-border predicates, AND-ed, evaluated by `Generator::EvaluateTerrainConditions`), Transform (align/offsets/RotationOffset+RandomRotation/scale), Render (cull/shadow). Each primitive embeds it as `Profile` + keeps only its own DISTRIBUTION fields. Per-primitive defaults set in each struct's ctor (deco: scale 0.8-1.2 + RandomRotation.Yaw=360; landmark: Ceiling + no align). | @@ -414,7 +414,7 @@ Maps depth→strate at runtime; owns passages. | `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | | `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. | | `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). | -| `BuildParamsFromDefinition` (static) | 771 | Base params + merge all referenced terrain op assets. The one place ops fold into params. | +| `BuildParamsFromDefinition` (static) | 1399 | Returns the definition's base `GenerationParams` only. Terrain-op assets are passed separately to `BuildChunkCache`, which applies one selected op per room and pre-bakes Column/Pit/Chimney features. | **`Public/VoxelTerrainOpDefinition.h` + `.cpp`** — `UVoxelTerrainOpDefinition : UPrimaryDataAsset` (h:67). One asset = one terrain op. `EVoxelTerrainOpType` (h:36): Terrace, LayerLines, @@ -446,13 +446,18 @@ The corpus is grouped by exact `ECaveGeneratorType`: the 2026-09-04 run loaded * vectors + 8 defaults = 12 members** in **8 groups**. Sibling families use their native structs (`FSlabGenerationParams`, `FMazeGenerationParams`, `FSurfaceGenerationParams`, `FVerticalShaftParams`, `FFloatingIslandParams`); no cross-archetype blend is attempted. -Spreads are measured per group, including the plain-native tunnel transport fields for reporting. -Rolls are pure in `(corpus contents, seed, index)`: weighted 2–3-parent same-archetype selection → -`FStrateGenerationParams::Lerp` or reflected native-family blend → ±15% of measured field range -jitter → editor-reflection clamps → ordered-pair repair. Bool `bTunnelsFlowTowardOrigin` inherits -from the dominant parent; integer/enum SNAP fields are not jittered. This API is not called by -runtime generation. The settings audit found **one unique path**, `/Game/VoxelForge/DA_Strate3`, -which explains the old one-vector corpus; the project assets were never in that pool. +Spreads are measured per group, including the plain-native tunnel fields. The fixed-lattice audit +found **23 live terrain-detail fields** (Terrace, LayerLines, Overhang, Ribbing, Cliff, Scallop, +Arch, Dome, Pinch) and **11 dead direct transport fields** (Column, Pit, Chimney); only the latter +remain excluded. Live fields receive one matching `UVoxelTerrainOpDefinition` header-default sample +in addition to authored strate values, so an all-default strate corpus still has useful activation +spread. Rolls are pure in `(corpus contents, seed, index)`: weighted 2–3-parent same-archetype +selection → `FStrateGenerationParams::Lerp` or reflected native-family blend → ±15% of measured +field range jitter → editor-reflection clamps → ordered-pair repair. Bool +`bTunnelsFlowTowardOrigin` inherits from the dominant parent; integer/enum SNAP fields are not +jittered. This API is not called by runtime generation. The settings audit found **one unique path**, +`/Game/VoxelForge/DA_Strate3`, which explains the old one-vector corpus; the project assets were +never in that pool. `FVoxelOpStackRecipe` is the serialisable Tier 4b manifest: root polarity, root/source/conversion class IDs, an ordered modifier ID list, and one native parameter-family ID per entry. Posts are not @@ -526,7 +531,8 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | -| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. Current run: **59/64 survival (92.2%)**, **1,336 Mixed + 549 AllSolid + 675 AllAir = 1,224 proved boxes**, **1,629,144 lattice voxels checked, 0 violations**, 114.845 s total. | +| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. Current detail-enabled run: **59/64 survival (92.2%)**, **1,332 Mixed + 553 AllSolid + 675 AllAir = 1,228 proved boxes**, **1,634,468 lattice voxels checked, 0 violations**, 113.317 s total. Feature scale **0→376**; walkable **0.000000→0.388506**; all 17 TunnelNetwork/Underwater candidates carried live detail activation. | +| ″ | `VoxelForge.Composer.TerrainDetailLiveness` | Fixed 4,096-point `GetDensityAt` lattice, legacy and operator-stack paths; changes one terrain-detail group at a time with an empty terrain-op pool. Proves 9 live groups / 23 fields and 3 dead groups / 11 fields; all 24 rows match. | | `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, checks exact arrival→departure connectivity, rerolls every recipe/stack for determinism, and brute-forces every uniform box verdict. Focused run: **46/64 survival (71.9%)**, **64 distinct recipes**, **RoomGraph 15 / Lattice 19 / Shaft 8 / Island 14 / Noise 8**, **k=4:11 / 5:13 / 6:11 / 7:10 / 8:19**, **489 proved boxes / 650,859 voxels / 0 violations**. | | `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | | `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. | diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index bb737a9..0f90985 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -264,10 +264,11 @@ The hand-authored C++ defaults in `VoxelStrateTypes.h` are also corpus members: `CrystalChamber=1`, `Maze=1`, `SurfaceWorld=2`, `VerticalShafts=1`, `FloatingIslands=2`, and `Underwater=1`. -The complete 253-descriptor spread table is emitted by the automation test, including sample count, -min/max/mean/standard deviation, excluded/reflected status, and clamp metadata. The 230 zero-range -rows are intentionally omitted below; these are the **23 rows with real spread** (float formatting -is the test's measured output): +The complete 253-descriptor spread table is emitted by the automation test, including authored and +synthetic operation-default sample counts, min/max/mean/standard deviation, excluded/reflected +status, and clamp metadata. The current run has **212 zero-range rows** and **41 rows with real +spread**. The shape-spread rows below are retained from the prior report; the newly measured +terrain-detail spread is listed explicitly after the liveness experiment. | Archetype | Field | N | Min | Max | Mean | StdDev | |---|---|---:|---:|---:|---:|---:| @@ -295,54 +296,133 @@ is the test's measured output): | FloatingIslands | TopFlatten | 2 | 0.600000024 | 0.800000012 | 0.700000018 | 0.099999994 | | FloatingIslands | SurfaceRoughness | 2 | 0 | 4 | 2 | 2 | -The reflection/use audit found **36 excluded fields**: the 34 terrain-op transport fields -`TerraceStepHeight`, `TerraceHardness`, `TerraceNoiseDisplacement`, `LayerLineSpacing`, -`LayerLineDepth`, `OverhangStrength`, `OverhangDepth`, `OverhangFrequency`, `RibbingSpacing`, -`RibbingDepth`, `CliffStrength`, `ScallopStrength`, `ScallopFrequency`, `ArchDensity`, -`ArchMinRadius`, `ArchMaxRadius`, `ColumnDensity`, `ColumnMinRadius`, `ColumnMaxRadius`, -`PitDensity`, `PitMinRadius`, `PitMaxRadius`, `PitDepth`, `ChimneyDensity`, `ChimneyMinRadius`, -`ChimneyMaxRadius`, `ChimneyHeight`, `DomeDensity`, `DomeMinRadius`, `DomeMaxRadius`, -`DomeHeightRatio`, `PinchDensity`, `PinchStrength`, `PinchLength` — populated per room by -`UVoxelTerrainOpDefinition` — plus the manager-owned runtime bounds `StrateTopWorldZ` and -`StrateBottomWorldZ`. The tunnel transport fields are measured directly from the native -`FStrateGenerationParams` X-macro even though they are not reflected `UPROPERTY`s; their values are -reported but deliberately not jittered. Same-named fields in sibling structs are not automatically -excluded: the exclusion applies to the tunnel transport slots whose generation source is the -terrain-op pool. The test prints the complete field-by-field table, including excluded fields, -reflection status, and clamp metadata. +The 34 terrain-detail fields now have the following effective spread. `N` is shown as +`authored+op-defaults=effective`; the TunnelNetwork group has 3 authored vectors and Underwater +has 1. The default seed is present only for the 23 live fields, one matching operation type per +field. The values are the test's population statistics (the operation shape defaults happen to be +identical to the FStrate defaults, so only activation fields have non-zero spread here). -The terrain-field source is now traced in code. `VoxelStrateManager.cpp` loads the active strate's -`TerrainOperations` soft-object pool, and `VoxelGenerator.cpp` passes that pool to -`VoxelCaveMorphology::BuildChunkCache`. For each nearest room, `FVoxelTerrainOpDefinition::ApplyTo` -overlays the selected operation onto a local copy (`FStrateGenerationParams LocalTerrainParams = -Params`) before the room operator runs. Thus the 34 values are transported through the strate -params, but their authored cave-detail values come from `UVoxelTerrainOpDefinition` assets selected -by the active strate's terrain-operation pool. Rolling those slots in the strate composer is not a -useful way to roll cave detail: selected room operations overwrite them. It is not a literal -absolute no-op, because the local base copy is still used where no operation overwrites a slot. -The correct future design is a **second corpus** over terrain-op assets plus the active pool's -membership/weights; this task does not build it. The project currently has **1** -`UVoxelTerrainOpDefinition` asset (the Asset Registry count used by the test). +| Field | Tunnel N | Tunnel min..max | Tunnel mean | Tunnel SD | Underwater N | Underwater min..max | Underwater mean | Underwater SD | Result | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---| +| TerraceStepHeight | 3+1=4 | 0..5 | 1.25 | 2.16506351 | 1+1=2 | 0..5 | 2.5 | 2.5 | LIVE | +| TerraceHardness | 3+1=4 | 0.5..0.5 | 0.5 | 0 | 1+1=2 | 0.5..0.5 | 0.5 | 0 | LIVE | +| TerraceNoiseDisplacement | 3+1=4 | 0.5..0.5 | 0.5 | 0 | 1+1=2 | 0.5..0.5 | 0.5 | 0 | LIVE | +| LayerLineSpacing | 3+1=4 | 0..4 | 1 | 1.73205081 | 1+1=2 | 0..4 | 2 | 2 | LIVE | +| LayerLineDepth | 3+1=4 | 0.300000012..0.300000012 | 0.300000012 | 0 | 1+1=2 | 0.300000012..0.300000012 | 0.300000012 | 0 | LIVE | +| OverhangStrength | 3+1=4 | 0..0.5 | 0.125 | 0.216506351 | 1+1=2 | 0..0.5 | 0.25 | 0.25 | LIVE | +| OverhangDepth | 3+1=4 | 5..5 | 5 | 0 | 1+1=2 | 5..5 | 5 | 0 | LIVE | +| OverhangFrequency | 3+1=4 | 0.0599999987..0.0599999987 | 0.0599999987 | 0 | 1+1=2 | 0.0599999987..0.0599999987 | 0.0599999987 | 0 | LIVE | +| RibbingSpacing | 3+1=4 | 0..3 | 0.75 | 1.29903811 | 1+1=2 | 0..3 | 1.5 | 1.5 | LIVE | +| RibbingDepth | 3+1=4 | 0.400000006..0.400000006 | 0.400000006 | 0 | 1+1=2 | 0.400000006..0.400000006 | 0.400000006 | 0 | LIVE | +| CliffStrength | 3+1=4 | 0..0.5 | 0.125 | 0.216506351 | 1+1=2 | 0..0.5 | 0.25 | 0.25 | LIVE | +| ScallopStrength | 3+1=4 | 0..0.5 | 0.125 | 0.216506351 | 1+1=2 | 0..0.5 | 0.25 | 0.25 | LIVE | +| ScallopFrequency | 3+1=4 | 0.100000001..0.100000001 | 0.100000001 | 0 | 1+1=2 | 0.100000001..0.100000001 | 0.100000001 | 0 | LIVE | +| ArchDensity | 3+1=4 | 0..0.100000001 | 0.0250000004 | 0.0433012708 | 1+1=2 | 0..0.100000001 | 0.0500000007 | 0.0500000007 | LIVE | +| ArchMinRadius | 3+1=4 | 3..3 | 3 | 0 | 1+1=2 | 3..3 | 3 | 0 | LIVE | +| ArchMaxRadius | 3+1=4 | 6..6 | 6 | 0 | 1+1=2 | 6..6 | 6 | 0 | LIVE | +| ColumnDensity | 3+0=3 | 0..0 | 0 | 0 | 1+0=1 | 0..0 | 0 | 0 | DEAD | +| ColumnMinRadius | 3+0=3 | 2..2 | 2 | 0 | 1+0=1 | 2..2 | 2 | 0 | DEAD | +| ColumnMaxRadius | 3+0=3 | 5..5 | 5 | 0 | 1+0=1 | 5..5 | 5 | 0 | DEAD | +| PitDensity | 3+0=3 | 0..0 | 0 | 0 | 1+0=1 | 0..0 | 0 | 0 | DEAD | +| PitMinRadius | 3+0=3 | 4..4 | 4 | 0 | 1+0=1 | 4..4 | 4 | 0 | DEAD | +| PitMaxRadius | 3+0=3 | 10..10 | 10 | 0 | 1+0=1 | 10..10 | 10 | 0 | DEAD | +| PitDepth | 3+0=3 | 25..25 | 25 | 0 | 1+0=1 | 25..25 | 25 | 0 | DEAD | +| ChimneyDensity | 3+0=3 | 0..0 | 0 | 0 | 1+0=1 | 0..0 | 0 | 0 | DEAD | +| ChimneyMinRadius | 3+0=3 | 2..2 | 2 | 0 | 1+0=1 | 2..2 | 2 | 0 | DEAD | +| ChimneyMaxRadius | 3+0=3 | 5..5 | 5 | 0 | 1+0=1 | 5..5 | 5 | 0 | DEAD | +| ChimneyHeight | 3+0=3 | 20..20 | 20 | 0 | 1+0=1 | 20..20 | 20 | 0 | DEAD | +| DomeDensity | 3+1=4 | 0..0.150000006 | 0.0375000015 | 0.0649519079 | 1+1=2 | 0..0.150000006 | 0.075000003 | 0.075000003 | LIVE | +| DomeMinRadius | 3+1=4 | 8..8 | 8 | 0 | 1+1=2 | 8..8 | 8 | 0 | LIVE | +| DomeMaxRadius | 3+1=4 | 15..15 | 15 | 0 | 1+1=2 | 15..15 | 15 | 0 | LIVE | +| DomeHeightRatio | 3+1=4 | 0.800000012..0.800000012 | 0.800000012 | 0 | 1+1=2 | 0.800000012..0.800000012 | 0.800000012 | 0 | LIVE | +| PinchDensity | 3+1=4 | 0..0.150000006 | 0.0375000015 | 0.0649519079 | 1+1=2 | 0..0.150000006 | 0.075000003 | 0.075000003 | LIVE | +| PinchStrength | 3+1=4 | 5..5 | 5 | 0 | 1+1=2 | 5..5 | 5 | 0 | LIVE | +| PinchLength | 3+1=4 | 12..12 | 12 | 0 | 1+1=2 | 12..12 | 12 | 0 | LIVE | + +The first pass excluded all **34** terrain-detail fields. That was an unmeasured structural claim, +so `VoxelForge.Composer.TerrainDetailLiveness` now tests it directly. For both the legacy and +operator-stack `GetDensityAt` paths, it uses a fixed **4,096-point** lattice (16×16×16, 4-voxel +spacing, the TunnelNetwork fixture), clears the terrain-operation pool, records a baseline, then +changes only one operation group to substantial values. The two paths produced bit-identical +results. The aggregate verdict is **9 live groups / 23 live fields** and **3 dead groups / 11 dead +fields**: + +| Operation group | Changed samples / 4,096 | Sign changes | Sum abs delta | Max abs delta | Verdict | +|---|---:|---:|---:|---:|---| +| Terrace | 1,314 | 40 | 2,177.12156 | 5.09695816 | LIVE | +| LayerLines | 532 | 8 | 163.082606 | 0.64941144 | LIVE | +| Overhang | 569 | 12 | 598.018486 | 4.41974068 | LIVE | +| Ribbing | 933 | 6 | 213.929947 | 1.49233627 | LIVE | +| Cliff | 391 | 3 | 372.185603 | 2.80882835 | LIVE | +| Scallop | 38 | 0 | 5.78068841 | 1.01755953 | LIVE | +| Arch | 418 | 190 | 7,327.12817 | 36.0000019 | LIVE | +| Column | 0 | 0 | 0 | 0 | DEAD | +| Pit | 0 | 0 | 0 | 0 | DEAD | +| Chimney | 0 | 0 | 0 | 0 | DEAD | +| Dome | 227 | 44 | 3,262.50304 | 24.0000019 | LIVE | +| Pinch | 226 | 22 | 1,217.39489 | 16.3533421 | LIVE | + +The result is identical for both paths, so each row above represents two measured rows. The +baseline mean was `-3.86253725` for every group. Variant means were, respectively, `-3.8647801`, +`-3.82272216`, `-4.00853786`, `-3.91476624`, `-3.86411419`, `-3.86112595`, `-5.6513869`, +`-3.86253725`, `-3.86253725`, `-3.86253725`, `-3.06602772`, and `-4.1597528` in the table's +order. The live fields are therefore removed from the exclusion list and are now eligible for the +roll. Only the 11 direct Column/Pit/Chimney transport fields plus the two runtime Z bounds remain +excluded. + +The dead-field call chain is concrete. `UVoxelStrateManager::BuildParamsFromDefinition` +(`Private/VoxelStrateManager.cpp:1399-1404`) returns only `Definition->GenerationParams`; it does +not merge terrain-op assets. `VoxelGenerator::GetDensityAt` (`Private/VoxelGenerator.cpp:1099-1109`) +gets the active definition's `TerrainOperations` pool and passes it to +`VoxelCaveMorphology::BuildChunkCache`. The cache applies the selected +`UVoxelTerrainOpDefinition` at `Private/VoxelCaveMorphology.cpp:1741`, then consumes +`PitDensity`/shape at `:1744-1764`, `ChimneyDensity`/shape at `:1767-1787`, and +`ColumnDensity`/shape at `:1790-1803`. Sampling uses the resulting `SDFCache.Pits` and +`SDFCache.Chimneys` at `Private/VoxelGenerator.cpp:1147-1206` and `SDFCache.Columns` at +`:1730-1752`; it does not read the direct strata Column/Pit/Chimney slots. The project contains +exactly **1** `UVoxelTerrainOpDefinition` asset, `Content/VoxelForge/Blocks/NewDataAsset.uasset`, +so a second asset corpus would have one member and no useful spread. + +The composer now seeds each of the 23 live fields with **one matching per-operation default** from +the `UVoxelTerrainOpDefinition` header (applied through `ApplyTo`, with no duplicated constants), +then adds any authored strate values. The current TunnelNetwork group has 3 authored samples + 1 +operation-default sample; Underwater has 1 + 1. The dead fields receive no synthetic seed and stay +excluded. The complete test table exposes `authored`, `op-defaults`, and effective `samples`. + +Recommendation for the dead trio: do **not** build an asset corpus for the one current asset. The +right future composer abstraction is direct terrain-op parameter structs/configurations, seeded by +the per-operation defaults and any authored op values, with the rolled op payload carried alongside +`FVoxelStrateRollInfo` and injected into the transient candidate's terrain-op pool. That requires a +small candidate/materialisation API and a populated-pool liveness/box test; simply jittering the +current direct FStrate slots would remain a silent no-op. This task rolls every field that the +existing direct strata path can actually move and leaves the dead trio neutral until that API exists. The bool policy is explicit: `bTunnelsFlowTowardOrigin` is inherited from the dominant parent. `OriginRoomMaxConnections` and `RoughnessNoiseType` retain `Lerp`'s existing SNAP behavior and are not jittered. In this editor build clamp metadata was available while the table was built; it is **not promised in a cooked runtime**, so the commandlet/cook-time bake remains owed. -The fed 64-candidate run (step 4 sweep, radius 256, max 8,000,000 cells, fixture seed 1337) -produced **59/64 survivors (92.2%)**. There were 60 non-vacuous candidates, 60 with largest -component share ≥ 0.50, 63 exact unsnapped arrival→departure law passes, and no roll failures. -The complete 64-row table is emitted by `VoxelForge.Composer.ParameterRoll`; each row reports -archetype, weighted parents, air fraction, largest share, walkable fraction, feature scale, and -arrival→departure verdict. The current run took **114.845 s** total (**0.014 s** corpus load, -**114.827 s** roll/measurement). +The controlled 64-candidate run (step 4 sweep, radius 256, max 8,000,000 cells, fixture seed 1337) +kept the existing corpus hash/RNG stream while enabling the live detail fields. It produced +**59/64 survivors (92.2%)**: 60 non-vacuous candidates, 60 with largest component share ≥ 0.50, +63 exact unsnapped arrival→departure law passes, and no roll failures. The complete 64-row table +is emitted by `VoxelForge.Composer.ParameterRoll`; each row reports archetype, weighted parents, +air fraction, largest share, walkable fraction, feature scale, and arrival→departure verdict. +Feature scale was **0→376** (the prior `190ff38` run was **4→376**), while walkable fraction was +**0.000000→0.388506** (prior **0.000→0.389**). Thus detail variation widened the feature-scale +range only at the low end by four voxels; it did not increase the upper scale or the walkable +spread. The 17 TunnelNetwork/Underwater candidates all carried at least one non-zero live detail +activation, and each of Terrace/LayerLines/Overhang/Ribbing/Cliff/Scallop/Arch/Dome/Pinch appeared +non-zero in all 17. The measurement is therefore not broadly sensitive to these detail fields in +this 64-candidate corpus, which is an empirical result, not a reason to remove the live knobs. The +controlled run took **113.317 s** total (**0.015 s** corpus load, **113.300 s** roll/measurement). -The §6.2 check is no longer vacuous: **1,224 boxes proved**, **1,629,144 voxels checked**, -**0 violations**. Its verdict mix was 1,336 Mixed, 549 AllSolid, and 675 AllAir; the law check -reported no violating candidate. The box law used unit step and brute-forced every voxel of each -uniform box, while the candidate sweep retained step 4. The test asserts zero violations and keeps -the law open to future changed candidates rather than treating this clean run as a license to tune -the survival rate. +The §6.2 check remains non-vacuous: **1,228 boxes proved**, **1,634,468 voxels checked**, and +**0 violations**. Its verdict mix was 1,332 Mixed, 553 AllSolid, and 675 AllAir; no candidate +violated the box law. The box law used unit step and brute-forced every voxel of each uniform box, +while the candidate sweep retained step 4. The test asserts zero violations and keeps the law open +to future changed candidates rather than treating this clean run as a license to tune the survival +rate. ### 3.4 The measurement pass — one grid, one flood fill, three jobs diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp index 96dff41..4653807 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerParameterRollTest.cpp @@ -330,6 +330,251 @@ namespace } return Report; } + + enum class ETerrainDetailProbeGroup : uint8 + { + Terrace, + LayerLines, + Overhang, + Ribbing, + Cliff, + Scallop, + Arch, + Column, + Pit, + Chimney, + Dome, + Pinch, + }; + + struct FTerrainDetailProbeSpec + { + ETerrainDetailProbeGroup Group; + const TCHAR* Name; + bool bExpectedLive; + }; + + static const FTerrainDetailProbeSpec GTerrainDetailProbeSpecs[] = + { + { ETerrainDetailProbeGroup::Terrace, TEXT("Terrace"), true }, + { ETerrainDetailProbeGroup::LayerLines,TEXT("LayerLines"),true }, + { ETerrainDetailProbeGroup::Overhang, TEXT("Overhang"), true }, + { ETerrainDetailProbeGroup::Ribbing, TEXT("Ribbing"), true }, + { ETerrainDetailProbeGroup::Cliff, TEXT("Cliff"), true }, + { ETerrainDetailProbeGroup::Scallop, TEXT("Scallop"), true }, + { ETerrainDetailProbeGroup::Arch, TEXT("Arch"), true }, + { ETerrainDetailProbeGroup::Column, TEXT("Column"), false }, + { ETerrainDetailProbeGroup::Pit, TEXT("Pit"), false }, + { ETerrainDetailProbeGroup::Chimney, TEXT("Chimney"), false }, + { ETerrainDetailProbeGroup::Dome, TEXT("Dome"), true }, + { ETerrainDetailProbeGroup::Pinch, TEXT("Pinch"), true }, + }; + + void VF_ClearTerrainDetailFields(FStrateGenerationParams& Params) + { + Params.TerraceStepHeight = 0.0f; + Params.TerraceHardness = 0.0f; + Params.TerraceNoiseDisplacement = 0.0f; + Params.LayerLineSpacing = 0.0f; + Params.LayerLineDepth = 0.0f; + Params.OverhangStrength = 0.0f; + Params.OverhangDepth = 0.0f; + Params.OverhangFrequency = 0.0f; + Params.RibbingSpacing = 0.0f; + Params.RibbingDepth = 0.0f; + Params.CliffStrength = 0.0f; + Params.ScallopStrength = 0.0f; + Params.ScallopFrequency = 0.0f; + Params.ArchDensity = 0.0f; + Params.ArchMinRadius = 0.0f; + Params.ArchMaxRadius = 0.0f; + Params.ColumnDensity = 0.0f; + Params.ColumnMinRadius = 0.0f; + Params.ColumnMaxRadius = 0.0f; + Params.PitDensity = 0.0f; + Params.PitMinRadius = 0.0f; + Params.PitMaxRadius = 0.0f; + Params.PitDepth = 0.0f; + Params.ChimneyDensity = 0.0f; + Params.ChimneyMinRadius = 0.0f; + Params.ChimneyMaxRadius = 0.0f; + Params.ChimneyHeight = 0.0f; + Params.DomeDensity = 0.0f; + Params.DomeMinRadius = 0.0f; + Params.DomeMaxRadius = 0.0f; + Params.DomeHeightRatio = 0.0f; + Params.PinchDensity = 0.0f; + Params.PinchStrength = 0.0f; + Params.PinchLength = 0.0f; + } + + void VF_SetTerrainDetailProbe(FStrateGenerationParams& Params, + ETerrainDetailProbeGroup Group) + { + switch (Group) + { + case ETerrainDetailProbeGroup::Terrace: + Params.TerraceStepHeight = 12.0f; + Params.TerraceHardness = 0.9f; + Params.TerraceNoiseDisplacement = 1.0f; + break; + case ETerrainDetailProbeGroup::LayerLines: + Params.LayerLineSpacing = 12.0f; + Params.LayerLineDepth = 1.0f; + break; + case ETerrainDetailProbeGroup::Overhang: + Params.OverhangStrength = 1.0f; + Params.OverhangDepth = 12.0f; + Params.OverhangFrequency = 0.03f; + break; + case ETerrainDetailProbeGroup::Ribbing: + Params.RibbingSpacing = 10.0f; + Params.RibbingDepth = 1.5f; + break; + case ETerrainDetailProbeGroup::Cliff: + Params.CliffStrength = 1.0f; + break; + case ETerrainDetailProbeGroup::Scallop: + Params.ScallopStrength = 2.0f; + Params.ScallopFrequency = 0.04f; + break; + case ETerrainDetailProbeGroup::Arch: + Params.ArchDensity = 1.0f; + Params.ArchMinRadius = 8.0f; + Params.ArchMaxRadius = 14.0f; + break; + case ETerrainDetailProbeGroup::Column: + Params.ColumnDensity = 1.0f; + Params.ColumnMinRadius = 8.0f; + Params.ColumnMaxRadius = 14.0f; + break; + case ETerrainDetailProbeGroup::Pit: + Params.PitDensity = 1.0f; + Params.PitMinRadius = 8.0f; + Params.PitMaxRadius = 14.0f; + Params.PitDepth = 64.0f; + break; + case ETerrainDetailProbeGroup::Chimney: + Params.ChimneyDensity = 1.0f; + Params.ChimneyMinRadius = 8.0f; + Params.ChimneyMaxRadius = 14.0f; + Params.ChimneyHeight = 64.0f; + break; + case ETerrainDetailProbeGroup::Dome: + Params.DomeDensity = 1.0f; + Params.DomeMinRadius = 14.0f; + Params.DomeMaxRadius = 24.0f; + Params.DomeHeightRatio = 1.25f; + break; + case ETerrainDetailProbeGroup::Pinch: + Params.PinchDensity = 1.0f; + Params.PinchStrength = 12.0f; + Params.PinchLength = 32.0f; + break; + } + } + + void VF_BuildTerrainDetailLattice(const VoxelForgeTest::FTestWorld& World, + TArray& OutPoints) + { + int32 TopVoxelZ = 0; + int32 BottomVoxelZ = 0; + OutPoints.Reset(); + if (!World.GetSlotVoxelZRange(VoxelForgeTest::FTestWorld::SlotTunnelNetwork, + TopVoxelZ, BottomVoxelZ)) + { + return; + } + + constexpr int32 Side = 16; + constexpr int32 Step = 4; + const int32 CentreZ = (TopVoxelZ + BottomVoxelZ) / 2; + OutPoints.Reserve(Side * Side * Side); + for (int32 IZ = 0; IZ < Side; ++IZ) + for (int32 IY = 0; IY < Side; ++IY) + for (int32 IX = 0; IX < Side; ++IX) + { + OutPoints.Add(FVector( + (float)(-32 + IX * Step), + (float)(-32 + IY * Step), + (float)(CentreZ - 32 + IZ * Step))); + } + } + + struct FTerrainDetailDensitySummary + { + uint32 Digest = 2166136261u; + double Mean = 0.0; + double Min = 0.0; + double Max = 0.0; + }; + + FTerrainDetailDensitySummary VF_SampleTerrainDetailLattice( + const VoxelForgeTest::FTestWorld& World, + const TArray& Points, + TArray& OutDensities) + { + FTerrainDetailDensitySummary Summary; + OutDensities.Reset(Points.Num()); + if (Points.Num() == 0 || !World.Generator.IsValid()) + { + return Summary; + } + + Summary.Min = FLT_MAX; + Summary.Max = -FLT_MAX; + double Sum = 0.0; + for (const FVector& Point : Points) + { + const float Density = World.Generator->GetDensityAt(Point.X, Point.Y, Point.Z); + OutDensities.Add(Density); + uint32 Bits = 0; + FMemory::Memcpy(&Bits, &Density, sizeof(Bits)); + Summary.Digest ^= Bits; + Summary.Digest *= 16777619u; + Summary.Min = FMath::Min(Summary.Min, (double)Density); + Summary.Max = FMath::Max(Summary.Max, (double)Density); + Sum += Density; + } + Summary.Mean = Sum / (double)Points.Num(); + return Summary; + } + + struct FTerrainDetailDensityDelta + { + int32 Changed = 0; + int32 SignChanged = 0; + double SumAbs = 0.0; + double MaxAbs = 0.0; + }; + + FTerrainDetailDensityDelta VF_CompareTerrainDetailSamples( + const TArray& Baseline, + const TArray& Variant) + { + FTerrainDetailDensityDelta Delta; + if (Baseline.Num() != Variant.Num()) + { + return Delta; + } + for (int32 Index = 0; Index < Baseline.Num(); ++Index) + { + const float A = Baseline[Index]; + const float B = Variant[Index]; + if (!VoxelForgeTest::BitEqual(A, B)) + { + ++Delta.Changed; + } + if ((A < 0.0f) != (B < 0.0f)) + { + ++Delta.SignChanged; + } + const double AbsDelta = FMath::Abs((double)B - (double)A); + Delta.SumAbs += AbsDelta; + Delta.MaxAbs = FMath::Max(Delta.MaxAbs, AbsDelta); + } + return Delta; + } } IMPLEMENT_SIMPLE_AUTOMATION_TEST( @@ -390,7 +635,7 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) AddInfo(FString::Printf( TEXT("Corpus membership: %d project strate assets discovered, %d project vectors loaded, " "%d defaults, %d total members. Terrain-op Asset Registry count (not rolled in this " - "task): %d."), + "task; live fields use header defaults instead): %d."), ProjectStrateAssets.Num(), NumProjectCorpusEntries, NumDefaultCorpusEntries, Corpus.Num(), ProjectTerrainOpAssets.Num())); AddInfo(CorpusMembership); @@ -422,19 +667,57 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) TestEqual(TEXT("all project strate assets plus eight defaults are corpus members"), Corpus.Num(), ProjectStrateAssets.Num() + 8); + static const TCHAR* LiveTerrainDetailFields[] = + { + TEXT("TerraceStepHeight"), TEXT("TerraceHardness"), TEXT("TerraceNoiseDisplacement"), + TEXT("LayerLineSpacing"), TEXT("LayerLineDepth"), + TEXT("OverhangStrength"), TEXT("OverhangDepth"), TEXT("OverhangFrequency"), + TEXT("RibbingSpacing"), TEXT("RibbingDepth"), TEXT("CliffStrength"), + TEXT("ScallopStrength"), TEXT("ScallopFrequency"), TEXT("ArchDensity"), + TEXT("ArchMinRadius"), TEXT("ArchMaxRadius"), + TEXT("DomeDensity"), TEXT("DomeMinRadius"), TEXT("DomeMaxRadius"), + TEXT("DomeHeightRatio"), TEXT("PinchDensity"), TEXT("PinchStrength"), + TEXT("PinchLength"), + }; + static const TCHAR* DeadTerrainDetailFields[] = + { + TEXT("ColumnDensity"), TEXT("ColumnMinRadius"), TEXT("ColumnMaxRadius"), + TEXT("PitDensity"), TEXT("PitMinRadius"), TEXT("PitMaxRadius"), TEXT("PitDepth"), + TEXT("ChimneyDensity"), TEXT("ChimneyMinRadius"), TEXT("ChimneyMaxRadius"), + TEXT("ChimneyHeight"), + }; + for (const TCHAR* FieldName : LiveTerrainDetailFields) + { + const FVoxelStrateFieldSpread* Spread = Corpus.FindSpread( + ECaveGeneratorType::TunnelNetwork, FString(FieldName)); + TestTrue(FString::Printf(TEXT("live terrain field %s is no longer excluded"), FieldName), + Spread != nullptr && !Spread->bExcluded); + } + for (const TCHAR* FieldName : DeadTerrainDetailFields) + { + const FVoxelStrateFieldSpread* Spread = Corpus.FindSpread( + ECaveGeneratorType::TunnelNetwork, FString(FieldName)); + TestTrue(FString::Printf(TEXT("dead terrain field %s remains excluded"), FieldName), + Spread != nullptr && Spread->bExcluded); + } + TestEqual(TEXT("exactly eleven direct terrain transport fields remain excluded"), + static_cast(UE_ARRAY_COUNT(DeadTerrainDetailFields)), 11); + FString SpreadTable = TEXT( "CORPUS SPREAD (population stddev; jitter range = ±15% of max-min; clamps are safety only)\n" - "archetype | struct | field | kind | excluded | reflected | samples | min | max | mean | stddev | clamp\n"); + "archetype | struct | field | kind | excluded | reflected | authored | op-defaults | samples | min | max | mean | stddev | clamp\n"); for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads()) { SpreadTable += FString::Printf( - TEXT("%s | %s | %s | %s | %s | %s | %d | %s | %s | %s | %s | %s\n"), + TEXT("%s | %s | %s | %s | %s | %s | %d | %d | %d | %s | %s | %s | %s | %s\n"), VF_GetStrateArchetypeName(Spread.Archetype), *Spread.ParamStructName, *Spread.FieldName, VF_FieldKindName(Spread.Kind), Spread.bExcluded ? TEXT("yes") : TEXT("no"), Spread.bReflected ? TEXT("yes") : TEXT("no"), + Spread.AuthoredSampleCount, + Spread.DefaultSeedCount, Spread.SampleCount, *VF_FormatSpreadValue(Spread.Min), *VF_FormatSpreadValue(Spread.Max), @@ -455,8 +738,10 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) "Bool policy: bTunnelsFlowTowardOrigin is inherited from the dominant weighted parent; " "int/enum SNAP fields use the native family blend and are not jittered. Parent selection " "is weight-1.0 (equal) because UVoxelStrateDefinition has no corpus-weight field; parents " - "are selected only within one exact archetype group. FStrateGenerationParams terrain-op " - "transport fields remain excluded; the sibling families' authored fields are rolled.")); + "are selected only within one exact archetype group. The 23 empirically live terrain-detail " + "fields are rolled, with one per-operation header-default sample added to their spread; " + "only the 11 empirically dead Column/Pit/Chimney transport fields remain excluded. The " + "sibling families' authored fields are rolled.")); TestTrue(TEXT("the reflected parameter schema is covered or explicitly excluded"), Corpus.IsSchemaValid()); @@ -509,6 +794,9 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) int32 TotalBoxProved = 0; int64 TotalBoxCheckedVoxels = 0; int32 TotalBoxViolations = 0; + int32 NumTunnelLikeCandidates = 0; + int32 NumCandidatesWithLiveDetail = 0; + int32 LiveActivationVariationCounts[9] = {}; TArray BoxViolationCandidates; FString FirstBoxViolation; @@ -534,6 +822,41 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) continue; } + if (Roll.Archetype == ECaveGeneratorType::TunnelNetwork + || Roll.Archetype == ECaveGeneratorType::Underwater) + { + const FStrateGenerationParams& DetailParams = + Roll.ArchetypeParams.TunnelNetworkParams; + const float LiveActivationValues[] = + { + DetailParams.TerraceStepHeight, + DetailParams.LayerLineSpacing, + DetailParams.OverhangStrength, + DetailParams.RibbingSpacing, + DetailParams.CliffStrength, + DetailParams.ScallopStrength, + DetailParams.ArchDensity, + DetailParams.DomeDensity, + DetailParams.PinchDensity, + }; + ++NumTunnelLikeCandidates; + bool bHasLiveDetail = false; + for (int32 DetailIndex = 0; + DetailIndex < static_cast(UE_ARRAY_COUNT(LiveActivationValues)); + ++DetailIndex) + { + if (LiveActivationValues[DetailIndex] != 0.0f) + { + bHasLiveDetail = true; + ++LiveActivationVariationCounts[DetailIndex]; + } + } + if (bHasLiveDetail) + { + ++NumCandidatesWithLiveDetail; + } + } + bool bParentsMatchArchetype = true; for (const int32 ParentIndex : Roll.ParentEntryIndices) { @@ -682,6 +1005,31 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) LargestComponentSurvivalThreshold, NumNonVacuous, NumLargestEnough, NumLawPasses, NumRollFailures, RollAndMeasureSeconds)); + AddInfo(FString::Printf( + TEXT("Live detail activation coverage: %d/%d TunnelNetwork/Underwater candidates had at " + "least one non-zero live activation; Terrace/Layer/Overhang/Ribbing/Cliff/Scallop/" + "Arch/Dome/Pinch non-zero candidate counts = %d/%d/%d/%d/%d/%d/%d/%d/%d."), + NumCandidatesWithLiveDetail, NumTunnelLikeCandidates, + LiveActivationVariationCounts[0], LiveActivationVariationCounts[1], + LiveActivationVariationCounts[2], LiveActivationVariationCounts[3], + LiveActivationVariationCounts[4], LiveActivationVariationCounts[5], + LiveActivationVariationCounts[6], LiveActivationVariationCounts[7], + LiveActivationVariationCounts[8])); + TestTrue(TEXT("at least one TunnelNetwork/Underwater candidate carries live detail variation"), + NumCandidatesWithLiveDetail > 0); + static const TCHAR* LiveActivationNames[] = + { + TEXT("Terrace"), TEXT("LayerLines"), TEXT("Overhang"), TEXT("Ribbing"), TEXT("Cliff"), + TEXT("Scallop"), TEXT("Arch"), TEXT("Dome"), TEXT("Pinch"), + }; + for (int32 DetailIndex = 0; + DetailIndex < static_cast(UE_ARRAY_COUNT(LiveActivationNames)); ++DetailIndex) + { + TestTrue(FString::Printf(TEXT("live %s activation appears in a rolled candidate"), + LiveActivationNames[DetailIndex]), + LiveActivationVariationCounts[DetailIndex] > 0); + } + AddInfo(FString::Printf( TEXT("Rolled §6.2 box verdict check: mixed=%d, AllSolid=%d, AllAir=%d, proved=%d, " "brute-force lattice samples=%lld, violations=%d; violating candidates=%s. " @@ -716,4 +1064,137 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters) return true; } +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeTerrainDetailLivenessTest, + "VoxelForge.Composer.TerrainDetailLiveness", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeTerrainDetailLivenessTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + (void)Parameters; + + constexpr int32 ExpectedLatticeSamples = 16 * 16 * 16; + FString Table = TEXT( + "TERRAIN DETAIL LIVENESS (same 4096-point lattice; only one group changed per row)\n" + "path | group | samples | baseline digest | variant digest | baseline mean | variant mean | " + "changed | sign changes | sum abs delta | max abs delta | verdict\n"); + int32 TotalRows = 0; + int32 FailedRows = 0; + + for (const bool bUseOperatorStack : { false, true }) + { + FTestWorld World; + World.Build(1337, 2, bUseOperatorStack); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + UVoxelStrateDefinition* Definition = + World.Definitions[FTestWorld::SlotTunnelNetwork].Get(); + if (Definition == nullptr) + { + AddError(TEXT("The fixture has no TunnelNetwork definition for the liveness experiment.")); + return false; + } + Definition->TerrainOperations.Reset(); + TestEqual( + FString::Printf(TEXT("%s fixture has no terrain-op asset pool"), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy")), + Definition->TerrainOperations.Num(), 0); + + TArray Points; + VF_BuildTerrainDetailLattice(World, Points); + TestEqual( + FString::Printf(TEXT("%s experiment uses the fixed lattice"), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy")), + Points.Num(), ExpectedLatticeSamples); + + FStrateGenerationParams BaselineParams = Definition->GenerationParams; + VF_ClearTerrainDetailFields(BaselineParams); + TArray FirstBaseline; + bool bHaveFirstBaseline = false; + + for (const FTerrainDetailProbeSpec& Spec : GTerrainDetailProbeSpecs) + { + Definition->GenerationParams = BaselineParams; + World.Reinitialize(); + + TArray BaselineSamples; + const FTerrainDetailDensitySummary BaselineSummary = + VF_SampleTerrainDetailLattice(World, Points, BaselineSamples); + if (!bHaveFirstBaseline) + { + FirstBaseline = BaselineSamples; + bHaveFirstBaseline = true; + } + else + { + bool bSameBaseline = FirstBaseline.Num() == BaselineSamples.Num(); + for (int32 SampleIndex = 0; bSameBaseline && SampleIndex < FirstBaseline.Num(); ++SampleIndex) + { + bSameBaseline = BitEqual(FirstBaseline[SampleIndex], BaselineSamples[SampleIndex]); + } + TestTrue( + FString::Printf(TEXT("%s baseline is stable before %s probe"), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"), + Spec.Name), + bSameBaseline); + } + + FStrateGenerationParams VariantParams = BaselineParams; + VF_SetTerrainDetailProbe(VariantParams, Spec.Group); + Definition->GenerationParams = VariantParams; + World.Reinitialize(); + + TArray VariantSamples; + const FTerrainDetailDensitySummary VariantSummary = + VF_SampleTerrainDetailLattice(World, Points, VariantSamples); + const FTerrainDetailDensityDelta Delta = + VF_CompareTerrainDetailSamples(BaselineSamples, VariantSamples); + + const bool bObservedLive = Delta.Changed > 0; + const bool bVerdictMatches = bObservedLive == Spec.bExpectedLive; + ++TotalRows; + if (!bVerdictMatches) + { + ++FailedRows; + } + TestTrue( + FString::Printf(TEXT("%s %s field group has the measured liveness verdict"), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"), + Spec.Name), + bVerdictMatches); + + Table += FString::Printf( + TEXT("%s | %s | %d | 0x%08x | 0x%08x | %.9g | %.9g | %d | %d | %.9g | %.9g | %s\n"), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"), + Spec.Name, + Points.Num(), + BaselineSummary.Digest, + VariantSummary.Digest, + BaselineSummary.Mean, + VariantSummary.Mean, + Delta.Changed, + Delta.SignChanged, + Delta.SumAbs, + Delta.MaxAbs, + bObservedLive ? TEXT("LIVE") : TEXT("DEAD")); + } + } + + AddInfo(Table); + AddInfo(FString::Printf( + TEXT("Terrain detail liveness: %d group/path rows measured, %d mismatches; LIVE groups " + "must have at least one bit-changed density and DEAD groups must have none. No " + "terrain-operation assets were present in the fixture pool."), + TotalRows, FailedRows)); + TestEqual(TEXT("all terrain detail liveness rows matched their measured verdict"), FailedRows, 0); + TestEqual(TEXT("both density paths measured every terrain detail group"), TotalRows, + static_cast(2 * UE_ARRAY_COUNT(GTerrainDetailProbeSpecs))); + return FailedRows == 0; +} + #endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index f41ed78..3862f54 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -13,6 +13,7 @@ #include "VoxelSettings.h" #include "VoxelStrateDefinition.h" +#include "VoxelTerrainOpDefinition.h" #include "VoxelDensityOpStack.h" #include @@ -547,6 +548,88 @@ namespace return VF_WriteRuntimeField(Memory, Archetype, FieldName, Value); } + const TCHAR* VF_GetTerrainOperationFieldPrefix(EVoxelTerrainOpType Type) + { + switch (Type) + { + case EVoxelTerrainOpType::Terrace: return TEXT("Terrace"); + case EVoxelTerrainOpType::LayerLines: return TEXT("LayerLine"); + case EVoxelTerrainOpType::Ribbing: return TEXT("Ribbing"); + case EVoxelTerrainOpType::Cliff: return TEXT("Cliff"); + case EVoxelTerrainOpType::Scallop: return TEXT("Scallop"); + case EVoxelTerrainOpType::Overhang: return TEXT("Overhang"); + case EVoxelTerrainOpType::Arch: return TEXT("Arch"); + case EVoxelTerrainOpType::Column: return TEXT("Column"); + case EVoxelTerrainOpType::Pit: return TEXT("Pit"); + case EVoxelTerrainOpType::Chimney: return TEXT("Chimney"); + case EVoxelTerrainOpType::Dome: return TEXT("Dome"); + case EVoxelTerrainOpType::Pinch: return TEXT("Pinch"); + default: return TEXT(""); + } + } + + void VF_AddTerrainOperationDefaultSamples( + TArray>& Values, + const TArray& Spreads) + { + if (Values.Num() != Spreads.Num()) + { + return; + } + + UVoxelTerrainOpDefinition* Operation = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + if (Operation == nullptr) + { + return; + } + + static const EVoxelTerrainOpType OperationTypes[] = + { + EVoxelTerrainOpType::Terrace, + EVoxelTerrainOpType::LayerLines, + EVoxelTerrainOpType::Ribbing, + EVoxelTerrainOpType::Cliff, + EVoxelTerrainOpType::Scallop, + EVoxelTerrainOpType::Overhang, + EVoxelTerrainOpType::Arch, + EVoxelTerrainOpType::Column, + EVoxelTerrainOpType::Pit, + EVoxelTerrainOpType::Chimney, + EVoxelTerrainOpType::Dome, + EVoxelTerrainOpType::Pinch, + }; + + for (const EVoxelTerrainOpType Type : OperationTypes) + { + const TCHAR* Prefix = VF_GetTerrainOperationFieldPrefix(Type); + Operation->Type = Type; + FStrateGenerationParams Defaults; + Operation->ApplyTo(Defaults); + + // Seed only the fields owned by this operation. The values come from the actual + // UVoxelTerrainOpDefinition UPROPERTY initializers through ApplyTo; no second asset + // corpus and no duplicated default constants are introduced here. + for (int32 FieldIndex = 0; FieldIndex < Spreads.Num(); ++FieldIndex) + { + const FVoxelStrateFieldSpread& Spread = Spreads[FieldIndex]; + if (!VF_IsTunnelArchetype(Spread.Archetype) + || Spread.bExcluded + || !Spread.FieldName.StartsWith(Prefix)) + { + continue; + } + + double Value = 0.0; + if (VF_ReadNamedField(&Defaults, Spread.Archetype, Spread.FieldName, Value) + && FMath::IsFinite(Value)) + { + Values[FieldIndex].Add(Value); + } + } + } + } + void VF_AddPropertyMetadata(FVoxelStrateFieldSpread& Spread, const FProperty* Property) { #if WITH_EDITOR @@ -607,26 +690,11 @@ namespace OutIndices.Add(VF_SpreadKey(Archetype, Name), OutSpreads.Num() - 1); } - void VF_ResetNonTunableFields(FStrateGenerationParams& Params) + void VF_ResetDeadTerrainTransportFields(FStrateGenerationParams& Params) { - // These are terrain-op transport values (written per room by UVoxelTerrainOpDefinition) - // plus manager-owned runtime Z bounds. They are not rolled in this composer pass. - Params.TerraceStepHeight = 0.0f; - Params.TerraceHardness = 0.0f; - Params.TerraceNoiseDisplacement = 0.0f; - Params.LayerLineSpacing = 0.0f; - Params.LayerLineDepth = 0.0f; - Params.OverhangStrength = 0.0f; - Params.OverhangDepth = 0.0f; - Params.OverhangFrequency = 0.0f; - Params.RibbingSpacing = 0.0f; - Params.RibbingDepth = 0.0f; - Params.CliffStrength = 0.0f; - Params.ScallopStrength = 0.0f; - Params.ScallopFrequency = 0.0f; - Params.ArchDensity = 0.0f; - Params.ArchMinRadius = 0.0f; - Params.ArchMaxRadius = 0.0f; + // Empirical liveness proved these direct FStrate slots are dead: the runtime bakes these + // three operation families from UVoxelTerrainOpDefinition values into room caches before + // sampling. Keep them at their neutral values until terrain-op structs are rolled too. Params.ColumnDensity = 0.0f; Params.ColumnMinRadius = 0.0f; Params.ColumnMaxRadius = 0.0f; @@ -638,13 +706,6 @@ namespace Params.ChimneyMinRadius = 0.0f; Params.ChimneyMaxRadius = 0.0f; Params.ChimneyHeight = 0.0f; - Params.DomeDensity = 0.0f; - Params.DomeMinRadius = 0.0f; - Params.DomeMaxRadius = 0.0f; - Params.DomeHeightRatio = 0.0f; - Params.PinchDensity = 0.0f; - Params.PinchStrength = 0.0f; - Params.PinchLength = 0.0f; Params.StrateTopWorldZ = 0.0f; Params.StrateBottomWorldZ = 0.0f; } @@ -662,7 +723,7 @@ namespace { if (VF_IsTunnelArchetype(Archetype)) { - VF_ResetNonTunableFields(Params.TunnelNetworkParams); + VF_ResetDeadTerrainTransportFields(Params.TunnelNetworkParams); } else { @@ -690,7 +751,12 @@ namespace break; } } - if (Spread != nullptr && !Spread->bReflected && !Spread->bExcluded) + double NativeValue = 0.0; + FStrateGenerationParams NativeDefaults; + const bool bNativeFStrateField = VF_IsTunnelArchetype(Archetype) + && VF_ReadFStrateField(NativeDefaults, FieldName, NativeValue); + if (Spread != nullptr && !Spread->bReflected && !Spread->bExcluded + && !bNativeFStrateField) { bSchemaValid = false; VF_AppendSchemaError(SchemaError, @@ -807,6 +873,38 @@ namespace return; } + // FStrateGenerationParams deliberately keeps the terrain-detail transport fields as + // native, non-UPROPERTY members. Handle those fields through the same measured spread + // policy as reflected fields; otherwise removing an exclusion would only change the + // report while the candidate value stayed at its blended parent value. + if (VF_IsTunnelArchetype(Archetype)) + { + for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads()) + { + if (Spread.Archetype != Archetype + || Spread.bReflected + || Spread.bExcluded + || Spread.Kind != EVoxelStrateFieldKind::Continuous) + { + continue; + } + + const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min); + if (!(CorpusRange > 0.0)) + { + continue; + } + + double Value = 0.0; + if (VF_ReadNamedField(Memory, Archetype, Spread.FieldName, Value)) + { + Value += static_cast((Rng.FRand() * 2.0f - 1.0f) * GJitterFraction) + * CorpusRange; + VF_WriteNamedField(Memory, Archetype, Spread.FieldName, Value); + } + } + } + TMap Properties; VF_CollectProperties(Struct, Properties); for (const TPair& Pair : Properties) @@ -1327,44 +1425,21 @@ const TArray& FVoxelStrateCorpus::GetNonTunableField { TArray Result; - const TCHAR* TerrainOpReason = TEXT( - "Transport slot only: generation overlays it per room from a UVoxelTerrainOpDefinition " - "selected through the active strate's TerrainOperations pool; it is not the authored " - "strate-generation source."); - VF_AddExclusion(Result, TEXT("TerraceStepHeight"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("TerraceHardness"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("TerraceNoiseDisplacement"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("LayerLineSpacing"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("LayerLineDepth"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("OverhangStrength"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("OverhangDepth"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("OverhangFrequency"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("RibbingSpacing"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("RibbingDepth"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("CliffStrength"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ScallopStrength"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ScallopFrequency"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ArchDensity"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ArchMinRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ArchMaxRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ColumnDensity"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ColumnMinRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ColumnMaxRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("PitDensity"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("PitMinRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("PitMaxRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("PitDepth"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ChimneyDensity"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ChimneyMinRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ChimneyMaxRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("ChimneyHeight"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("DomeDensity"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("DomeMinRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("DomeMaxRadius"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("DomeHeightRatio"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("PinchDensity"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("PinchStrength"), TerrainOpReason); - VF_AddExclusion(Result, TEXT("PinchLength"), TerrainOpReason); + const TCHAR* DeadTerrainOpReason = TEXT( + "Empirically dead when only this FStrateGenerationParams slot changes: the runtime " + "bakes Column/Pit/Chimney geometry from UVoxelTerrainOpDefinition values into the " + "room cache, so this direct strata slot does not feed GetDensityAt."); + VF_AddExclusion(Result, TEXT("ColumnDensity"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ColumnMinRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ColumnMaxRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("PitDensity"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("PitMinRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("PitMaxRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("PitDepth"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ChimneyDensity"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ChimneyMinRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ChimneyMaxRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ChimneyHeight"), DeadTerrainOpReason); VF_AddExclusion(Result, TEXT("StrateTopWorldZ"), TEXT( "Runtime Z bound supplied by UVoxelStrateManager for the active layout slot; not a tunable.")); @@ -1500,10 +1575,17 @@ void FVoxelStrateCorpus::RebuildSpreads() } } + for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex) + { + FieldSpreads[FieldIndex].AuthoredSampleCount = Values[FieldIndex].Num(); + } + VF_AddTerrainOperationDefaultSamples(Values, FieldSpreads); + for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex) { FVoxelStrateFieldSpread& Spread = FieldSpreads[FieldIndex]; const TArray& Samples = Values[FieldIndex]; + Spread.DefaultSeedCount = Samples.Num() - Spread.AuthoredSampleCount; Spread.SampleCount = Samples.Num(); if (Samples.Num() == 0) { diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index 11e5a9c..4a3641e 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -120,7 +120,7 @@ enum class EVoxelStrateFieldKind : uint8 Enum, }; -/** A field deliberately kept out of the parameter roll. */ +/** A field deliberately kept out of the parameter roll after the measured liveness audit. */ struct VOXELFORGE_API FVoxelStrateFieldExclusion { FString FieldName; @@ -137,6 +137,10 @@ struct VOXELFORGE_API FVoxelStrateFieldSpread bool bExcluded = false; bool bReflected = false; + // Actual authored corpus samples and synthetic per-operation default samples are kept + // separate in the report. SampleCount is their effective total used for the statistics. + int32 AuthoredSampleCount = 0; + int32 DefaultSeedCount = 0; int32 SampleCount = 0; double Min = 0.0; double Max = 0.0; @@ -246,10 +250,10 @@ public: ECaveGeneratorType Archetype, const FString& FieldName) const; const FVoxelStrateFieldSpread* FindSpread(const FString& FieldName) const; - /** Stable hash of source paths, archetypes, weights, and every listed scalar field. */ + /** Stable hash of source paths, archetypes, weights, and every listed scalar corpus field. */ uint32 GetContentsHash() const; - /** Explicit exclusion list discovered from the struct reflection/use audit. */ + /** Explicit exclusion list retained after the fixed-lattice terrain-detail liveness audit. */ static const TArray& GetNonTunableFields(); /** True in an editor build where UPROPERTY metadata was available while spreads were built. */