diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2184ce8..45c3665 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -290,6 +290,16 @@ is a simple straight tube. Placement, reach, and shape draws for inter-strate pa connection count cannot shift any later passage. Global passage settings were removed from `VoxelSettings`. +After a passage's XY is selected, `GeneratePassages` asks the **destination** strate for a lower-mouth +height through the pure free function `VF_SuggestOpenPointZ` (`VoxelCaveMorphology`). TunnelNetwork and +Underwater use the nearest hash-placed room centre, with `MakeStrateSeed(world-seed, strate-index)` +matching the room graph's existing identity; FlatPlain and CrystalChamber recompute their slab void +band and reject column-overlap points. Maze, SurfaceWorld, VerticalShafts, and FloatingIslands remain +explicitly unanswerable for this tier: they keep the historical random reach and emit a warning. This +query is source-level only — it does not construct an operator stack, touch a cache, or call back into +`UVoxelStrateManager` while `Initialize` is building the layout. The resulting lower endpoint is +clamped to the destination's seal-free interior. + ### 8.9 Carving — brush shapes + editor controls `FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/ `Falloff` + `GetWorldBounds`. `UVoxelDiffLayer::GetDensityOffset` switches per shape; chunk diff --git a/CODEMAP.md b/CODEMAP.md index 1352cee..9122390 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -318,6 +318,12 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system. | `EvaluateSDFCached` | 757 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. **Signature changed (perf pass 2): `RoomShapeVariety` param REMOVED** — the shape roll + capsule trig are pre-baked into `FCachedRoom` (`ShapeType/ShapeA/ShapeB/ShapeR`) by `BuildChunkCache`, bit-identical. | | `EvaluateSDF` | 738 | Convenience wrapper (builds temp cache) for one-off queries. | +`MakeStrateSeed` (h:~477) is the shared pure world-seed/strate-index salt used by both +`BuildChunkCache` and destination landing queries. `VF_SuggestOpenPointZ` (h:~550, implemented in +`VoxelCaveMorphology.cpp`) is the pure landing-site query: nearest hash room centre for +TunnelNetwork/Underwater, slab void midpoint for FlatPlain/CrystalChamber, and `false` for the other +archetypes. It never touches a generator, operator stack, manager, cache, or mutable state. + Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per voxel is the single biggest CPU win. @@ -354,7 +360,7 @@ Maps depth→strate at runtime; owns passages. | Method | .cpp line | Role | |--------|-----------|------| | `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + pool sorted by soft-asset path, then unchanged Fisher-Yates shuffle), logs every **cave** slot whose operator-stack opt-in is disabled, then `GeneratePassages`. SurfaceWorld is deliberately excluded from that diagnostic because its exact-lattice T1.d path does not depend on the flag. | -| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. | +| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. After XY placement, the destination's pure `VF_SuggestOpenPointZ` targets the lower mouth and the result is clamped outside the destination seal bands; unsupported answers warn and preserve the old random reach. | | `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. | | `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. | | `FindSlotIndexForChunkZ` | 427 | Z → layout index. | @@ -454,6 +460,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | | `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | +| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. | ## 4. The density pipeline (most-edited hot path) diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index def8dc6..8f55e76 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -343,7 +343,7 @@ the parameter named "max". It proves anything below `TerrainZ` solid and skips meshing. Any future 3D generation inside mountains needs a guard, or the caves vanish with no error. -### ⚠️ 6.4 The primordial law has a real hole TODAY +### 🔨 6.4 The primordial law hole — HALF FIXED (4 of 8 archetypes), built + green `UVoxelStrateManager::GeneratePassages` places every inter-strate passage as independently salted hashes of `(seed, upper-strate index, connection index)` → random angle around the (0,0) spine → @@ -561,3 +561,51 @@ acceptance conditions. Both now met: ⚠️ **Read the PROVED counts as measurements, never as contracts.** What is asserted is that none of them is *wrong* — a false verdict leaves no geometry and no collision behind it. + +--- + +## 11. Tier 1 status — passages aim at real open space (PARTIAL, built + green) + +**Landed 2026-08-17.** `VF_SuggestOpenPointZ` (in `VoxelCaveMorphology`) is a **pure free function** of +(archetype, params, seed, XY) — it touches no operator stack, no `UVoxelStrateManager`, no cache. That +matters: `GeneratePassages` runs inside `Initialize`, when the layout is half-built and `LayoutVersion` +is mid-flight, so constructing anything holding a manager pointer there is a re-entrancy trap. + +`GeneratePassages` now asks the **destination** strate for an open Z near the chosen XY and aims the +lower mouth there, clamped strictly inside the interior (never into a seal band). Where the archetype +cannot answer, **today's random reach is preserved unchanged**. + +### ⚠️ Coverage is PARTIAL and the number is the point +Measured by `VoxelForge.Determinism.PassageLandsInOpenSpace`: + +> *7 inter-strate passages, 3 checked, 48/48 ring samples air, 4 query-false (4 unique archetypes; +> 4 unsupported, 0 supported-but-no-point).* + +✅ **Answering:** TunnelNetwork · Underwater · FlatPlain · CrystalChamber. +❌ **Not answering:** Maze · VerticalShafts · SurfaceWorld (and by extension FloatingIslands). + +⇒ **The primordial law is guaranteed for four archetypes of eight.** The improvement over this morning +is not that the hole is closed — it is that the gap is now **measured instead of invisible**. Follow-on +work: Maze can answer from its lattice nodes, VerticalShafts from its shafts, SurfaceWorld from just +above `TerrainZ`, FloatingIslands from just above a blob top. + +### Two seed contracts, do not mix them +- **TunnelNetwork / Underwater** → `VoxelCaveMorphology::MakeStrateSeed` (the strate's own room seed). +- **Slab archetypes** → the generator **world** seed, which is what `GetSlabDensity` uses. + +Seeding the query from the passage salt hash — which my spec originally asked for — would inspect a +cave graph that **does not exist** and return confident answers about imaginary rooms. Codex caught it. + +### The test is a proxy, and says so +The first version sampled `GetDensityAt` at the mouth and asserted air — **vacuous**, because +`VF_ApplyPassageCarving` is a structural-post invariant, so the tube is air *at its own mouth by +construction*. It passed whether the aiming worked or not. Replaced with a **16-point lateral ring** at +`MouthRadius + 2 × PassageBlend` (~14 voxels at defaults), requiring ≥ 8/16 air. A mouth in a real room +has open space around it; a mouth in bedrock has only the tube it dug itself. +⚠️ **It is a proxy for connectivity, not a proof.** The proof is a flood fill — Tier 2. + +### ⚠️ A forked placement envelope, guarded by that test +`VF_FindNearestHashRoomZ` re-derives the room vertical placement envelope that `BuildChunkCache` also +computes. Calling the per-chunk cache builder from `Initialize` is impractical, so this is a deliberate +second copy. **Both sites now carry a PLACEMENT CONTRACT comment naming the other**, and the ring test +is the only thing standing between a placement change and silent bedrock passages. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp new file mode 100644 index 0000000..9169176 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp @@ -0,0 +1,275 @@ +// VoxelForgePassageOpenSpaceTest.cpp +// Vérifie que les bouches basses des passages ciblées tombent dans l'air de leur strate destination. +// Verifies that targeted lower passage mouths land in air in their destination strate. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelCaveMorphology.h" +#include "VoxelDensityPrimitives.h" +#include "VoxelForgeTestFixture.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgePassageLandsInOpenSpaceTest, + "VoxelForge.Determinism.PassageLandsInOpenSpace", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + // The query and production density path use different seed identities for room graphs and + // slabs. Keep the test's oracle explicit so a future seed change cannot silently inspect a + // different room layout. / La requête et la densité utilisent des identités de seed différentes + // pour les graphes de salles et les slabs ; garder l'oracle explicite évite une dérive silencieuse. + int32 OpenPointSeedFor( + const UVoxelStrateDefinition& Definition, + const FStrateSlot& Destination, + int32 WorldSeed) + { + const bool bUsesCaveRooms = + Definition.GeneratorType == ECaveGeneratorType::TunnelNetwork + || Definition.GeneratorType == ECaveGeneratorType::Underwater; + return bUsesCaveRooms + ? static_cast(VoxelCaveMorphology::MakeStrateSeed( + static_cast(WorldSeed), Destination.StrateIndex)) + : WorldSeed; + } + + FString ArchetypeName(ECaveGeneratorType Archetype) + { + if (const UEnum* ArchetypeEnum = StaticEnum()) + { + return ArchetypeEnum->GetNameStringByValue(static_cast(Archetype)); + } + return FString::Printf(TEXT("Value_%d"), static_cast(Archetype)); + } +} + +bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + // Seed 4468 deliberately gives the supported Underwater destination a mouth near the centre + // of a real hash room, while the old random lower reach places that same mouth well outside + // the room's vertical extent. That makes disabling the Z aiming fail deterministically instead + // of relying on a lucky seed. + World.Build(/*InSeed=*/4468); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + const TArray& Layout = World.StrateManager->GetLayout(); + const TArray& Passages = World.StrateManager->GetPassages(); + const int32 WorldSeed = World.Settings->Seed; + + int32 NumInterStratePassages = 0; + int32 NumChecked = 0; + int32 NumQueryFalse = 0; + int32 NumUnsupported = 0; + int32 NumSupportedWithoutPoint = 0; + int32 NumRingAirSamples = 0; + int32 NumRingSamples = 0; + TSet FalseArchetypes; + bool bAllAnswerableMouthRingsHaveAir = true; + bool bAllAnswerableEndpointsMatchQuery = true; + + for (const FVoxelPassage& Passage : Passages) + { + // The surface entry is a same-strate shaft and is not an inter-strate destination query. + // Le puits de surface relie la même strate ; ce n'est pas un passage inter-strates à tester. + if (Passage.UpperStrateIndex == Passage.LowerStrateIndex) + { + continue; + } + + ++NumInterStratePassages; + if (!Layout.IsValidIndex(Passage.LowerStrateIndex)) + { + AddError(FString::Printf( + TEXT("Passage lower endpoint references invalid destination strate index %d."), + Passage.LowerStrateIndex)); + continue; + } + + const FStrateSlot& Destination = Layout[Passage.LowerStrateIndex]; + const UVoxelStrateDefinition* Definition = Destination.Definition; + if (!Definition) + { + AddError(FString::Printf( + TEXT("Passage destination strate %d has no definition."), + Destination.StrateIndex)); + continue; + } + + if (!Layout.IsValidIndex(Passage.UpperStrateIndex)) + { + AddError(FString::Printf( + TEXT("Passage to strate %d references invalid source strate index %d."), + Destination.StrateIndex, + Passage.UpperStrateIndex)); + continue; + } + + const FStrateSlot& Source = Layout[Passage.UpperStrateIndex]; + const UVoxelStrateDefinition* SourceDefinition = Source.Definition; + if (!SourceDefinition) + { + AddError(FString::Printf( + TEXT("Passage source strate %d has no definition."), + Source.StrateIndex)); + continue; + } + + const FStratePassageConfig& PassageConfig = SourceDefinition->PassageConfig; + if (!FMath::IsFinite(PassageConfig.MouthRadius) || PassageConfig.MouthRadius <= 0.0f) + { + AddError(FString::Printf( + TEXT("Passage to strate %d (%s) has invalid mouth radius %.9g."), + Destination.StrateIndex, + *ArchetypeName(Definition->GeneratorType), + PassageConfig.MouthRadius)); + continue; + } + + const float StrateTopZ = (float)(Destination.TopChunkZ + 1) * CHUNK_SIZE; + const float StrateBottomZ = (float)Destination.BottomChunkZ * CHUNK_SIZE; + const int32 QuerySeed = OpenPointSeedFor(*Definition, Destination, WorldSeed); + + float SuggestedZ = 0.0f; + const bool bCanAnswer = VF_SuggestOpenPointZ( + Definition->GeneratorType, + Definition->GenerationParams, + Definition->SlabParams, + QuerySeed, + StrateTopZ, + StrateBottomZ, + Passage.LowerPoint.X, + Passage.LowerPoint.Y, + SuggestedZ); + + if (!bCanAnswer) + { + ++NumQueryFalse; + FalseArchetypes.Add(static_cast(Definition->GeneratorType)); + + const bool bExpectedToBeSupported = + Definition->GeneratorType == ECaveGeneratorType::TunnelNetwork + || Definition->GeneratorType == ECaveGeneratorType::Underwater + || Definition->GeneratorType == ECaveGeneratorType::FlatPlain + || Definition->GeneratorType == ECaveGeneratorType::CrystalChamber; + if (bExpectedToBeSupported) + { + ++NumSupportedWithoutPoint; + } + else + { + ++NumUnsupported; + } + continue; + } + + ++NumChecked; + + // Control-point endpoints are anchored (vertical wobble has a zero envelope at a mouth), + // so the generated endpoint must be exactly the query result. + // Les extrémités sont ancrées (la wobble verticale vaut zéro à une bouche) : l'endpoint + // généré doit donc être exactement le résultat de la requête. + // A mouth's sine envelope is intentionally zero; allow only the tiny float envelope + // produced by evaluating that zero at PI, never a meaningful targeting miss. + // L'enveloppe de bouche est intentionnellement nulle ; tolérer seulement l'infime erreur + // flottante de son évaluation à PI, jamais un échec de visée significatif. + if (!FMath::IsNearlyEqual(Passage.LowerPoint.Z, SuggestedZ, 0.01f)) + { + bAllAnswerableEndpointsMatchQuery = false; + AddError(FString::Printf( + TEXT("Passage to strate %d (%s) missed its suggested open Z: endpoint %.9g, query %.9g."), + Destination.StrateIndex, + *ArchetypeName(Definition->GeneratorType), + Passage.LowerPoint.Z, + SuggestedZ)); + } + + // A center sample is VACUOUS: VF_ApplyPassageCarving deliberately makes the mouth air, + // even when the destination archetype is solid there. Probe a lateral ring instead. + // The ring is Cfg.MouthRadius + two complete PassageBlend bands (4 voxels each): the + // first clears the passage's carve/blend reach, and the second is a safety margin beyond + // EvaluateModifierSDF's 3-voxel junction smoothing. At this radius the passage itself + // cannot supply the air being counted; a nearby destination room/void must do so. + // Require 8 of 16 samples (50%): that is a meaningful open circumference for a mouth, + // while a mouth in bedrock has zero air once its own tube is outside the ring. This is a + // connectivity PROXY, not proof that the mouth is connected to the room: a disconnected + // pocket can pass it. A real proof needs a flood fill (Tier 2). + // Un échantillon au centre serait VACU : le carve structurel force la bouche à l'air. + // L'anneau latéral doit donc trouver de l'air dans la strate de destination elle-même. + constexpr int32 RingSampleCount = 16; + constexpr int32 MinimumAirSamples = 8; + const float RingRadius = PassageConfig.MouthRadius + + 2.0f * VoxelDensityReach::PassageBlend; + + int32 AirSamples = 0; + for (int32 RingSample = 0; RingSample < RingSampleCount; ++RingSample) + { + const float Angle = 2.0f * PI * (float)RingSample / (float)RingSampleCount; + const float SampleX = Passage.LowerPoint.X + FMath::Cos(Angle) * RingRadius; + const float SampleY = Passage.LowerPoint.Y + FMath::Sin(Angle) * RingRadius; + const float Density = World.Generator->GetDensityAt( + SampleX, + SampleY, + Passage.LowerPoint.Z); + + ++NumRingSamples; + if (Density >= 0.0f) + { + ++AirSamples; + ++NumRingAirSamples; + } + } + + if (AirSamples < MinimumAirSamples) + { + bAllAnswerableMouthRingsHaveAir = false; + AddError(FString::Printf( + TEXT("Passage to strate %d (%s) has too little destination air around its mouth: %d/%d ring samples at radius %.3f are air; mouth (%.3f, %.3f, %.3f)."), + Destination.StrateIndex, + *ArchetypeName(Definition->GeneratorType), + AirSamples, + RingSampleCount, + RingRadius, + Passage.LowerPoint.X, + Passage.LowerPoint.Y, + Passage.LowerPoint.Z)); + } + } + + AddInfo(FString::Printf( + TEXT("Passage open-space check: %d inter-strate passages, %d checked, %d/%d ring samples air, %d query-false (%d unique archetypes; %d unsupported, %d supported-but-no-point)."), + NumInterStratePassages, + NumChecked, + NumRingAirSamples, + NumRingSamples, + NumQueryFalse, + FalseArchetypes.Num(), + NumUnsupported, + NumSupportedWithoutPoint)); + + // A test that inspected nothing is not evidence of the invariant. Fail loudly in both cases. + // Un test qui n'a rien inspecté ne prouve pas l'invariant : échouer explicitement dans les deux cas. + if (NumInterStratePassages == 0) + { + AddError(TEXT("VACUOUS: the fixture generated zero inter-strate passages.")); + } + if (NumChecked == 0) + { + AddError(TEXT("VACUOUS: zero answerable passage mouths were checked; the passage invariant was not exercised.")); + } + + return NumInterStratePassages > 0 + && NumChecked > 0 + && bAllAnswerableMouthRingsHaveAir + && bAllAnswerableEndpointsMatchQuery; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 58d2b29..f6da984 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -30,6 +30,7 @@ #include "VoxelCaveMorphology.h" #include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01 #include "VoxelStrateTypes.h" +#include "VoxelNoise.h" // Pure FBM used by the slab landing query #include "VoxelTerrainOpDefinition.h" //============================================================================= @@ -87,6 +88,313 @@ static void BakeRoomFeature( } } +namespace +{ + // Find the nearest hash room's vertical centre without constructing a cache. The search is + // deliberately bounded: an empty neighbourhood is an honest "no answer", not a guessed Z. + // Cherche le centre vertical de la salle hachée la plus proche sans construire de cache. La + // recherche est bornée : un voisinage vide signifie "pas de réponse", jamais un Z inventé. + bool VF_FindNearestHashRoomZ( + const FStrateGenerationParams& Params, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float& OutZ) + { + if (!FMath::IsFinite(StrateTopZ) || !FMath::IsFinite(StrateBottomZ) + || !FMath::IsFinite(WorldX) || !FMath::IsFinite(WorldY) + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + const float CellSize = Params.RoomSpacing; + const float RadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius); + if (!FMath::IsFinite(CellSize) || !FMath::IsFinite(Params.RoomDensity) + || !FMath::IsFinite(Params.MinRoomRadius) || !FMath::IsFinite(Params.MaxRoomRadius) + || !FMath::IsFinite(Params.RoomHeightRatio) + || !FMath::IsFinite(Params.BoundarySealThickness) + || !FMath::IsFinite(Params.MaxTunnelLength) + || CellSize <= 0.0f || Params.RoomDensity <= 0.0f + || Params.MaxTunnelLength <= 0.0f + || RadiusEnvelope <= 0.0f || Params.RoomHeightRatio <= 0.0f + || Params.BoundarySealThickness < 0.0f) + { + return false; + } + + // PLACEMENT CONTRACT: this envelope MUST remain identical to the room-center placement + // code in VoxelCaveMorphology::BuildChunkCache below. The pure query deliberately does + // not call that per-chunk cache builder during Initialize, so this is intentionally a + // second copy. + // VoxelForge.Determinism.PassageLandsInOpenSpace is the fixed ring test that catches drift. + // The extra room-height buffer keeps the generated room body away from both seal bands. + const float RoomZBuffer = RadiusEnvelope * Params.RoomHeightRatio; + const float StrateMinZ = StrateBottomZ + Params.BoundarySealThickness + RoomZBuffer; + const float StrateMaxZ = StrateTopZ - Params.BoundarySealThickness - RoomZBuffer; + const float StrateRangeZ = StrateMaxZ - StrateMinZ; + if (!FMath::IsFinite(StrateRangeZ) || StrateRangeZ <= 0.0f) + { + return false; + } + + // A passage is placed within the configured reach from the spine. Start the search a little + // farther than that reach, then expand deterministic rings until the nearest candidate is + // proven; cap pathological asset values so Initialize cannot become an unbounded grid scan. + const float SearchDistance = FMath::Max(CellSize * 2.0f, + FMath::Max(Params.MaxTunnelLength, 0.0f)); + const float SearchCells = SearchDistance / CellSize; + const int32 InitialSearchRadius = FMath::Min( + 32, + FMath::Max(2, FMath::CeilToInt(SearchCells) + 1)); + constexpr int32 MaxSearchRadius = 32; + + const int32 BaseCellX = FMath::FloorToInt(WorldX / CellSize); + const int32 BaseCellY = FMath::FloorToInt(WorldY / CellSize); + + float BestDistSq = FLT_MAX; + float BestZ = 0.0f; + int32 BestCellX = 0; + int32 BestCellY = 0; + bool bFound = false; + + auto ConsiderCell = [&](int32 CellX, int32 CellY) + { + // Keep this roll byte-for-byte aligned with BuildChunkCache's room placement. + const uint32 CellHash = VoxelHash::Cell(CellX, CellY, (uint32)Seed); + if (VoxelHash::ToFloat01(CellHash) >= Params.RoomDensity) return; + + const float JitterX = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x12345678u)); + const float JitterY = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x9ABCDEF0u)); + const float JitterZ = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x55AA55AAu)); + + const float RoomX = (CellX + 0.15f + JitterX * 0.7f) * CellSize; + const float RoomY = (CellY + 0.15f + JitterY * 0.7f) * CellSize; + const float RoomZ = StrateMinZ + JitterZ * FMath::Max(StrateRangeZ, 1.0f); + const float DX = RoomX - WorldX; + const float DY = RoomY - WorldY; + const float DistSq = DX * DX + DY * DY; + + const bool bCloser = DistSq < BestDistSq; + const bool bTie = DistSq == BestDistSq + && (CellX < BestCellX || (CellX == BestCellX && CellY < BestCellY)); + if (!bCloser && !bTie) return; + + BestDistSq = DistSq; + BestZ = RoomZ; + BestCellX = CellX; + BestCellY = CellY; + bFound = true; + }; + + auto ScanRing = [&](int32 Radius) + { + for (int32 CellY = BaseCellY - Radius; CellY <= BaseCellY + Radius; ++CellY) + { + for (int32 CellX = BaseCellX - Radius; CellX <= BaseCellX + Radius; ++CellX) + { + if (FMath::Max(FMath::Abs(CellX - BaseCellX), FMath::Abs(CellY - BaseCellY)) != Radius) + { + continue; + } + ConsiderCell(CellX, CellY); + } + } + }; + + // The nearest possible room centre in any cell outside this square is at least this far + // from the query point (the placement jitter is bounded to [0.15, 0.85] cell). + auto IsNearestProven = [&](int32 Radius) + { + if (!bFound) return false; + const float OutsideDistance = ((float)Radius + 0.15f) * CellSize; + return BestDistSq < OutsideDistance * OutsideDistance; + }; + + for (int32 CellY = BaseCellY - InitialSearchRadius; + CellY <= BaseCellY + InitialSearchRadius; + ++CellY) + { + for (int32 CellX = BaseCellX - InitialSearchRadius; + CellX <= BaseCellX + InitialSearchRadius; + ++CellX) + { + ConsiderCell(CellX, CellY); + } + } + + bool bNearestProven = IsNearestProven(InitialSearchRadius); + for (int32 Radius = InitialSearchRadius + 1; + Radius <= MaxSearchRadius && !bNearestProven; + ++Radius) + { + ScanRing(Radius); + bNearestProven = IsNearestProven(Radius); + } + + if (!bFound || !bNearestProven) return false; + + // The placement envelope above normally makes this automatic. Keep the explicit check so + // malformed but finite parameters never turn a boundary value into an open-point claim. + const float InnerTop = StrateTopZ - Params.BoundarySealThickness; + const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness; + if (!FMath::IsFinite(BestZ) || BestZ <= InnerBottom || BestZ >= InnerTop) + { + return false; + } + + OutZ = BestZ; + return true; + } + + // The slab source is an XY height band. Evaluate the same two pure height fields as + // GetSlabDensity, then choose their midpoint after intersecting the seal-free interior. + // La source slab est une bande de hauteurs XY : on recalcule les deux champs purs comme + // GetSlabDensity, puis on prend leur milieu après intersection avec l'intérieur sans seal. + bool VF_SuggestSlabOpenPointZ( + const FSlabGenerationParams& Params, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float& OutZ) + { + if (!FMath::IsFinite(StrateTopZ) || !FMath::IsFinite(StrateBottomZ) + || !FMath::IsFinite(WorldX) || !FMath::IsFinite(WorldY) + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + if (!FMath::IsFinite(Params.FloorRelativeHeight) + || !FMath::IsFinite(Params.CeilingRelativeHeight) + || !FMath::IsFinite(Params.FloorRoughness) + || !FMath::IsFinite(Params.FloorRoughnessFrequency) + || !FMath::IsFinite(Params.CeilingRoughness) + || !FMath::IsFinite(Params.CeilingRoughnessFrequency) + || !FMath::IsFinite(Params.ColumnDensity) + || !FMath::IsFinite(Params.ColumnMinRadius) + || !FMath::IsFinite(Params.ColumnMaxRadius) + || !FMath::IsFinite(Params.ColumnSpacing) + || !FMath::IsFinite(Params.BoundarySealThickness) + || !FMath::IsFinite(Params.BaseDensity) + || Params.BoundarySealThickness < 0.0f) + { + return false; + } + + const float StrateHeight = StrateTopZ - StrateBottomZ; + const uint32 SeedU = (uint32)Seed; + + const float FloorZ = StrateBottomZ + StrateHeight * Params.FloorRelativeHeight; + float FloorNoise = 0.0f; + if (Params.FloorRoughness > 0.0f) + { + const float FF = Params.FloorRoughnessFrequency; + FloorNoise = VoxelNoise::FBM( + WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f), + 0.0f, 3 + ) * VOXEL_NOISE_SCALE * Params.FloorRoughness; + } + const float FloorSurface = FloorZ + FloorNoise; + + const float CeilZ = StrateBottomZ + StrateHeight * Params.CeilingRelativeHeight; + float CeilNoise = 0.0f; + if (Params.CeilingRoughness > 0.0f) + { + const float CF = Params.CeilingRoughnessFrequency; + const float RawNoise = VoxelNoise::FBM( + WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, + WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, + 3000.0f, 3 + ) * VOXEL_NOISE_SCALE; + CeilNoise = FMath::Abs(RawNoise) * Params.CeilingRoughness; + } + const float CeilSurface = FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f); + + // VF_ApplyBoundarySeal's bands are [Top-Thickness, Top) and + // (Bottom, Bottom+Thickness]. The midpoint below stays strictly inside both limits. + const float InnerTop = StrateTopZ - Params.BoundarySealThickness; + const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness; + const float OpenBottom = FMath::Max(FloorSurface, InnerBottom); + const float OpenTop = FMath::Min(CeilSurface, InnerTop); + if (!FMath::IsFinite(OpenBottom) || !FMath::IsFinite(OpenTop) || OpenTop <= OpenBottom) + { + return false; + } + + // Slab columns are infinite-height. If one reaches this XY, no Z in the slab void is a + // confident pre-passage landing point, so report "unanswerable" instead of guessing. + if (Params.ColumnDensity > 0.0f && Params.ColumnSpacing > 0.0f) + { + const float Spacing = Params.ColumnSpacing; + const int32 ColumnCellX = FMath::FloorToInt(WorldX / Spacing); + const int32 ColumnCellY = FMath::FloorToInt(WorldY / Spacing); + + for (int32 DY = -1; DY <= 1; ++DY) + { + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 CellX = ColumnCellX + DX; + const int32 CellY = ColumnCellY + DY; + const uint32 H = VoxelHash::Cell(CellX, CellY, SeedU ^ 0xC01C01u); + if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue; + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + const float ColumnX = (CellX + 0.15f + JX * 0.7f) * Spacing; + const float ColumnY = (CellY + 0.15f + JY * 0.7f) * Spacing; + const float Radius = FMath::Lerp(Params.ColumnMinRadius, Params.ColumnMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + const float ColumnDX = WorldX - ColumnX; + const float ColumnDY = WorldY - ColumnY; + if (FMath::Sqrt(ColumnDX * ColumnDX + ColumnDY * ColumnDY) - Radius < 2.0f) + { + return false; + } + } + } + } + + OutZ = (OpenBottom + OpenTop) * 0.5f; + return FMath::IsFinite(OutZ); + } +} + +bool VF_SuggestOpenPointZ( + ECaveGeneratorType Archetype, + const FStrateGenerationParams& CaveParams, + const FSlabGenerationParams& SlabParams, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float& OutZ) +{ + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return VF_FindNearestHashRoomZ(CaveParams, Seed, StrateTopZ, StrateBottomZ, + WorldX, WorldY, OutZ); + + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return VF_SuggestSlabOpenPointZ(SlabParams, Seed, StrateTopZ, StrateBottomZ, + WorldX, WorldY, OutZ); + + default: + // No confident source-level answer for Maze, SurfaceWorld, shafts, or islands yet. + // Pas de réponse source sûre pour Maze, SurfaceWorld, shafts ou islands pour le moment. + return false; + } +} + //============================================================================= // PHASE 1: BUILD CHUNK CACHE //============================================================================= @@ -113,7 +421,7 @@ void VoxelCaveMorphology::BuildChunkCache( OutCache.Columns.Reset(); // Combine world seed with strate index so each strate gets unique caves - const uint32 StrateSeed = VoxelHash::Mix(Seed ^ (uint32)(StrateIndex * 7919 + 104729)); + const uint32 StrateSeed = VoxelCaveMorphology::MakeStrateSeed(Seed, StrateIndex); const float CellSize = Params.RoomSpacing; if (CellSize <= 0.0f) return; @@ -167,6 +475,11 @@ void VoxelCaveMorphology::BuildChunkCache( //========================================================================= // Vertical range for room CENTER placement. + // PLACEMENT CONTRACT: this formula MUST remain identical to + // VF_FindNearestHashRoomZ above. The pure landing query deliberately does not call this + // per-chunk cache builder during Initialize, so the fixed + // VoxelForge.Determinism.PassageLandsInOpenSpace ring test is the guard that catches drift + // between the two sites. //========================================================================= // Buffer = seal thickness + max room half-height. // This guarantees the tallest possible room (RoomRadiusEnvelope * RoomHeightRatio) diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 54070b6..7bfd1ac 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -266,6 +266,19 @@ void UVoxelStrateManager::GeneratePassages() constexpr uint32 PassageSaltPhase = 0xA1100009u; constexpr uint32 PassageSaltBendFreq = 0xA110000Au; + int32 TotalPassages = 0; + int32 NumAimedAtOpenPoint = 0; + TSet NoQueryArchetypes; + + const auto ArchetypeName = [](ECaveGeneratorType Archetype) + { + if (const UEnum* ArchetypeEnum = StaticEnum()) + { + return ArchetypeEnum->GetNameStringByValue(static_cast(Archetype)); + } + return FString::Printf(TEXT("Value_%d"), static_cast(Archetype)); + }; + //========================================================================= // INTER-STRATE PASSAGES: tunnels connecting consecutive strates. // Each passage is randomly assigned one of 5 types, which determines @@ -317,13 +330,92 @@ void UVoxelStrateManager::GeneratePassages() const float PX = FMath::Cos(Angle) * Distance; const float PY = FMath::Sin(Angle) * Distance; + ++TotalPassages; + + // Ask the destination archetype for a source-level open Z at this XY. This is pure + // and safe during Initialize: it does not construct an operator stack or call back + // into the manager. Cave room graphs use the same strate seed as BuildChunkCache; + // slab fields use the world seed consumed by GetSlabDensity. + float SuggestedLowerZ = 0.0f; + bool bAimedAtOpenPoint = false; + const UVoxelStrateDefinition* LowerDef = Lower.Definition; + if (LowerDef) + { + const bool bUsesRoomSeed = + LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork + || LowerDef->GeneratorType == ECaveGeneratorType::Underwater; + const int32 LowerQuerySeed = bUsesRoomSeed + ? static_cast(VoxelCaveMorphology::MakeStrateSeed( + static_cast(CachedSeed), Lower.StrateIndex)) + : CachedSeed; + + bAimedAtOpenPoint = VF_SuggestOpenPointZ( + LowerDef->GeneratorType, + LowerDef->GenerationParams, + LowerDef->SlabParams, + LowerQuerySeed, + LowerTopZ, + (float)(Lower.BottomChunkZ) * CHUNK_SIZE, + PX, + PY, + SuggestedLowerZ); + + if (bAimedAtOpenPoint) + { + ++NumAimedAtOpenPoint; + } + else + { + NoQueryArchetypes.Add(ArchetypeName(LowerDef->GeneratorType)); + } + } + // LENGTH: reach into each strate, capped to the interior. const float UpperReach = FMath::Min( PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltUpperReach), UpperMax); const float LowerReach = FMath::Min( PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltLowerReach), LowerMax); const float TopZ = UpperBottomZ + UpperReach; - const float BottomZ = LowerTopZ - LowerReach; + float BottomZ = LowerTopZ - LowerReach; + + if (bAimedAtOpenPoint) + { + float LowerSealThickness = 0.0f; + switch (LowerDef->GeneratorType) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + LowerSealThickness = LowerDef->GenerationParams.BoundarySealThickness; + break; + + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + LowerSealThickness = LowerDef->SlabParams.BoundarySealThickness; + break; + + default: + break; + } + + const float LowerBottomZ = (float)(Lower.BottomChunkZ) * CHUNK_SIZE; + const float InnerBottomZ = LowerBottomZ + LowerSealThickness; + const float InnerTopZ = LowerTopZ - LowerSealThickness; + if (FMath::IsFinite(SuggestedLowerZ) + && FMath::IsFinite(InnerBottomZ) + && FMath::IsFinite(InnerTopZ) + && InnerBottomZ < InnerTopZ) + { + // VF_SuggestOpenPointZ already guarantees a strict interior answer. Keep a + // tiny margin in the final clamp so a future source query cannot land on a + // seal boundary through rounding. + const float StrictMargin = FMath::Min( + KINDA_SMALL_NUMBER, (InnerTopZ - InnerBottomZ) * 0.25f); + BottomZ = FMath::Clamp( + SuggestedLowerZ, + InnerBottomZ + StrictMargin, + InnerTopZ - StrictMargin); + } + } const int32 Segments = FMath::Clamp(Cfg.Segments, 1, 48); Passage.ControlPoints.Reset(); @@ -422,6 +514,25 @@ void UVoxelStrateManager::GeneratePassages() } } + TArray SortedNoQueryArchetypes; + for (const FString& Archetype : NoQueryArchetypes) + { + SortedNoQueryArchetypes.Add(Archetype); + } + SortedNoQueryArchetypes.Sort(); + FString NoQueryList = TEXT("none"); + if (SortedNoQueryArchetypes.Num() > 0) + { + NoQueryList = FString::Join(SortedNoQueryArchetypes, TEXT(", ")); + } + + UE_LOG(LogTemp, Log, + TEXT("[StrateManager] Passage landing sites: %d/%d aimed at generator-confirmed open space, %d fell back to random reach (archetypes with no query: %s)."), + NumAimedAtOpenPoint, + TotalPassages, + TotalPassages - NumAimedAtOpenPoint, + *NoQueryList); + //========================================================================= // SURFACE ENTRY SHAFT — the one auto-opened (0,0) connection. // A straight vertical shaft at (0,0) piercing the TOP seal of the topmost diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 3f9448e..3e87606 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -33,8 +33,10 @@ // Forward declarations struct FStrateGenerationParams; +struct FSlabGenerationParams; struct FStrateTerrainOpEntry; class UVoxelTerrainOpDefinition; +enum class ECaveGeneratorType : uint8; //============================================================================= // SDF PRIMITIVES @@ -468,6 +470,15 @@ struct FChunkSDFCache namespace VoxelCaveMorphology { + // The room graph's seed is the world seed salted by the strate index. Keep this formula + // shared by BuildChunkCache and landing-site queries so a query cannot inspect another + // strate's room layout. / La seed du graphe des salles est la seed monde salée par l'index + // de strate. La formule est partagée entre BuildChunkCache et les requêtes d'atterrissage. + FORCEINLINE uint32 MakeStrateSeed(uint32 WorldSeed, int32 StrateIndex) + { + return VoxelHash::Mix(WorldSeed ^ (uint32)(StrateIndex * 7919 + 104729)); + } + // PHASE 1: Build the SDF cache for a rectangular region. // Collects all rooms, computes nearest-neighbor backbone, decides tunnel // connections, and pre-computes all tunnel geometry (radii, offsets, midpoints). @@ -523,3 +534,30 @@ namespace VoxelCaveMorphology uint32 Seed, int32 StrateIndex ); } + +/** + * Suggest a deterministic open-space Z for an inter-strate passage mouth. + * + * This is deliberately a pure source query: it does not touch a generator, an operator stack, + * a strate manager, a cache, or mutable state. For TunnelNetwork/Underwater, Seed must be the + * strate-specific room seed returned by VoxelCaveMorphology::MakeStrateSeed; for slab archetypes, + * Seed is the generator world seed used by GetSlabDensity. + * + * Propose un Z d'espace ouvert déterministe pour la bouche d'un passage inter-strates. Cette + * requête ne touche ni générateur, ni pile d'opérateurs, ni StrateManager, ni cache, ni état + * mutable. Pour TunnelNetwork/Underwater, Seed est la seed de salles de la strate; pour les + * archétypes slab, c'est la seed monde utilisée par GetSlabDensity. + * + * @return true when the archetype can provide a confident point; false when it cannot. + */ +VOXELFORGE_API bool VF_SuggestOpenPointZ( + ECaveGeneratorType Archetype, + const FStrateGenerationParams& CaveParams, + const FSlabGenerationParams& SlabParams, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float& OutZ +);