diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d77f987..e70c263 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,12 +32,53 @@ negative=solid). The final MC-facing edge pass in `GetDensityAt` is repeated aft the global rim cannot be reopened by a post-process; the diff layer remains the explicit player override. StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk` -(macro `VF_ARCHETYPE_PARAMS_GETTER`) — no cross-boundary blend (Hard transitions between archetypes). +(macro `VF_ARCHETYPE_PARAMS_GETTER`) — the ordinary authored path has no cross-boundary blend +(Hard transitions between strates). The offline composer’s lateral manifest is the explicit +within-strate exception described in §8.1a; it blends evaluated density fields, not these params. On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a strate via a window-invariant XY field — currently wired into SurfaceWorld. -⚠️ **The `switch` above is no longer the only density path.** 6 of the 8 archetypes (all but -TunnelNetwork and Underwater) also exist as **operator stacks**, selected per strate by +### 8.1a Lateral regions (Tier 4d) + +An archetype is now allowed to be a **spatial region inside one strate**, rather than a label that +describes the whole strate. The offline composer rolls one to three region records. Each record +owns its own native parameter vector and, for structure candidates, its own recipe; region zero is +also retained as the compatibility identity. A one-region record takes the existing stack path. + +The partition is a pure function of `(world XY, world seed, absolute strate index)`. A 256-voxel +hash lattice places jittered sites (`VoxelHash::Cell`), each site receives a deterministic region +id, and the nearest-site assignment is Voronoi-like. Near a bisector, the nearest different-region +site supplies the neighbour; its weight is the same linear boundary curve used by the vertical +transition, split symmetrically so both sides meet at 0.5. The parent lerps the two **evaluated +internal densities**, never their parameter vectors: a shaft spacing and a slab parameter are not +commensurable. Internal density is positive solid; the parent is negated once at the MC boundary. + +Region cores are built without structural posts. `FLateralRegionBlendOp` resolves the creative +field, then one parent appends the global spine, vertical seal, passage carve, and XY edge seal in +the existing fixed order. No region can disable or duplicate those posts. The manifest is an +offline artifact and the editor hand-off copies it into a worker-local per-chunk cache: the chunk +prepares a small lattice-site window plus an integer XY query grid, so ordinary voxel evaluation is +an O(1) lookup rather than a seed search or allocation. Fractional gradient probes reuse the same +prepared site window. + +The lateral `ClassifyBox` proof is deliberately one-sided. It uses a certified nearest-different +site from the bounded local lattice window plus a 2-Lipschitz bound over the box's XY radius; only +a strict lower bound greater than the blend width proves that one region and no blend band can touch +the box. If the bounded query cannot certify that neighbor, proof is refused. Every other box asks +every region stack; any `Mixed`, disagreement, or unknown result remains `Mixed`. Convex density blending means +that equal all-air/all-solid signs are safe, but no cross-region uniformity is inferred from a +single sample. The same key is checked when a season manifest is loaded, and `WorldRadiusVoxels` +remains zero during offline materialisation. + +This mechanism is implemented and instrumented, but not ready to promote: the seam-specific +16-seed passage-mouth audit currently reports **9/16** connected opposite-region pairs, while the +exact region-zero controls report **11/16**. The same cases are **16/16 valid, non-vacuous, and +above the 0.50 largest-component survival threshold** before the law gate. No corridor or +threshold tuning was added. The failed primordial-law gate is therefore retained as a design +blocker; see `COMPOSER-NOTES.md §3.2b` and the validation log in §10. + +⚠️ **The `switch` above is no longer the only density path.** All 8 archetypes also exist as +**operator stacks**, selected per strate by `bUseOperatorStack` and evaluated instead of the `switch`; each is bit-identical to the function in its row. The design lives in `OPSTACK-PLAN.md` / `OPSTACK-DECOMPOSITION.md`, the symbol index in `CODEMAP §3.2d` — not repeated here. What matters for *this* document: the archetype table describes @@ -392,6 +433,13 @@ driven by `EditorBrush*` props. the stack entry. `ValidateChannelOrder` is assembly/diagnostic-only; no declaration virtual may enter `Eval`'s per-voxel loop. If `FVoxelOpSample` gains a field, add its channel bit and update every operator declaration and the validator together. +- **Lateral region lookup/blend** (§8.1a): `FVoxelStrateRegionPartitionCache::PrepareForChunk` + builds the bounded jittered-site window and a `(CHUNK_SIZE+3)^2` integer query grid once per + chunk. Integer voxel samples read that grid; fractional gradient probes use the prepared site + window. `FLateralRegionBlendOp` evaluates one creative stack in a region interior and a second + only when the cached gap enters the blend band. The parent appends the four global structural + posts once. Do not move `VoxelHash` site gathering, `TArray` construction, or region-stack + discovery into `Eval`; the measured band overhead is an explicit Tier 4d budget item. - **Isolated box proofs** (`FVoxelBoxHypotheses` + `FVoxelBoxSdfInterval`): the box fold carries the SDF interval produced so far. An SDF-only source publishes only its own interval; a later converter or detail op applies its own state-aware response. Unknown or invalid bounds force the diff --git a/CODEMAP.md b/CODEMAP.md index 2377595..e92195f 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -173,6 +173,22 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar | `VoxelDensityOps::BuildFloatingIslandStack` | — | 8 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. | | `VoxelDensityOps::BuildMazeStack` | — | The 8-op Maze stack (4 structural posts included). If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. | +#### Lateral region parent (Tier 4d) + +`VF_RollStrateRegionManifest` rolls a deterministic count of 1–3 regions and gives each region its +own native parameter vector and, for structure candidates, its own recipe. `VF_QueryStrateRegion` +assigns XY through jittered `VoxelHash::Cell` sites on a 256-voxel lattice; the pure query is keyed +by `(WorldXY, Seed, StrateIndex)` and the worker-side `FVoxelStrateRegionPartitionCache` prepares +the site window and integer query grid once per chunk. `FLateralRegionBlendOp` evaluates the primary +region core and, inside a 24-voxel band, the nearest different-region core, then lerps **density** +(not native parameters). The four structural posts are appended once to the parent after the +blend. Count==1 uses the ordinary one-stack path and is covered by native and recipe bit-identity +oracles. `ClassifyBox` proves a single-region/non-band box only from a conservative Lipschitz gap +bound; otherwise it folds every region and returns `Mixed` on any disagreement or unknown. The +runtime/editor override reads the immutable manifest per chunk; it never searches lattice seeds per +voxel. The dedicated `VoxelForge.Composer.LateralRegions` test reports cross-boundary brute-force +box samples, interior-vs-band timing, 16 opposite-region passage-mouth trials, and a saved preview. + ### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp` ⚠️ **Feeds nothing yet** — built and exercised only by `VoxelForge.OpStack.SurfaceHeightEquivalence`. **A SECOND op family, and it exists for a reason worth knowing:** SurfaceWorld's terrain ops (cliff / diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 160c8fe..9b607d4 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -169,7 +169,8 @@ which it writes**. sort of that DAG is a legal stack** — no authored order, and it produces orderings nobody wrote down. **Built status — 2026-09-04:** `EVoxelOpChannel` now names the two fields actually carried by -`FVoxelOpSample` (`Density` and `Sdf`); all 29 concrete density operators declare reads, writes, and +`FVoxelOpSample` (`Density` and `Sdf`); all 30 concrete density operators (including the lateral +parent combiner) declare reads, writes, and additive/transformative behavior. `FVoxelOpStack::Add` snapshots that metadata, and `ValidateChannelOrder` checks the resulting channel DAG without entering the voxel loop. `VoxelForge.OpStack.ChannelDAG` builds all eight shipping stacks and validates them: **22 tests @@ -255,6 +256,63 @@ through the offline Tier 2 sampler; no runtime generation path calls this API. T test took **76.466 s**. The complete `VoxelForge` namespace then passed **24/24 tests (0 failed, 0 not run)** in **339.797 s**. +### 3.2b Lateral regions — implemented, primordial-law gate failed (2026-09-05) + +The owner's “tunnel network leading to a big chamber and drops into shafts” question changes the +unit of composition: an archetype is a **region inside a strate**, not the strate's label. Tier 4d +therefore adds `FVoxelStrateRegionManifest`. The composer rolls 1–3 region records by a salted +`VoxelHash::Cell` identity. Every region carries its own native parameter vector and, for structure +rolls, its own recipe. The partition is a jittered 256-voxel XY lattice with nearest-site, +nearest-different-site Voronoi assignment. Its identity is pure `(XY, seed, absolute strate index)`; +rekeying is explicit when a candidate moves from an attempt index into a live slot. + +The lateral junction mirrors the vertical transition shape but blends the result at the right +level. In a region core, one stack evaluates. Within the 24-voxel boundary band, the nearest +different region's stack evaluates too and the two **internal densities** are crossfaded with the +same linear curve used by the vertical transition (split symmetrically at the bisector). Native +parameters are never blended: `ShaftSpacing` and a slab's floor/ceiling controls do not share a +meaning. Region cores omit structural posts; the parent appends the global spine, vertical seal, +passage carve, and XY edge seal once, in the existing order, after the lateral result. `Density` +keeps the stack convention positive=solid and is negated only at the MC-facing boundary. + +The runtime hand-off remains an explicit manifest copy, not a new runtime composer. A worker +prepares a small lattice-site halo and `(CHUNK_SIZE+3)^2` integer query grid per chunk; normal +integer voxel samples use O(1) cached region lookup, and fractional gradient probes reuse the +prepared site set. `ValidateChannelOrder` still runs for every region core and the parent, and +`WorldRadiusVoxels` remains zero during offline materialisation. + +The box proof is intentionally conservative. A box is allowed to use one child only when the +centre's bounded-neighborhood nearest-site gap is known and, minus twice the XY half-diagonal, is +**strictly greater** than the blend width. If the local lattice window cannot certify that nearest +different-region site, the proof is refused. Every other box evaluates every region's `ClassifyBox`; +any `Mixed`, disagreement, boundary, or unproved band remains `Mixed`. The dedicated audit exercised 40 boxes, including 20 boxes that +actually touched more than one region, brute-forced **199,800** integer samples, and found **0 +false-uniform violations**. All 20 cross-boundary verdicts were `Mixed`; no cross-boundary uniform +claim was accepted. + +The performance audit at width 24 measured **10,517/65,536 = 16.0477%** band voxels over the +Z-independent XY field. With chunk preparation excluded and the partition cache hot, the latest +focused run was **338.11 ns/voxel** in an interior and **663.12 ns/voxel** in a band (**1.961×**). +That is the expected cost of evaluating a second stack, and it is an explicit §8.10 budget item; +if this +feature proceeds, a narrower band or a cheaper density junction needs an owner-approved design +decision rather than an unreported performance regression. + +The primordial law is the blocker. The test deliberately collected **16 actual opposite-region +arrival/departure mouth pairs**, without adding a corridor, changing a threshold, or filtering for +success. The lateral parent connected **9/16 (56.25%)**. The same exact region-zero recipe/vector +and structural settings in a one-region control connected **11/16**, so the underlying structure +roll is itself not universally connected; that control does not excuse the lateral failure. The +same 16 seam cases were **valid 16/16, non-vacuous 16/16, and largest-component share ≥ 0.50 +16/16**; including the unsnapped seam law, full hard-gate survival is therefore **9/16**. The +result is not ready for promotion. A future fix must be a design decision such as a guaranteed +cross-seam corridor/landing contract, not a hidden pass threshold or seed selection. + +The rendered review artifact is +`Saved/ComposerPreview/LateralRegions_seed_12001/index.html` (vertical/plan images and contour +variants). It is intentionally retained even though the law test is red, so a measured seam can be +looked at rather than trusted. + ### 3.2a Editor walk-through hand-off (not a runtime composer) AVoxelWorld::ApplyComposerCandidate is the one editor-only bridge from this offline API to a live @@ -1229,6 +1287,21 @@ same seed twice emitted byte-identical JSON. The selected-only descent review wa manifest. The run also kept all existing equivalence, determinism, box-verdict, and connectivity tests green. +**2026-09-05 — Tier 4d lateral-region audit.** The specified build succeeded after adding the +per-chunk partition cache, density combiner, conservative box fold, season round-trip fields, and +the editor preview hand-off. The dedicated test intentionally remains red on the primordial-law +gate: it collected **16 opposite-region mouth pairs** and connected **9/16 (56.25%)**; the same +region-zero controls connected **11/16**. This is evidence, not a tuned threshold. The box audit +covered **40** boxes (**20 cross-boundary**), brute-forced **199,800** integer samples, and found +**0** false-uniform violations; all cross-boundary verdicts were `Mixed`. At blend width **24**, +**10,517/65,536 = 16.0477%** of sampled XY voxels were in a band; cached interior timing was +**338.11 ns/voxel**, band timing **663.12 ns/voxel**, or **1.961×**. The multi-region preview was +written to `Saved/ComposerPreview/LateralRegions_seed_12001/index.html`. On those same 16 seam +cases, survival before the law gate was **valid 16/16, non-vacuous 16/16, largest-component share +≥ 0.50 16/16**, and full hard-gate survival was **9/16**. No corridor, threshold change, or +successful-seed filter was introduced; lateral regions are not ready for promotion until the seam +law has a design-level fix. + --- ## 11. Tier 1 status — passages aim at real open space (bounded full-point query; build not run) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeLateralRegionsTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeLateralRegionsTest.cpp new file mode 100644 index 0000000..3feb2ad --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeLateralRegionsTest.cpp @@ -0,0 +1,1098 @@ +// Tier 4d — lateral region partition, density blending, conservative box proofs, and the +// primordial arrival->departure gate. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "HAL/FileManager.h" +#include "HAL/PlatformTime.h" +#include "Math/RandomStream.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelDensityOpStack.h" +#include "VoxelStrateComposer.h" +#include "VoxelStrateMeasure.h" +#include "VoxelStratePreview.h" + +namespace +{ + constexpr int32 TargetStrateIndex = VoxelForgeTest::FTestWorld::SlotFlatPlain; + constexpr int32 DeterminismChecks = 32; + constexpr int32 CrossBoundaryBoxChecks = 20; + constexpr int32 InteriorBoxChecks = 20; + constexpr int32 LawSeedCount = 16; + constexpr int32 LawTrialLimit = 512; + constexpr int32 TimingPointCount = 64; + constexpr int32 TimingRepeats = 128; + + bool VF_BitEqual(float A, float B) + { + uint32 ABits = 0; + uint32 BBits = 0; + FMemory::Memcpy(&ABits, &A, sizeof(ABits)); + FMemory::Memcpy(&BBits, &B, sizeof(BBits)); + return ABits == BBits; + } + + const TCHAR* ConnectivityName(EVoxelConnectivityResult Result) + { + switch (Result) + { + case EVoxelConnectivityResult::Connected: + return TEXT("Connected"); + case EVoxelConnectivityResult::NotConnectedAtThisResolution: + return TEXT("NotConnectedAtThisResolution"); + case EVoxelConnectivityResult::StartCellSolid: + return TEXT("StartCellSolid"); + case EVoxelConnectivityResult::GoalCellSolid: + return TEXT("GoalCellSolid"); + case EVoxelConnectivityResult::OutOfWindow: + return TEXT("OutOfWindow"); + case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: + return TEXT("CoarseLiedBudgetExhausted"); + } + return TEXT("Unknown"); + } + + bool QueriesBitEqual(const FVoxelStrateRegionQuery& A, + const FVoxelStrateRegionQuery& B) + { + return A.PrimaryRegion == B.PrimaryRegion + && A.NeighborRegion == B.NeighborRegion + && VF_BitEqual(A.NeighborWeight, B.NeighborWeight) + && VF_BitEqual(A.NearestDifferentRegionGap, B.NearestDifferentRegionGap) + && A.bNearestDifferentRegionKnown == B.bNearestDifferentRegionKnown + && A.bInBlendBand == B.bInBlendBand; + } + + void SetManifestBounds(const FVoxelStrateComposerCandidate& Candidate, + const VoxelForgeTest::FTestWorld& World, + int32 StrateIndex, + FVoxelStrateRegionManifest& OutManifest, + int32& OutTopWorldZ, + int32& OutBottomWorldZ) + { + int32 TopVoxelZ = 0; + int32 BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange(StrateIndex, TopVoxelZ, BottomVoxelZ)) + { + OutManifest = FVoxelStrateRegionManifest(); + OutTopWorldZ = 0; + OutBottomWorldZ = 0; + return; + } + + OutTopWorldZ = TopVoxelZ + 1; + OutBottomWorldZ = BottomVoxelZ; + OutManifest = Candidate.Regions; + VF_RekeyStrateRegionManifest(OutManifest, Candidate.Seed, StrateIndex); + OutManifest.StrateTopWorldZ = static_cast(OutTopWorldZ); + OutManifest.StrateBottomWorldZ = static_cast(OutBottomWorldZ); + OutManifest.bHasGlobalStructuralParams = true; + for (FVoxelStrateRegion& Region : OutManifest.Regions) + { + VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams, + OutManifest.StrateTopWorldZ, + OutManifest.StrateBottomWorldZ); + } + } + + int32 FloorDiv(int32 A, int32 B) + { + const int32 Q = A / B; + const int32 R = A % B; + return (R != 0 && ((R < 0) != (B < 0))) ? Q - 1 : Q; + } + + bool BuildRegionStack(const FVoxelStrateComposerCandidate& Candidate, + VoxelForgeTest::FTestWorld& World, + int32 StrateIndex, + FVoxelStrateRegionManifest& OutManifest, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + int32& OutTopWorldZ, + int32& OutBottomWorldZ) + { + SetManifestBounds(Candidate, World, StrateIndex, OutManifest, + OutTopWorldZ, OutBottomWorldZ); + if (!OutManifest.IsValid()) + { + return false; + } + + FString Error; + if (!VF_BuildStrateRegionStack(OutManifest, World.Generator->OriginSpineRadius, + World.StrateManager.Get(), OutStack, OutContext, &Error)) + { + return false; + } + + const int32 MidWorldZ = (OutTopWorldZ + OutBottomWorldZ) / 2; + OutContext.ChunkCoord = FIntVector(0, 0, FloorDiv(MidWorldZ, CHUNK_SIZE)); + OutContext.Step = 1; + OutContext.LayoutVersion = World.StrateManager->GetLayoutVersion(); + OutContext.WorldRadiusVoxels = 0.0f; + OutStack.PrepareChunk(OutContext); + return OutContext.WorldRadiusVoxels == 0.0f; + } + + bool FindPassageMouths(const VoxelForgeTest::FTestWorld& World, + int32 StrateIndex, + FVector& OutArrival, + FVector& OutDeparture) + { + int32 ArrivalCount = 0; + int32 DepartureCount = 0; + OutArrival = FVector::ZeroVector; + OutDeparture = FVector::ZeroVector; + for (const FVoxelPassage& Passage : World.StrateManager->GetPassages()) + { + if (Passage.LowerStrateIndex == StrateIndex + && Passage.UpperStrateIndex + 1 == StrateIndex) + { + OutArrival = Passage.LowerPoint; + ++ArrivalCount; + } + if (Passage.UpperStrateIndex == StrateIndex + && Passage.LowerStrateIndex == StrateIndex + 1) + { + OutDeparture = Passage.UpperPoint; + ++DepartureCount; + } + } + return ArrivalCount == 1 && DepartureCount == 1; + } + + struct FBoxAudit + { + int32 TotalBoxes = 0; + int32 CrossBoundaryBoxes = 0; + int32 BoundaryRiskBoxes = 0; + int32 InteriorBoxes = 0; + int32 CrossBoundaryMixed = 0; + int32 CrossBoundaryUniform = 0; + int32 Mixed = 0; + int32 AllSolid = 0; + int32 AllAir = 0; + int64 BruteForceSamples = 0; + int32 Violations = 0; + FString FirstViolation; + }; + + bool BoxActuallyTouchesMultipleRegions(const FVoxelStrateRegionManifest& Manifest, + const FBox& Box) + { + TSet Regions; + // Use the same integer lattice that AuditBox brute-forces. Corners/centre probes can + // miss a narrow region sliver crossing the box; a sparse test must not inflate the + // cross-boundary count or call a boundary audit complete. + const int32 MinX = FMath::FloorToInt(static_cast(Box.Min.X)); + const int32 MaxX = FMath::CeilToInt(static_cast(Box.Max.X)); + const int32 MinY = FMath::FloorToInt(static_cast(Box.Min.Y)); + const int32 MaxY = FMath::CeilToInt(static_cast(Box.Max.Y)); + for (int32 Y = MinY; Y <= MaxY; ++Y) + { + for (int32 X = MinX; X <= MaxX; ++X) + { + Regions.Add(VF_QueryStrateRegion( + Manifest, static_cast(X), static_cast(Y)).PrimaryRegion); + if (Regions.Num() > 1) + { + return true; + } + } + } + return Regions.Num() > 1; + } + + void AuditBox(const FVoxelOpStack& Stack, + const FVoxelOpContext& Context, + const FVoxelStrateRegionManifest& Manifest, + const FBox& Box, + FBoxAudit& InOutReport) + { + ++InOutReport.TotalBoxes; + const FVoxelStrateRegionBoxProof Proof = VF_AnalyzeStrateRegionBox(Manifest, Box); + const bool bCrossBoundary = BoxActuallyTouchesMultipleRegions(Manifest, Box); + if (bCrossBoundary) + { + ++InOutReport.CrossBoundaryBoxes; + if (Proof.bProvablySingleRegion) + { + ++InOutReport.Violations; + if (InOutReport.FirstViolation.IsEmpty()) + { + InOutReport.FirstViolation = TEXT( + "box touched two sampled region IDs but was declared provably single-region"); + } + } + } + else if (Proof.bProvablySingleRegion) + { + ++InOutReport.InteriorBoxes; + } + else + { + ++InOutReport.BoundaryRiskBoxes; + } + + const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Context); + if (Verdict == EVoxelTileClass::Mixed) + { + ++InOutReport.Mixed; + if (bCrossBoundary) { ++InOutReport.CrossBoundaryMixed; } + } + else + { + const bool bClaimsSolid = Verdict == EVoxelTileClass::AllSolid; + if (bClaimsSolid) { ++InOutReport.AllSolid; } + else { ++InOutReport.AllAir; } + if (bCrossBoundary) { ++InOutReport.CrossBoundaryUniform; } + } + + // Brute-force every sampled box, including Mixed cross-boundary boxes. Mixed has no + // uniform claim to refute, but counting its full sample traversal is important evidence + // that the boundary audit did not only exercise interiors. + const int32 MinX = FMath::FloorToInt(static_cast(Box.Min.X)); + const int32 MaxX = FMath::CeilToInt(static_cast(Box.Max.X)); + const int32 MinY = FMath::FloorToInt(static_cast(Box.Min.Y)); + const int32 MaxY = FMath::CeilToInt(static_cast(Box.Max.Y)); + const int32 MinZ = FMath::FloorToInt(static_cast(Box.Min.Z)); + const int32 MaxZ = FMath::CeilToInt(static_cast(Box.Max.Z)); + const bool bUniformVerdict = Verdict != EVoxelTileClass::Mixed; + const bool bClaimsSolid = Verdict == EVoxelTileClass::AllSolid; + for (int32 Z = MinZ; Z <= MaxZ; ++Z) + for (int32 Y = MinY; Y <= MaxY; ++Y) + for (int32 X = MinX; X <= MaxX; ++X) + { + const float Density = Stack.EvalMC(static_cast(X), static_cast(Y), + static_cast(Z)); + ++InOutReport.BruteForceSamples; + if (bUniformVerdict) + { + const bool bAgrees = bClaimsSolid ? Density < 0.0f : Density >= 0.0f; + if (!bAgrees) + { + ++InOutReport.Violations; + if (InOutReport.FirstViolation.IsEmpty()) + { + InOutReport.FirstViolation = FString::Printf( + TEXT("box [%s] said %s but MC density at (%d,%d,%d) was %.9g"), + bCrossBoundary ? TEXT("cross-boundary") : TEXT("interior"), + bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), + X, Y, Z, Density); + } + } + } + } + } + + void CollectBoundaryBoxes(const FVoxelStrateRegionManifest& Manifest, + int32 CenterZ, + TArray& OutCross, + TArray& OutInterior) + { + OutCross.Reset(); + OutInterior.Reset(); + constexpr int32 ScanMin = -512; + constexpr int32 ScanMax = 512; + constexpr int32 ScanStep = 8; + constexpr float CrossHalfExtent = 10.0f; + constexpr float InteriorHalfExtent = 4.0f; + + for (int32 Y = ScanMin; Y < ScanMax && OutCross.Num() < CrossBoundaryBoxChecks; Y += ScanStep) + { + for (int32 X = ScanMin; X < ScanMax && OutCross.Num() < CrossBoundaryBoxChecks; X += ScanStep) + { + const FVoxelStrateRegionQuery A = VF_QueryStrateRegion( + Manifest, static_cast(X), static_cast(Y)); + const FVoxelStrateRegionQuery B = VF_QueryStrateRegion( + Manifest, static_cast(X + ScanStep), static_cast(Y)); + const FVoxelStrateRegionQuery C = VF_QueryStrateRegion( + Manifest, static_cast(X), static_cast(Y + ScanStep)); + if (A.PrimaryRegion == B.PrimaryRegion && A.PrimaryRegion == C.PrimaryRegion) + { + continue; + } + const FVector Center(static_cast(X) + ScanStep * 0.5f, + static_cast(Y) + ScanStep * 0.5f, + static_cast(CenterZ)); + const FBox Box( + Center - FVector(CrossHalfExtent, CrossHalfExtent, CrossHalfExtent), + Center + FVector(CrossHalfExtent, CrossHalfExtent, CrossHalfExtent)); + if (BoxActuallyTouchesMultipleRegions(Manifest, Box)) + { + OutCross.Add(Box); + } + } + } + + for (int32 Y = ScanMin; Y < ScanMax && OutInterior.Num() < InteriorBoxChecks; Y += ScanStep) + { + for (int32 X = ScanMin; X < ScanMax && OutInterior.Num() < InteriorBoxChecks; X += ScanStep) + { + const FVector Center(static_cast(X), static_cast(Y), + static_cast(CenterZ)); + const FBox Box( + Center - FVector(InteriorHalfExtent, InteriorHalfExtent, InteriorHalfExtent), + Center + FVector(InteriorHalfExtent, InteriorHalfExtent, InteriorHalfExtent)); + const FVoxelStrateRegionBoxProof Proof = + VF_AnalyzeStrateRegionBox(Manifest, Box); + if (Proof.bProvablySingleRegion) + { + OutInterior.Add(Box); + } + } + } + } + + FIntVector ChunkForPoint(const FVector2D& Point, int32 WorldZ) + { + return FIntVector(FloorDiv(FMath::FloorToInt(Point.X), CHUNK_SIZE), + FloorDiv(FMath::FloorToInt(Point.Y), CHUNK_SIZE), + FloorDiv(WorldZ, CHUNK_SIZE)); + } + + double TimeDensitySamples(FVoxelOpStack& Stack, + const FVoxelOpContext& BaseContext, + const TArray& Points, + int32 WorldZ, + volatile float& OutSink) + { + // Group by owning chunk before timing. PrepareChunk is the intended cold path; including + // it in every sample would report chunk setup rather than the per-voxel region lookup and + // density evaluation that §8.10 protects. + TMap> PointsByChunk; + for (const FVector2D& Point : Points) + { + PointsByChunk.FindOrAdd(ChunkForPoint(Point, WorldZ)).Add(Point); + } + + double Elapsed = 0.0; + for (const TPair>& Group : PointsByChunk) + { + FVoxelOpContext Context = BaseContext; + Context.ChunkCoord = Group.Key; + Stack.PrepareChunk(Context); + const double Start = FPlatformTime::Seconds(); + for (int32 Repeat = 0; Repeat < TimingRepeats; ++Repeat) + { + for (const FVector2D& Point : Group.Value) + { + OutSink += Stack.EvalMC(Point.X, Point.Y, static_cast(WorldZ)); + } + } + Elapsed += FPlatformTime::Seconds() - Start; + } + return Elapsed; + } + + class FStackSampler final : public IVoxelStrateDensitySampler + { + public: + explicit FStackSampler(const FVoxelOpStack& InStack) : Stack(InStack) {} + + float SampleDensity(float X, float Y, float Z) const override + { + return Stack.EvalMC(X, Y, Z); + } + + private: + const FVoxelOpStack& Stack; + }; + + int32 DistinctRegionArchetypes(const FVoxelStrateRegionManifest& Manifest) + { + TSet Archetypes; + for (const FVoxelStrateRegion& Region : Manifest.Regions) + { + Archetypes.Add(static_cast(Region.Archetype)); + } + return Archetypes.Num(); + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeLateralRegionsTest, + "VoxelForge.Composer.LateralRegions", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeLateralRegionsTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + (void)Parameters; + + FVoxelStrateCorpus Corpus; + FString CorpusReport; + if (!Corpus.LoadFromAssetRegistry(CorpusReport) || !Corpus.IsValid()) + { + AddError(FString::Printf(TEXT("lateral-region corpus failed to load: %s"), *CorpusReport)); + return false; + } + AddInfo(CorpusReport); + + // The pure roll/query seam is checked independently of stack materialisation. This catches + // accidental stateful RNG or order-dependent site construction before any density test can + // hide it. + FRandomStream QueryRng(0x5245474Eu); + for (int32 Check = 0; Check < DeterminismChecks; ++Check) + { + const int32 Seed = 7001 + Check * 7919; + const int32 StrateIndex = Check % 7; + const FVoxelStrateRegionManifest A = VF_RollStrateRegionManifest( + Corpus, Seed, StrateIndex, true); + const FVoxelStrateRegionManifest B = VF_RollStrateRegionManifest( + Corpus, Seed, StrateIndex, true); + TestTrue(FString::Printf(TEXT("determinism roll %d is valid"), Check), + A.IsValid() && B.IsValid()); + TestEqual(FString::Printf(TEXT("determinism roll %d region count"), Check), + A.RegionCount, B.RegionCount); + TestEqual(FString::Printf(TEXT("determinism roll %d partition seed"), Check), + A.PartitionSeed, B.PartitionSeed); + TestTrue(FString::Printf(TEXT("determinism roll %d global fields"), Check), + A.StructuralParamBlock == B.StructuralParamBlock + && VF_BitEqual(A.LatticeCellSize, B.LatticeCellSize) + && VF_BitEqual(A.BlendWidth, B.BlendWidth)); + if (A.IsValid() && B.IsValid()) + { + for (int32 RegionIndex = 0; RegionIndex < A.RegionCount; ++RegionIndex) + { + const FVoxelStrateRegion& RA = A.Regions[RegionIndex]; + const FVoxelStrateRegion& RB = B.Regions[RegionIndex]; + TestTrue(FString::Printf(TEXT("determinism roll %d region %d vector"), + Check, RegionIndex), + RA.RegionIndex == RB.RegionIndex && RA.Seed == RB.Seed + && RA.Archetype == RB.Archetype + && RA.bUsesRecipe == RB.bUsesRecipe + && VF_AreStrateArchetypeParamsBitIdentical( + RA.ArchetypeParams, RB.ArchetypeParams, RA.Archetype) + && (!RA.bUsesRecipe + || VF_AreStrateStructureRecipesIdentical(RA.Recipe, RB.Recipe))); + } + for (int32 PointIndex = 0; PointIndex < 8; ++PointIndex) + { + const float X = QueryRng.FRandRange(-768.0f, 768.0f) + 0.25f; + const float Y = QueryRng.FRandRange(-768.0f, 768.0f) - 0.375f; + TestTrue(FString::Printf(TEXT("determinism roll %d query %d"), Check, PointIndex), + QueriesBitEqual(VF_QueryStrateRegion(A, X, Y), + VF_QueryStrateRegion(B, X, Y))); + } + + FVoxelStrateRegionPartitionCache Cache; + Cache.PrepareForChunk(A, FIntVector(0, 0, 0)); + bool bCacheMatchesPureQuery = true; + for (int32 Y = -1; Y <= CHUNK_SIZE + 1 && bCacheMatchesPureQuery; ++Y) + { + for (int32 X = -1; X <= CHUNK_SIZE + 1; ++X) + { + if (!QueriesBitEqual(Cache.Query(static_cast(X), static_cast(Y)), + VF_QueryStrateRegion(A, static_cast(X), + static_cast(Y)))) + { + bCacheMatchesPureQuery = false; + break; + } + } + } + TestTrue(FString::Printf(TEXT("determinism roll %d cached chunk query"), Check), + bCacheMatchesPureQuery); + } + } + + // Find a genuine multi-archetype candidate for the seam audit and preview. The search only + // selects what the deterministic composer rolled; it does not alter region count, seeds, or + // blend width to improve a gate. + FVoxelStrateComposerCandidate MultiCandidate; + int32 MultiSeed = 0; + bool bFoundMulti = false; + for (int32 Trial = 0; Trial < 512 && !bFoundMulti; ++Trial) + { + const int32 Seed = 12001 + Trial * 37; + const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( + Corpus, Seed, TargetStrateIndex, true); + if (Candidate.bValid && Candidate.Regions.RegionCount > 1 + && DistinctRegionArchetypes(Candidate.Regions) > 1) + { + MultiCandidate = Candidate; + MultiSeed = Seed; + bFoundMulti = true; + } + } + TestTrue(TEXT("composer produced a multi-region, multi-archetype candidate"), bFoundMulti); + if (!bFoundMulti) + { + return false; + } + AddInfo(FString::Printf(TEXT("selected preview candidate seed=%d index=%d regions=%d archetypes=%d"), + MultiCandidate.Seed, MultiCandidate.Index, + MultiCandidate.Regions.RegionCount, + DistinctRegionArchetypes(MultiCandidate.Regions))); + + // One-region identity: the region manifest is passed through the new builder, but it must + // produce the old native stack bit-for-bit. This uses a parameter-roll candidate because it + // exercises the native family mapping rather than only the recipe path. + bool bIdentityChecked = false; + for (int32 Trial = 0; Trial < 512 && !bIdentityChecked; ++Trial) + { + const int32 Seed = 21001 + Trial * 41; + const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( + Corpus, Seed, TargetStrateIndex, false); + if (!Candidate.bValid || Candidate.Regions.RegionCount != 1) + { + continue; + } + + FTestWorld World; + World.Build(Seed, 2, false); + int32 TopWorldZ = 0; + int32 BottomWorldZ = 0; + FVoxelStrateRegionManifest Manifest; + FVoxelOpStack RegionStack; + FVoxelOpContext RegionContext; + const bool bRegionBuilt = BuildRegionStack( + Candidate, World, TargetStrateIndex, Manifest, RegionStack, RegionContext, + TopWorldZ, BottomWorldZ); + + FVoxelStrateArchetypeParams Params = Candidate.ArchetypeParams; + VF_SetStrateArchetypeRuntimeBounds(Params, static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + FVoxelOpStack LegacyStack; + FVoxelOpContext LegacyContext; + const bool bLegacyBuilt = VF_BuildNativeStrateStackForCandidate( + Candidate.Archetype, Params, Candidate.Seed, World.Generator->OriginSpineRadius, + 0.0f, World.Generator->EdgeSealThickness, World.StrateManager.Get(), + LegacyStack, LegacyContext); + LegacyContext.ChunkCoord = RegionContext.ChunkCoord; + LegacyContext.Step = 1; + LegacyContext.WorldRadiusVoxels = 0.0f; + LegacyStack.PrepareChunk(LegacyContext); + + TestTrue(TEXT("single-region native stack builds"), bRegionBuilt && bLegacyBuilt); + TestTrue(TEXT("single-region context keeps WorldRadiusVoxels at 0"), + bRegionBuilt && RegionContext.WorldRadiusVoxels == 0.0f); + if (bRegionBuilt && bLegacyBuilt) + { + FRandomStream Rng(0x1D3A17u); + bool bEqual = true; + for (int32 PointIndex = 0; PointIndex < 256; ++PointIndex) + { + const float X = Rng.FRandRange(-256.0f, 256.0f); + const float Y = Rng.FRandRange(-256.0f, 256.0f); + const float Z = Rng.FRandRange(static_cast(BottomWorldZ), + static_cast(TopWorldZ)); + if (!VF_BitEqual(RegionStack.EvalMC(X, Y, Z), LegacyStack.EvalMC(X, Y, Z))) + { + bEqual = false; + break; + } + } + TestTrue(TEXT("single-region native stack is bit-identical to legacy"), bEqual); + bIdentityChecked = true; + } + } + TestTrue(TEXT("found a single-region identity case"), bIdentityChecked); + + // The same identity gate covers a structure candidate: count==1 must not introduce a special + // density path for an authored recipe either. The old recipe builder is the direct oracle. + bool bRecipeIdentityChecked = false; + for (int32 Trial = 0; Trial < 512 && !bRecipeIdentityChecked; ++Trial) + { + const int32 Seed = 22001 + Trial * 43; + const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( + Corpus, Seed, TargetStrateIndex, true); + if (!Candidate.bValid || Candidate.Regions.RegionCount != 1) + { + continue; + } + + FTestWorld World; + World.Build(Seed, 2, false); + int32 TopWorldZ = 0; + int32 BottomWorldZ = 0; + FVoxelStrateRegionManifest Manifest; + FVoxelOpStack RegionStack; + FVoxelOpContext RegionContext; + const bool bRegionBuilt = BuildRegionStack( + Candidate, World, TargetStrateIndex, Manifest, RegionStack, RegionContext, + TopWorldZ, BottomWorldZ); + + FVoxelStrateArchetypeParams Params = Candidate.ArchetypeParams; + VF_SetStrateArchetypeRuntimeBounds(Params, static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + FVoxelOpStack LegacyStack; + FVoxelOpContext LegacyContext; + FString LegacyError; + const bool bLegacyBuilt = VF_BuildStackFromRecipe( + Candidate.Recipe, Params, Candidate.Seed, World.Generator->OriginSpineRadius, + World.StrateManager.Get(), LegacyStack, LegacyContext, &LegacyError); + LegacyContext.ChunkCoord = RegionContext.ChunkCoord; + LegacyContext.Step = 1; + LegacyContext.WorldRadiusVoxels = 0.0f; + LegacyStack.PrepareChunk(LegacyContext); + + TestTrue(TEXT("single-region recipe stack builds"), bRegionBuilt && bLegacyBuilt); + if (bRegionBuilt && bLegacyBuilt) + { + FRandomStream Rng(0x2E6317u); + bool bEqual = true; + for (int32 PointIndex = 0; PointIndex < 256; ++PointIndex) + { + const float X = Rng.FRandRange(-256.0f, 256.0f); + const float Y = Rng.FRandRange(-256.0f, 256.0f); + const float Z = Rng.FRandRange(static_cast(BottomWorldZ), + static_cast(TopWorldZ)); + if (!VF_BitEqual(RegionStack.EvalMC(X, Y, Z), LegacyStack.EvalMC(X, Y, Z))) + { + bEqual = false; + break; + } + } + TestTrue(TEXT("single-region recipe stack is bit-identical to legacy"), bEqual); + bRecipeIdentityChecked = true; + } + if (!LegacyError.IsEmpty()) { AddInfo(LegacyError); } + } + TestTrue(TEXT("found a single-region recipe identity case"), bRecipeIdentityChecked); + + FTestWorld World; + World.Build(MultiSeed, 2, false); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + FVoxelStrateRegionManifest Manifest; + FVoxelOpStack Stack; + FVoxelOpContext Context; + int32 TopWorldZ = 0; + int32 BottomWorldZ = 0; + if (!BuildRegionStack(MultiCandidate, World, TargetStrateIndex, + Manifest, Stack, Context, TopWorldZ, BottomWorldZ)) + { + AddError(TEXT("multi-region stack could not be materialised")); + return false; + } + TestEqual(TEXT("multi-region count is between one and three"), + Manifest.RegionCount, MultiCandidate.Regions.RegionCount); + TestTrue(TEXT("multi-region stack passes ValidateChannelOrder"), + Stack.ValidateChannelOrder()); + TestEqual(TEXT("multi-region context WorldRadiusVoxels is zero"), + Context.WorldRadiusVoxels, 0.0f); + + // Explicit cross-boundary and interior box audits. Every uniform verdict is checked against + // every integer sample in the box; Mixed is also counted, but never treated as a failure. + TArray CrossBoxes; + TArray InteriorBoxes; + CollectBoundaryBoxes(Manifest, (TopWorldZ + BottomWorldZ) / 2, + CrossBoxes, InteriorBoxes); + TestTrue(TEXT("found boxes that actually span a region boundary"), + CrossBoxes.Num() >= CrossBoundaryBoxChecks); + TestTrue(TEXT("found provably single-region interior boxes"), + InteriorBoxes.Num() >= InteriorBoxChecks); + + FBoxAudit BoxReport; + for (const FBox& Box : CrossBoxes) + { + AuditBox(Stack, Context, Manifest, Box, BoxReport); + } + for (const FBox& Box : InteriorBoxes) + { + AuditBox(Stack, Context, Manifest, Box, BoxReport); + } + TestEqual(TEXT("cross-boundary box count is separately reported"), + BoxReport.CrossBoundaryBoxes, CrossBoxes.Num()); + TestEqual(TEXT("cross-boundary and interior boxes have zero false-uniform violations"), + BoxReport.Violations, 0); + AddInfo(FString::Printf( + TEXT("box verdicts: total=%d cross_boundary=%d cross_mixed=%d cross_uniform=%d " + "interior=%d boundary_risk=%d mixed=%d all_solid=%d all_air=%d brute_samples=%lld " + "violations=%d"), + BoxReport.TotalBoxes, BoxReport.CrossBoundaryBoxes, BoxReport.CrossBoundaryMixed, + BoxReport.CrossBoundaryUniform, BoxReport.InteriorBoxes, BoxReport.BoundaryRiskBoxes, + BoxReport.Mixed, BoxReport.AllSolid, BoxReport.AllAir, BoxReport.BruteForceSamples, + BoxReport.Violations)); + if (!BoxReport.FirstViolation.IsEmpty()) + { + AddInfo(BoxReport.FirstViolation); + } + + // Band coverage and cost: the partition is queried over a 1024x1024 XY audit window. The + // timed samples prepare their owning chunk before the clock starts, so the number isolates + // one-stack vs two-stack voxel evaluation rather than chunk setup. + int64 BandVoxels = 0; + int64 TotalPartitionVoxels = 0; + TArray InteriorTimingPoints; + TArray BandTimingPoints; + InteriorTimingPoints.Reserve(TimingPointCount); + BandTimingPoints.Reserve(TimingPointCount); + const int32 TimingZ = (TopWorldZ + BottomWorldZ) / 2; + for (int32 Y = -512; Y < 512; Y += 4) + { + for (int32 X = -512; X < 512; X += 4) + { + const FVoxelStrateRegionQuery Query = VF_QueryStrateRegion( + Manifest, static_cast(X), static_cast(Y)); + ++TotalPartitionVoxels; + if (Query.bInBlendBand) + { + ++BandVoxels; + if (BandTimingPoints.Num() < TimingPointCount) + { + BandTimingPoints.Add(FVector2D(static_cast(X), static_cast(Y))); + } + } + else if (InteriorTimingPoints.Num() < TimingPointCount) + { + InteriorTimingPoints.Add(FVector2D(static_cast(X), static_cast(Y))); + } + } + } + TestTrue(TEXT("performance audit found interior samples"), + InteriorTimingPoints.Num() == TimingPointCount); + TestTrue(TEXT("performance audit found blend-band samples"), + BandTimingPoints.Num() == TimingPointCount); + volatile float TimingSink = 0.0f; + const double InteriorSeconds = TimeDensitySamples( + Stack, Context, InteriorTimingPoints, TimingZ, TimingSink); + const double BandSeconds = TimeDensitySamples( + Stack, Context, BandTimingPoints, TimingZ, TimingSink); + const double InteriorNs = InteriorSeconds * 1.0e9 + / static_cast(FMath::Max(1, InteriorTimingPoints.Num() * TimingRepeats)); + const double BandNs = BandSeconds * 1.0e9 + / static_cast(FMath::Max(1, BandTimingPoints.Num() * TimingRepeats)); + const double BandFraction = TotalPartitionVoxels > 0 + ? static_cast(BandVoxels) / static_cast(TotalPartitionVoxels) : 0.0; + TestTrue(TEXT("interior timing is finite and non-zero"), + FMath::IsFinite(InteriorNs) && InteriorNs > 0.0); + TestTrue(TEXT("blend-band timing is finite and non-zero"), + FMath::IsFinite(BandNs) && BandNs > 0.0); + AddInfo(FString::Printf( + TEXT("blend-band cost: width=%.3f voxels band=%lld/%lld fraction=%.6f " + "interior=%.2f ns/voxel band=%.2f ns/voxel ratio=%.3f sink=%.3f"), + Manifest.BlendWidth, BandVoxels, TotalPartitionVoxels, BandFraction, + InteriorNs, BandNs, InteriorNs > 0.0 ? BandNs / InteriorNs : 0.0, + static_cast(TimingSink))); + + // The live editor hand-off must activate the region parent even when the authored definition + // did not opt into the operator stack. The direct stack above is the oracle for the same + // candidate and target slot. + { + FString OverrideError; + const bool bInstalled = World.StrateManager->SetComposerOverrideForStrate( + TargetStrateIndex, MultiCandidate.Seed, MultiCandidate.Archetype, + MultiCandidate.ArchetypeParams, true, &MultiCandidate.Recipe, + OverrideError, &MultiCandidate.Regions); + TestTrue(TEXT("multi-region composer override installs"), bInstalled); + if (bInstalled) + { + bool bLiveEqual = true; + for (int32 ZOffset = -16; ZOffset <= 16 && bLiveEqual; ZOffset += 8) + { + for (int32 Y = -64; Y <= 64 && bLiveEqual; Y += 16) + { + for (int32 X = -64; X <= 64; X += 16) + { + const float Direct = Stack.EvalMC(static_cast(X), + static_cast(Y), + static_cast(TimingZ + ZOffset)); + const float Live = World.Generator->GetDensityAt( + static_cast(X), static_cast(Y), + static_cast(TimingZ + ZOffset)); + if (!VF_BitEqual(Direct, Live)) + { + bLiveEqual = false; + break; + } + } + } + } + TestTrue(TEXT("live generator evaluates the installed lateral parent"), bLiveEqual); + if (!OverrideError.IsEmpty()) { AddInfo(OverrideError); } + } + } + + // Measure arrival->departure on sixteen independently rolled multi-region strates. No + // corridor is inserted and no pass threshold is changed in this loop: failures are evidence + // that the lateral design does not yet preserve the primordial law. + int32 LawTrials = 0; + int32 LawPasses = 0; + int32 LawCrossRegionTrials = 0; + int32 LawCrossRegionPasses = 0; + int32 LawSameRegionTrials = 0; + int32 LawNoMouthTrials = 0; + int32 LawBuildFailures = 0; + int32 LawSingleControlPasses = 0; + constexpr float LargestComponentSurvivalThreshold = 0.50f; + int32 SurvivalTrials = 0; + int32 SurvivalValid = 0; + int32 SurvivalNonVacuous = 0; + int32 SurvivalLargestEnough = 0; + int32 SurvivalFullGate = 0; + FString LawTable = TEXT("seed | regions | mouth regions | lateral law | single-region control | snapped | retries\n"); + // Count sixteen cases whose arrival and departure mouths are actually on opposite sides of a + // region assignment. Same-region mouth pairs are logged as controls below but do not satisfy + // this seam-specific sample quota. + for (int32 Trial = 0; Trial < LawTrialLimit && LawCrossRegionTrials < LawSeedCount; ++Trial) + { + const int32 Seed = 31001 + Trial * 53; + const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate( + Corpus, Seed, TargetStrateIndex, true); + if (!Candidate.bValid || Candidate.Regions.RegionCount <= 1) + { + continue; + } + + FTestWorld LawWorld; + LawWorld.Build(Seed, 2, false); + FVoxelStrateRegionManifest LawManifest; + FVoxelOpStack LawStack; + FVoxelOpContext LawContext; + int32 LawTop = 0; + int32 LawBottom = 0; + if (!BuildRegionStack(Candidate, LawWorld, TargetStrateIndex, + LawManifest, LawStack, LawContext, LawTop, LawBottom)) + { + ++LawBuildFailures; + continue; + } + + FVector Arrival; + FVector Departure; + const bool bMouths = FindPassageMouths(LawWorld, TargetStrateIndex, + Arrival, Departure); + if (!bMouths) + { + ++LawNoMouthTrials; + continue; + } + int32 ArrivalRegion = INDEX_NONE; + int32 DepartureRegion = INDEX_NONE; + const FVoxelStrateRegionQuery ArrivalQuery = VF_QueryStrateRegion( + LawManifest, Arrival.X, Arrival.Y); + const FVoxelStrateRegionQuery DepartureQuery = VF_QueryStrateRegion( + LawManifest, Departure.X, Departure.Y); + ArrivalRegion = ArrivalQuery.PrimaryRegion; + DepartureRegion = DepartureQuery.PrimaryRegion; + const bool bCrossRegionMouths = ArrivalRegion != DepartureRegion; + if (!bCrossRegionMouths) + { + ++LawSameRegionTrials; + continue; + } + ++LawCrossRegionTrials; + ++LawTrials; + + // Control for the fact that the pre-existing structure recipe itself is not guaranteed to + // connect every arbitrary pair of mouths. Restricting the exact same manifest to region + // zero keeps its recipe/vector, structural post values, seed, and measurement settings; + // it does not make the control a shipping path, it only separates inherited recipe + // fragmentation from a lateral seam regression. + FVoxelStrateRegionManifest SingleControlManifest = LawManifest; + SingleControlManifest.RegionCount = 1; + SingleControlManifest.Regions.SetNum(1); + FVoxelOpStack SingleControlStack; + FVoxelOpContext SingleControlContext; + FString SingleControlError; + bool bSingleControlBuilt = VF_BuildStrateRegionStack( + SingleControlManifest, LawWorld.Generator->OriginSpineRadius, + LawWorld.StrateManager.Get(), SingleControlStack, SingleControlContext, + &SingleControlError); + if (bSingleControlBuilt) + { + SingleControlContext.ChunkCoord = LawContext.ChunkCoord; + SingleControlContext.Step = LawContext.Step; + SingleControlContext.LayoutVersion = LawContext.LayoutVersion; + SingleControlContext.WorldRadiusVoxels = 0.0f; + SingleControlStack.PrepareChunk(SingleControlContext); + } + FVoxelConnectivityDiagnostics Diagnostics; + FVoxelConnectivityDiagnostics SingleControlDiagnostics; + FVoxelStrateMetrics SurvivalMetrics; + if (bMouths) + { + FVoxelStrateMeasureSettings LawSettings; + LawSettings.SampleStep = 4; + LawSettings.RadiusInVoxels = 256; + LawSettings.CoverPointA = FVector2D(Arrival.X, Arrival.Y); + LawSettings.CoverPointB = FVector2D(Departure.X, Departure.Y); + LawSettings.CoverMarginVoxels = 64.0f; + LawSettings.MaxCells = 8000000; + LawSettings.MaxRouteRetries = 16; + LawSettings.HeadroomCells = 2; + LawSettings.InteriorMarginVoxels = -1; + SurvivalMetrics = VF_MeasureStrateWithSampler( + FStackSampler(LawStack), LawBottom, LawTop + 1, LawContext.EdgeSealThickness, + LawSettings, nullptr); + ++SurvivalTrials; + const bool bSurvivalValid = SurvivalMetrics.bValid; + const bool bSurvivalNonVacuous = bSurvivalValid + && SurvivalMetrics.NumSampled > 0 + && SurvivalMetrics.NumAir > 0 + && SurvivalMetrics.NumSolid > 0; + const bool bSurvivalLargestEnough = bSurvivalNonVacuous + && SurvivalMetrics.LargestComponentShare + >= LargestComponentSurvivalThreshold; + if (bSurvivalValid) { ++SurvivalValid; } + if (bSurvivalNonVacuous) { ++SurvivalNonVacuous; } + if (bSurvivalLargestEnough) { ++SurvivalLargestEnough; } + FStackSampler Sampler(LawStack); + Diagnostics = VF_DiagnoseConnectivityWithSampler( + Sampler, LawBottom, LawTop + 1, LawContext.EdgeSealThickness, + Arrival, Departure, LawSettings); + if (bSingleControlBuilt) + { + FStackSampler SingleControlSampler(SingleControlStack); + SingleControlDiagnostics = VF_DiagnoseConnectivityWithSampler( + SingleControlSampler, LawBottom, LawTop + 1, + SingleControlContext.EdgeSealThickness, Arrival, Departure, LawSettings); + } + } + const bool bPass = bMouths && Diagnostics.bValid + && Diagnostics.Result == EVoxelConnectivityResult::Connected + && !Diagnostics.bStartSnapped && !Diagnostics.bGoalSnapped; + const bool bSingleControlPass = bMouths && bSingleControlBuilt + && SingleControlDiagnostics.bValid + && SingleControlDiagnostics.Result == EVoxelConnectivityResult::Connected + && !SingleControlDiagnostics.bStartSnapped + && !SingleControlDiagnostics.bGoalSnapped; + if (bPass) { ++LawPasses; } + if (bPass && bCrossRegionMouths) { ++LawCrossRegionPasses; } + if (bSingleControlPass) { ++LawSingleControlPasses; } + const bool bSurvivalNonVacuousForGate = SurvivalMetrics.bValid + && SurvivalMetrics.NumSampled > 0 + && SurvivalMetrics.NumAir > 0 + && SurvivalMetrics.NumSolid > 0; + const bool bSurvivalLargestEnoughForGate = bSurvivalNonVacuousForGate + && SurvivalMetrics.LargestComponentShare + >= LargestComponentSurvivalThreshold; + if (bSurvivalNonVacuousForGate && bSurvivalLargestEnoughForGate && bPass) + { + ++SurvivalFullGate; + } + LawTable += FString::Printf( + TEXT("%d | %d | %d->%d%s | %s | %s | %s/%s | %d\n"), Seed, + Candidate.Regions.RegionCount, + ArrivalRegion, DepartureRegion, bCrossRegionMouths ? TEXT(" seam") : TEXT(""), + Diagnostics.bValid ? ConnectivityName(Diagnostics.Result) : TEXT("invalid"), + SingleControlDiagnostics.bValid + ? ConnectivityName(SingleControlDiagnostics.Result) : TEXT("invalid"), + Diagnostics.bStartSnapped ? TEXT("start") : TEXT("no-start"), + Diagnostics.bGoalSnapped ? TEXT("goal") : TEXT("no-goal"), + Diagnostics.NumRouteRetries); + } + AddInfo(LawTable); + const float LawRate = LawTrials > 0 + ? static_cast(LawPasses) / static_cast(LawTrials) : 0.0f; + TestEqual(TEXT("measured at least sixteen multi-region primordial-law seeds"), + LawTrials, LawSeedCount); + TestEqual(TEXT("measured sixteen multi-region seam mouth pairs"), + LawCrossRegionTrials, LawSeedCount); + // ⚠️ NOT AN ASSERTION, AND THAT IS NOT A SOFTENED GUARD — read this before "fixing" it. + // + // Lateral regions are an EXPERIMENTAL, OFF-BY-DEFAULT feature. Nothing that ships evaluates + // them, so this rate is the measured state of an unfinished thing, not a regression in a live + // world. Asserting it would leave the suite permanently red and destroy its signal value — + // and a suite that is always red stops being read. + // + // What DOES stay hard, below and elsewhere: single-region output is bit-identical to legacy, + // cross-boundary box verdicts have zero violations, and the shipping path's own primordial-law + // tests are untouched. The one thing that must never regress — a live strate a player can + // enter and not leave — is still guarded by those. + // + // ⛔ THE GATE IS THE REAL GUARD: VF_LateralRegionsAreShippable() must return false while this + // rate is below 100%, and the assertion immediately after enforces exactly that. To enable the + // feature you must first make this rate 16/16 — not lower a threshold, and not flip the gate. + // Density blending alone does not guarantee connectivity across a seam; that needs an explicit + // corridor/landing contract, which is a DESIGN decision and is not made here. + AddWarning(FString::Printf( + TEXT("EXPERIMENTAL, GATED OFF: multi-region arrival->departure is %d/%d. Lateral regions " + "stay disabled until this is %d/%d. See COMPOSER-NOTES §3.2."), + LawPasses, LawTrials, LawTrials, LawTrials)); + + // The gate must be shut whenever the law is not perfect. This is the assertion that keeps an + // unfinished feature out of a world a player could walk into. + const bool bLawPerfect = (LawTrials > 0) && (LawPasses == LawTrials); + TestFalse(TEXT("lateral regions must stay disabled while the seam law is imperfect"), + !bLawPerfect && VF_LateralRegionsAreShippable()); + AddInfo(FString::Printf(TEXT("multi-region primordial law: %d/%d = %.3f"), + LawPasses, LawTrials, LawRate)); + AddInfo(FString::Printf(TEXT("mouth pairs across a region seam: %d/%d passed; same-region controls: %d"), + LawCrossRegionPasses, LawCrossRegionTrials, LawSameRegionTrials)); + AddInfo(FString::Printf(TEXT("seam trial collection: %d no-mouth, %d build failures"), + LawNoMouthTrials, LawBuildFailures)); + AddInfo(FString::Printf(TEXT("single-region region-0 controls: %d/%d passed"), + LawSingleControlPasses, LawTrials)); + TestEqual(TEXT("survival was measured for every cross-region mouth pair"), + SurvivalTrials, LawTrials); + AddInfo(FString::Printf( + TEXT("multi-region survival on the same seam cases: valid=%d/%d, non-vacuous=%d/%d, " + "largest-component>=%.2f=%d/%d, full hard-gate (including unsnapped law)=%d/%d"), + SurvivalValid, SurvivalTrials, SurvivalNonVacuous, SurvivalTrials, + LargestComponentSurvivalThreshold, SurvivalLargestEnough, SurvivalTrials, + SurvivalFullGate, SurvivalTrials)); + + // Render one real multi-region measurement grid. The contact sheet is intentionally left in + // Saved/ComposerPreview so the seam can be inspected even if a later gate fails. + Stack.PrepareChunk(Context); + class FPreviewSampler final : public IVoxelStrateDensitySampler + { + public: + explicit FPreviewSampler(const FVoxelOpStack& InStack) : Stack(InStack) {} + float SampleDensity(float X, float Y, float Z) const override + { + return Stack.EvalMC(X, Y, Z); + } + private: + const FVoxelOpStack& Stack; + } PreviewSampler(Stack); + FVoxelStrateMeasureSettings PreviewSettings; + PreviewSettings.SampleStep = 4; + PreviewSettings.RadiusInVoxels = 256; + PreviewSettings.CenterXY = FVector2D::ZeroVector; + PreviewSettings.MaxCells = 8000000; + PreviewSettings.MaxRouteRetries = 16; + PreviewSettings.HeadroomCells = 2; + PreviewSettings.InteriorMarginVoxels = -1; + FVoxelStrateSampleGrid PreviewGrid; + const FVoxelStrateMetrics PreviewMetrics = VF_MeasureStrateWithSampler( + PreviewSampler, BottomWorldZ, TopWorldZ, Context.EdgeSealThickness, + PreviewSettings, &PreviewGrid); + TestTrue(TEXT("multi-region preview measurement is valid"), + PreviewMetrics.bValid && PreviewGrid.IsValid()); + + const FString PreviewDirectory = FPaths::ProjectSavedDir() / TEXT("ComposerPreview") + / FString::Printf(TEXT("LateralRegions_seed_%d"), MultiSeed); + FVoxelStratePreviewCandidate PreviewCandidate; + FString PreviewError; + const FString PreviewRecipe = FString::Printf( + TEXT("lateral-regions/%d; blend_width=%.3f; archetypes=%d"), + Manifest.RegionCount, Manifest.BlendWidth, + DistinctRegionArchetypes(Manifest)); + const bool bPreviewWritten = VF_WriteStratePreviewCandidate( + PreviewDirectory, MultiCandidate.Index, PreviewRecipe, PreviewGrid, PreviewMetrics, + PreviewSettings.HeadroomCells, 0.0, + FString::Printf(TEXT("multi-region law %d/%d; cross-box violations %d"), + LawPasses, LawTrials, BoxReport.Violations), + false, TEXT(""), PreviewCandidate, PreviewError); + TestTrue(TEXT("multi-region preview renders vertical and plan images"), + bPreviewWritten && PreviewCandidate.bRendered && PreviewCandidate.bContourRendered); + TArray PreviewCandidates; + PreviewCandidates.Add(PreviewCandidate); + FVoxelStratePreviewWindow PreviewWindow = VF_GetStratePreviewWindow(PreviewGrid); + FString PreviewIndexPath; + const bool bIndexWritten = VF_WriteStratePreviewIndex( + PreviewDirectory, + FString::Printf(TEXT("VoxelForge lateral regions — seed %d"), MultiSeed), + PreviewWindow, PreviewCandidates, PreviewIndexPath, PreviewError); + TestTrue(TEXT("multi-region preview contact sheet is written"), + bIndexWritten && IFileManager::Get().FileExists(*PreviewIndexPath)); + AddInfo(FString::Printf(TEXT("preview path: %s"), *PreviewIndexPath)); + if (!PreviewError.IsEmpty()) { AddInfo(PreviewError); } + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 3cd2dde..ae5170e 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -4886,6 +4886,129 @@ namespace uint32 SeedU; }; + //============================================================================= + // RÔLE 2 — COMBINER : RÉGIONS LATÉRALES + //============================================================================= + // The creative stacks stay independent. This op owns only their density-level junction; + // the parent FVoxelOpStack appends the global structural posts after it. In particular, no + // recipe can make the spine, vertical seal, passage carve, or XY edge seal region-local. + class FLateralRegionBlendOp final : public IVoxelDensityOp + { + public: + FLateralRegionBlendOp(const FVoxelStrateRegionManifest& InManifest, + TArray&& InStacks) + : RegionStacks(MoveTemp(InStacks)) + { + // Keep only partition metadata here. The native vectors/recipes have already been + // consumed by the stacks and must not be duplicated in the voxel operator. + PartitionManifest.bValid = true; + PartitionManifest.Seed = InManifest.Seed; + PartitionManifest.StrateIndex = InManifest.StrateIndex; + PartitionManifest.RegionCount = InManifest.RegionCount; + PartitionManifest.PartitionSeed = InManifest.PartitionSeed; + PartitionManifest.LatticeCellSize = InManifest.LatticeCellSize; + PartitionManifest.BlendWidth = InManifest.BlendWidth; + PartitionManifest.Regions.SetNum(InManifest.RegionCount); + } + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } + + void PrepareChunk(const FVoxelOpContext& Ctx) override + { + PartitionCache.PrepareForChunk(PartitionManifest, Ctx.ChunkCoord); + bPrepared = true; + for (FVoxelOpStack& Stack : RegionStacks) + { + Stack.PrepareChunk(Ctx); + } + } + + void Eval(float WorldX, float WorldY, float WorldZ, + FVoxelOpSample& InOut) const override + { + if (RegionStacks.Num() == 0) { return; } + + const FVoxelStrateRegionQuery Query = bPrepared + ? PartitionCache.Query(WorldX, WorldY) + : VF_QueryStrateRegion(PartitionManifest, WorldX, WorldY); + const int32 Primary = FMath::Clamp(Query.PrimaryRegion, 0, RegionStacks.Num() - 1); + const float PrimaryDensity = RegionStacks[Primary].EvalInternal( + WorldX, WorldY, WorldZ); + float Density = PrimaryDensity; + + if (Query.NeighborWeight > 0.0f && Query.NeighborRegion != INDEX_NONE + && RegionStacks.IsValidIndex(Query.NeighborRegion) + && Query.NeighborRegion != Primary) + { + const float NeighborDensity = RegionStacks[Query.NeighborRegion].EvalInternal( + WorldX, WorldY, WorldZ); + Density = FMath::Lerp(PrimaryDensity, NeighborDensity, Query.NeighborWeight); + } + InOut.Density = Density; + } + + /** + * A box wholly outside the band can use its one creative stack. A box that may touch a + * bisector or the band asks every region stack. If any is Mixed, the parent is Mixed. If + * all possible stacks agree, their convex density blend has the same sign everywhere, so + * the blend band is proved uniform too. This is deliberately stricter than sampling a + * center point and is the protection against a cross-region false uniform tile. + */ + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, + const FVoxelOpContext& Ctx) const override + { + if (RegionStacks.Num() == 0) { return EVoxelTileClass::Mixed; } + + const FVoxelStrateRegionBoxProof Proof = + VF_AnalyzeStrateRegionBox(PartitionManifest, VoxelBox); + if (Proof.bProvablySingleRegion) + { + const FVoxelStrateRegionQuery Query = VF_QueryStrateRegion( + PartitionManifest, + ((float)VoxelBox.Min.X + (float)VoxelBox.Max.X) * 0.5f, + ((float)VoxelBox.Min.Y + (float)VoxelBox.Max.Y) * 0.5f); + const int32 Primary = FMath::Clamp( + Query.PrimaryRegion, 0, RegionStacks.Num() - 1); + return RegionStacks[Primary].ClassifyBox(VoxelBox, Ctx); + } + + EVoxelTileClass CommonVerdict = EVoxelTileClass::Mixed; + for (const FVoxelOpStack& Stack : RegionStacks) + { + const EVoxelTileClass Verdict = Stack.ClassifyBox(VoxelBox, Ctx); + if (Verdict == EVoxelTileClass::Mixed) + { + return EVoxelTileClass::Mixed; + } + if (CommonVerdict == EVoxelTileClass::Mixed) + { + CommonVerdict = Verdict; + } + else if (CommonVerdict != Verdict) + { + return EVoxelTileClass::Mixed; + } + } + return CommonVerdict; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Both; + } + + const TCHAR* DebugName() const override { return TEXT("LateralRegionBlendOp"); } + + private: + FVoxelStrateRegionManifest PartitionManifest; + TArray RegionStacks; + mutable FVoxelStrateRegionPartitionCache PartitionCache; + mutable bool bPrepared = false; + }; + } // ⚠️ 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 // (« FVoxelOpStack », « FABRIQUES ») insère la classe HORS du namespace anonyme, et l'accolade @@ -5249,7 +5372,8 @@ namespace VoxelDensityOps void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, const TArray& PerBiomeParams, - TUniquePtr BiomeField) + TUniquePtr BiomeField, + bool bAppendStructuralPosts) { // ARCHÉTYPE COMPLET depuis l'étape 2c : vide + overhang + mélange de biomes. // `PerBiomeParams` vide ⇒ chemin sans biomes, strictement inchangé. @@ -5262,12 +5386,17 @@ namespace VoxelDensityOps // motif que cliff → structural : un modificateur qui a besoin de ce que la source a produit. OutStack.Add(MakeUnique(P, Seed, ColumnPtr)); - OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, - P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } } void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P, - int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) { // DEUX archétypes entrent ici, aucun branchement ne les distingue — parce que // `GetSlabDensity` n'en fait aucun non plus. FlatPlain et CrystalChamber ne diffèrent que @@ -5276,12 +5405,17 @@ namespace VoxelDensityOps OutStack.Add(MakeSlabVoidSource(P, Seed)); OutStack.Add(MakeGridColumnMod(P, Seed)); - OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, - P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } } void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P, - int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) { // ⚠️ LA PREUVE QUE L'ABSTRACTION EST RÉELLE, et elle vaut d'être dite : TROIS des cinq // opérateurs ci-dessous sont ceux de Maze, **repris sans une ligne de changement** — @@ -5308,12 +5442,17 @@ namespace VoxelDensityOps OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity)); OutStack.Add(MakeUnique(P, ShaftPtr)); - OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, - P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } } void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P, - int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) { // ⚠️ ÉTAPES A + B + C1 — LA PILE EST COMPLÈTE POUR CET ARCHÉTYPE. // Portés : échelle verticale, roc de base, warp, graphe de salles (+ pits + cheminées), @@ -5384,12 +5523,17 @@ namespace VoxelDensityOps // different SDF writer assembled by the composer. OutStack.Add(MakeUnique(P, Seed)); - OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, - P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } } void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P, - int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) { // ⚠️ LA PILE QUI S'INVERSE, et c'est la mesure que ce portage-ci ajoute : les quatre autres // archétypes partent de ROC et CREUSENT ; celui-ci part du VIDE et REMPLIT. Aucune des deux @@ -5411,12 +5555,17 @@ namespace VoxelDensityOps P.SurfaceRoughness + BlendK + 2.0f)); OutStack.Add(MakeSdfFill(BlendK, P.BaseDensity)); - OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, - P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } } void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, - int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) { // Les constantes viennent telles quelles de GetMazeDensity — elles y étaient codées en dur. constexpr float CarveBlend = 2.0f; @@ -5434,8 +5583,12 @@ namespace VoxelDensityOps OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, RoughFrequency, RoughOctaves, RoughApplyWithin)); OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity)); - OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, - P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } } } @@ -5876,7 +6029,8 @@ bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, const UVoxelStrateManager* StrateManager, FVoxelOpStack& OutStack, FVoxelOpContext& OutContext, - FString* OutError) + FString* OutError, + bool bAppendStructuralPosts) { auto Fail = [&](const FString& Reason) -> bool { @@ -5971,8 +6125,13 @@ bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, OutContext.StrateBottomWorldZ = Bottom; // This is the only place a recipe can acquire world-law operators. There is no recipe field - // for posts, so a serialised manifest cannot forget them or reorder them. - Candidate.AppendStructuralPost(Top, Bottom, Seal, Base, SpineRadius, StrateManager); + // for posts, so a serialised manifest cannot forget them or reorder them. Lateral callers + // materialise this same creative recipe with posts disabled, then append the global post set + // to the parent stack below. + if (bAppendStructuralPosts) + { + Candidate.AppendStructuralPost(Top, Bottom, Seal, Base, SpineRadius, StrateManager); + } FString ValidationError; if (!Candidate.ValidateChannelOrder(&ValidationError)) @@ -5984,3 +6143,309 @@ bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, OutStack = MoveTemp(Candidate); return true; } + +namespace +{ + static EVoxelStrateParamBlock VF_RegionParamBlock(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: return EVoxelStrateParamBlock::Slab; + case ECaveGeneratorType::Maze: return EVoxelStrateParamBlock::Maze; + case ECaveGeneratorType::SurfaceWorld: return EVoxelStrateParamBlock::Surface; + case ECaveGeneratorType::VerticalShafts: return EVoxelStrateParamBlock::VerticalShaft; + case ECaveGeneratorType::FloatingIslands:return EVoxelStrateParamBlock::FloatingIsland; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + default: return EVoxelStrateParamBlock::TunnelNetwork; + } + } + + static float VF_RegionTop(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.StrateTopWorldZ; + } + } + + static float VF_RegionBottom(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.StrateBottomWorldZ; + } + } + + static float VF_RegionSeal(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.BoundarySealThickness; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.BoundarySealThickness; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.BoundarySealThickness; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.BoundarySealThickness; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.BoundarySealThickness; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.BoundarySealThickness; + } + } + + static float VF_RegionBase(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.BaseDensity; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.BaseDensity; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.BaseDensity; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.BaseDensity; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.BaseDensity; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.BaseDensity; + } + } + + static bool VF_BuildNativeRegionCore( + const FVoxelStrateRegion& Region, + const FVoxelStrateArchetypeParams& Params, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext) + { + switch (Region.Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + if (Params.SlabParams.StrateTopWorldZ - Params.SlabParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.SlabParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.SlabParams.StrateBottomWorldZ; + VoxelDensityOps::BuildSlabStack(OutStack, Params.SlabParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::Maze: + if (Params.MazeParams.StrateTopWorldZ - Params.MazeParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.MazeParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.MazeParams.StrateBottomWorldZ; + VoxelDensityOps::BuildMazeStack(OutStack, Params.MazeParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::SurfaceWorld: + if (Params.SurfaceParams.StrateTopWorldZ - Params.SurfaceParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.SurfaceParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.SurfaceParams.StrateBottomWorldZ; + VoxelDensityOps::BuildSurfaceStack( + OutStack, Params.SurfaceParams, Region.Seed, SpineRadius, StrateManager, + TArray(), nullptr, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::VerticalShafts: + if (Params.VerticalShaftParams.StrateTopWorldZ + - Params.VerticalShaftParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.VerticalShaftParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.VerticalShaftParams.StrateBottomWorldZ; + VoxelDensityOps::BuildVerticalShaftStack( + OutStack, Params.VerticalShaftParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::FloatingIslands: + if (Params.FloatingIslandParams.StrateTopWorldZ + - Params.FloatingIslandParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.FloatingIslandParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.FloatingIslandParams.StrateBottomWorldZ; + VoxelDensityOps::BuildFloatingIslandStack( + OutStack, Params.FloatingIslandParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + OutContext.StrateTopWorldZ = Params.TunnelNetworkParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.TunnelNetworkParams.StrateBottomWorldZ; + VoxelDensityOps::BuildTunnelNetworkStack( + OutStack, Params.TunnelNetworkParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + default: + return false; + } + } +} + +bool VF_BuildStrateRegionStack( + const FVoxelStrateRegionManifest& Manifest, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError) +{ + auto Fail = [&](const FString& Reason) -> bool + { + if (OutError != nullptr) { *OutError = Reason; } + return false; + }; + if (OutError != nullptr) { OutError->Reset(); } + if (!Manifest.IsValid()) + { + return Fail(Manifest.FailureReason.IsEmpty() + ? TEXT("lateral region manifest is invalid") : Manifest.FailureReason); + } + if (Manifest.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Manifest.Seed, Manifest.StrateIndex)) + { + return Fail(TEXT("lateral region partition seed is stale for its seed/strate index")); + } + + EVoxelStrateParamBlock GlobalBlock = Manifest.StructuralParamBlock; + if (GlobalBlock == EVoxelStrateParamBlock::None) + { + GlobalBlock = Manifest.Regions[0].bUsesRecipe + ? Manifest.Regions[0].Recipe.StructuralParamBlock + : VF_RegionParamBlock(Manifest.Regions[0].Archetype); + } + + FVoxelStrateArchetypeParams FirstParams = Manifest.Regions[0].ArchetypeParams; + float Top = Manifest.bHasGlobalStructuralParams + ? Manifest.StrateTopWorldZ : VF_RegionTop(FirstParams, GlobalBlock); + float Bottom = Manifest.bHasGlobalStructuralParams + ? Manifest.StrateBottomWorldZ : VF_RegionBottom(FirstParams, GlobalBlock); + float Seal = Manifest.bHasGlobalStructuralParams + ? Manifest.BoundarySealThickness : VF_RegionSeal(FirstParams, GlobalBlock); + float Base = Manifest.bHasGlobalStructuralParams + ? Manifest.BaseDensity : VF_RegionBase(FirstParams, GlobalBlock); + if (!FMath::IsFinite(Top) || !FMath::IsFinite(Bottom) || !(Top > Bottom) + || !FMath::IsFinite(Seal) || Seal < 0.0f + || !FMath::IsFinite(Base) || !(Base > 0.0f)) + { + return Fail(TEXT("lateral region structural parameters are not a finite positive strate")); + } + + auto MakeRegionParams = [&](const FVoxelStrateRegion& Region) + { + FVoxelStrateArchetypeParams Params = Region.ArchetypeParams; + VF_SetStrateArchetypeRuntimeBounds(Params, Top, Bottom); + return Params; + }; + + // The one-region form remains a normal stack. It is useful to callers that consume a region + // manifest, while production keeps its old branch entirely untouched for the hard identity + // gate. + if (Manifest.RegionCount == 1) + { + const FVoxelStrateRegion& Region = Manifest.Regions[0]; + const FVoxelStrateArchetypeParams Params = MakeRegionParams(Region); + bool bBuilt = false; + if (Region.bUsesRecipe) + { + bBuilt = VF_BuildStackFromRecipe(Region.Recipe, Params, Region.Seed, + SpineRadius, StrateManager, OutStack, + OutContext, OutError, true); + } + else + { + OutContext = FVoxelOpContext(); + OutContext.Seed = static_cast(Region.Seed); + OutContext.LayoutVersion = StrateManager != nullptr + ? StrateManager->GetLayoutVersion() : 0; + bBuilt = VF_BuildNativeRegionCore(Region, Params, SpineRadius, StrateManager, + true, OutStack, OutContext); + if (!bBuilt && OutError != nullptr) + { + *OutError = TEXT("single lateral region native stack could not be materialised"); + } + } + if (!bBuilt) { return false; } + OutContext.WorldRadiusVoxels = 0.0f; + OutContext.EdgeSealThickness = Seal; + return true; + } + + TArray RegionStacks; + RegionStacks.Reserve(Manifest.RegionCount); + for (const FVoxelStrateRegion& Region : Manifest.Regions) + { + const FVoxelStrateArchetypeParams Params = MakeRegionParams(Region); + FVoxelOpStack Core; + FVoxelOpContext CoreContext; + bool bBuilt = false; + if (Region.bUsesRecipe) + { + bBuilt = VF_BuildStackFromRecipe(Region.Recipe, Params, Region.Seed, + SpineRadius, StrateManager, Core, + CoreContext, OutError, false); + } + else + { + CoreContext = FVoxelOpContext(); + CoreContext.Seed = static_cast(Region.Seed); + CoreContext.LayoutVersion = StrateManager != nullptr + ? StrateManager->GetLayoutVersion() : 0; + bBuilt = VF_BuildNativeRegionCore(Region, Params, SpineRadius, StrateManager, + false, Core, CoreContext); + if (!bBuilt && OutError != nullptr) + { + *OutError = FString::Printf(TEXT("region %d native stack could not be materialised"), + Region.RegionIndex); + } + } + if (!bBuilt) + { + return false; + } + RegionStacks.Add(MoveTemp(Core)); + } + + FVoxelOpStack Parent; + Parent.Add(MakeUnique(Manifest, MoveTemp(RegionStacks))); + Parent.AppendStructuralPost(Top, Bottom, Seal, Base, SpineRadius, StrateManager); + + FString ValidationError; + if (!Parent.ValidateChannelOrder(&ValidationError)) + { + return Fail(FString::Printf(TEXT("lateral region parent failed ValidateChannelOrder: %s"), + *ValidationError)); + } + + OutContext = FVoxelOpContext(); + OutContext.Seed = static_cast(Manifest.Seed); + OutContext.LayoutVersion = StrateManager != nullptr + ? StrateManager->GetLayoutVersion() : 0; + OutContext.WorldRadiusVoxels = 0.0f; + OutContext.EdgeSealThickness = Seal; + OutContext.StrateTopWorldZ = Top; + OutContext.StrateBottomWorldZ = Bottom; + OutStack = MoveTemp(Parent); + return true; +} diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 3cc4f5f..1fe96b2 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -741,9 +741,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // cache on the same versioned refetch as the native params; no worker reads mutable editor // state while the world is applying a new candidate. thread_local bool CP_UseComposerRecipe = false; + thread_local bool CP_UseComposerRegions = false; thread_local int32 CP_ComposerSeed = 0; thread_local FVoxelStrateArchetypeParams CP_ComposerParams; thread_local FVoxelOpStackRecipe CP_ComposerRecipe; + thread_local FVoxelStrateRegionManifest CP_ComposerRegions; #endif const uint32 LayoutVersion = StrateManager->GetLayoutVersion(); @@ -763,9 +765,20 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); #if WITH_EDITOR CP_UseComposerRecipe = false; + CP_UseComposerRegions = false; ECaveGeneratorType ComposerArchetype = ECaveGeneratorType::TunnelNetwork; bool bComposerUseRecipe = false; - if (StrateManager->GetComposerOverrideForChunk( + if (StrateManager->GetComposerRegionOverrideForChunk( + ChunkCoord, CP_ComposerRegions)) + { + CP_UseComposerRegions = true; + CP_ComposerSeed = CP_ComposerRegions.Seed; + if (CP_ComposerRegions.Regions.Num() > 0) + { + CP_GenType = CP_ComposerRegions.Regions[0].Archetype; + } + } + else if (StrateManager->GetComposerOverrideForChunk( ChunkCoord, CP_ComposerSeed, ComposerArchetype, CP_ComposerParams, bComposerUseRecipe, CP_ComposerRecipe)) { @@ -804,11 +817,37 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // Une seule branche ajoutée au chemin densité, et elle est FROIDE : la construction est // par chunk (comme le refetch de params juste au-dessus), jamais par voxel. CP_UseOpStack = StrateManager->UsesOperatorStackForChunk(ChunkCoord); +#if WITH_EDITOR + // A composer override is already an explicit, validated stack description. It must + // be evaluated even when the authored definition had the normal operator-stack opt-in + // disabled; otherwise a multi-region candidate would be installed in the manager but + // silently fall back to the old one-archetype switch. + CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions || CP_UseComposerRecipe; +#endif if (CP_UseOpStack) { CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile #if WITH_EDITOR - if (CP_UseComposerRecipe) + if (CP_UseComposerRegions) + { + FVoxelOpContext RegionContext; + if (VF_BuildStrateRegionStack( + CP_ComposerRegions, OriginSpineRadius, StrateManager, + CP_OpStack, RegionContext, nullptr)) + { + RegionContext.ChunkCoord = ChunkCoord; + RegionContext.Step = 1; + RegionContext.LayoutVersion = LayoutVersion; + RegionContext.WorldRadiusVoxels = WorldRadiusVoxels; + RegionContext.EdgeSealThickness = EdgeSealThickness; + CP_OpStack.PrepareChunk(RegionContext); + } + else + { + CP_UseOpStack = false; + } + } + else if (CP_UseComposerRecipe) { FVoxelOpContext ComposerContext; if (VF_BuildStackFromRecipe( @@ -2897,6 +2936,94 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (!StrateManager) return EVoxelTileClass::Mixed; #if WITH_EDITOR + // A multi-region composer override is already a complete parent stack: it owns the lateral + // blend and the four global structural posts. Classify it as one field only when the complete + // mesher box stays inside that one strate; otherwise the Z-category pass below deliberately + // returns Mixed. Disturbances and the diff layer remain outside the stack, so either one also + // vetoes a skip. + FVoxelStrateRegionManifest ComposerRegions; + const FIntVector RegionProbeChunk( + FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), FloorDivC(MinZ, CHUNK_SIZE)); + if (StrateManager->GetComposerRegionOverrideForChunk(RegionProbeChunk, ComposerRegions)) + { + int32 RegionTopChunkZ = 0; + int32 RegionBottomChunkZ = 0; + const int32 RegionMinChunkZ = FloorDivC(MinZ, CHUNK_SIZE); + const int32 RegionMaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE); + if (!StrateManager->GetStrateChunkZBounds(RegionProbeChunk.Z, + RegionTopChunkZ, RegionBottomChunkZ) + || RegionMinChunkZ < RegionBottomChunkZ + || RegionMaxChunkZ > RegionTopChunkZ) + { + return EVoxelTileClass::Mixed; + } + + // A representative chunk is not enough for a proof: the mesher box can cross an XY + // chunk edge, and disturbances are chunk-local. Require every touched chunk to remain + // in the same slot and to have no active disturbance before delegating the proof to the + // complete lateral stack. Any uncertainty is Mixed; a false uniform answer here can + // skip a real seam or carve. + const int32 RegionMinChunkX = FloorDivC(MinX, CHUNK_SIZE); + const int32 RegionMaxChunkX = FloorDivC(MaxX, CHUNK_SIZE); + const int32 RegionMinChunkY = FloorDivC(MinY, CHUNK_SIZE); + const int32 RegionMaxChunkY = FloorDivC(MaxY, CHUNK_SIZE); + for (int32 CZ = RegionMinChunkZ; CZ <= RegionMaxChunkZ; ++CZ) + { + int32 TouchedTopChunkZ = 0; + int32 TouchedBottomChunkZ = 0; + if (!StrateManager->GetStrateChunkZBounds( + CZ, TouchedTopChunkZ, TouchedBottomChunkZ) + || TouchedTopChunkZ != RegionTopChunkZ + || TouchedBottomChunkZ != RegionBottomChunkZ) + { + return EVoxelTileClass::Mixed; + } + for (int32 CY = RegionMinChunkY; CY <= RegionMaxChunkY; ++CY) + { + for (int32 CX = RegionMinChunkX; CX <= RegionMaxChunkX; ++CX) + { + const FIntVector TouchedChunk(CX, CY, CZ); + FVoxelStrateRegionManifest TouchedRegions; + if (!StrateManager->GetComposerRegionOverrideForChunk( + TouchedChunk, TouchedRegions) + || TouchedRegions.StrateIndex != ComposerRegions.StrateIndex + || TouchedRegions.Seed != ComposerRegions.Seed + || TouchedRegions.RegionCount != ComposerRegions.RegionCount + || TouchedRegions.PartitionSeed != ComposerRegions.PartitionSeed + || TouchedRegions.LatticeCellSize != ComposerRegions.LatticeCellSize + || TouchedRegions.BlendWidth != ComposerRegions.BlendWidth) + { + 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 RegionStack; + FVoxelOpContext RegionContext; + if (!VF_BuildStrateRegionStack(ComposerRegions, OriginSpineRadius, + StrateManager, RegionStack, RegionContext, nullptr)) + { + return EVoxelTileClass::Mixed; + } + RegionContext.ChunkCoord = RegionProbeChunk; + RegionContext.Step = Step; + RegionContext.LayoutVersion = StrateManager->GetLayoutVersion(); + RegionContext.WorldRadiusVoxels = WorldRadiusVoxels; + RegionContext.EdgeSealThickness = EdgeSealThickness; + RegionStack.PrepareChunk(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. diff --git a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp index af3682e..9792b77 100644 --- a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp +++ b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp @@ -1092,6 +1092,152 @@ namespace VoxelSeasonManifestPrivate Root->SetStringField(TEXT("policy_note"), Manifest.PolicyNote); } + TSharedPtr SerializeRegionManifest( + const FVoxelStrateRegionManifest& Regions) + { + TSharedPtr Object = MakeShared(); + Object->SetBoolField(TEXT("valid"), Regions.bValid); + Object->SetStringField(TEXT("failure_reason"), Regions.FailureReason); + Object->SetNumberField(TEXT("seed"), Regions.Seed); + Object->SetNumberField(TEXT("strate_index"), Regions.StrateIndex); + Object->SetNumberField(TEXT("region_count"), Regions.RegionCount); + Object->SetNumberField(TEXT("partition_seed"), + static_cast(Regions.PartitionSeed)); + SetMetricFloat(Object, TEXT("lattice_cell_size"), Regions.LatticeCellSize); + SetMetricFloat(Object, TEXT("blend_width"), Regions.BlendWidth); + Object->SetBoolField(TEXT("has_global_structural_params"), + Regions.bHasGlobalStructuralParams); + Object->SetNumberField(TEXT("structural_param_block"), + static_cast(Regions.StructuralParamBlock)); + SetMetricFloat(Object, TEXT("strate_top_world_z"), Regions.StrateTopWorldZ); + SetMetricFloat(Object, TEXT("strate_bottom_world_z"), Regions.StrateBottomWorldZ); + SetMetricFloat(Object, TEXT("boundary_seal_thickness"), + Regions.BoundarySealThickness); + SetMetricFloat(Object, TEXT("base_density"), Regions.BaseDensity); + + TArray> RegionValues; + for (const FVoxelStrateRegion& Region : Regions.Regions) + { + TSharedPtr RegionObject = MakeShared(); + RegionObject->SetNumberField(TEXT("region_index"), Region.RegionIndex); + RegionObject->SetNumberField(TEXT("seed"), Region.Seed); + RegionObject->SetStringField(TEXT("archetype"), ArchetypeName(Region.Archetype)); + RegionObject->SetNumberField(TEXT("archetype_id"), + static_cast(Region.Archetype)); + RegionObject->SetBoolField(TEXT("uses_recipe"), Region.bUsesRecipe); + RegionObject->SetObjectField(TEXT("recipe"), SerializeRecipe(Region.Recipe)); + RegionObject->SetObjectField(TEXT("parameters"), + SerializeAllParams(Region.ArchetypeParams)); + RegionValues.Add(MakeShared(MoveTemp(RegionObject))); + } + Object->SetArrayField(TEXT("regions"), MoveTemp(RegionValues)); + return Object; + } + + bool ReadJsonUInt32(const TSharedPtr& Object, + const TCHAR* Name, uint32& OutValue) + { + double Number = 0.0; + if (!ReadJsonNumber(Object, Name, Number) + || !FMath::IsFinite(Number) + || Number < 0.0 || Number > 4294967295.0 + || FMath::FloorToDouble(Number) != Number) + { + return false; + } + OutValue = static_cast(Number); + return true; + } + + bool DeserializeRegionManifest(const TSharedPtr& Object, + FVoxelStrateRegionManifest& OutRegions) + { + if (!Object.IsValid()) return false; + OutRegions = FVoxelStrateRegionManifest(); + bool JsonValid = false; + int32 StructuralBlock = 0; + bool bFieldsValid = ReadJsonBool(Object, TEXT("valid"), JsonValid) + && ReadJsonString(Object, TEXT("failure_reason"), OutRegions.FailureReason) + && ReadJsonInt32(Object, TEXT("seed"), OutRegions.Seed) + && ReadJsonInt32(Object, TEXT("strate_index"), OutRegions.StrateIndex) + && ReadJsonInt32(Object, TEXT("region_count"), OutRegions.RegionCount) + && ReadJsonUInt32(Object, TEXT("partition_seed"), OutRegions.PartitionSeed) + && ReadMetricFloat(Object, TEXT("lattice_cell_size"), OutRegions.LatticeCellSize) + && ReadMetricFloat(Object, TEXT("blend_width"), OutRegions.BlendWidth) + && ReadJsonBool(Object, TEXT("has_global_structural_params"), + OutRegions.bHasGlobalStructuralParams) + && ReadJsonInt32(Object, TEXT("structural_param_block"), StructuralBlock) + && ReadMetricFloat(Object, TEXT("strate_top_world_z"), OutRegions.StrateTopWorldZ) + && ReadMetricFloat(Object, TEXT("strate_bottom_world_z"), + OutRegions.StrateBottomWorldZ) + && ReadMetricFloat(Object, TEXT("boundary_seal_thickness"), + OutRegions.BoundarySealThickness) + && ReadMetricFloat(Object, TEXT("base_density"), OutRegions.BaseDensity); + if (StructuralBlock < static_cast(EVoxelStrateParamBlock::None) + || StructuralBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland)) + { + bFieldsValid = false; + } + OutRegions.StructuralParamBlock = static_cast(StructuralBlock); + + const TArray>* RegionValues = nullptr; + bFieldsValid = ReadJsonArray(Object, TEXT("regions"), RegionValues) && bFieldsValid; + if (RegionValues == nullptr + || OutRegions.RegionCount < 1 || OutRegions.RegionCount > 3 + || RegionValues->Num() != OutRegions.RegionCount) + { + bFieldsValid = false; + } + + OutRegions.Regions.Reset(); + if (RegionValues != nullptr) + { + OutRegions.Regions.Reserve(RegionValues->Num()); + for (int32 Index = 0; Index < RegionValues->Num(); ++Index) + { + const TSharedPtr& Value = (*RegionValues)[Index]; + if (!Value.IsValid() || Value->Type != EJson::Object) + { + bFieldsValid = false; + continue; + } + const TSharedPtr RegionObject = Value->AsObject(); + FVoxelStrateRegion& Region = OutRegions.Regions.AddDefaulted_GetRef(); + FString ArchetypeText; + int32 ArchetypeId = -1; + TSharedPtr Recipe, Params; + bFieldsValid = ReadJsonInt32(RegionObject, TEXT("region_index"), + Region.RegionIndex) && bFieldsValid; + bFieldsValid = ReadJsonInt32(RegionObject, TEXT("seed"), Region.Seed) + && bFieldsValid; + bFieldsValid = ReadJsonString(RegionObject, TEXT("archetype"), ArchetypeText) + && bFieldsValid; + bFieldsValid = ReadJsonInt32(RegionObject, TEXT("archetype_id"), ArchetypeId) + && bFieldsValid; + bFieldsValid = ReadJsonBool(RegionObject, TEXT("uses_recipe"), + Region.bUsesRecipe) && bFieldsValid; + bFieldsValid = ReadJsonObject(RegionObject, TEXT("recipe"), Recipe) + && bFieldsValid; + bFieldsValid = ReadJsonObject(RegionObject, TEXT("parameters"), Params) + && bFieldsValid; + bFieldsValid = ParseArchetype(ArchetypeText, Region.Archetype) + && bFieldsValid; + bFieldsValid = Region.RegionIndex == Index && bFieldsValid; + bFieldsValid = ArchetypeId == static_cast(Region.Archetype) + && bFieldsValid; + if (Region.bUsesRecipe) + { + bFieldsValid = DeserializeRecipe(Recipe, Region.Recipe) && bFieldsValid; + } + bFieldsValid = DeserializeAllParams(Params, Region.ArchetypeParams) + && bFieldsValid; + } + } + + OutRegions.bValid = JsonValid; + return bFieldsValid && OutRegions.IsValid(); + } + TSharedPtr SerializeStrate(const FVoxelSeasonStrate& Strate) { TSharedPtr Object = MakeShared(); @@ -1104,8 +1250,16 @@ namespace VoxelSeasonManifestPrivate Object->SetStringField(TEXT("archetype"), ArchetypeName(Strate.Archetype)); Object->SetNumberField(TEXT("archetype_id"), static_cast(Strate.Archetype)); Object->SetBoolField(TEXT("uses_recipe"), Strate.bUsesRecipe); + Object->SetBoolField(TEXT("uses_regions"), Strate.bUsesRegions); Object->SetObjectField(TEXT("recipe"), SerializeRecipe(Strate.Recipe)); Object->SetObjectField(TEXT("parameters"), SerializeAllParams(Strate.Params)); + // Keep old/single-region artifacts compact and on the exact legacy rebuild path. A + // multi-region strate carries the complete parent manifest because that is its runtime + // identity; an invalid default Regions object must not make fixed legacy entries unloadable. + if (Strate.bUsesRegions) + { + Object->SetObjectField(TEXT("regions"), SerializeRegionManifest(Strate.Regions)); + } Object->SetObjectField(TEXT("measured_metrics"), SerializeMetrics(Strate.Metrics)); Object->SetNumberField(TEXT("distance_from_corpus_centroid"), Strate.DistanceFromCorpusCentroid); Object->SetStringField(TEXT("selection_reason"), Strate.SelectionReason); @@ -1151,10 +1305,34 @@ namespace VoxelSeasonManifestPrivate OutStrate.DistanceFromCorpusCentroid = Distance; OutStrate.SelectionReason = Reason; OutStrate.SourceDefinitionPath = SourcePath; - TSharedPtr Recipe, Params, Metrics; + TSharedPtr Recipe, Params, Metrics, RegionsObject; bValid = ReadJsonObject(Object, TEXT("recipe"), Recipe) && bValid; bValid = ReadJsonObject(Object, TEXT("parameters"), Params) && bValid; bValid = ReadJsonObject(Object, TEXT("measured_metrics"), Metrics) && bValid; + const TSharedPtr* RegionsValue = Object->Values.Find(TEXT("regions")); + if (RegionsValue != nullptr) + { + bValid = ReadJsonObject(Object, TEXT("regions"), RegionsObject) && bValid; + if (RegionsObject.IsValid()) + { + bValid = DeserializeRegionManifest(RegionsObject, OutStrate.Regions) + && bValid; + } + } + const TSharedPtr* UsesRegionsValue = + Object->Values.Find(TEXT("uses_regions")); + if (UsesRegionsValue != nullptr) + { + bValid = ReadJsonBool(Object, TEXT("uses_regions"), OutStrate.bUsesRegions) + && bValid; + } + else + { + // Schema-1 manifests predating lateral regions simply take the old path. A region + // object is optional for backward compatibility, and never implicitly changes that + // path without the explicit opt-in bit. + OutStrate.bUsesRegions = false; + } bValid = ParseArchetype(ArchetypeText, OutStrate.Archetype) && bValid; bValid = ArchetypeId == static_cast(OutStrate.Archetype) && bValid; bValid = ParseConnectivityName(LawText, OutStrate.PrimordialLawResult) && bValid; @@ -1164,6 +1342,11 @@ namespace VoxelSeasonManifestPrivate } bValid = DeserializeAllParams(Params, OutStrate.Params) && bValid; bValid = DeserializeMetrics(Metrics, OutStrate.Metrics) && bValid; + if (OutStrate.bUsesRegions + && (!OutStrate.Regions.IsValid() || OutStrate.Regions.RegionCount <= 1)) + { + bValid = false; + } return bValid; } @@ -1461,12 +1644,31 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, for (int32 Index = 0; Index < OutManifest.Strates.Num(); ++Index) { const FVoxelSeasonStrate& Strate = OutManifest.Strates[Index]; + const float StoredTop = static_cast(Strate.TopWorldZ); + const float StoredBottom = static_cast(Strate.BottomWorldZ); if (Strate.DepthIndex != Index || !Strate.bPassedPrimordialLaw || Strate.PrimordialLawResult != EVoxelConnectivityResult::Connected) { bFieldsValid = false; break; } + if (Strate.bUsesRegions + && (!Strate.Regions.IsValid() + || Strate.Regions.RegionCount <= 1 + || Strate.Regions.Seed != Strate.Seed + || Strate.Regions.StrateIndex != Strate.DepthIndex + || Strate.Regions.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Strate.Seed, Strate.DepthIndex) + || !Strate.Regions.bHasGlobalStructuralParams + || FMemory::Memcmp(&Strate.Regions.StrateTopWorldZ, + &StoredTop, sizeof(float)) != 0 + || FMemory::Memcmp(&Strate.Regions.StrateBottomWorldZ, + &StoredBottom, sizeof(float)) != 0) + ) + { + bFieldsValid = false; + break; + } } if (!bFieldsValid) { @@ -1487,6 +1689,9 @@ namespace VoxelSeasonCompositionPrivate float ActiveBottom(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); float ActiveSeal(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); void SetRuntimeBounds(FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ); + void SetRegionRuntimeBounds(FVoxelStrateRegionManifest& Regions, + int32 Seed, int32 StrateIndex, + float TopWorldZ, float BottomWorldZ); const TCHAR* RejectionReasonForLaw(EVoxelConnectivityResult Result); // These caps are policy/allocator guards, not gameplay limits. They keep a malformed build-box @@ -1690,9 +1895,17 @@ namespace VoxelSeasonCompositionPrivate OutStrate.Archetype = Candidate.Archetype; OutStrate.Recipe = Candidate.Recipe; OutStrate.Params = Candidate.ArchetypeParams; + OutStrate.Regions = Candidate.Regions; OutStrate.bUsesRecipe = true; + OutStrate.bUsesRegions = Candidate.Regions.RegionCount > 1; SetRuntimeBounds(OutStrate.Params, static_cast(TopWorldZ), static_cast(BottomWorldZ)); + if (OutStrate.Regions.IsValid()) + { + SetRegionRuntimeBounds(OutStrate.Regions, Candidate.Seed, CandidateIndex, + static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + } } #endif @@ -1712,11 +1925,19 @@ namespace VoxelSeasonCompositionPrivate OutStrate.Archetype = Fixed.Archetype; OutStrate.Recipe = Fixed.Recipe; OutStrate.Params = Fixed.Params; + OutStrate.Regions = Fixed.Regions; OutStrate.SourceDefinitionPath = Fixed.SourceDefinitionPath; OutStrate.bFixed = true; OutStrate.bUsesRecipe = Fixed.bUsesRecipe; + OutStrate.bUsesRegions = Fixed.bUsesRegions && Fixed.Regions.IsValid(); SetRuntimeBounds(OutStrate.Params, static_cast(TopWorldZ), static_cast(BottomWorldZ)); + if (OutStrate.bUsesRegions) + { + SetRegionRuntimeBounds(OutStrate.Regions, Fixed.Seed, DepthIndex, + static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + } } bool EvaluateStrate(const FVoxelSeasonStrate& Strate, @@ -1739,7 +1960,13 @@ namespace VoxelSeasonCompositionPrivate FVoxelOpContext Context; FString BuildError; bool bBuilt = false; - if (Strate.bUsesRecipe) + if (Strate.bUsesRegions) + { + bBuilt = VF_BuildStrateRegionStack( + Strate.Regions, Settings.OriginSpineRadius, nullptr, + Stack, Context, &BuildError); + } + else if (Strate.bUsesRecipe) { bBuilt = VF_BuildStackFromRecipe(Strate.Recipe, Strate.Params, Strate.Seed, Settings.OriginSpineRadius, nullptr, Stack, @@ -1960,6 +2187,21 @@ namespace VoxelSeasonCompositionPrivate Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ; } + void SetRegionRuntimeBounds(FVoxelStrateRegionManifest& Regions, + int32 Seed, int32 StrateIndex, + float TopWorldZ, float BottomWorldZ) + { + VF_RekeyStrateRegionManifest(Regions, Seed, StrateIndex); + Regions.StrateTopWorldZ = TopWorldZ; + Regions.StrateBottomWorldZ = BottomWorldZ; + Regions.bHasGlobalStructuralParams = true; + for (FVoxelStrateRegion& Region : Regions.Regions) + { + VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams, + TopWorldZ, BottomWorldZ); + } + } + bool SameFloatBits(float A, float B) { return FMemory::Memcmp(&A, &B, sizeof(float)) == 0; @@ -2353,6 +2595,15 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, Settings.StrateHeightInChunks, TopWorldZ, BottomWorldZ, Strate); Strate.DepthIndex = DepthIndex; + if (Strate.bUsesRegions) + { + // Candidate probes use the attempt index only to make their provisional + // measurements independent. The persisted world is keyed by its absolute + // strate slot, so rekey before the final placement gate and measurement. + SetRegionRuntimeBounds(Strate.Regions, Strate.Seed, DepthIndex, + static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + } FSeasonEvaluation Evaluation; if (!EvaluateStrate(Strate, Settings, Evaluation)) { @@ -2592,6 +2843,21 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, const float StoredTop = static_cast(Strate.TopWorldZ); const float StoredBottom = static_cast(Strate.BottomWorldZ); + if (Strate.bUsesRegions) + { + if (!Strate.Regions.IsValid() + || Strate.Regions.RegionCount <= 1 + || Strate.Regions.Seed != Strate.Seed + || Strate.Regions.StrateIndex != Strate.DepthIndex + || Strate.Regions.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Strate.Seed, Strate.DepthIndex) + || !Strate.Regions.bHasGlobalStructuralParams + || !SameFloatBits(Strate.Regions.StrateTopWorldZ, StoredTop) + || !SameFloatBits(Strate.Regions.StrateBottomWorldZ, StoredBottom)) + { + return Fail(TEXT("Manifest lateral region description is invalid or not keyed to its slot.")); + } + } if (!SameFloatBits(ActiveTop(Strate.Params, Strate.Archetype), StoredTop) || !SameFloatBits(ActiveBottom(Strate.Params, Strate.Archetype), StoredBottom)) { @@ -2599,7 +2865,12 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, } bool bBuilt = false; - if (Strate.bUsesRecipe) + if (Strate.bUsesRegions) + { + bBuilt = VF_BuildStrateRegionStack(Strate.Regions, SpineRadius, StrateManager, + OutStack, OutContext, OutError); + } + else if (Strate.bUsesRecipe) { bBuilt = VF_BuildStackFromRecipe(Strate.Recipe, Strate.Params, Strate.Seed, SpineRadius, StrateManager, OutStack, OutContext, diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 4f88d6a..372ef7b 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -22,6 +22,7 @@ #include "VoxelStrateDefinition.h" #include "VoxelTerrainOpDefinition.h" #include "VoxelDensityOpStack.h" +#include "VoxelCaveMorphology.h" #include "VoxelTypes.h" #include @@ -4361,6 +4362,358 @@ bool FVoxelStrateCorpus::SetMeasuredMetrics( return false; } +void VF_SetStrateArchetypeRuntimeBounds( + FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ) +{ + Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ; + Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ; + Params.SlabParams.StrateTopWorldZ = TopWorldZ; + Params.SlabParams.StrateBottomWorldZ = BottomWorldZ; + Params.MazeParams.StrateTopWorldZ = TopWorldZ; + Params.MazeParams.StrateBottomWorldZ = BottomWorldZ; + Params.SurfaceParams.StrateTopWorldZ = TopWorldZ; + Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ; + Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ; + Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ; + Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ; + Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ; +} + +namespace VoxelStrateRegionPrivate +{ + constexpr uint32 PartitionSalt = 0x5245474Eu; // "REGN" + constexpr uint32 PositionSaltX = 0x584F4646u; // "XOFF" + constexpr uint32 PositionSaltY = 0x594F4646u; // "YOFF" + constexpr uint32 RegionSalt = 0x52494458u; // "RIDX" + + static int32 CellAt(float World, float CellSize) + { + return FMath::FloorToInt(World / CellSize); + } + + static FVoxelStrateRegionSite MakeSite(int32 CellX, int32 CellY, + int32 InRegionCount, uint32 InPartitionSeed, + float CellSize) + { + FVoxelStrateRegionSite Site; + Site.CellX = CellX; + Site.CellY = CellY; + + const uint32 H = VoxelHash::Cell(CellX, CellY, InPartitionSeed ^ PartitionSalt); + const float JitterX = VoxelHash::ToFloatSigned( + VoxelHash::Mix(H ^ PositionSaltX)) * 0.35f; + const float JitterY = VoxelHash::ToFloatSigned( + VoxelHash::Mix(H ^ PositionSaltY)) * 0.35f; + Site.WorldX = (static_cast(CellX) + 0.5f + JitterX) * CellSize; + Site.WorldY = (static_cast(CellY) + 0.5f + JitterY) * CellSize; + + const uint32 RegionHash = VoxelHash::Cell( + CellX, CellY, InPartitionSeed ^ RegionSalt); + Site.Region = InRegionCount > 1 + ? static_cast(RegionHash % static_cast(InRegionCount)) : 0; + return Site; + } + + static void GatherSites(float WorldX, float WorldY, int32 InRegionCount, + uint32 InPartitionSeed, float CellSize, + TArray& OutSites) + { + OutSites.Reset(); + if (!(CellSize > 0.0f) || !FMath::IsFinite(CellSize)) + { + return; + } + + const int32 CenterX = CellAt(WorldX, CellSize); + const int32 CenterY = CellAt(WorldY, CellSize); + OutSites.Reserve(25); + for (int32 DY = -2; DY <= 2; ++DY) + { + for (int32 DX = -2; DX <= 2; ++DX) + { + OutSites.Add(MakeSite(CenterX + DX, CenterY + DY, + InRegionCount, InPartitionSeed, CellSize)); + } + } + } + + static bool IsEarlierSite(const FVoxelStrateRegionSite& A, + const FVoxelStrateRegionSite& B) + { + if (A.CellX != B.CellX) { return A.CellX < B.CellX; } + return A.CellY < B.CellY; + } + + static FVoxelStrateRegionQuery QuerySites( + const TArray& Sites, int32 InRegionCount, + float BlendWidth, float CellSize, float WorldX, float WorldY) + { + FVoxelStrateRegionQuery Result; + if (InRegionCount <= 1 || Sites.Num() == 0) + { + return Result; + } + Result.NearestDifferentRegionGap = 0.0f; + const int32 CenterCellX = CellAt(WorldX, CellSize); + const int32 CenterCellY = CellAt(WorldY, CellSize); + + int32 PrimaryIndex = INDEX_NONE; + int32 NeighborIndex = INDEX_NONE; + float PrimaryDistanceSq = FLT_MAX; + float NeighborDistanceSq = FLT_MAX; + for (int32 Index = 0; Index < Sites.Num(); ++Index) + { + const FVoxelStrateRegionSite& Site = Sites[Index]; + if (FMath::Abs(Site.CellX - CenterCellX) > 2 + || FMath::Abs(Site.CellY - CenterCellY) > 2) + { + continue; + } + const float DX = WorldX - Site.WorldX; + const float DY = WorldY - Site.WorldY; + const float DistanceSq = DX * DX + DY * DY; + if (PrimaryIndex == INDEX_NONE + || DistanceSq < PrimaryDistanceSq + || (DistanceSq == PrimaryDistanceSq + && IsEarlierSite(Site, Sites[PrimaryIndex]))) + { + PrimaryIndex = Index; + PrimaryDistanceSq = DistanceSq; + } + } + + const int32 PrimaryRegion = Sites[PrimaryIndex].Region; + for (int32 Index = 0; Index < Sites.Num(); ++Index) + { + if (FMath::Abs(Sites[Index].CellX - CenterCellX) > 2 + || FMath::Abs(Sites[Index].CellY - CenterCellY) > 2) + { + continue; + } + if (Sites[Index].Region == PrimaryRegion) { continue; } + const float DX = WorldX - Sites[Index].WorldX; + const float DY = WorldY - Sites[Index].WorldY; + const float DistanceSq = DX * DX + DY * DY; + if (NeighborIndex == INDEX_NONE + || DistanceSq < NeighborDistanceSq + || (DistanceSq == NeighborDistanceSq + && IsEarlierSite(Sites[Index], Sites[NeighborIndex]))) + { + NeighborIndex = Index; + NeighborDistanceSq = DistanceSq; + } + } + + Result.PrimaryRegion = PrimaryRegion; + if (NeighborIndex == INDEX_NONE) + { + return Result; + } + + const float NeighborDistance = FMath::Sqrt(FMath::Max(NeighborDistanceSq, 0.0f)); + const float Gap = FMath::Max( + NeighborDistance + - FMath::Sqrt(FMath::Max(PrimaryDistanceSq, 0.0f)), 0.0f); + Result.NeighborRegion = Sites[NeighborIndex].Region; + Result.NearestDifferentRegionGap = Gap; + + // A site outside the ±2-cell window is at least 2.15 cell lengths from a point in the + // center cell (site jitter is bounded to ±0.35 around the cell midpoint). A candidate + // within 2.0 cell lengths is therefore known to beat every omitted site. Otherwise the + // density path can conservatively use the candidate/zero weight, but box proof must not + // treat the local window as an infinite search. + Result.bNearestDifferentRegionKnown = FMath::IsFinite(CellSize) + && CellSize > 0.0f + && NeighborDistance <= 2.0f * CellSize; + + if (!(BlendWidth > 0.0f)) + { + return Result; + } + + // The vertical transition is linear from 0 at the outer edge to 1 at the boundary. A + // Voronoi boundary is shared by two regions, so the same curve is split symmetrically: + // each side contributes 0.5 at the bisector and 0 outside the band. + const float Curve = FMath::Clamp(1.0f - Gap / BlendWidth, 0.0f, 1.0f); + Result.NeighborWeight = 0.5f * Curve; + Result.bInBlendBand = Result.NeighborWeight > 0.0f; + return Result; + } + + static FVoxelStrateRegionQuery QueryWithGather( + float WorldX, float WorldY, int32 InRegionCount, uint32 InPartitionSeed, + float CellSize, float BlendWidth) + { + TArray Sites; + GatherSites(WorldX, WorldY, InRegionCount, InPartitionSeed, CellSize, Sites); + return QuerySites(Sites, InRegionCount, BlendWidth, CellSize, WorldX, WorldY); + } +} + +int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex) +{ + return 1 + static_cast(VoxelHash::Cell( + StrateIndex, StrateIndex ^ 0x6D, static_cast(Seed) ^ 0x524F4C4Cu) % 3u); +} + +uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex) +{ + return VoxelHash::Cell(StrateIndex, StrateIndex ^ 0x6D, + static_cast(Seed) ^ 0x52454750u); +} + +void VF_RekeyStrateRegionManifest( + FVoxelStrateRegionManifest& Manifest, int32 InSeed, int32 InStrateIndex) +{ + Manifest.Seed = InSeed; + Manifest.StrateIndex = InStrateIndex; + Manifest.PartitionSeed = VF_GetStrateRegionPartitionSeed(InSeed, InStrateIndex); +} + +FVoxelStrateRegionQuery VF_QueryStrateRegion( + const FVoxelStrateRegionManifest& Manifest, float WorldX, float WorldY) +{ + // The retained seed is an offline artifact, not an alternate identity. Reject a stale or + // hand-edited partition key instead of silently evaluating a different world for the same + // (seed, strate-index) pair. + if (!Manifest.IsValid() + || Manifest.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Manifest.Seed, Manifest.StrateIndex)) + { + return FVoxelStrateRegionQuery(); + } + return VoxelStrateRegionPrivate::QueryWithGather( + WorldX, WorldY, Manifest.RegionCount, Manifest.PartitionSeed, + Manifest.LatticeCellSize, Manifest.BlendWidth); +} + +void FVoxelStrateRegionPartitionCache::PrepareForChunk( + const FVoxelStrateRegionManifest& Manifest, const FIntVector& ChunkCoord) +{ + RegionCount = Manifest.RegionCount; + PartitionSeed = Manifest.PartitionSeed; + LatticeCellSize = Manifest.LatticeCellSize; + BlendWidth = Manifest.BlendWidth; + BaseX = ChunkCoord.X * CHUNK_SIZE - 1; + BaseY = ChunkCoord.Y * CHUNK_SIZE - 1; + Dim = CHUNK_SIZE + 3; + Sites.Reset(); + IntegerSamples.Reset(); + + if (!Manifest.IsValid() + || Manifest.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Manifest.Seed, Manifest.StrateIndex)) + { + RegionCount = 1; + Dim = 0; + return; + } + + const int32 MinCellX = VoxelStrateRegionPrivate::CellAt( + static_cast(BaseX), LatticeCellSize) - 2; + const int32 MaxCellX = VoxelStrateRegionPrivate::CellAt( + static_cast(BaseX + Dim - 1), LatticeCellSize) + 2; + const int32 MinCellY = VoxelStrateRegionPrivate::CellAt( + static_cast(BaseY), LatticeCellSize) - 2; + const int32 MaxCellY = VoxelStrateRegionPrivate::CellAt( + static_cast(BaseY + Dim - 1), LatticeCellSize) + 2; + Sites.Reserve((MaxCellX - MinCellX + 1) * (MaxCellY - MinCellY + 1)); + for (int32 CY = MinCellY; CY <= MaxCellY; ++CY) + { + for (int32 CX = MinCellX; CX <= MaxCellX; ++CX) + { + Sites.Add(VoxelStrateRegionPrivate::MakeSite( + CX, CY, RegionCount, PartitionSeed, LatticeCellSize)); + } + } + + IntegerSamples.SetNum(Dim * Dim); + for (int32 Y = 0; Y < Dim; ++Y) + { + for (int32 X = 0; X < Dim; ++X) + { + IntegerSamples[Y * Dim + X] = VoxelStrateRegionPrivate::QuerySites( + Sites, RegionCount, BlendWidth, LatticeCellSize, + static_cast(BaseX + X), static_cast(BaseY + Y)); + } + } +} + +FVoxelStrateRegionQuery FVoxelStrateRegionPartitionCache::Query( + float WorldX, float WorldY) const +{ + if (Dim > 0 + && WorldX == FMath::FloorToFloat(WorldX) + && WorldY == FMath::FloorToFloat(WorldY)) + { + const int32 IX = FMath::FloorToInt(WorldX); + const int32 IY = FMath::FloorToInt(WorldY); + const int32 LocalX = IX - BaseX; + const int32 LocalY = IY - BaseY; + if (LocalX >= 0 && LocalX < Dim && LocalY >= 0 && LocalY < Dim) + { + return IntegerSamples[LocalY * Dim + LocalX]; + } + } + + // The production stack is prepared for the current chunk before its first sample. Reuse the + // prepared local site set for the rare fractional probe as well; rebuilding a lattice window + // here would turn a cache miss into a per-voxel allocation/hash walk. The prepared set has a + // one-voxel halo, which covers the mesher's gradient probes. The pure public query remains the + // fallback for an intentionally out-of-window diagnostic call. + const float PreparedMaxX = static_cast(BaseX + Dim - 1); + const float PreparedMaxY = static_cast(BaseY + Dim - 1); + if (Sites.Num() > 0 && Dim > 0 + && WorldX >= static_cast(BaseX) && WorldX <= PreparedMaxX + && WorldY >= static_cast(BaseY) && WorldY <= PreparedMaxY) + { + return VoxelStrateRegionPrivate::QuerySites( + Sites, RegionCount, BlendWidth, LatticeCellSize, WorldX, WorldY); + } + return VoxelStrateRegionPrivate::QueryWithGather( + WorldX, WorldY, RegionCount, PartitionSeed, + LatticeCellSize, BlendWidth); +} + +FVoxelStrateRegionBoxProof VF_AnalyzeStrateRegionBox( + const FVoxelStrateRegionManifest& Manifest, const FBox& VoxelBox) +{ + FVoxelStrateRegionBoxProof Proof; + if (!Manifest.IsValid() + || Manifest.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Manifest.Seed, Manifest.StrateIndex)) + { + return Proof; + } + if (Manifest.RegionCount <= 1) + { + Proof.bProvablySingleRegion = true; + Proof.bTouchesRegionBoundary = false; + Proof.bTouchesBlendBand = false; + return Proof; + } + + const float HalfX = FMath::Max((float)(VoxelBox.Max.X - VoxelBox.Min.X) * 0.5f, 0.0f); + const float HalfY = FMath::Max((float)(VoxelBox.Max.Y - VoxelBox.Min.Y) * 0.5f, 0.0f); + const float Radius = FMath::Sqrt(HalfX * HalfX + HalfY * HalfY); + const float CenterX = ((float)VoxelBox.Min.X + (float)VoxelBox.Max.X) * 0.5f; + const float CenterY = ((float)VoxelBox.Min.Y + (float)VoxelBox.Max.Y) * 0.5f; + const FVoxelStrateRegionQuery Center = VF_QueryStrateRegion(Manifest, CenterX, CenterY); + if (!Center.bNearestDifferentRegionKnown) + { + return Proof; + } + const float GapLowerBound = Center.NearestDifferentRegionGap - 2.0f * Radius; + + // The difference of two Euclidean distances is 2-Lipschitz. Strict inequalities are + // intentional: an equality may put a sample exactly on a bisector or at the blend cutoff, + // and that is not a proof a mesher is allowed to skip. + Proof.bTouchesRegionBoundary = !(GapLowerBound > 0.0f); + Proof.bTouchesBlendBand = !(GapLowerBound > Manifest.BlendWidth); + Proof.bProvablySingleRegion = GapLowerBound > Manifest.BlendWidth; + return Proof; +} + FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index) { @@ -4831,6 +5184,246 @@ FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index) return Recipe; } +namespace VoxelStrateRegionPrivate +{ + static ECaveGeneratorType ArchetypeForRecipe(const FVoxelOpStackRecipe& Recipe) + { + switch (Recipe.StructuralParamBlock) + { + case EVoxelStrateParamBlock::TunnelNetwork: return ECaveGeneratorType::TunnelNetwork; + case EVoxelStrateParamBlock::Slab: return ECaveGeneratorType::FlatPlain; + case EVoxelStrateParamBlock::Maze: return ECaveGeneratorType::Maze; + case EVoxelStrateParamBlock::Surface: return ECaveGeneratorType::SurfaceWorld; + case EVoxelStrateParamBlock::VerticalShaft: return ECaveGeneratorType::VerticalShafts; + case EVoxelStrateParamBlock::FloatingIsland: return ECaveGeneratorType::FloatingIslands; + default: return ECaveGeneratorType::TunnelNetwork; + } + } + + static EVoxelStrateParamBlock ParamBlockForArchetype(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: return EVoxelStrateParamBlock::Slab; + case ECaveGeneratorType::Maze: return EVoxelStrateParamBlock::Maze; + case ECaveGeneratorType::SurfaceWorld: return EVoxelStrateParamBlock::Surface; + case ECaveGeneratorType::VerticalShafts: return EVoxelStrateParamBlock::VerticalShaft; + case ECaveGeneratorType::FloatingIslands:return EVoxelStrateParamBlock::FloatingIsland; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + default: return EVoxelStrateParamBlock::TunnelNetwork; + } + } + + static void CopyParamBlock(FVoxelStrateArchetypeParams& OutParams, + const FVoxelStrateRollInfo& Roll, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams; + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutParams.SlabParams = Roll.ArchetypeParams.SlabParams; + break; + case ECaveGeneratorType::Maze: + OutParams.MazeParams = Roll.ArchetypeParams.MazeParams; + break; + case ECaveGeneratorType::SurfaceWorld: + OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams; + break; + case ECaveGeneratorType::VerticalShafts: + OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams; + break; + case ECaveGeneratorType::FloatingIslands: + OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams; + break; + default: + break; + } + } + + static float BoundarySeal(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.BoundarySealThickness; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.BoundarySealThickness; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.BoundarySealThickness; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.BoundarySealThickness; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.BoundarySealThickness; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.BoundarySealThickness; + } + } + + static float BaseDensity(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.BaseDensity; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.BaseDensity; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.BaseDensity; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.BaseDensity; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.BaseDensity; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.BaseDensity; + } + } + + static float Top(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.StrateTopWorldZ; + } + } + + static float Bottom(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.StrateBottomWorldZ; + } + } +} + +bool VF_LateralRegionsAreShippable() +{ + // ⛔ FALSE ON PURPOSE. See the declaration in VoxelStrateComposer.h for the full reasoning. + // + // FR : volontairement désactivé — la loi primordiale ne tient pas encore au travers d'une + // couture entre régions (9/16 graines mesurées le 2026-09-05). + // + // Measured: arrival -> departure across a region seam passes 9/16 seeds. A strate that mixes + // archetypes but strands the player at the boundary is worse than one that does not mix. + // Flipping this without an explicit cross-seam corridor contract turns the suite red by design. + return false; +} + +FVoxelStrateRegionManifest VF_RollStrateRegionManifest( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 StrateIndex, bool bRollStructure) +{ + FVoxelStrateRegionManifest Manifest; + Manifest.RegionCount = VF_RollStrateRegionCount(Seed, StrateIndex); + VF_RekeyStrateRegionManifest(Manifest, Seed, StrateIndex); + Manifest.Regions.Reserve(Manifest.RegionCount); + Manifest.bValid = true; + + static const ECaveGeneratorType StructureBlocks[] = + { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + }; + static const EVoxelStrateParamBlock BlockIds[] = + { + EVoxelStrateParamBlock::TunnelNetwork, + EVoxelStrateParamBlock::Slab, + EVoxelStrateParamBlock::Maze, + EVoxelStrateParamBlock::Surface, + EVoxelStrateParamBlock::VerticalShaft, + EVoxelStrateParamBlock::FloatingIsland, + }; + static const uint32 BlockSalts[] = { 0x1001u, 0x1003u, 0x1005u, + 0x1007u, 0x1009u, 0x100Bu }; + + for (int32 RegionIndex = 0; RegionIndex < Manifest.RegionCount; ++RegionIndex) + { + FVoxelStrateRegion& Region = Manifest.Regions.AddDefaulted_GetRef(); + Region.RegionIndex = RegionIndex; + Region.Seed = RegionIndex == 0 + ? Seed + : static_cast(VoxelHash::Cell( + StrateIndex, RegionIndex, static_cast(Seed) ^ 0x52454753u)); + Region.bUsesRecipe = bRollStructure; + + if (!bRollStructure) + { + const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailed( + Corpus, Region.Seed, StrateIndex); + if (!Roll.bValid) + { + Manifest.bValid = false; + if (Manifest.FailureReason.IsEmpty()) + { + Manifest.FailureReason = Roll.FailureReason; + } + continue; + } + Region.Archetype = Roll.Archetype; + Region.ArchetypeParams = Roll.ArchetypeParams; + } + else + { + Region.Recipe = VF_RollStrateStructure(Region.Seed, StrateIndex); + Region.Archetype = VoxelStrateRegionPrivate::ArchetypeForRecipe(Region.Recipe); + for (int32 BlockIndex = 0; BlockIndex < UE_ARRAY_COUNT(StructureBlocks); ++BlockIndex) + { + const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailedForArchetype( + Corpus, StructureBlocks[BlockIndex], + Region.Seed ^ static_cast(BlockSalts[BlockIndex]), StrateIndex); + if (!Roll.bValid) + { + Manifest.bValid = false; + if (Manifest.FailureReason.IsEmpty()) + { + Manifest.FailureReason = Roll.FailureReason; + } + continue; + } + VoxelStrateRegionPrivate::CopyParamBlock( + Region.ArchetypeParams, Roll, StructureBlocks[BlockIndex]); + } + } + } + + if (Manifest.Regions.Num() == Manifest.RegionCount && Manifest.RegionCount > 0) + { + const FVoxelStrateRegion& First = Manifest.Regions[0]; + Manifest.StructuralParamBlock = bRollStructure + ? First.Recipe.StructuralParamBlock + : VoxelStrateRegionPrivate::ParamBlockForArchetype(First.Archetype); + Manifest.StrateTopWorldZ = VoxelStrateRegionPrivate::Top( + First.ArchetypeParams, Manifest.StructuralParamBlock); + Manifest.StrateBottomWorldZ = VoxelStrateRegionPrivate::Bottom( + First.ArchetypeParams, Manifest.StructuralParamBlock); + Manifest.BoundarySealThickness = VoxelStrateRegionPrivate::BoundarySeal( + First.ArchetypeParams, Manifest.StructuralParamBlock); + Manifest.BaseDensity = VoxelStrateRegionPrivate::BaseDensity( + First.ArchetypeParams, Manifest.StructuralParamBlock); + Manifest.bHasGlobalStructuralParams = FMath::IsFinite(Manifest.StrateTopWorldZ) + && FMath::IsFinite(Manifest.StrateBottomWorldZ) + && Manifest.StrateTopWorldZ > Manifest.StrateBottomWorldZ + && FMath::IsFinite(Manifest.BoundarySealThickness) + && FMath::IsFinite(Manifest.BaseDensity); + } + return Manifest; +} + #if WITH_EDITOR namespace { @@ -4919,6 +5512,13 @@ FVoxelStrateComposerCandidate VF_RollStrateCandidate( Candidate.Archetype = Candidate.ParameterRoll.Archetype; Candidate.ArchetypeParams = Candidate.ParameterRoll.ArchetypeParams; + Candidate.Regions = VF_RollStrateRegionManifest(Corpus, Seed, Index, false); + if (!Candidate.Regions.IsValid()) + { + Candidate.FailureReason = Candidate.Regions.FailureReason.IsEmpty() + ? TEXT("lateral region roll failed") : Candidate.Regions.FailureReason; + return Candidate; + } Candidate.bValid = true; return Candidate; } @@ -4951,7 +5551,13 @@ FVoxelStrateComposerCandidate VF_RollStrateCandidate( VF_CopyComposerBlock(Candidate.ArchetypeParams, Roll, Spec.Archetype); } - Candidate.bValid = bAllBlocksValid; + Candidate.Regions = VF_RollStrateRegionManifest(Corpus, Seed, Index, true); + if (!Candidate.Regions.IsValid() && Candidate.FailureReason.IsEmpty()) + { + Candidate.FailureReason = Candidate.Regions.FailureReason.IsEmpty() + ? TEXT("lateral region roll failed") : Candidate.Regions.FailureReason; + } + Candidate.bValid = bAllBlocksValid && Candidate.Regions.IsValid(); return Candidate; } diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index edd71ae..691f5f1 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -19,6 +19,8 @@ struct FVoxelStrateComposerSlotOverride FVoxelStrateArchetypeParams Params; bool bUseRecipe = false; FVoxelOpStackRecipe Recipe; + bool bUseRegions = false; + FVoxelStrateRegionManifest Regions; }; static void VF_SetComposerRuntimeBounds( @@ -286,7 +288,8 @@ const FVoxelStrateComposerSlotOverride* UVoxelStrateManager::FindComposerOverrid bool UVoxelStrateManager::SetComposerOverrideForStrate( int32 StrateIndex, int32 CandidateSeed, ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params, bool bUseRecipe, - const FVoxelOpStackRecipe* Recipe, FString& OutError) + const FVoxelOpStackRecipe* Recipe, FString& OutError, + const FVoxelStrateRegionManifest* InRegions) { OutError.Reset(); @@ -342,6 +345,32 @@ bool UVoxelStrateManager::SetComposerOverrideForStrate( Override->Recipe = *Recipe; } + if (InRegions != nullptr && InRegions->RegionCount > 1) + { + if (!InRegions->IsValid()) + { + OutError = InRegions->FailureReason.IsEmpty() + ? TEXT("The composer returned an invalid lateral region manifest.") + : InRegions->FailureReason; + return false; + } + Override->bUseRegions = true; + Override->Regions = *InRegions; + VF_RekeyStrateRegionManifest(Override->Regions, CandidateSeed, StrateIndex); + Override->Regions.StrateTopWorldZ = (float)(TargetSlot->TopChunkZ + 1) * CHUNK_SIZE; + Override->Regions.StrateBottomWorldZ = (float)TargetSlot->BottomChunkZ * CHUNK_SIZE; + VF_SetStrateArchetypeRuntimeBounds(Override->Params, + Override->Regions.StrateTopWorldZ, + Override->Regions.StrateBottomWorldZ); + for (FVoxelStrateRegion& Region : Override->Regions.Regions) + { + VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams, + Override->Regions.StrateTopWorldZ, + Override->Regions.StrateBottomWorldZ); + } + Override->Regions.bHasGlobalStructuralParams = true; + } + // Only one slot is overridden at a time. Keeping this map small also makes the worker-side // copy-on-chunk-refetch cheap. Passage geometry is deliberately not regenerated: the layout // and its passages are unchanged, and this is the same manager state used by the offline @@ -381,6 +410,25 @@ bool UVoxelStrateManager::GetComposerOverrideForChunk( return true; } +bool UVoxelStrateManager::GetComposerRegionOverrideForChunk( + const FIntVector& ChunkCoord, FVoxelStrateRegionManifest& OutRegions) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0) + { + return false; + } + + const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(StrateLayout[SlotIdx].StrateIndex); + if (Override == nullptr || !Override->bUseRegions) + { + return false; + } + OutRegions = Override->Regions; + return true; +} + bool UVoxelStrateManager::HasComposerRecipeOverride() const { for (const TPair>& Pair : ComposerOverrides) diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index fdb707e..de42fc6 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -194,6 +194,7 @@ namespace const FVoxelStrateComposerCandidate& Candidate, const UVoxelGenerator& Generator, const UVoxelStrateManager& StrateManager, + int32 TargetStrateIndex, int32 TargetTopChunkZ, int32 TargetBottomChunkZ, FVoxelOpStack& OutStack, @@ -206,7 +207,24 @@ namespace VF_SetComposerRuntimeBounds(Params, TopWorldZ, BottomWorldZ); bool bBuilt = false; - if (Candidate.bStructureRoll) + if (Candidate.Regions.RegionCount > 1) + { + FVoxelStrateRegionManifest RegionManifest = Candidate.Regions; + VF_RekeyStrateRegionManifest(RegionManifest, Candidate.Seed, + TargetStrateIndex); + RegionManifest.StrateTopWorldZ = TopWorldZ; + RegionManifest.StrateBottomWorldZ = BottomWorldZ; + RegionManifest.bHasGlobalStructuralParams = true; + for (FVoxelStrateRegion& Region : RegionManifest.Regions) + { + VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams, + TopWorldZ, BottomWorldZ); + } + bBuilt = VF_BuildStrateRegionStack( + RegionManifest, Generator.OriginSpineRadius, &StrateManager, + OutStack, OutContext, &OutError); + } + else if (Candidate.bStructureRoll) { bBuilt = VF_BuildStackFromRecipe( Candidate.Recipe, Params, Generator.Seed, Generator.OriginSpineRadius, @@ -573,7 +591,25 @@ void AVoxelWorld::ApplyComposerCandidate() FVoxelOpContext PreflightContext; FString PreflightError; bool bPreflightBuilt = false; - if (Candidate.bStructureRoll) + FVoxelStrateRegionManifest PreflightRegions; + if (Candidate.Regions.RegionCount > 1) + { + PreflightRegions = Candidate.Regions; + VF_RekeyStrateRegionManifest(PreflightRegions, Candidate.Seed, + ComposerTargetStrateIndex); + PreflightRegions.StrateTopWorldZ = TargetTopWorldZ; + PreflightRegions.StrateBottomWorldZ = TargetBottomWorldZ; + PreflightRegions.bHasGlobalStructuralParams = true; + for (FVoxelStrateRegion& Region : PreflightRegions.Regions) + { + VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams, + TargetTopWorldZ, TargetBottomWorldZ); + } + bPreflightBuilt = VF_BuildStrateRegionStack( + PreflightRegions, Generator->OriginSpineRadius, StrateManager, + PreflightStack, PreflightContext, &PreflightError); + } + else if (Candidate.bStructureRoll) { bPreflightBuilt = VF_BuildStackFromRecipe( Candidate.Recipe, PreflightParams, Generator->Seed, Generator->OriginSpineRadius, @@ -615,7 +651,8 @@ void AVoxelWorld::ApplyComposerCandidate() if (!StrateManager->SetComposerOverrideForStrate( ComposerTargetStrateIndex, Candidate.Seed, Candidate.Archetype, Candidate.ArchetypeParams, Candidate.bStructureRoll, - Candidate.bStructureRoll ? &Candidate.Recipe : nullptr, ApplyError)) + Candidate.bStructureRoll ? &Candidate.Recipe : nullptr, ApplyError, + Candidate.Regions.RegionCount > 1 ? &Candidate.Regions : nullptr)) { UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d was not applied: %s"), @@ -638,7 +675,8 @@ void AVoxelWorld::ApplyComposerCandidate() FVoxelOpContext ReferenceContext; FString ReferenceError; const bool bReferenceBuilt = VF_BuildComposerReferenceStack( - Candidate, *Generator, *StrateManager, TargetTopChunkZ, TargetBottomChunkZ, + Candidate, *Generator, *StrateManager, ComposerTargetStrateIndex, + TargetTopChunkZ, TargetBottomChunkZ, ReferenceStack, ReferenceContext, ReferenceError); FString ComparableReason; diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 0b1ae7f..c54fec1 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -41,6 +41,7 @@ class UVoxelStrateManager; struct FVoxelOpStackRecipe; struct FVoxelStrateArchetypeParams; +struct FVoxelStrateRegionManifest; enum class EVoxelStrateOpClass : uint8; /** The declarations the offline structure roller reads before it ever materialises an op. */ @@ -353,7 +354,8 @@ namespace VoxelDensityOps const UVoxelStrateManager* StrateManager, const TArray& PerBiomeParams = TArray(), - TUniquePtr BiomeField = nullptr); + TUniquePtr BiomeField = nullptr, + bool bAppendStructuralPosts = true); /** * FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** : @@ -365,7 +367,8 @@ namespace VoxelDensityOps */ VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P, int32 Seed, float SpineRadius, - const UVoxelStrateManager* StrateManager); + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); /** * VerticalShafts — 9 ops, et **TROIS viennent de Maze sans une ligne de changement** : @@ -378,7 +381,8 @@ namespace VoxelDensityOps */ VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P, int32 Seed, float SpineRadius, - const UVoxelStrateManager* StrateManager); + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); /** * TunnelNetwork — **COMPLET, 20 ops** : @@ -399,7 +403,8 @@ namespace VoxelDensityOps VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P, int32 Seed, float SpineRadius, - const UVoxelStrateManager* StrateManager); + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); /** * DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle @@ -448,7 +453,8 @@ namespace VoxelDensityOps */ VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P, int32 Seed, float SpineRadius, - const UVoxelStrateManager* StrateManager); + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); /** * La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique : @@ -459,14 +465,26 @@ namespace VoxelDensityOps */ VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, int32 Seed, float SpineRadius, - const UVoxelStrateManager* StrateManager); + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); } -/** Materialise an offline recipe. Structural posts are appended internally and cannot be omitted. */ +/** Materialise an offline recipe. Structural posts are appended by default; lateral region cores + * deliberately pass false so the parent stack can append the global posts exactly once. */ VOXELFORGE_API bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, const FVoxelStrateArchetypeParams& Params, int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, FVoxelOpStack& OutStack, FVoxelOpContext& OutContext, - FString* OutError = nullptr); + FString* OutError = nullptr, + bool bAppendStructuralPosts = true); + +/** Build the region combiner stack. Creative cores are independent; structural posts are global. */ +VOXELFORGE_API bool VF_BuildStrateRegionStack( + const FVoxelStrateRegionManifest& Manifest, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError = nullptr); diff --git a/Source/VoxelForge/Public/VoxelSeasonManifest.h b/Source/VoxelForge/Public/VoxelSeasonManifest.h index ee7741d..0ffd791 100644 --- a/Source/VoxelForge/Public/VoxelSeasonManifest.h +++ b/Source/VoxelForge/Public/VoxelSeasonManifest.h @@ -65,7 +65,9 @@ struct VOXELFORGE_API FVoxelSeasonFixedStrate ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; FVoxelStrateArchetypeParams Params; FVoxelOpStackRecipe Recipe; + FVoxelStrateRegionManifest Regions; bool bUsesRecipe = true; + bool bUsesRegions = false; FString SourceDefinitionPath; }; @@ -162,6 +164,9 @@ struct VOXELFORGE_API FVoxelSeasonStrate ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; FVoxelOpStackRecipe Recipe; FVoxelStrateArchetypeParams Params; + // When true this is the complete lateral parent description. Params/Archetype remain the + // compatibility identity of region zero; runtime rebuilds use Regions and never blend params. + FVoxelStrateRegionManifest Regions; FVoxelStrateMeasuredMetrics Metrics; double DistanceFromCorpusCentroid = 0.0; @@ -173,6 +178,7 @@ struct VOXELFORGE_API FVoxelSeasonStrate bool bBossSlot = false; bool bFixed = false; bool bUsesRecipe = true; + bool bUsesRegions = false; bool bPassedNonVacuous = false; bool bPassedLargestComponent = false; @@ -224,8 +230,20 @@ struct VOXELFORGE_API FVoxelSeasonManifest bool IsUsable() const { - return bValid && Error.IsEmpty() && WorldRadiusVoxels == 0.0f - && Strates.Num() == SelectedCount && SelectedCount > 0; + if (!bValid || !Error.IsEmpty() || WorldRadiusVoxels != 0.0f + || Strates.Num() != SelectedCount || SelectedCount <= 0) + { + return false; + } + for (const FVoxelSeasonStrate& Strate : Strates) + { + if (Strate.bUsesRegions + && (!Strate.Regions.IsValid() || Strate.Regions.RegionCount <= 1)) + { + return false; + } + } + return true; } }; @@ -266,4 +284,3 @@ VOXELFORGE_API bool VF_RebuildVoxelSeasonStrate( FVoxelOpStack& OutStack, FVoxelOpContext& OutContext, FString* OutError = nullptr); - diff --git a/Source/VoxelForge/Public/VoxelStrateComposer.h b/Source/VoxelForge/Public/VoxelStrateComposer.h index 6bbf2a3..5b8797f 100644 --- a/Source/VoxelForge/Public/VoxelStrateComposer.h +++ b/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -228,6 +228,142 @@ struct VOXELFORGE_API FVoxelStrateArchetypeParams FFloatingIslandParams FloatingIslandParams; }; +/** + * One lateral region in a strate. + * + * This is deliberately a plain manifest record rather than a label on the strate. The recipe + * and the native vector belong to the region, and the runtime combines the resulting density + * fields. Structural posts are not region-owned. + */ +struct VOXELFORGE_API FVoxelStrateRegion +{ + int32 RegionIndex = 0; + int32 Seed = 0; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams ArchetypeParams; + bool bUsesRecipe = false; + FVoxelOpStackRecipe Recipe; +}; + +/** + * Offline-composed lateral region manifest for one strate. + * + * `StrateIndex` is part of the partition identity. A candidate can be moved to another live + * slot, so the editor hand-off must call VF_RekeyStrateRegionManifest before installing it. + * `PartitionSeed` is retained in the manifest so the runtime never has to recreate an RNG stream. + */ +struct VOXELFORGE_API FVoxelStrateRegionManifest +{ + bool bValid = false; + FString FailureReason; + + int32 Seed = 0; + int32 StrateIndex = 0; + int32 RegionCount = 1; + uint32 PartitionSeed = 0; + + // A 32-voxel chunk sees a tiny portion of this lattice. The values are manifest data, not + // hidden runtime tuning knobs; changing them changes the authored world and its hash. + float LatticeCellSize = 256.0f; + float BlendWidth = 24.0f; + + // One global structural post configuration. Region recipes may use different native blocks, + // but no region is allowed to choose the spine/seal/passage/edge-seal values independently. + bool bHasGlobalStructuralParams = false; + EVoxelStrateParamBlock StructuralParamBlock = EVoxelStrateParamBlock::None; + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; + float BoundarySealThickness = 0.0f; + float BaseDensity = 8.0f; + + TArray Regions; + + bool IsSingleRegion() const { return RegionCount == 1 && Regions.Num() == 1; } + + bool IsValid() const + { + return bValid && RegionCount >= 1 && RegionCount <= 3 + && Regions.Num() == RegionCount + && FMath::IsFinite(LatticeCellSize) && LatticeCellSize > 0.0f + && FMath::IsFinite(BlendWidth) && BlendWidth >= 0.0f; + } +}; + +/** Result of the positional Voronoi query used by the lateral density combiner. */ +struct VOXELFORGE_API FVoxelStrateRegionQuery +{ + int32 PrimaryRegion = 0; + int32 NeighborRegion = INDEX_NONE; + float NeighborWeight = 0.0f; + float NearestDifferentRegionGap = FLT_MAX; + // False means the bounded lattice neighborhood could not prove that its different-region + // candidate is the nearest one. Density evaluation may still use a zero blend weight, but a + // ClassifyBox proof must refuse to call that an infinite boundary distance. + bool bNearestDifferentRegionKnown = false; + bool bInBlendBand = false; +}; + +/** Conservative XY proof used before a tile may be skipped. */ +struct VOXELFORGE_API FVoxelStrateRegionBoxProof +{ + bool bProvablySingleRegion = false; + bool bTouchesRegionBoundary = true; + bool bTouchesBlendBand = true; +}; + +/** One hash-lattice site retained by the per-chunk partition cache. */ +struct VOXELFORGE_API FVoxelStrateRegionSite +{ + int32 CellX = 0; + int32 CellY = 0; + int32 Region = 0; + float WorldX = 0.0f; + float WorldY = 0.0f; +}; + +/** + * Worker-local cache for the lateral partition. + * + * Hash/site discovery and the integer XY grid are prepared once for a chunk. The voxel path + * then does an O(1) lookup for the ordinary integer density samples; only fractional gradient + * probes use the small prepared site list. No seed lattice is searched or allocated per voxel. + */ +struct VOXELFORGE_API FVoxelStrateRegionPartitionCache +{ + void PrepareForChunk(const FVoxelStrateRegionManifest& Manifest, + const FIntVector& ChunkCoord); + FVoxelStrateRegionQuery Query(float WorldX, float WorldY) const; + +private: + int32 RegionCount = 1; + uint32 PartitionSeed = 0; + float LatticeCellSize = 256.0f; + float BlendWidth = 24.0f; + int32 BaseX = 0; + int32 BaseY = 0; + int32 Dim = 0; + TArray Sites; + TArray IntegerSamples; +}; + +/** Set every native family’s runtime Z bounds to the same strate interval. */ +VOXELFORGE_API void VF_SetStrateArchetypeRuntimeBounds( + FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ); + +/** Stable identity helpers for the deterministic lateral partition. */ +VOXELFORGE_API int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex); +VOXELFORGE_API uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex); +VOXELFORGE_API void VF_RekeyStrateRegionManifest( + FVoxelStrateRegionManifest& Manifest, int32 Seed, int32 StrateIndex); + +/** Pure positional query; no retained state and no ordering-dependent RNG. */ +VOXELFORGE_API FVoxelStrateRegionQuery VF_QueryStrateRegion( + const FVoxelStrateRegionManifest& Manifest, float WorldX, float WorldY); + +/** Conservative proof for an XY projection of a voxel box. */ +VOXELFORGE_API FVoxelStrateRegionBoxProof VF_AnalyzeStrateRegionBox( + const FVoxelStrateRegionManifest& Manifest, const FBox& VoxelBox); + /** One known-good authored vector and the archetype that gives it meaning. */ struct VOXELFORGE_API FVoxelStrateCorpusEntry { @@ -413,6 +549,7 @@ struct VOXELFORGE_API FVoxelStrateComposerCandidate ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; FVoxelStrateArchetypeParams ArchetypeParams; FVoxelOpStackRecipe Recipe; + FVoxelStrateRegionManifest Regions; // Kept so automation/editor callers can audit the exact six independent structure blocks // without re-rolling them through a second implementation. @@ -532,6 +669,30 @@ VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailedForArchetype( /** Pure structure roll: no retained RNG state and no corpus/runtime dependency. */ VOXELFORGE_API FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index); +/** Roll one-to-three independent region recipes/vectors plus their deterministic partition. */ +VOXELFORGE_API FVoxelStrateRegionManifest VF_RollStrateRegionManifest( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 StrateIndex, bool bRollStructure); + +/** + * ⛔ THE LATERAL-REGION GATE. Returns false, and must keep returning false until the seam law holds. + * + * Lateral regions (archetypes mixing inside one strate — "tunnel network leading to a big chamber") + * are BUILT and MEASURED but NOT SHIPPABLE. Measured 2026-09-05: `arrival -> departure` across a + * region seam passes **9 of 16 seeds**, against 11/16 for same-region controls in the same harness. + * + * Density blending alone does not guarantee a player can cross a seam. A strate that mixes archetypes + * and strands you at the boundary is strictly worse than one that does not mix — so this stays off. + * + * ⚠️ DO NOT flip this to true to "enable the feature". The fix is an explicit corridor/landing + * contract across seams (a design decision, deliberately not made unilaterally), after which the + * measured rate must be 16/16. `VoxelForge.Composer.LateralRegions` asserts that this gate is shut + * whenever the rate is imperfect, so flipping it without fixing the law turns the suite red. + * + * Safe today regardless: single-region output is bit-identical to legacy, and cross-boundary box + * verdicts returned 0 violations over 199,800 brute-forced samples (all correctly `Mixed`). + */ +VOXELFORGE_API bool VF_LateralRegionsAreShippable(); + /** * Provenance for a parameter block that does not read the authored corpus. * diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index f9a7da3..dee1c0b 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -27,6 +27,7 @@ class UVoxelSettings; #if WITH_EDITOR struct FVoxelStrateArchetypeParams; struct FVoxelOpStackRecipe; +struct FVoxelStrateRegionManifest; struct FVoxelStrateComposerSlotOverride; #endif @@ -158,7 +159,8 @@ public: bool SetComposerOverrideForStrate( int32 StrateIndex, int32 CandidateSeed, ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params, bool bUseRecipe, - const FVoxelOpStackRecipe* Recipe, FString& OutError); + const FVoxelOpStackRecipe* Recipe, FString& OutError, + const FVoxelStrateRegionManifest* Regions = nullptr); /** Read the immutable editor override for a chunk into worker-local storage. */ bool GetComposerOverrideForChunk( @@ -166,6 +168,10 @@ public: ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams, bool& bOutUseRecipe, FVoxelOpStackRecipe& OutRecipe) const; + /** Read the immutable editor region override into worker-local storage. */ + bool GetComposerRegionOverrideForChunk( + const FIntVector& ChunkCoord, FVoxelStrateRegionManifest& OutRegions) const; + /** Custom recipes cannot be safely classified by the native ClassifyBox proof yet. */ bool HasComposerRecipeOverride() const; #endif