diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1cac800..95223ef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2292,6 +2292,30 @@ it also checks the orphan control, brute-force interval samples and the composed The Linux58 report contains the measured reachability, surface, peer-determinism, timing and capture results; the code path is not promoted to legacy Maze/VerticalShafts generation. +### 8.55 Unreal CoreGeometry asset bridge and U1 asset version + +`UVoxelStrateDefinition::CoreGeometry` is optional and defaults to recipe `None` with no stamps, +rooms, or tunnels. The Unreal adapter preserves the established passage planner, then resolves +authored stamps and cavern graph records, then finalizes the layout. Stable names are required and +are scoped by strate in Core; landmarks may target a stamp, room, or tunnel by that stable name. +Core profile heightfields are restricted to SurfaceWorld. Native caves require the TunnelNetwork or +Underwater archetype; native lattice requires Maze or VerticalShafts. Core recipe/version fields +enter the resolved layout identity when selected. An imported manifest cannot be mixed with a +per-strate profiled heightfield. + +The checked-out Core API contains stamp vocabularies V1, V2, and V3. Unreal uses V1 for the original +stamp kinds and V3 for monolith/platform motifs, then combines and strictly validates the result. +Research17 names V2, but the source API and motif vocabulary require V3; source was followed. +Research27 water remains out of scope for U1. + +The shared VoxelForge custom asset version is 2: version 1 records the tunnel-floor slope migration; +version 2 adds load-time conversion for inter-strate gap, strate height, and transition width. The +saved strate-layout width is used, currently 8 m. Old packages are converted in memory and marked +dirty, but U1 does not save them. This does not yet migrate the rest of Research8's reflected +spatial-property inventory: legacy chunk distances, owner/Core voxel lengths and frequencies, +placement vectors, and other centimetre fields remain in their prior units. The U1 report records +the five BuildHost asset values, hashes, and remaining work. + ## 9. Multiplayer model (listen-server first, dedicated-friendly) > **Status: transport is DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a diff --git a/CODEMAP.md b/CODEMAP.md index f061fd1..cf09526 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -88,19 +88,22 @@ therefore **128 m**, and a 64-chunk strate is **512 m**. Core still stores work at 25 cm those tiles happen to span 8 m, but they are not the layout unit. The gameplay capsule remains **0.34 m radius / 1.76 m tall** (**2.72 / 14.08 legacy voxels**). Research-field dimensions are authored in metres and converted at runtime. The live -strate generator continues to read voxel-valued parameters from its assets, so those authored -dimensions become half as large in metres unless their asset values are doubled. +strate generator continues to read voxel-valued shape parameters from its assets, so those authored +dimensions become half as large in metres unless their asset values are doubled. U1 adds a +versioned in-memory conversion for strate height, transition width, and inter-strate gap only; the +broader Research8 voxel/chunk/centimetre inventory is not yet migrated. The intended metre-scale families remain: room diameters **8-20 m**, origin hub **24 m**, room cells **32 m**, round tunnel bores **3-4 m**, maze cells **16 m** with a **6.25 m nominal bore** (**3.125 m** radius; **3.0625 m** worst-case usable floor after roughness), slab open span **22.4 m**, shaft diameters **4-7 m**, island diameters **12-24 m**, and ordinary inter-strate bores **3-4 m**. Source new fixed physical dimensions are authored in metres and converted to voxel counts at the boundary. -The owner's voxel-valued live strate assets are not migrated by C++: unchanged saved values therefore -produce half-size rooms, tunnels, features and noise periods in metres. See the step-3 migration list -in the handoff report for the exact `DA_Settings` fields and values. +The owner's voxel-valued live strate shape settings are not migrated by U1: unchanged saved values +therefore produce half-size rooms, tunnels, features and noise periods in metres. See the U1 report +for the conversion coverage and remaining Research8 fields. -**Owner asset migration (no packages were edited):** preserve `DA_Settings` distances by changing +**Prior reauthoring recommendation (not applied in U1; no packages were edited):** preserve +`DA_Settings` distances by changing `ViewDistanceXY 32→64`, `ViewDistanceUp/Down 10→20`, `PawnCollisionRadiusChunks 2→4`, `StrateViewMarginChunks 6→12`, `ClipRadius 6→12`, `RenderDistanceChunks 1536→3072`, `EdgeSealThickness 128→256 voxels`, decoration radius/near radius `12/6→24/12 chunks`, region @@ -110,8 +113,9 @@ size `8→16 cells`, spacing/far spacing `8→16 voxels`, march step `4→8 voxe `12→24 cells`; it is currently disabled. Keep `MaxClipLevel=6` and zero-valued disabled/unbounded limits unchanged; double finite world radii and gaps, and multiply finite voxel-volume caps by 8. The loaded `Crystal Cave` slot in `DA_Strate3` is 64 chunks tall; use 128 chunks to preserve its -former 512 m span. Its voxel-authored live-generator lengths remain half-size until that asset is -re-authored; the editor-only research field is the step-3 target. +former 512 m span. U1 automatically converts only strate height and transition widths and the +settings gap at load; the remaining recommendations above are pending. Its voxel-authored +live-generator lengths remain half-size until their asset fields are migrated. Step-3 six-worker scale benchmark (idle machine, `Saved/Research/step3/scale/step3_scale_cacheperf_v1`): the 519³ field took **7.133834–7.825292 s** across three runs (median **7.347496 s**); the 259³ @@ -1323,6 +1327,30 @@ See ARCHITECTURE §8.54 and [`Standalone/Research/WorldAssemblerM3/Linux58/REPORT.md`](Standalone/Research/WorldAssemblerM3/Linux58/REPORT.md) for resolved records, final-field reachability, controls, timing, hashes and captures. +### 3.37 Unreal CoreGeometry assets and partial metre upgrade — World Assembler U1 + +`Public/VoxelStrateTypes.h` declares optional `FVoxelForgeCoreGeometry`. Its default recipe is Off +and its stamp, room, and tunnel arrays are empty. `Private/VoxelForgeCoreAdapter.cpp` maps authored +metres to Core layout requests after passage planning and before finalizing the layout. Stamps use +the V1 resolver for original stamp kinds and V3 for monolith/platform motifs because the checked-out +Core API exposes V3; authored cavern records use `ResolveResolvedCavernGraphV1`. Stable feature +names are namespaced by strate in the resolved layout. `FStrateLandmark::CoreGeometryStableName` +binds one landmark to such a resolved record, and `VoxelContentManager.cpp` builds its lookup from +the finalized snapshot. `VoxelWorld.cpp` retains resolved layout identity in content hashing. + +`Private/VoxelForgeAssetVersion.h` sets the shared custom version to 2. `UVoxelSettings::Serialize` +and `UVoxelStrateDefinition::Serialize`/`PostLoad` upgrade only inter-strate gap, strate height, +and transition width using the saved 8 m layout chunk. These conversions are version-guarded and +idempotent. No package save is performed by the audit. Other Research8 chunks, owner-voxel lengths, +Core-voxel lengths/frequencies, and centimetre fields remain on their existing units and need a +separate migration. + +Likely compile/UHT touchpoints are the reflected nested fields in `VoxelStrateTypes.h`, Core snapshot +and resolver signatures in `VoxelForgeCoreAdapter.cpp`, `FCustomVersionRegistration` and the old +tunnel-slope version in `VoxelStrateDefinition.cpp`, plus the standalone `VoxelForgeExplore` audit +in `VoxelForgeExploreCommandlet.cpp`. `GetStrateForChunk` still receives layout-chunk Z, not a Core +tile index. See `Standalone/Research/WorldAssemblerM3/Unreal1/REPORT.md` for BuildHost evidence. + ## 4. The density pipeline (most-edited hot path) ### 4.1 `GetDensityWithParams` (TunnelNetwork) — VoxelGenerator.cpp:3749 diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp index 6e47814..ce5175d 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp @@ -399,6 +399,7 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters) LegacySettings->WorldRadiusVoxels = 987.0f; LegacySettings->TotalStrates = 1; LegacyDefinition->StrateHeightInChunks = 7; + LegacyDefinition->StrateHeightMeters = 7.0 * 8.0; LegacySettings->StratePool.Add( TSoftObjectPtr(LegacyDefinition.Get())); TStrongObjectPtr LegacyManager( diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeCorrectnessRoundTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeCorrectnessRoundTest.cpp index cc5f5cb..d7a5a52 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeCorrectnessRoundTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeCorrectnessRoundTest.cpp @@ -525,6 +525,7 @@ bool FVoxelForgeZeroTransitionBlendTest::RunTest(const FString& Parameters) { UVoxelStrateDefinition* Definition = World.Definitions[Index].Get(); Definition->TransitionBlendChunks = 0; + Definition->TransitionWidthMeters = 0.0; Definition->TransitionType = (Index & 1) == 0 ? EVoxelStrateTransition::Gradient : EVoxelStrateTransition::Interleaved; } diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp index 819ece5..cc03334 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp @@ -44,6 +44,7 @@ namespace Settings->Seed = LayoutSeed; Settings->TotalStrates = NumPoolDefinitions; Settings->InterStrateGapChunks = 1; + Settings->InterStrateGapMeters = Settings->StrateLayoutChunkSizeMeters; Settings->bOpenSurfaceEntry = true; static const ECaveGeneratorType GeneratorTypes[] = { @@ -69,6 +70,8 @@ namespace FString::Printf(TEXT("Layout order test strate %d"), Index)); Def->GeneratorType = GeneratorTypes[Index]; Def->StrateHeightInChunks = 3 + Index; + Def->StrateHeightMeters = static_cast(Def->StrateHeightInChunks) + * Settings->StrateLayoutChunkSizeMeters; Def->TransitionType = EVoxelStrateTransition::Hard; // Non-trivial, distinct passage configs exercise the connection index and the diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h index 1a83d0d..75c7f0a 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h +++ b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h @@ -79,6 +79,8 @@ namespace VoxelForgeTest NewObject(GetTransientPackage(), NAME_None, RF_Transient)); Settings->Seed = InSeed; Settings->InterStrateGapChunks = InGapChunks; + Settings->InterStrateGapMeters = static_cast(InGapChunks) + * Settings->StrateLayoutChunkSizeMeters; // WorldRadiusVoxels/EdgeSealThickness intentionally inherit the UVoxelSettings defaults; // WorldEdgeSeal overrides them explicitly when it needs a small test world or radius 0. @@ -110,6 +112,8 @@ namespace VoxelForgeTest // refinement and roll tests use the smaller synthetic volume as a bounded memory // control; the owner-facing showcase opts into 8 chunks below. Def->StrateHeightInChunks = FMath::Max(1, InStrateHeightInChunks); + Def->StrateHeightMeters = static_cast(Def->StrateHeightInChunks) + * Settings->StrateLayoutChunkSizeMeters; // Hard transitions: param blending across a boundary would make "which archetype // owns this chunk" ambiguous, and these tests want an unambiguous mapping. Def->TransitionType = EVoxelStrateTransition::Hard; diff --git a/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h b/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h index edc4be6..2822d88 100644 --- a/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h +++ b/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h @@ -79,7 +79,7 @@ namespace VoxelClipmapDesiredTiles return Radius; } const int32 SafeLevel = FMath::Clamp(MaxLevel, 0, 8); - const int64 Scale = static_cast(uint32{1} << static_cast(SafeLevel)); + const int64 Scale = int64{1} << static_cast(SafeLevel); const int64 Numerator = static_cast(Parameters.RenderDistanceChunks) + Scale - 1; const int64 DesiredRadius = Numerator / Scale; diff --git a/Source/VoxelForge/Private/VoxelContentManager.cpp b/Source/VoxelForge/Private/VoxelContentManager.cpp index 933d1ef..65d0b9c 100644 --- a/Source/VoxelForge/Private/VoxelContentManager.cpp +++ b/Source/VoxelForge/Private/VoxelContentManager.cpp @@ -18,6 +18,7 @@ #include "Materials/MaterialInterface.h" #include "Tasks/Task.h" #include "HAL/PlatformProcess.h" +#include "Misc/Crc.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes #include #include @@ -322,11 +323,54 @@ void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InSt Seed = InSeed; NativeSurfaceSnapshot.Reset(); NativeFloatingIslandSnapshot.Reset(); + CoreGeometryAnchors.Reset(); if (Settings) { FString ManifestJson, ManifestHash, ManifestError; const AVoxelWorld* VoxelWorld = Cast(Owner.Get()); + FString ResolvedLayoutJson, ResolvedLayoutIdentity; + if (VoxelWorld != nullptr + && VoxelWorld->GetVoxelForgeCoreResolvedLayout(ResolvedLayoutJson, ResolvedLayoutIdentity)) + { + FTCHARToUTF8 Utf8Layout(*ResolvedLayoutJson); + std::string LayoutError; + const std::optional ResolvedLayout = + VoxelForgeCore::ResolvedLayoutSnapshotV3::FromJson( + std::string_view(Utf8Layout.Get(), static_cast(Utf8Layout.Length())), LayoutError); + if (ResolvedLayout) + { + const auto AddAnchor = [this](const std::string& StableId, int32 StrateIndex, + const std::array& PositionMicrometres) + { + const FVector PositionCm( + static_cast(PositionMicrometres[0]) / 10000.0, + static_cast(PositionMicrometres[1]) / 10000.0, + static_cast(PositionMicrometres[2]) / 10000.0); + const FString FullName = UTF8_TO_TCHAR(StableId.c_str()); + FResolvedCoreGeometryAnchor Anchor{ StrateIndex, PositionCm }; + CoreGeometryAnchors.Add(FullName, Anchor); + const int32 Marker = FullName.Find(TEXT("/core/"), ESearchCase::CaseSensitive, ESearchDir::FromEnd); + if (Marker != INDEX_NONE) + { + const FString ShortName = FullName.Mid(Marker + 6); + if (!ShortName.IsEmpty()) CoreGeometryAnchors.Add(ShortName, Anchor); + } + }; + for (const VoxelForgeCore::ResolvedLayoutStampV1& Stamp : ResolvedLayout->stamps) + AddAnchor(Stamp.stableId, Stamp.strateIndex, Stamp.positionMicrometres); + for (const VoxelForgeCore::ResolvedCavernRoomV1& Room : ResolvedLayout->cavernRooms) + AddAnchor(Room.stableId, Room.strateIndex, Room.centerMicrometres); + for (const VoxelForgeCore::ResolvedCavernEdgeV1& Edge : ResolvedLayout->cavernEdges) + if (!Edge.polylineMicrometres.empty()) + AddAnchor(Edge.stableId, Edge.strateIndex, Edge.polylineMicrometres.front()); + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelContentManager] Could not read resolved Core geometry anchors: %s"), + UTF8_TO_TCHAR(LayoutError.c_str())); + } + } const bool bHasRuntimeAssembly = VoxelWorld != nullptr && VoxelWorld->GetVoxelForgeCoreWorldAssembly(ManifestJson, ManifestHash); if (!bHasRuntimeAssembly) @@ -1042,8 +1086,19 @@ bool UVoxelContentManager::MeasurePlacementCostForValidation( OutAudit.MaximumDensityResidual=FMath::Max(OutAudit.MaximumDensityResidual, FMath::Abs(static_cast(Density))); ++OutAudit.AnchorChecks; - if (FMath::Abs(Density)>0.25f) - { OutError=TEXT("Populated fixture is not anchored on the canonical field."); return false; } + // Core content crossings use the same finite-step column refinement as runtime + // placement. Allow at most half a Core voxel of field residual in this audit. + // Les croisements de contenu Core gardent le raffinement discret du runtime; + // l'audit tolere au plus un demi-voxel Core de residu de champ. + if (FMath::Abs(Density)>0.5f) + { + OutError=FString::Printf( + TEXT("Populated fixture is not anchored on the canonical field: residual=%.4f core_vox=(%.3f,%.3f,%.3f) local_cm=(%.2f,%.2f,%.2f)."), + Density, Local.X/CurrentCtx.VoxelSizeCm, + Local.Y/CurrentCtx.VoxelSizeCm, Local.Z/CurrentCtx.VoxelSizeCm, + Local.X, Local.Y, Local.Z); + return false; + } } } if (!Spawns.IsEmpty()) @@ -2397,6 +2452,28 @@ void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos) const float ExclWorld = FMath::Max(0.0f, L.ExclusionRadiusChunks) * ChunkWorld; if (ExclWorld > 0.0f) bAnyExclusion = true; + if (!L.CoreGeometryStableName.IsEmpty()) + { + const FResolvedCoreGeometryAnchor* Anchor = CoreGeometryAnchors.Find(L.CoreGeometryStableName); + if (Anchor == nullptr || Anchor->StrateIndex != StrateIndex) continue; + const uint32 StableNameHash = FCrc::StrCrc32(*L.CoreGeometryStableName); + const uint32 H = DecoHash(static_cast(StableNameHash), StrateIndex, + 0, 0, 0, EntryIdx, LocalSeed, 0xC04E6E0u); + if (VoxelHash::ToFloat01(H) > L.SpawnProbability) continue; + const float LocalX = static_cast(Anchor->LocalPositionCm.X); + const float LocalY = static_cast(Anchor->LocalPositionCm.Y); + const float dx = LocalX - LocalPlayer.X, dy = LocalY - LocalPlayer.Y; + const float DistSq = dx * dx + dy * dy; + if (DistSq <= GatherWorld * GatherWorld) + { + // A Core-bound landmark is one stable candidate, independent of the hash lattice. + // Un landmark lie a Core reste un candidat stable, independant de la grille de hachage. + Cands.Add({ FIntVector(EntryIdx, 0, EntryIdx), EntryIdx, H, LocalX, LocalY, + ExclWorld, L.Priority, DistSq <= RadiusWorld * RadiusWorld }); + } + continue; + } + if (L.AnchorMode == ELandmarkAnchor::HashLattice) { const float SpacingChunks = FMath::Max(1.0f, L.SpacingChunks); diff --git a/Source/VoxelForge/Private/VoxelForgeAssetVersion.h b/Source/VoxelForge/Private/VoxelForgeAssetVersion.h new file mode 100644 index 0000000..89ef778 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelForgeAssetVersion.h @@ -0,0 +1,12 @@ +#pragma once + +#include "CoreMinimal.h" + +// Shared custom package version for idempotent VoxelForge asset migrations. +// Version de package partagee pour les migrations VoxelForge idempotentes. +namespace VoxelForgeAssetVersion +{ + inline const FGuid GUID(0xA76D5E31, 0x4B2C4F9A, 0xB8C1D204, 0x6E9F7738); + inline constexpr int32 TunnelFloorSlopeDegrees = 1; + inline constexpr int32 MetreAuthoringV1 = 2; +} diff --git a/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp b/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp index 55077b4..08b3813 100644 --- a/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp +++ b/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp @@ -82,6 +82,113 @@ std::int64_t WorldAssemblyWavelength(double Frequency, double FallbackVoxels) return std::max(1, WorldAssemblyFixed(Wavelength)); } +std::int64_t WorldAssemblyMicrometresToSubvoxels( + std::int64_t Micrometres, std::uint32_t CoreVoxelMicrometres); + +bool WorldAssemblyMetresToMicrometres(double Metres, std::int64_t& OutMicrometres) +{ + if (!std::isfinite(Metres) + || std::abs(Metres) > static_cast(std::numeric_limits::max()) / 1000000.0) + { + return false; + } + OutMicrometres = static_cast(std::llround(Metres * 1000000.0)); + return true; +} + +bool WorldAssemblyMetresToSubvoxels(double Metres, std::uint32_t CoreVoxelMicrometres, + std::int64_t& OutSubvoxels) +{ + std::int64_t Micrometres = 0; + if (!WorldAssemblyMetresToMicrometres(Metres, Micrometres) || CoreVoxelMicrometres == 0) + { + return false; + } + OutSubvoxels = WorldAssemblyMicrometresToSubvoxels(Micrometres, CoreVoxelMicrometres); + return true; +} + +bool WorldAssemblyVectorToMicrometres(const FVector& Metres, std::array& Out) +{ + return WorldAssemblyMetresToMicrometres(Metres.X, Out[0]) + && WorldAssemblyMetresToMicrometres(Metres.Y, Out[1]) + && WorldAssemblyMetresToMicrometres(Metres.Z, Out[2]); +} + +bool WorldAssemblyVectorToMicrometres(const FVector2D& Metres, std::array& Out) +{ + return WorldAssemblyMetresToMicrometres(Metres.X, Out[0]) + && WorldAssemblyMetresToMicrometres(Metres.Y, Out[1]); +} + +std::string CoreGeometryStableId(int32 StrateIndex, const FString& StableName) +{ + FTCHARToUTF8 Utf8Name(*StableName); + return std::string("strate/") + std::to_string(StrateIndex) + "/core/" + Utf8Name.Get(); +} + +bool HasCoreGeometry(const UVoxelStrateDefinition& Definition) +{ + const FVoxelForgeCoreGeometry& Geometry = Definition.CoreGeometry; + return Geometry.GeneratorRecipe != EVoxelForgeCoreGeneratorRecipe::None + || !Geometry.Stamps.IsEmpty() || !Geometry.Rooms.IsEmpty() || !Geometry.Tunnels.IsEmpty(); +} + +bool ConfigureCoreGeometryStratum(const FStrateSlot& Slot, ECaveGeneratorType GeneratorType, + bool bImportedOverride, VoxelForgeCore::ResolvedLayoutStratumV2& Item, FString& OutError) +{ + const FVoxelForgeCoreGeometry& Geometry = Slot.Definition->CoreGeometry; + switch (Geometry.GeneratorRecipe) + { + case EVoxelForgeCoreGeneratorRecipe::None: + return true; + case EVoxelForgeCoreGeneratorRecipe::NativeCaves: + if (GeneratorType != ECaveGeneratorType::TunnelNetwork + && GeneratorType != ECaveGeneratorType::Underwater) + { + OutError = FString::Printf(TEXT("Strate %d selects NativeCaves but its generator archetype is not TunnelNetwork/Underwater."), + Slot.StrateIndex); + return false; + } + Item.generatorKind = "Caves"; + Item.recipeId = "Core.Caves.PrimitiveField"; + Item.recipeVersion = 1; + Item.boundDispatchPath = "Core native cave graph uses resolved room and tunnel records"; + Item.queryDispatchPath = "NativeCaveDensityAt evaluates the resolved cavern graph"; + return true; + case EVoxelForgeCoreGeneratorRecipe::NativeLattice: + if (GeneratorType != ECaveGeneratorType::Maze + && GeneratorType != ECaveGeneratorType::VerticalShafts) + { + OutError = FString::Printf(TEXT("Strate %d selects NativeLattice but its generator archetype is not Maze/VerticalShafts."), + Slot.StrateIndex); + return false; + } + Item.recipeId = "Core.Lattice.PrimitiveField"; + Item.recipeVersion = 1; + Item.boundDispatchPath = "Core native lattice uses resolved room and explicit tunnel path records"; + Item.queryDispatchPath = "NativeLatticeDensityAt evaluates the resolved cavern graph"; + return true; + case EVoxelForgeCoreGeneratorRecipe::ProfiledHeightfield: + if (GeneratorType != ECaveGeneratorType::SurfaceWorld) + { + OutError = FString::Printf(TEXT("Strate %d selects ProfiledHeightfield but its generator archetype is not SurfaceWorld."), + Slot.StrateIndex); + return false; + } + if (bImportedOverride) + { + OutError = FString::Printf(TEXT("Strate %d has ProfiledHeightfield authoring, but an imported WorldAssemblyManifest owns that recipe. Clear the imported override to use per-strate CoreGeometry."), + Slot.StrateIndex); + return false; + } + Item.recipeId = "SurfaceWorld.ProfiledHeightfield"; + Item.recipeVersion = 1; + return true; + } + return true; +} + std::uint64_t WorldAssemblyFeatureId(std::uint32_t Seed, std::int64_t X, std::int64_t Y, std::uint32_t Kind) { @@ -111,7 +218,7 @@ std::int64_t WorldAssemblyMicrometresToSubvoxels( std::int64_t Micrometres, std::uint32_t CoreVoxelMicrometres) { if (CoreVoxelMicrometres == 0) return 0; - return WorldAssemblyRoundRatio(Micrometres * WorldAssemblySubvoxelsPerVoxel, + return WorldAssemblyRoundRatio(Micrometres * VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel, static_cast(CoreVoxelMicrometres)); } @@ -370,6 +477,54 @@ bool BuildSurfaceStratumPlan(const UVoxelStrateManager* StrateManager, BottomVoxel, HeightVoxels)); } + if (Definition != nullptr + && Definition->CoreGeometry.GeneratorRecipe == EVoxelForgeCoreGeneratorRecipe::ProfiledHeightfield) + { + const FVoxelForgeCoreGeometry& Geometry = Definition->CoreGeometry; + WorldAssemblySurfaceProfileV1 Profile; + Profile.version = 1; + switch (Geometry.HeightfieldProfile) + { + case EVoxelForgeCoreHeightfieldProfile::PeriodicHills: + Profile.kind = WorldAssemblySurfaceProfileKindV1::periodicHills; + break; + case EVoxelForgeCoreHeightfieldProfile::MirroredRelief: + Profile.kind = WorldAssemblySurfaceProfileKindV1::mirroredRelief; + break; + case EVoxelForgeCoreHeightfieldProfile::FlatField: + Profile.kind = WorldAssemblySurfaceProfileKindV1::flatField; + break; + } + std::array AmplitudeMicrometres{}; + std::array WavelengthMicrometres{}; + std::int64_t BaseSubvoxels = 0; + std::int64_t ClearanceSubvoxels = 0; + if (!WorldAssemblyVectorToMicrometres(Geometry.HeightfieldAmplitudeMeters, AmplitudeMicrometres) + || !WorldAssemblyVectorToMicrometres(Geometry.HeightfieldWavelengthMeters, WavelengthMicrometres) + || !WorldAssemblyMetresToSubvoxels(Geometry.HeightfieldBaseMeters, CoreVoxelMicrometres, BaseSubvoxels) + || !WorldAssemblyMetresToSubvoxels(Geometry.MinimumArenaClearanceMeters, + CoreVoxelMicrometres, ClearanceSubvoxels) + || !std::isfinite(Geometry.MirrorLambda)) + { + OutError = FString::Printf(TEXT("Strate %d Core profiled-heightfield fields contain a non-finite or out-of-range metre value."), + Slot.StrateIndex); + return false; + } + Profile.h0Subvoxels = BaseSubvoxels; + Profile.amplitudeXSubvoxels = WorldAssemblyMicrometresToSubvoxels( + AmplitudeMicrometres[0], CoreVoxelMicrometres); + Profile.amplitudeYSubvoxels = WorldAssemblyMicrometresToSubvoxels( + AmplitudeMicrometres[1], CoreVoxelMicrometres); + Profile.wavelengthXSubvoxels = WorldAssemblyMicrometresToSubvoxels( + WavelengthMicrometres[0], CoreVoxelMicrometres); + Profile.wavelengthYSubvoxels = WorldAssemblyMicrometresToSubvoxels( + WavelengthMicrometres[1], CoreVoxelMicrometres); + Profile.mirrorLambdaQ16 = WorldAssemblyQ16(Geometry.MirrorLambda); + Profile.minimumArenaClearanceSubvoxels = ClearanceSubvoxels; + ResolveWorldAssemblySurfaceProfilePhasesV1(Profile, Recipe.seed); + Recipe.surfaceProfile = Profile; + } + Stratum.strateIndex = Slot.StrateIndex; Stratum.topChunkZ = Slot.TopChunkZ; Stratum.bottomChunkZ = Slot.BottomChunkZ; @@ -836,6 +991,349 @@ void WriteCoreJsonValue(FCoreJsonWriter& Writer, const TCHAR* Identifier, } } +bool ResolveAuthoredCoreGeometry(VoxelForgeCore::ResolvedLayoutSnapshotV3& Layout, + const UVoxelStrateManager* StrateManager, std::int32_t Seed, FString& OutError) +{ + using namespace VoxelForgeCore; + std::vector LegacyStamps, MotifStamps; + std::vector Rooms; + std::vector Edges; + TSet FeatureNames; + TSet NativeGraphStrates; + const auto StableId = [&FeatureNames, &OutError](const FString& Name, int32 StrateIndex, + const TCHAR* Kind, std::string& OutId) + { + if (Name.IsEmpty() || Name != Name.TrimStartAndEnd()) + { + OutError = FString::Printf(TEXT("Core %s on strate %d needs a non-empty stable name without surrounding whitespace."), Kind, StrateIndex); + return false; + } + if (FeatureNames.Contains(Name)) + { + OutError = FString::Printf(TEXT("Core stable name '%s' is duplicated across this world."), *Name); + return false; + } + FeatureNames.Add(Name); + OutId = CoreGeometryStableId(StrateIndex, Name); + return true; + }; + const auto Microns = [&OutError](double Value, int32 StrateIndex, const TCHAR* Field, std::int64_t& Out) + { + if (WorldAssemblyMetresToMicrometres(Value, Out)) return true; + OutError = FString::Printf(TEXT("Strate %d CoreGeometry.%s must be finite and bounded in metres."), StrateIndex, Field); + return false; + }; + const auto VectorMicrons = [&OutError](const FVector& Value, int32 StrateIndex, const TCHAR* Field, + std::array& Out) + { + if (WorldAssemblyVectorToMicrometres(Value, Out)) return true; + OutError = FString::Printf(TEXT("Strate %d CoreGeometry.%s must contain finite, bounded metre coordinates."), StrateIndex, Field); + return false; + }; + + for (const FStrateSlot& Slot : StrateManager->GetLayout()) + { + const UVoxelStrateDefinition* Definition = Slot.Definition; + if (!Definition || !HasCoreGeometry(*Definition)) continue; + const FVoxelForgeCoreGeometry& Geometry = Definition->CoreGeometry; + if (Geometry.GeneratorRecipe == EVoxelForgeCoreGeneratorRecipe::NativeCaves + || Geometry.GeneratorRecipe == EVoxelForgeCoreGeneratorRecipe::NativeLattice) + NativeGraphStrates.Add(Slot.StrateIndex); + + for (const FVoxelForgeCoreStampDefinition& Authored : Geometry.Stamps) + { + ResolvedLayoutStampRequestV1 Request; + if (!StableId(Authored.StableName, Slot.StrateIndex, TEXT("stamp"), Request.stableId) + || !VectorMicrons(Authored.AnchorPositionMeters, Slot.StrateIndex, TEXT("Stamps.AnchorPositionMeters"), Request.anchorPositionMicrometres) + || !VectorMicrons(Authored.HalfExtentsMeters, Slot.StrateIndex, TEXT("Stamps.HalfExtentsMeters"), Request.halfExtentsMicrometres) + || !Microns(Authored.PlacementRadiusMeters, Slot.StrateIndex, TEXT("Stamps.PlacementRadiusMeters"), Request.placementRadiusMicrometres) + || !Microns(Authored.SmoothWidthMeters, Slot.StrateIndex, TEXT("Stamps.SmoothWidthMeters"), Request.smoothWidthMicrometres) + || !Microns(Authored.IrregularityAmplitudeMeters, Slot.StrateIndex, TEXT("Stamps.IrregularityAmplitudeMeters"), Request.irregularityAmplitudeMicrometres) + || !Microns(Authored.IrregularityWavelengthMeters, Slot.StrateIndex, TEXT("Stamps.IrregularityWavelengthMeters"), Request.irregularityWavelengthMicrometres) + || !Microns(Authored.RoundingRadiusMeters, Slot.StrateIndex, TEXT("Stamps.RoundingRadiusMeters"), Request.roundingRadiusMicrometres) + || !Microns(Authored.MinimumSpacingMeters, Slot.StrateIndex, TEXT("Stamps.MinimumSpacingMeters"), Request.minimumSpacingMicrometres) + || !Microns(Authored.FloorMeters, Slot.StrateIndex, TEXT("Stamps.FloorMeters"), Request.floorMicrometres)) return false; + Request.strateIndex = Slot.StrateIndex; + switch (Authored.Kind) + { + case EVoxelForgeCoreStampKind::CarveEllipsoid: Request.kind = ResolvedSdfStampKindV1::carveEllipsoid; break; + case EVoxelForgeCoreStampKind::CarveArch: Request.kind = ResolvedSdfStampKindV1::carveArch; break; + case EVoxelForgeCoreStampKind::FillPillar: Request.kind = ResolvedSdfStampKindV1::fillPillar; break; + case EVoxelForgeCoreStampKind::FillDais: Request.kind = ResolvedSdfStampKindV1::fillDais; break; + case EVoxelForgeCoreStampKind::FillMonolith: Request.kind = ResolvedSdfStampKindV1::fillMonolith; break; + case EVoxelForgeCoreStampKind::FillPlatform: Request.kind = ResolvedSdfStampKindV1::fillPlatform; break; + } + Request.attachmentPolicy = Authored.bAttachToFloor ? 1u : 0u; + Request.yawQuarterTurns = Authored.YawQuarterTurns; + if (Request.kind == ResolvedSdfStampKindV1::fillMonolith || Request.kind == ResolvedSdfStampKindV1::fillPlatform) + MotifStamps.push_back(std::move(Request)); + else LegacyStamps.push_back(std::move(Request)); + } + + TSet RoomNames; + for (const FVoxelForgeCoreRoomDefinition& Authored : Geometry.Rooms) + { + ResolvedCavernRoomV1 Room; + if (!StableId(Authored.StableName, Slot.StrateIndex, TEXT("room"), Room.stableId) + || !VectorMicrons(Authored.CenterMeters, Slot.StrateIndex, TEXT("Rooms.CenterMeters"), Room.centerMicrometres) + || !VectorMicrons(Authored.HalfExtentsMeters, Slot.StrateIndex, TEXT("Rooms.HalfExtentsMeters"), Room.halfExtentsMicrometres) + || !Microns(Authored.FloorMeters, Slot.StrateIndex, TEXT("Rooms.FloorMeters"), Room.floorMicrometres) + || !Microns(Authored.SmoothWidthMeters, Slot.StrateIndex, TEXT("Rooms.SmoothWidthMeters"), Room.smoothWidthMicrometres) + || !Microns(Authored.UnionWidthMeters, Slot.StrateIndex, TEXT("Rooms.UnionWidthMeters"), Room.unionWidthMicrometres) + || !Microns(Authored.IrregularityAmplitudeMeters, Slot.StrateIndex, TEXT("Rooms.IrregularityAmplitudeMeters"), Room.irregularityAmplitudeMicrometres) + || !Microns(Authored.IrregularityWavelengthMeters, Slot.StrateIndex, TEXT("Rooms.IrregularityWavelengthMeters"), Room.irregularityWavelengthMicrometres)) return false; + Room.strateIndex = Slot.StrateIndex; + RoomNames.Add(Authored.StableName); + Rooms.push_back(std::move(Room)); + } + + for (const FVoxelForgeCoreTunnelDefinition& Authored : Geometry.Tunnels) + { + ResolvedCavernEdgeV1 Edge; + if (!StableId(Authored.StableName, Slot.StrateIndex, TEXT("tunnel"), Edge.stableId)) return false; + if (Authored.StartAnchorStableName.IsEmpty() || Authored.EndRoomStableName.IsEmpty() + || !RoomNames.Contains(Authored.EndRoomStableName)) + { + OutError = FString::Printf(TEXT("Core tunnel '%s' needs a start-anchor stable name and an end-room name that identifies a room on strate %d."), *Authored.StableName, Slot.StrateIndex); + return false; + } + FTCHARToUTF8 StartAnchorUtf8(*Authored.StartAnchorStableName); + Edge.startAnchorStableId = StartAnchorUtf8.Get(); + Edge.endRoomStableId = CoreGeometryStableId(Slot.StrateIndex, Authored.EndRoomStableName); + Edge.strateIndex = Slot.StrateIndex; + const FVector ControlPoints[4] = { Authored.ControlPoint0Meters, Authored.ControlPoint1Meters, + Authored.ControlPoint2Meters, Authored.ControlPoint3Meters }; + for (std::size_t Point = 0; Point < 4; ++Point) + if (!WorldAssemblyVectorToMicrometres(ControlPoints[Point], Edge.controlPointsMicrometres[Point])) + { + OutError = FString::Printf(TEXT("Core tunnel '%s' has a non-finite or out-of-range control point in metres."), *Authored.StableName); + return false; + } + if (!Microns(Authored.BoreRadiusMeters, Slot.StrateIndex, TEXT("Tunnels.BoreRadiusMeters"), Edge.boreRadiusMicrometres) + || !Microns(Authored.BoreHeightMeters, Slot.StrateIndex, TEXT("Tunnels.BoreHeightMeters"), Edge.boreHeightMicrometres) + || !Microns(Authored.FloorSupportDepthMeters, Slot.StrateIndex, TEXT("Tunnels.FloorSupportDepthMeters"), Edge.floorSupportDepthMicrometres) + || !Microns(Authored.SmoothWidthMeters, Slot.StrateIndex, TEXT("Tunnels.SmoothWidthMeters"), Edge.smoothWidthMicrometres) + || !Microns(Authored.UnionWidthMeters, Slot.StrateIndex, TEXT("Tunnels.UnionWidthMeters"), Edge.unionWidthMicrometres) + || !Microns(Authored.IrregularityAmplitudeMeters, Slot.StrateIndex, TEXT("Tunnels.IrregularityAmplitudeMeters"), Edge.irregularityAmplitudeMicrometres) + || !Microns(Authored.IrregularityWavelengthMeters, Slot.StrateIndex, TEXT("Tunnels.IrregularityWavelengthMeters"), Edge.irregularityWavelengthMicrometres)) return false; + for (const FVector& Point : Authored.PolylineMeters) + { + std::array Converted{}; + if (!WorldAssemblyVectorToMicrometres(Point, Converted)) + { + OutError = FString::Printf(TEXT("Core tunnel '%s' has an invalid metre polyline point."), *Authored.StableName); + return false; + } + Edge.polylineMicrometres.push_back(Converted); + } + Edges.push_back(std::move(Edge)); + } + if ((!Geometry.Rooms.IsEmpty() || !Geometry.Tunnels.IsEmpty()) + && Geometry.GeneratorRecipe != EVoxelForgeCoreGeneratorRecipe::NativeCaves + && Geometry.GeneratorRecipe != EVoxelForgeCoreGeneratorRecipe::NativeLattice) + { + OutError = FString::Printf(TEXT("Strate %d authors a cavern graph without selecting NativeCaves or NativeLattice."), Slot.StrateIndex); + return false; + } + } + + for (const FStrateSlot& Slot : StrateManager->GetLayout()) + { + if (!Slot.Definition) continue; + for (const FStrateLandmark& Landmark : Slot.Definition->Landmarks) + if (!Landmark.CoreGeometryStableName.IsEmpty() && !FeatureNames.Contains(Landmark.CoreGeometryStableName)) + { + OutError = FString::Printf(TEXT("Landmark on strate %d references unknown Core stable name '%s'."), Slot.StrateIndex, *Landmark.CoreGeometryStableName); + return false; + } + } + for (int32 StrateIndex : NativeGraphStrates) + { + const bool bHasRoom = std::any_of(Rooms.begin(), Rooms.end(), [StrateIndex](const auto& Room) { return Room.strateIndex == StrateIndex; }); + const bool bHasEdge = std::any_of(Edges.begin(), Edges.end(), [StrateIndex](const auto& Edge) { return Edge.strateIndex == StrateIndex; }); + if (!bHasRoom || !bHasEdge) + { + OutError = FString::Printf(TEXT("Native cave/lattice recipe on strate %d needs at least one authored room and one tunnel."), StrateIndex); + return false; + } + } + + if (!LegacyStamps.empty() || !MotifStamps.empty()) + { + std::string CoreError; + const ResolvedLayoutSnapshotV3 BaseLayout = Layout; + if (!LegacyStamps.empty() && !ResolveResolvedLayoutStampsV1(Layout, Seed, LegacyStamps, CoreError)) + { + OutError = FString::Printf(TEXT("Core authored stamp resolution failed: %s"), UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + if (!MotifStamps.empty()) + { + ResolvedLayoutSnapshotV3 MotifLayout = BaseLayout; + if (!ResolveResolvedLayoutStampsV3(MotifLayout, Seed, MotifStamps, CoreError)) + { + OutError = FString::Printf(TEXT("Core authored motif resolution failed: %s"), UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + if (LegacyStamps.empty()) Layout = std::move(MotifLayout); + else + { + Layout.stamps.insert(Layout.stamps.end(), MotifLayout.stamps.begin(), MotifLayout.stamps.end()); + Layout.stampVocabularyVersion = ResolvedLayoutStampVocabularyVersionV3; + std::vector Issues; + if (!Layout.finalize(CoreError) || !Layout.validate(ResolvedLayoutValidationModeV3::strict, Issues, CoreError)) + { + OutError = FString::Printf(TEXT("Combined Core stamp layout failed validation: %s"), UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + } + } + } + if (!Rooms.empty() || !Edges.empty()) + { + std::string CoreError; + if (!ResolveResolvedCavernGraphV1(Layout, Seed, Rooms, Edges, CoreError)) + { + OutError = FString::Printf(TEXT("Core authored cavern graph resolution failed: %s"), UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + } + return true; +} + +bool AttachCoreGeometryOnlyLayout(const UVoxelStrateManager* StrateManager, + double OwnerVoxelCm, double CoreVoxelCm, std::uint32_t LayoutChunkMicrometres, + std::int64_t LayoutChunkSubvoxels, const TSharedRef& Root, FString& OutError) +{ + using namespace VoxelForgeCore; + ResolvedLayoutSnapshotV3 Layout; + Layout.worldIdentity = std::string("owner-world/seed/") + std::to_string(StrateManager->GetWorldSeed()); + Layout.ownerVoxelSizeMicrometres = static_cast(std::llround(OwnerVoxelCm * 10000.0)); + Layout.coreVoxelSizeMicrometres = static_cast(std::llround(CoreVoxelCm * 10000.0)); + Layout.strateLayoutChunkSizeMicrometres = LayoutChunkMicrometres; + Layout.strateLayoutChunkWidthSubvoxels = LayoutChunkSubvoxels; + const std::uint32_t CoreVoxelMicrometres = Layout.coreVoxelSizeMicrometres; + const auto GeneratorName = [](ECaveGeneratorType Type) -> const char* + { + switch (Type) + { + case ECaveGeneratorType::TunnelNetwork: return "TunnelNetwork"; + case ECaveGeneratorType::FlatPlain: return "FlatPlain"; + case ECaveGeneratorType::CrystalChamber: return "CrystalChamber"; + case ECaveGeneratorType::Maze: return "Maze"; + case ECaveGeneratorType::SurfaceWorld: return "SurfaceWorld"; + case ECaveGeneratorType::VerticalShafts: return "VerticalShafts"; + case ECaveGeneratorType::FloatingIslands: return "FloatingIslands"; + case ECaveGeneratorType::Underwater: return "Underwater"; + } + return "Unknown"; + }; + const auto TransitionName = [](EVoxelStrateTransition Type) -> const char* + { + switch (Type) + { + case EVoxelStrateTransition::Gradient: return "Gradient"; + case EVoxelStrateTransition::Hard: return "Hard"; + case EVoxelStrateTransition::Interleaved: return "Interleaved"; + } + return "Unknown"; + }; + std::vector PassageConfigs; + const std::int64_t OwnerVoxelMicrometres = Layout.ownerVoxelSizeMicrometres; + for (const FStrateSlot& Slot : StrateManager->GetLayout()) + { + if (!Slot.Definition) continue; + const ECaveGeneratorType Type = StrateManager->GetGeneratorTypeForChunk(FIntVector(0, 0, Slot.TopChunkZ)); + ResolvedLayoutStratumV2 Item; + Item.strateIndex = Slot.StrateIndex; + Item.generatorKind = GeneratorName(Type); + Item.stableId = Layout.worldIdentity + "/strate/" + std::to_string(Slot.StrateIndex) + + "/generator/" + GeneratorName(Type); + Item.recipeId = Type == ECaveGeneratorType::SurfaceWorld ? "SurfaceWorld.Heightfield2D" + : Type == ECaveGeneratorType::FloatingIslands ? "FloatingIslands.ClusterField" + : std::string("legacy.") + GeneratorName(Type); + Item.recipeVersion = 0; + if (!ConfigureCoreGeometryStratum(Slot, Type, false, Item, OutError)) return false; + Item.voxelSizeMicrometres = CoreVoxelMicrometres; + Item.interiorBottomMicrometres = Slot.BottomWorldMicrometres; + Item.interiorTopMicrometres = Slot.TopWorldMicrometres; + Item.managerChunkWidthMicrometres = LayoutChunkMicrometres; + Item.dispatchChunkWidthMicrometres = LayoutChunkMicrometres; + Item.transitionMode = TransitionName(Slot.ResolvedTransitionType); + Item.transitionBlendChunks = Slot.ResolvedTransitionBlendChunks; + Item.transitionWidthMicrometres = Slot.TransitionWidthMicrometres; + Item.transitionWarpAmplitudeMicrometres = Item.transitionMode == "Interleaved" + ? std::min(Item.transitionWidthMicrometres / 2, static_cast(LayoutChunkMicrometres)) : 0; + Item.transitionInterpenetrationMicrometres = Item.transitionMode == "Interleaved" + ? Item.transitionWidthMicrometres : Item.transitionMode == "Gradient" ? Item.transitionWidthMicrometres / 4 : 0; + Item.transitionFallsBackToHard = Item.transitionMode != "Hard" && Item.transitionWidthMicrometres <= 0; + Item.transitionHonouredInCore = Item.transitionMode == "Hard" || !Item.transitionFallsBackToHard; + Item.managerLookup = "UVoxelStrateManager::GetGeneratorTypeForChunk; upper strate owns transitions"; + Item.boundDispatchPath = "CoreGeometry-only resolved strate layout"; + Item.queryDispatchPath = "CoreGeometry-only native layout field"; + + const std::int64_t SealMicrometres = static_cast(std::llround( + std::max(0.0f, StrateBoundarySealThickness(*Slot.Definition)) * OwnerVoxelMicrometres)); + if (SealMicrometres <= 0) + { + OutError = FString::Printf(TEXT("Strate %d has no positive BoundarySealThickness for the shared ceiling and bedrock bands."), Slot.StrateIndex); + return false; + } + const std::int64_t SolidDensityQ16 = std::max(1, + WorldAssemblyQ16(std::max(0.05f, StrateBoundarySolidDensity(*Slot.Definition)))); + Item.ceiling = { "ceiling", Item.interiorTopMicrometres - SealMicrometres, + Item.interiorTopMicrometres, true, true, false, "solid ceiling from strate BoundarySealThickness", + "interior-facing lower surface", "shared resolved-layout density band", SolidDensityQ16 }; + Item.bedrock = { "bedrock", Item.interiorBottomMicrometres, + Item.interiorBottomMicrometres + SealMicrometres, true, true, false, + "solid bedrock floor from strate BoundarySealThickness", "interior-facing upper surface", + "shared resolved-layout density band", SolidDensityQ16 }; + Item.content = { "content", Item.interiorBottomMicrometres + SealMicrometres, + Item.interiorTopMicrometres - SealMicrometres, true, true, false, + "resolved generator interior", "none", "none" }; + Layout.strates.push_back(std::move(Item)); + + const FStratePassageConfig& Passage = Slot.ResolvedPassageConfig; + ResolvedLayoutPassageConfigV1 Config; + Config.upperStrateIndex = Slot.StrateIndex; + Config.connections = Passage.Connections; + Config.style = static_cast(Passage.Style); + Config.mouthRadiusSubvoxels = std::max(WorldAssemblyMicrometresToSubvoxels( + Slot.PassageMouthRadiusMicrometres, CoreVoxelMicrometres), + WorldAssemblyMicrometresToSubvoxels(12 * OwnerVoxelMicrometres, CoreVoxelMicrometres)); + Config.midRadiusSubvoxels = std::max(WorldAssemblyMicrometresToSubvoxels( + Slot.PassageMidRadiusMicrometres, CoreVoxelMicrometres), + WorldAssemblyMicrometresToSubvoxels(12 * OwnerVoxelMicrometres, CoreVoxelMicrometres)); + Config.reachMinimumSubvoxels = WorldAssemblyMicrometresToSubvoxels(Slot.PassageReachMinimumMicrometres, CoreVoxelMicrometres); + Config.reachMaximumSubvoxels = WorldAssemblyMicrometresToSubvoxels(Slot.PassageReachMaximumMicrometres, CoreVoxelMicrometres); + Config.distanceMinimumSubvoxels = WorldAssemblyMicrometresToSubvoxels(Slot.PassageDistanceMinimumMicrometres, CoreVoxelMicrometres); + Config.distanceMaximumSubvoxels = WorldAssemblyMicrometresToSubvoxels(Slot.PassageDistanceMaximumMicrometres, CoreVoxelMicrometres); + Config.wanderSubvoxels = WorldAssemblyMicrometresToSubvoxels(Slot.PassageWanderMicrometres, CoreVoxelMicrometres); + Config.segments = static_cast(FMath::Clamp(Passage.Segments, 1, 48)); + Config.verticalWobbleSubvoxels = WorldAssemblyMicrometresToSubvoxels(Slot.PassageVerticalWobbleMicrometres, CoreVoxelMicrometres); + Config.spiralRadiusSubvoxels = WorldAssemblyMicrometresToSubvoxels(Slot.PassageSpiralRadiusMicrometres, CoreVoxelMicrometres); + Config.spiralTurnsQ16 = static_cast(std::llround(static_cast(Passage.SpiralTurns) * 65536.0)); + Config.cascadeSteps = static_cast(FMath::Clamp(Passage.CascadeSteps, 1, 16)); + Config.cascadeLedgeSubvoxels = WorldAssemblyMicrometresToSubvoxels(Slot.PassageCascadeLedgeMicrometres, CoreVoxelMicrometres); + PassageConfigs.push_back(Config); + } + std::string CoreError; + if (!PlanResolvedLayoutPassagesV1(Layout, StrateManager->GetWorldSeed(), PassageConfigs, CoreError)) + { + OutError = FString::Printf(TEXT("Resolved inter-strate passage planning failed: %s"), UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + if (!ResolveAuthoredCoreGeometry(Layout, StrateManager, StrateManager->GetWorldSeed(), OutError)) return false; + if (!Layout.finalize(CoreError)) + { + OutError = FString::Printf(TEXT("Core resolved-layout export failed: %s"), UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + const std::string Json = Layout.ToJson(); + Root->SetStringField(TEXT("resolved_layout_json"), UTF8_TO_TCHAR(Json.c_str())); + return true; +} + bool AttachValidatedWorldAssembly(const UVoxelSettings* Settings, const UVoxelStrateManager* StrateManager, double CoreVoxelSizeCm, @@ -876,6 +1374,7 @@ bool AttachValidatedWorldAssembly(const UVoxelSettings* Settings, Generated.recipe.layoutChunkWidthSubvoxels=LayoutChunkWidthSubvoxels; int32 SurfaceSlotCount = 0; int32 IslandSlotCount = 0; + int32 CoreGeometrySlotCount = 0; for (const FStrateSlot& Slot : StrateManager->GetLayout()) { if (Slot.Definition == nullptr) continue; @@ -883,10 +1382,13 @@ bool AttachValidatedWorldAssembly(const UVoxelSettings* Settings, FIntVector(0, 0, Slot.TopChunkZ)); SurfaceSlotCount += Type == ECaveGeneratorType::SurfaceWorld ? 1 : 0; IslandSlotCount += Type == ECaveGeneratorType::FloatingIslands ? 1 : 0; + CoreGeometrySlotCount += HasCoreGeometry(*Slot.Definition) ? 1 : 0; } if (SurfaceSlotCount == 0 && IslandSlotCount == 0) { - return true; + return CoreGeometrySlotCount == 0 ? true + : AttachCoreGeometryOnlyLayout(StrateManager, OwnerVoxelSizeCm, CoreVoxelSizeCm, + LayoutChunkSizeMicrometres, LayoutChunkWidthSubvoxels, Root, OutError); } if (SurfaceSlotCount == 0) { @@ -1128,6 +1630,10 @@ bool AttachValidatedWorldAssembly(const UVoxelSettings* Settings, : Type == ECaveGeneratorType::FloatingIslands ? "FloatingIslands.ClusterField" : std::string("legacy.") + generatorName(Type); Item.recipeVersion = Type == ECaveGeneratorType::SurfaceWorld ? Manifest->recipe.schemaVersion : 0; + if (!ConfigureCoreGeometryStratum(Slot, Type, bImportedOverride, Item, OutError)) + { + return false; + } Item.voxelSizeMicrometres = ExpectedCoreVoxelMicrons; Item.interiorBottomMicrometres = Slot.BottomWorldMicrometres; Item.interiorTopMicrometres = Slot.TopWorldMicrometres; @@ -1264,6 +1770,11 @@ bool AttachValidatedWorldAssembly(const UVoxelSettings* Settings, UTF8_TO_TCHAR(PassagePlanError.c_str())); return false; } + if (!ResolveAuthoredCoreGeometry(Layout, StrateManager, + StrateManager->GetWorldSeed(), OutError)) + { + return false; + } std::string LayoutError; if (!Layout.finalize(LayoutError)) { @@ -1372,6 +1883,10 @@ bool VoxelForgeCoreAdapter::SerializeResolvedWorld( static_cast(std::llround(OwnerVoxelSizeCm * 10000.0))); Root->SetNumberField(TEXT("core_voxel_size_cm"), ResolvedCoreVoxelSizeCm); Root->SetNumberField(TEXT("core_voxel_size_microns"), CoreVoxelSizeMicrons); + // Core import des longueurs owner avec le rapport physique resolu, pas un ratio suppose. + // Core imports owner lengths with the resolved physical ratio, never an assumed scale. + Root->SetNumberField(TEXT("owner_to_core_length_scale"), + OwnerVoxelSizeCm / ResolvedCoreVoxelSizeCm); Root->SetNumberField(TEXT("seed"), Seed); Root->SetBoolField(TEXT("open_surface_entry"), Settings->bOpenSurfaceEntry); Root->SetNumberField(TEXT("origin_spine_radius"), OriginSpineRadius); diff --git a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp index 9b015d2..2714e99 100644 --- a/Source/VoxelForge/Private/VoxelSeasonManifest.cpp +++ b/Source/VoxelForge/Private/VoxelSeasonManifest.cpp @@ -1958,10 +1958,11 @@ namespace VoxelSeasonCompositionPrivate } void CopyDefinitionParams(const UVoxelStrateDefinition& Definition, - FVoxelSeasonFixedStrate& OutFixed) + double LayoutChunkMeters, FVoxelSeasonFixedStrate& OutFixed) { OutFixed = FVoxelSeasonFixedStrate(); - OutFixed.HeightInChunks = Definition.StrateHeightInChunks; + OutFixed.HeightInChunks = FMath::Max(1, FMath::RoundToInt( + Definition.StrateHeightMeters / FMath::Max(0.001, LayoutChunkMeters))); OutFixed.Archetype = Definition.GeneratorType; OutFixed.SourceDefinitionPath = Definition.GetPathName(); OutFixed.bUsesRecipe = false; @@ -1999,7 +2000,9 @@ namespace VoxelSeasonCompositionPrivate return false; } FVoxelSeasonFixedStrate Fixed; - CopyDefinitionParams(*Definition, Fixed); + const double LayoutChunkMeters = Settings.WorldSettings != nullptr + ? Settings.WorldSettings->StrateLayoutChunkSizeMeters : 8.0; + CopyDefinitionParams(*Definition, LayoutChunkMeters, Fixed); Fixed.Seed = SeasonSeed; if (Fixed.SourceDefinitionPath.IsEmpty()) { @@ -2974,7 +2977,9 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* Wo if (WorldSettings != nullptr) { Settings.SelectedStrateCount = WorldSettings->TotalStrates; - Settings.InterStrateGapChunks = WorldSettings->InterStrateGapChunks; + Settings.InterStrateGapChunks = FMath::Max(0, FMath::RoundToInt( + WorldSettings->InterStrateGapMeters * 1000000.0 + / FMath::Max(1.0, static_cast(WorldSettings->StrateLayoutChunkSizeMeters) * 1000000.0))); Settings.OriginSpineRadius = WorldSettings->OriginSpineRadius; Settings.WorldRadiusVoxels = WorldSettings->WorldRadiusVoxels; Settings.SeasonNumber = WorldSettings->CurrentSeason; diff --git a/Source/VoxelForge/Private/VoxelSettings.cpp b/Source/VoxelForge/Private/VoxelSettings.cpp index 89f5488..6cc6e52 100644 --- a/Source/VoxelForge/Private/VoxelSettings.cpp +++ b/Source/VoxelForge/Private/VoxelSettings.cpp @@ -1,5 +1,7 @@ #include "VoxelSettings.h" +#include "VoxelForgeAssetVersion.h" +#include "VoxelStrateDefinition.h" #include "VoxelSeasonAsset.h" #if WITH_EDITOR @@ -9,6 +11,74 @@ #include #include +void UVoxelSettings::Serialize(FArchive& Ar) +{ + Ar.UsingCustomVersion(VoxelForgeAssetVersion::GUID); + Super::Serialize(Ar); + if (Ar.IsLoading() && Ar.CustomVer(VoxelForgeAssetVersion::GUID) + < VoxelForgeAssetVersion::MetreAuthoringV1) + { + const double LayoutChunkMeters = FMath::IsFinite(StrateLayoutChunkSizeMeters) + ? FMath::Max(0.001, static_cast(StrateLayoutChunkSizeMeters)) : 8.0; + InterStrateGapMeters = static_cast(FMath::Max(0, InterStrateGapChunks)) + * LayoutChunkMeters; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeAssetUpgrade] type=Settings asset=%s gap_chunks=%d gap_m=%.6f layout_chunk_m=%.6f"), + *GetPathName(), InterStrateGapChunks, InterStrateGapMeters, LayoutChunkMeters); + } +} + +void UVoxelSettings::PostLoad() +{ + Super::PostLoad(); + if (GetLinkerCustomVersion(VoxelForgeAssetVersion::GUID) + >= VoxelForgeAssetVersion::MetreAuthoringV1) + { + return; + } + + // Resolve the legacy count against this settings asset's saved physical layout width. + // Convertit les anciens comptes avec la largeur physique sauvegardee dans ces reglages. + const double LayoutChunkMeters = FMath::IsFinite(StrateLayoutChunkSizeMeters) + ? FMath::Max(0.001, static_cast(StrateLayoutChunkSizeMeters)) : 8.0; + InterStrateGapMeters = static_cast(FMath::Max(0, InterStrateGapChunks)) + * LayoutChunkMeters; + + TSet Migrated; + const auto MigrateStrate = [this, LayoutChunkMeters, &Migrated]( + const TSoftObjectPtr& SoftDefinition) + { + UVoxelStrateDefinition* Definition = SoftDefinition.LoadSynchronous(); + if (Definition == nullptr || Migrated.Contains(Definition) + || Definition->GetLinkerCustomVersion(VoxelForgeAssetVersion::GUID) + >= VoxelForgeAssetVersion::MetreAuthoringV1) + { + return; + } + Migrated.Add(Definition); + const int32 OldHeightChunks = Definition->StrateHeightInChunks; + const int32 OldTransitionChunks = Definition->TransitionBlendChunks; + Definition->StrateHeightMeters = static_cast(FMath::Max(1, OldHeightChunks)) + * LayoutChunkMeters; + Definition->TransitionWidthMeters = static_cast(FMath::Max(0, OldTransitionChunks)) + * LayoutChunkMeters; + Definition->MarkPackageDirty(); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeAssetUpgrade] type=Strate asset=%s height_chunks=%d height_m=%.6f transition_chunks=%d transition_m=%.6f effective_layout_chunk_m=%.6f"), + *Definition->GetPathName(), OldHeightChunks, Definition->StrateHeightMeters, + OldTransitionChunks, Definition->TransitionWidthMeters, LayoutChunkMeters); + }; + for (const TSoftObjectPtr& Definition : StratePool) + { + MigrateStrate(Definition); + } + for (const TPair>& Definition : FixedStrates) + { + MigrateStrate(Definition.Value); + } + MarkPackageDirty(); +} + int32 UVoxelSettings::GetEffectiveWorldSeed() const { if (!Season.IsNull()) diff --git a/Source/VoxelForge/Private/VoxelStrateDefinition.cpp b/Source/VoxelForge/Private/VoxelStrateDefinition.cpp index e8ee7fe..1ad7c31 100644 --- a/Source/VoxelForge/Private/VoxelStrateDefinition.cpp +++ b/Source/VoxelForge/Private/VoxelStrateDefinition.cpp @@ -1,20 +1,17 @@ // Serialized strate-definition migrations. #include "VoxelStrateDefinition.h" +#include "VoxelForgeAssetVersion.h" #include "Serialization/CustomVersion.h" #include "VoxelPassageGeometry.h" namespace { - // The field name is retained for package compatibility, but version 1 changes its meaning - // from a raw tangent/gradient to authored degrees. This GUID is local to the plugin. - const FGuid GVoxelForgeStrateSerializationVersion( - 0xA76D5E31, 0x4B2C4F9A, 0xB8C1D204, 0x6E9F7738); - constexpr int32 GVoxelForgeStrateGentleSlopeDegreesVersion = 1; + constexpr int32 GVoxelForgeStrateGentleSlopeDegreesVersion = VoxelForgeAssetVersion::TunnelFloorSlopeDegrees; FCustomVersionRegistration GVoxelForgeStrateSerializationRegistration( - GVoxelForgeStrateSerializationVersion, - GVoxelForgeStrateGentleSlopeDegreesVersion, + VoxelForgeAssetVersion::GUID, + VoxelForgeAssetVersion::MetreAuthoringV1, TEXT("VoxelForgeStrateSerialization")); float LegacyGradientToDegrees(float LegacyGradient) @@ -58,6 +55,31 @@ namespace } } +void UVoxelStrateDefinition::Serialize(FArchive& Ar) +{ + Ar.UsingCustomVersion(VoxelForgeAssetVersion::GUID); + Super::Serialize(Ar); + + if (Ar.IsLoading() && Ar.CustomVer(VoxelForgeAssetVersion::GUID) + < VoxelForgeAssetVersion::MetreAuthoringV1) + { + // A stand-alone strate has no owning settings object during Serialize. Convert using the + // current 8 m layout default here; UVoxelSettings corrects referenced old strates using + // their saved effective width during its own load upgrade. + // Une strate seule n'a pas ses reglages proprietaires ici; UVoxelSettings corrige ensuite + // les references avec leur largeur physique sauvegardee. + constexpr double LegacyDefaultLayoutChunkMeters = 8.0; + StrateHeightMeters = static_cast(FMath::Max(1, StrateHeightInChunks)) + * LegacyDefaultLayoutChunkMeters; + TransitionWidthMeters = static_cast(FMath::Max(0, TransitionBlendChunks)) + * LegacyDefaultLayoutChunkMeters; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeAssetUpgrade] type=Strate asset=%s height_chunks=%d height_m=%.6f transition_chunks=%d transition_m=%.6f fallback_layout_chunk_m=%.6f"), + *GetPathName(), StrateHeightInChunks, StrateHeightMeters, + TransitionBlendChunks, TransitionWidthMeters, LegacyDefaultLayoutChunkMeters); + } +} + void UVoxelStrateDefinition::PostLoad() { Super::PostLoad(); @@ -67,7 +89,7 @@ void UVoxelStrateDefinition::PostLoad() // is normalized to the ignored compatibility default before any runtime copy is made. GenerationParams.bTunnelFloorEnabled = true; - if (GetLinkerCustomVersion(GVoxelForgeStrateSerializationVersion) + if (GetLinkerCustomVersion(VoxelForgeAssetVersion::GUID) >= GVoxelForgeStrateGentleSlopeDegreesVersion) { return; diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 51966ee..73a0fef 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -1255,7 +1255,8 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) ? SeasonManifest.Strates.Num() : Settings->TotalStrates; const int32 LayoutGapChunks = bUseSeason ? SeasonManifest.InterStrateGapChunks - : FMath::Max(0, Settings->InterStrateGapChunks); + : FMath::Max(0, FMath::RoundToInt(static_cast(Settings->InterStrateGapMeters) + * 1000000.0 / std::max(1, StrateLayoutChunkSizeMicrometres))); //========================================================================= // STEP 1: Build shuffled pool (seed-based randomization) @@ -1369,7 +1370,8 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) } Slot.Definition->GeneratorType = SeasonStrate.Archetype; - Slot.Definition->StrateHeightInChunks = SeasonStrate.HeightInChunks; + Slot.Definition->StrateHeightMeters = static_cast(SeasonStrate.HeightInChunks) + * StrateLayoutChunkSizeMicrometres / 1000000.0; Slot.Definition->GenerationParams = SeasonStrate.Params.TunnelNetworkParams; Slot.Definition->SlabParams = SeasonStrate.Params.SlabParams; Slot.Definition->MazeParams = SeasonStrate.Params.MazeParams; @@ -1426,7 +1428,9 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) } // Resolve the authored chunk height into an exact physical interval. - Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks; + Slot.HeightInChunks = FMath::Max(1, FMath::RoundToInt( + Slot.Definition->StrateHeightMeters * 1000000.0 + / std::max(1, StrateLayoutChunkSizeMicrometres))); Slot.TopWorldMicrometres = CurrentTopWorldMicrometres; Slot.HeightMicrometres = static_cast(Slot.HeightInChunks) * StrateLayoutChunkSizeMicrometres; @@ -1439,9 +1443,11 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) } Slot.ResolvedTransitionType = Slot.Definition->TransitionType; - Slot.ResolvedTransitionBlendChunks = Slot.Definition->TransitionBlendChunks; - Slot.TransitionWidthMicrometres = static_cast( - Slot.ResolvedTransitionBlendChunks) * StrateLayoutChunkSizeMicrometres; + Slot.ResolvedTransitionBlendChunks = FMath::Max(0, FMath::RoundToInt( + Slot.Definition->TransitionWidthMeters * 1000000.0 + / std::max(1, StrateLayoutChunkSizeMicrometres))); + Slot.TransitionWidthMicrometres = static_cast(Slot.ResolvedTransitionBlendChunks) + * StrateLayoutChunkSizeMicrometres; Slot.ResolvedPassageConfig = Slot.Definition->PassageConfig; Slot.PassageMouthRadiusMicrometres = VF_OwnerVoxelsToMicrometres( Slot.ResolvedPassageConfig.MouthRadius); diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 87f2bd1..a39a2a7 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -7567,6 +7567,14 @@ bool AVoxelWorld::GetVoxelForgeCoreWorldAssembly( return bUseVoxelForgeCore && !OutCanonicalJson.IsEmpty() && !OutContentHash.IsEmpty(); } +bool AVoxelWorld::GetVoxelForgeCoreResolvedLayout( + FString& OutCanonicalJson, FString& OutStableIdentity) const +{ + OutCanonicalJson = VoxelForgeCoreResolvedLayoutJson; + OutStableIdentity = VoxelForgeCoreResolvedLayoutIdentity; + return bUseVoxelForgeCore && !OutCanonicalJson.IsEmpty() && !OutStableIdentity.IsEmpty(); +} + bool AVoxelWorld::QueryVoxelForgeCoreDensityAt(FVector WorldLocation, float& OutDensity) const { OutDensity = 0.0f; @@ -7585,6 +7593,8 @@ bool AVoxelWorld::InitializeVoxelForgeCoreWorld(FString& OutFailureReason) OutFailureReason.Reset(); VoxelForgeCoreWorldAssemblyJson.Reset(); VoxelForgeCoreWorldAssemblyHash.Reset(); + VoxelForgeCoreResolvedLayoutJson.Reset(); + VoxelForgeCoreResolvedLayoutIdentity.Reset(); if (!bUseVoxelForgeCore) return true; if (!Settings || !StrateManager) { @@ -7617,6 +7627,17 @@ bool AVoxelWorld::InitializeVoxelForgeCoreWorld(FString& OutFailureReason) UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreRuntime] %s"), *OutFailureReason); return false; } + if (const VoxelForgeCore::ResolvedLayoutSnapshotV3* ResolvedLayout = Snapshot->resolvedLayout()) + { + const std::string CanonicalLayout = ResolvedLayout->ToJson(); + if (CanonicalLayout.empty()) + { + OutFailureReason = TEXT("the resolved Core layout could not be serialized canonically."); + return false; + } + VoxelForgeCoreResolvedLayoutJson = UTF8_TO_TCHAR(CanonicalLayout.c_str()); + VoxelForgeCoreResolvedLayoutIdentity = UTF8_TO_TCHAR(ResolvedLayout->stableIdentity.c_str()); + } if (const VoxelForgeCore::WorldAssemblyManifest* Assembly = Snapshot->worldAssembly()) { const std::string CanonicalAssembly = Assembly->ToJson(); @@ -7793,6 +7814,8 @@ void AVoxelWorld::FallBackToLegacyAfterCoreInitializationFailure(const FString& } VoxelForgeCoreWorldAssemblyJson.Reset(); VoxelForgeCoreWorldAssemblyHash.Reset(); + VoxelForgeCoreResolvedLayoutJson.Reset(); + VoxelForgeCoreResolvedLayoutIdentity.Reset(); bUseVoxelForgeCore = false; // The Core adapter sets this before validation; legacy generation must not inherit it. // L'adaptateur Core l'active avant validation ; le générateur historique ne doit pas en hériter. @@ -15117,6 +15140,7 @@ FString AVoxelWorld::GetCurrentSeasonContentHash() const } FString AssemblyHash = VoxelForgeCoreWorldAssemblyHash; + FString ResolvedLayoutIdentity = VoxelForgeCoreResolvedLayoutIdentity; if (AssemblyHash.IsEmpty()) { FString AssemblyJson; @@ -15153,12 +15177,17 @@ FString AVoxelWorld::GetCurrentSeasonContentHash() const AssemblyHash = FString::Printf(TEXT("%016llx"), static_cast(Assembly->contentHash)); } + if (const VoxelForgeCore::ResolvedLayoutSnapshotV3* ResolvedLayout = Snapshot->resolvedLayout()) + { + ResolvedLayoutIdentity = UTF8_TO_TCHAR(ResolvedLayout->stableIdentity.c_str()); + } } } const bool bHybridMesher = CoreMesherRecipe != EVoxelForgeCoreMesherRecipe::MarchingCubesV1; const uint32 CoreVoxelSizeMicrons = static_cast( FMath::RoundToInt(GetActiveVoxelSizeCm() * 10000.0f)); - if (AssemblyHash.IsEmpty() && !bHybridMesher && CoreVoxelSizeMicrons == 250000) + if (AssemblyHash.IsEmpty() && ResolvedLayoutIdentity.IsEmpty() + && !bHybridMesher && CoreVoxelSizeMicrons == 250000) { return SeasonHash; } @@ -15183,6 +15212,11 @@ FString AVoxelWorld::GetCurrentSeasonContentHash() const Payload += TEXT("|world_assembly="); Payload += AssemblyHash; } + if (!ResolvedLayoutIdentity.IsEmpty()) + { + Payload += TEXT("|resolved_layout="); + Payload += ResolvedLayoutIdentity; + } if (CoreVoxelSizeMicrons != 250000) { Payload += FString::Printf(TEXT("|core_voxel_size_microns=%u"), CoreVoxelSizeMicrons); diff --git a/Source/VoxelForge/Public/VoxelContentManager.h b/Source/VoxelForge/Public/VoxelContentManager.h index cf868f8..3d6cebd 100644 --- a/Source/VoxelForge/Public/VoxelContentManager.h +++ b/Source/VoxelForge/Public/VoxelContentManager.h @@ -340,6 +340,14 @@ private: float SuppressRadiusWorld = 0.0f; }; + // Game-thread lookup copied from the immutable resolved Core layout. + // Recherche game-thread copiee depuis la disposition Core resolue et immuable. + struct FResolvedCoreGeometryAnchor + { + int32 StrateIndex = INDEX_NONE; + FVector LocalPositionCm = FVector::ZeroVector; + }; + /** WORKER-THREAD surface find → fills OutSpawns for one cell. SurfaceWorld uses the height oracle * (cheap, O(1)/column); other archetypes ray-march the density column. No UObject access except * Generator (thread-safe). Determinism-critical. Resolves the dominant biome PER COLUMN @@ -436,6 +444,7 @@ private: // (kept until the cell leaves range so the surface-find isn't repeated). Strate-bounded. // Keyed by FIntVector(cellX|passageIdx, cellY|side, entryIndex) — lattice cell OR passage endpoint. TMap LandmarkInstances; + TMap CoreGeometryAnchors; int32 LastLandmarkStrate = INT32_MIN; // strate change → wipe + rebuild landmarks // Set in BeginDestroy; worker tasks check it before touching us. diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index dd9b9ff..d29c897 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -21,6 +21,8 @@ class UVoxelSettings : public UPrimaryDataAsset GENERATED_BODY() public: + virtual void Serialize(FArchive& Ar) override; + virtual void PostLoad() override; /** Season seed/radius replace the mutable authored values only when Season is assigned. */ int32 GetEffectiveWorldSeed() const; @@ -411,14 +413,16 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates", meta = (ClampMin = "1")) int32 TotalStrates = 10; - // Vertical SEPARATION between consecutive strates, in chunks of solid bedrock. - // 0 = strates touch (just the seals between them). Higher = a thick rock layer - // between each biome that the player must dig through at (0,0), and that the - // auto-carved passages tunnel across. - // 0 → adjacent layers (default) · 1-2 → a real bedrock band · 4+ → deep separation - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates", meta = (ClampMin = "0", ClampMax = "16")) + // Legacy count retained so pre-metre assets still deserialize during the upgrade. + // Ancien compte conserve pour lire les assets precedant les metres. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates", meta = (DeprecatedProperty, DeprecationMessage = "Use InterStrateGapMeters.")) int32 InterStrateGapChunks = 0; + // Physical separation between strata; the layout remains aligned to whole layout chunks. + // Separation physique; la disposition reste alignee sur des chunks entiers. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates", meta = (Units = "m", ClampMin = "0", ClampMax = "1024")) + double InterStrateGapMeters = 0.0; + // Physical width of one vertical strate-layout chunk. This is independent of the // Core tile width (32^3 voxels) and of the source generator's voxel resolution. // 8 m preserves the owner's current SurfaceWorld extent at 25 cm Core voxels. diff --git a/Source/VoxelForge/Public/VoxelStrateDefinition.h b/Source/VoxelForge/Public/VoxelStrateDefinition.h index 5fdb22f..a8fbb8f 100644 --- a/Source/VoxelForge/Public/VoxelStrateDefinition.h +++ b/Source/VoxelForge/Public/VoxelStrateDefinition.h @@ -48,6 +48,7 @@ class VOXELFORGE_API UVoxelStrateDefinition : public UPrimaryDataAsset GENERATED_BODY() public: + virtual void Serialize(FArchive& Ar) override; virtual void PostLoad() override; //========================================================================= @@ -66,14 +67,14 @@ public: // DIMENSIONS //========================================================================= - // How many chunks tall this strate is. - // Each strate can have a different height! - // Human-scale defaults use 16 chunks = 64 m. This gives a 20 m+ vertical budget for - // cathedral rooms, tall shafts, and the surface sky cap without changing the lateral gate. - // 1 chunk = 32 voxels x 12.5cm = 4 m; 16 chunks preserves the 64 m authored default. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (ClampMin = "1", ClampMax = "256")) + // Legacy serialized chunk count kept for Blueprint/package compatibility during the unit migration. + // Ancien nombre de chunks conserve pour la compatibilite Blueprint/package pendant la migration. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (DeprecatedProperty, DeprecationMessage = "Use StrateHeightMeters.")) int32 StrateHeightInChunks = 16; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (Units = "m", ClampMin = "1", ClampMax = "2048")) + double StrateHeightMeters = 128.0; + //========================================================================= // BOUNDARY TRANSITION //========================================================================= @@ -99,9 +100,13 @@ public: // 2 → moderate (good default) // 4 → wide, gentle blend across many chunks UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Boundary", - meta = (ClampMin = "1", ClampMax = "8", EditCondition = "TransitionType != EVoxelStrateTransition::Hard")) + meta = (DeprecatedProperty, DeprecationMessage = "Use TransitionWidthMeters.")) int32 TransitionBlendChunks = 2; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Boundary", + meta = (Units = "m", ClampMin = "0", ClampMax = "512", EditCondition = "TransitionType != EVoxelStrateTransition::Hard")) + double TransitionWidthMeters = 16.0; + //========================================================================= // GENERATOR TYPE //========================================================================= @@ -115,6 +120,11 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation") ECaveGeneratorType GeneratorType = ECaveGeneratorType::TunnelNetwork; + // Optional native Core authoring; leaving it empty keeps the established generator path. + // Edition Core native facultative; laissez vide pour garder le generateur existant. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Core Geometry") + FVoxelForgeCoreGeometry CoreGeometry; + /** * Authoritative per-strate worm switch. Existing assets default to enabled. The manager * resolves a disabled strate to WormStrength == 0 before any fused, stack, composer, or diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index f459f74..0952a7e 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -200,6 +200,208 @@ enum class ECaveGeneratorType : uint8 Underwater UMETA(DisplayName = "Underwater (flooded cavern system)"), }; +// Optional native-Core recipe selected independently for one strate. +// Recette Core native facultative, choisie independamment pour chaque strate. +UENUM(BlueprintType) +enum class EVoxelForgeCoreGeneratorRecipe : uint8 +{ + None UMETA(DisplayName = "Off (legacy recipe)"), + NativeCaves UMETA(DisplayName = "Native caves"), + NativeLattice UMETA(DisplayName = "Native lattice"), + ProfiledHeightfield UMETA(DisplayName = "Profiled heightfield") +}; + +UENUM(BlueprintType) +enum class EVoxelForgeCoreHeightfieldProfile : uint8 +{ + PeriodicHills, + MirroredRelief, + FlatField +}; + +UENUM(BlueprintType) +enum class EVoxelForgeCoreStampKind : uint8 +{ + CarveEllipsoid, + CarveArch, + FillPillar, + FillDais, + FillMonolith, + FillPlatform +}; + +// Stable authored volume stamp. All distances and positions are metres at the asset boundary. +// Volume stable, defini par son auteur. Toutes les positions et distances sont en metres. +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelForgeCoreStampDefinition +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + FString StableName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + EVoxelForgeCoreStampKind Kind = EVoxelForgeCoreStampKind::CarveEllipsoid; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m")) + FVector AnchorPositionMeters = FVector::ZeroVector; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double PlacementRadiusMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0.5")) + FVector HalfExtentsMeters = FVector(2.0, 2.0, 2.0); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double SmoothWidthMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double IrregularityAmplitudeMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double IrregularityWavelengthMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double RoundingRadiusMeters = 0.5; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double MinimumSpacingMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (ClampMin = "0", ClampMax = "3")) + uint8 YawQuarterTurns = 0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + bool bAttachToFloor = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m")) + double FloorMeters = 0.0; +}; + +// Authored cavern chamber, in local world metres. +// Salle cavernicole authored, en metres locaux du monde. +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelForgeCoreRoomDefinition +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + FString StableName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m")) + FVector CenterMeters = FVector::ZeroVector; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0.5")) + FVector HalfExtentsMeters = FVector(8.0, 8.0, 4.0); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m")) + double FloorMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double SmoothWidthMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double UnionWidthMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double IrregularityAmplitudeMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double IrregularityWavelengthMeters = 0.0; +}; + +// Authored cubic tunnel between a stable start anchor and a stable room name. +// Tunnel cubique authored entre une ancre stable et une salle nommee. +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelForgeCoreTunnelDefinition +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + FString StableName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + FString StartAnchorStableName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + FString EndRoomStableName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m")) + FVector ControlPoint0Meters = FVector::ZeroVector; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m")) + FVector ControlPoint1Meters = FVector::ZeroVector; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m")) + FVector ControlPoint2Meters = FVector::ZeroVector; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m")) + FVector ControlPoint3Meters = FVector::ZeroVector; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0.5")) + double BoreRadiusMeters = 2.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "1")) + double BoreHeightMeters = 4.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double FloorSupportDepthMeters = 0.5; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double SmoothWidthMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double UnionWidthMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double IrregularityAmplitudeMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry", meta = (Units = "m", ClampMin = "0")) + double IrregularityWavelengthMeters = 0.0; + + // Lattice tunnels require an explicit bounded polyline; cave tunnels use the cubic curve. + // Les tunnels en treillis exigent une polyligne bornee; les tunnels caves utilisent la courbe cubique. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + TArray PolylineMeters; +}; + +// Optional Core authored recipe. Empty arrays and None preserve the prior asset output. +// Recette Core authored facultative. None et les tableaux vides conservent la sortie historique. +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelForgeCoreGeometry +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + EVoxelForgeCoreGeneratorRecipe GeneratorRecipe = EVoxelForgeCoreGeneratorRecipe::None; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + EVoxelForgeCoreHeightfieldProfile HeightfieldProfile = EVoxelForgeCoreHeightfieldProfile::PeriodicHills; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry|Heightfield", meta = (Units = "m")) + double HeightfieldBaseMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry|Heightfield", meta = (Units = "m")) + FVector2D HeightfieldAmplitudeMeters = FVector2D(20.0, 20.0); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry|Heightfield", meta = (Units = "m", ClampMin = "0")) + FVector2D HeightfieldWavelengthMeters = FVector2D(256.0, 256.0); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry|Heightfield", meta = (ClampMin = "0.01", ClampMax = "4.0")) + double MirrorLambda = 1.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry|Heightfield", meta = (Units = "m", ClampMin = "0")) + double MinimumArenaClearanceMeters = 0.0; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + TArray Stamps; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + TArray Rooms; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Core Geometry") + TArray Tunnels; +}; + /** * EVoxelStrateTransition — How two adjacent strates meet at their shared boundary. * @@ -2289,6 +2491,11 @@ struct VOXELFORGE_API FStrateLandmark UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark") ELandmarkAnchor AnchorMode = ELandmarkAnchor::HashLattice; + // Optional stable Core feature binding; an empty value keeps lattice/mouth placement unchanged. + // Liaison facultative vers une ancre Core stable; vide, le placement existant reste identique. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Core Geometry") + FString CoreGeometryStableName; + // ----- Hash lattice (AnchorMode == HashLattice — scattered placement; cheap at any radius) ----- // Average spacing between instances, IN CHUNKS = the lattice cell size (one candidate per cell, so cost diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index a3f3c43..9e04f3a 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -614,6 +614,7 @@ public: FVector GetVoxelForgeCoreDefaultCenterWorld() const; bool QueryVoxelForgeCoreDensityAt(FVector WorldLocation, float& OutDensity) const; bool GetVoxelForgeCoreWorldAssembly(FString& OutCanonicalJson, FString& OutContentHash) const; + bool GetVoxelForgeCoreResolvedLayout(FString& OutCanonicalJson, FString& OutStableIdentity) const; UFUNCTION(BlueprintPure, Category = "Voxel World|Strate") int32 GetStrateAtPosition(FVector WorldPosition) const; @@ -778,6 +779,8 @@ private: TSharedPtr VoxelForgeCoreState; FString VoxelForgeCoreWorldAssemblyJson; FString VoxelForgeCoreWorldAssemblyHash; + FString VoxelForgeCoreResolvedLayoutJson; + FString VoxelForgeCoreResolvedLayoutIdentity; FVector CoreFallbackCenterLocalCm = FVector::ZeroVector; bool bCoreRegionCompilePending = false; TArray PendingCoreRegionTiles; diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 9de9dca..553ebe4 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -18023,6 +18023,7 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin const double CarveBrushRadiusCoreVoxels = CarveBrushRadiusLegacyVoxels * static_cast(VOXEL_SIZE) / CoreVoxelSizeCm; const int32 CollisionRadius = FMath::Clamp(AuthoredSettings->PawnCollisionRadiusChunks, 1, 4); + FIntVector ColdStartCollisionCenter = FIntVector::ZeroValue; uint64 ExpectedEditDigest = 0; std::array InitialAggregates{}; std::array TravelAggregates{}; @@ -18208,10 +18209,17 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin BiomeProbeSpanVoxels); VoxelForgeCore::World ReferenceWorld(std::move(*Snapshot)); - const std::vector Desired = ReferenceWorld.desiredTiles({}); + // Match the actor's cold-start fallback camera when the transient world has no pawn. + // Aligner la reference sur la camera de repli de l'acteur sans pawn au demarrage. + const VoxelForgeCore::WorldConfiguration& ReferenceConfiguration = + ReferenceWorld.configuration(); + const VoxelForgeCore::Vec3d InitialCameraVoxels = ReferenceConfiguration.viewerStartFound + ? ReferenceConfiguration.viewerStartVoxels : VoxelForgeCore::Vec3d{}; + const std::vector Desired = + ReferenceWorld.desiredTiles(InitialCameraVoxels); if (Desired.empty()) { - OutError = TEXT("Standalone Core returned an empty reference clipmap at the origin."); + OutError = TEXT("Standalone Core returned an empty reference clipmap at the cold-start camera."); return false; } ExpectedInitialTileCount = Desired.size(); @@ -18258,7 +18266,23 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin UTF8_TO_TCHAR(CoreError.c_str())); return false; } - ReferenceWorld.retainTileRegions(Desired); + // Match the runtime collision probe around the same cold-start viewer tile. + // Reproduire la sonde de collision autour de la meme tuile de depart a froid. + ColdStartCollisionCenter = FIntVector( + FMath::FloorToInt(InitialCameraVoxels.x / 32.0), + FMath::FloorToInt(InitialCameraVoxels.y / 32.0), + FMath::FloorToInt(InitialCameraVoxels.z / 32.0)); + std::vector ColdStartCollisionTiles; + ColdStartCollisionTiles.reserve(static_cast(2 * CollisionRadius + 1) + * static_cast(2 * CollisionRadius + 1) + * static_cast(2 * CollisionRadius + 1)); + for (int32 Z = -CollisionRadius; Z <= CollisionRadius; ++Z) + for (int32 Y = -CollisionRadius; Y <= CollisionRadius; ++Y) + for (int32 X = -CollisionRadius; X <= CollisionRadius; ++X) + ColdStartCollisionTiles.push_back({ + {ColdStartCollisionCenter.X + X, + ColdStartCollisionCenter.Y + Y, + ColdStartCollisionCenter.Z + Z}, 0}); if (Arguments.bCoreHybridCliffOnly) { @@ -18332,6 +18356,47 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin Options.mesherRecipe = VoxelForgeCore::MesherRecipe::worldAssemblerHybridV1; else Options.mesherRecipe = VoxelForgeCore::MesherRecipe::marchingCubesV1; + + // Keep collision hashes outside ExpectedInitialDesiredSet; the collision cube is an + // additional runtime request even where it overlaps the visual clipmap. + // Garder les hachages de collision hors de l'ensemble visuel initial, meme s'ils se + // recoupent avec le clipmap. + std::vector RetainedReferenceTiles = Desired; + RetainedReferenceTiles.insert(RetainedReferenceTiles.end(), + ColdStartCollisionTiles.begin(), ColdStartCollisionTiles.end()); + ReferenceWorld.retainTileRegions(RetainedReferenceTiles); + if (!ReferenceWorld.compileTileRegion(ColdStartCollisionTiles, 16, Scheduler, CoreError)) + { + OutError = FString::Printf(TEXT("Standalone collision reference compile failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + std::size_t CollisionBodyTiles = 0; + for (const VoxelForgeCore::TileKey& Key : ColdStartCollisionTiles) + { + const std::optional Mesh = + ReferenceWorld.generateTile(Key, Options, CoreError); + if (!Mesh) + { + OutError = FString::Printf(TEXT("Standalone collision tile generation failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + FVoxelForgeCoreTileHashRecord Record; + Record.bEmpty = Mesh->indices.empty(); + Record.bHasHashes = Mesh->hasDensityHash && Mesh->hasMeshHash + && Mesh->hasPayloadHash && Mesh->hasTopologyHash; + if (!Record.bEmpty) ++CollisionBodyTiles; + ExpectedHashes.Add(FVoxelTileKey( + FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod), Record); + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] standalone_collision_reference=1 center=(%d,%d,%d) tiles=%d body=%llu no_body=%llu"), + ColdStartCollisionCenter.X, ColdStartCollisionCenter.Y, ColdStartCollisionCenter.Z, + static_cast(ColdStartCollisionTiles.size()), + static_cast(CollisionBodyTiles), + static_cast(ColdStartCollisionTiles.size() - CollisionBodyTiles)); + std::vector AggregateRows; AggregateRows.reserve(Desired.size()); bool bFoundCarveVertex = false; @@ -19367,7 +19432,7 @@ bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FStrin for (int32 Y = -CollisionRadius; Y <= CollisionRadius; ++Y) for (int32 X = -CollisionRadius; X <= CollisionRadius; ++X) { - const FVoxelTileKey Key(FIntVector(X, Y, Z), 0); + const FVoxelTileKey Key(ColdStartCollisionCenter + FIntVector(X, Y, Z), 0); ExpectedCollisionSet.Add(Key); const FVoxelForgeCoreTileHashRecord* Record = ExpectedHashes.Find(Key); if (Record == nullptr) @@ -21361,6 +21426,44 @@ int32 RunBatch(const FString& Params) int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) { + if (FParse::Param(*Params, TEXT("coreassetupgradeaudit"))) + { + // Load the untouched BuildHost copies and emit their in-memory conversions; never save a package. + // Charge les copies BuildHost intactes et affiche les conversions en memoire, sans sauver de package. + UVoxelSettings* SettingsAsset = LoadObject(nullptr, + TEXT("/Game/VoxelForge/DA_Settings.DA_Settings")); + if (SettingsAsset == nullptr) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeAssetMigrationAudit] missing DA_Settings copy")); + return 2; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeAssetMigrationAudit] asset=%s old_gap_chunks=%d new_gap_m=%.6f layout_chunk_m=%.6f"), + *SettingsAsset->GetPathName(), SettingsAsset->InterStrateGapChunks, + SettingsAsset->InterStrateGapMeters, SettingsAsset->StrateLayoutChunkSizeMeters); + + const TCHAR* StratePaths[] = { + TEXT("/Game/VoxelForge/DA_Strate1.DA_Strate1"), + TEXT("/Game/VoxelForge/DA_Strate2.DA_Strate2"), + TEXT("/Game/VoxelForge/DA_Strate3.DA_Strate3"), + TEXT("/Game/VoxelForge/DA_Strate4.DA_Strate4") + }; + for (const TCHAR* Path : StratePaths) + { + UVoxelStrateDefinition* Definition = LoadObject(nullptr, Path); + if (Definition == nullptr) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeAssetMigrationAudit] missing strate copy %s"), Path); + return 2; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeAssetMigrationAudit] asset=%s old_height_chunks=%d new_height_m=%.6f old_transition_chunks=%d new_transition_m=%.6f"), + *Definition->GetPathName(), Definition->StrateHeightInChunks, + Definition->StrateHeightMeters, Definition->TransitionBlendChunks, + Definition->TransitionWidthMeters); + } + return 0; + } if (FParse::Param(*Params, TEXT("step2f"))) { return RunStep2f(Params); diff --git a/Standalone/Research/WorldAssemblerM3/Unreal1/REPORT.md b/Standalone/Research/WorldAssemblerM3/Unreal1/REPORT.md new file mode 100644 index 0000000..8849f27 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Unreal1/REPORT.md @@ -0,0 +1,233 @@ +# VoxelForge U1 — Unreal catch-up report + +Date: 2026-10-26. All builds, Unreal launches, logs, and asset reads used the isolated +`Saved/BuildHost` host project. The owner `VoxelM.uproject` and editor were not opened or built. +The RealtimeMeshComponent tree was read-only and unchanged. No `.uasset` was edited or saved. + +## Research reports and code/API decisions + +- Research14's first-build checklist was reviewed. BuildHost compiled Runtime, Editor, and Core; + `GetStrateForChunk` continues to receive the layout-chunk Z coordinate. The signed hash and bounded + shift changes are included in the clean MSVC build. +- Research17 asks for the optional CoreGeometry recipe, named stamps and cavern records, landmark + binding, and resolved-layout identity. These are reflected and mapped. The report refers to stamp + resolver V2, while the checked-out API includes V1/V2/V3 and requires V3 for the new + monolith/platform motif vocabulary. Unreal calls V1 for original stamps and V3 for motifs, then + combines and validates them. Source was followed. +- Research8 describes the full spatial-property inventory and notes a 4 m legacy chunk versus the + separate layout chunk. The current code resolves the layout chunk at 8 m; the migration follows + that saved setting. U1 migrates only strate height, transition width, and inter-strate gap. The + remaining inventory is listed under “Unit upgrade limits”; item 3 is therefore partial. +- Research27 water was read and deliberately not implemented this round. + +## Part 1 — compile and BuildHost gates + +Command: + +```powershell +.\Tools\BuildVoxelForgeCoreHost.ps1 -MaxParallelActions 12 -TimeoutSeconds 900 +``` + +Final result: `BuildHost UBT attempt 1 passed`; UBT reports `Result: Succeeded`. Runtime, Editor, +and Core inside Unreal compiled with MSVC. The Core link audit passed: 18 manifest shims linked, +118,399 `dumpbin` lines checked against 521 Unreal-style copied type names. Five existing asset +copy hashes matched the recorded BuildHost hashes. Log: +`Saved/BuildHost/UBT-CoreIntegration.log`; link audit: +`Saved/BuildHost/VoxelForgeCore_ue_link_safety.log`. + +Compile errors fixed: + +| Error | Cause and fix | +|---|---| +| `VoxelClipmapDesiredTiles.h(82)` C4334 | The shift is intentionally 64-bit at the supported coordinate bound. The left operand is now `int64{1}` before shifting by the validated level, avoiding the old 32-bit shift followed by widening. | +| `VoxelForgeCoreAdapter.cpp(114)` C2065 | Core moved the subvoxel constant into its namespace. The adapter now references `VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel`. | +| UHT: `Units` metadata on `TArray` | Unreal permits that metadata on numeric and struct properties, not this array. Removed the invalid array metadata; each authored point is still interpreted and converted as metres by the adapter. | +| Adapter C2143 with cascading undeclared identifiers | An incomplete duplicate `AttachValidatedWorldAssembly` signature remained above the real definition after editing. Removed the duplicate line; the valid definition compiled. | + +The initial memory preflight used `Get-CimInstance`, which was denied, then `Get-Counter`, which +could not find the requested counter. Neither launched Unreal. The scripts now use +`GlobalMemoryStatusEx`; measured free memory was above 4 GiB before launches. The Floating Islands +launch's separate process-list expression emitted a non-terminating PowerShell binding warning; a +second process check confirmed exactly one normal-priority Unreal process, and the gate completed. + +### Gates + +| Gate | Exact command | Result | +|---|---|---| +| SurfaceWorld, 25 cm | `.\Tools\TestVoxelForgeCoreWorldHost.ps1 -TimeoutSeconds 900 -CoreVoxelSizeCm 25` | PASS: native SurfaceWorld, collision geometry, anchor movement, and carving; peak private memory 3,216,474,112 bytes. W1/W8/W16 stable: density `0x25d62110da7f3651`, mesh `0xd7184d5ad92b107f`, payload `0xf7dde7d6ec40fd34`, topology `0x60bbc7c8ef8bb309`. | +| Floating Islands, 25 cm | `.\Tools\TestVoxelForgeCoreWorldHost.ps1 -TimeoutSeconds 900 -CoreVoxelSizeCm 25 -FloatingIslandsOnly` | PASS: standalone water-fixed audit, LOD0/1, W1/W8/W16; peak private memory 2,591,363,072 bytes. Coverage checked 573 local samples and 3,438 faces; 432 LOD transition faces. Standalone density/mesh/payload/topology hashes: `0xc2ba8f32b81dacec` / `0x91fe8a21be55324d` / `0x7a487c46247ce77c` / `0x1eaee14ac02b0a80`. | +| Core-off legacy | `.\Tools\TestVoxelForgeCoreWorldHost.ps1 -TimeoutSeconds 900 -CoreVoxelSizeCm 25 -CoreOffTest` | PASS: legacy voxel size 12.5 cm; 3 density samples; peak private memory 1,536,880,640 bytes. | +| M2 analytic fixtures | `.\Tools\TestVoxelForgeCoreWorldHost.ps1 -TimeoutSeconds 900 -CoreVoxelSizeCm 25 -CoreMesherRecipe WorldAssemblerHybridV1 -HybridFixturesOnly` | PASS: 5 analytic fixtures and 1 border check; peak private memory 1,988,993,024 bytes. This is the focused M2 fixture gate, not all 625 transitions. | + +### Cold spawn and high-speed traces + +Each trace used a fresh BuildHost Unreal process, the current owner plan, Core at 25 cm, and normal +priority. Trace files and full command logs are under +`Saved/BuildHost/HostProject/Plugins/VoxelForge/Saved/CoreWorldValidation/Logs/`. + +Cold owner-plan command: + +```powershell +.\Tools\TestVoxelForgeCoreWorldHost.ps1 -TimeoutSeconds 900 -CoreVoxelSizeCm 25 -StreamingTraceOnly +``` + +The 2,578-tile initial set was fully ready in 5.779 s. The first LOD0 ring contained 512 tiles; its +ready event arrived in 1.794 s with 1.017 s reported for that epoch. Summing `worker_task_s` from +tile records through `initial_set_ready` gives 34.777 worker-seconds. The runtime summary reports +spawn tick p95/max 5.136/21.230 ms and walk tick p95/max 4.467/42.794 ms at 6 m/s. Per-LOD0 tile +worker generation was p95/max 32.835/52.929 ms. Peak private memory was 2,906,755,072 bytes. + +High-speed command: + +```powershell +.\Tools\TestVoxelForgeCoreWorldHost.ps1 -TimeoutSeconds 900 -CoreVoxelSizeCm 25 -StreamingTraceOnly -TestMoveSpeedCmPerSecond 4000 -EnforceCollisionGate +``` + +PASS, peak private memory 3,172,507,648 bytes. The initial set was ready in 7.858 s (2,578 loaded +at the initial milestone); the first ring had 512 tiles, arrived at 2.070 s, and reported 0.913 s. +Initial tile-worker sum was 36.056 worker-seconds. End-of-run desired/loaded counts were 2,582/4,816. Spawn tick +p95/max was 3.354/12.768 ms; walk tick p95/max was 39.577/50.165 ms at 40 m/s. The trace records +58 obsolete tile aborts during the high-speed walk. + +The final timing processes used these BuildHost plugin binaries (SHA-256): + +| Module | Binary | SHA-256 | +|---|---|---| +| Runtime | `UnrealEditor-VoxelForge.dll` | `D1D889293F4AB94FD7B2B6DB376BC3395A070AE929B46387A23022F84F351CCE` | +| Core | `UnrealEditor-VoxelForgeCore.dll` | `B8801421D1757B00FF8106789DC3C368162E585DD0CD70A8F20959FF362D81B4` | +| Editor | `UnrealEditor-VoxelForgeEditor.dll` | `1A5692D336A9D2A66D7CE699CC7F91BA77B35962BAA7FC2EC36E3F1F443F2DE0` | + +Against the 2026-10-20 L14 reference (full set about 3.9 s, ring 0.50 s, LOD0 1.11 s, 15.8 +worker-seconds, tick p95 about 2.7 ms), this cold trace is slower: full-set time +48%, ring epoch +time about +103%, first-ring wall time +62% against the 1.11 s LOD0 reference, initial worker sum +about +120%, and 6 m/s spawn/walk p95 are +90%/+66%. The measured first-ring wall event is 1.794 s. +The 40 m/s trace also has a high walk p95, with rapid +tile churn and 58 obsolete results visible in the trace. Quality gates pass, but this performance +regression is not explained by a controlled A/B; the L15–L61 Core source changed since L14, so no +single cause is claimed. + +## Part 2 — CoreGeometry wiring + +`UVoxelStrateDefinition` now has an optional CoreGeometry section. `None` and empty arrays are the +defaults. Recipe options are native caves, native lattice, and profiled heightfield (periodic hills, +mirrored relief, or flat profile). Assets can author stable-named SDF stamps, rooms, and tunnel +records in metres. The adapter plans passages first, resolves stamps and graph records next, +validates, and then finalizes the layout. A stable landmark name binds to the resolved record. Core +geometry anchors are read from the finalized JSON; UObject and RMC work stays on the game thread. + +### Core capability access from Unreal + +| Core capability from L15–L61 | Selectable from an Unreal asset? | Wired or remaining | +|---|---|---| +| Native caves | Yes | `CoreGeometry.GeneratorRecipe=NativeCaves` plus stable-named room/tunnel records; requires TunnelNetwork or Underwater. | +| Profiled heightfields | Yes | SurfaceWorld only; periodic hills, mirrored relief, or flat profile with metre fields. | +| Native lattice | Yes | `NativeLattice` plus room/tunnel/polyline records; requires Maze or VerticalShafts. Graph geometry is authored, not route-planned by the asset layer. | +| Cavern graph | Yes | Stable room and tunnel records reach `ResolveResolvedCavernGraphV1`. | +| SDF stamps | Yes | Stable-named carve ellipsoid/arch and fill pillar/dais stamps use V1 resolver. | +| Motifs | Yes, for monolith/platform | FillMonolith and FillPlatform use stamp vocabulary V3; Research17's V2 name differs from current source. | +| Core slab generator | No | Legacy FlatPlain and CrystalChamber remain available, but CoreGeometry has no Core slab recipe option or adapter mapping. | +| Ore veins | No | Core exposes an ore-vein request/resolver API, but CoreGeometry has no reflected request field or adapter mapping. | + +No water feature was added. + +### No-CoreGeometry and owner asset hashes + +All five BuildHost copies retain their recorded hashes before and after the audit; the migration +commandlet only loaded and inspected them. The old assets have no authored CoreGeometry, so the new +recipe remains Off and all arrays remain default-empty. Current owner-plan W1/W8/W16 output hashes +are recorded above. The current owner-plan assembly hash is `2d9e50e902e140e8`. The L14 pre-round Core output hash is not byte-comparable: L15–L61 changed Core +in the meantime. Thus the asset hashes and the current no-geometry hash matrix show the new fields +are inactive on the owner's assets; they do not claim byte identity with L14. + +## Part 3 — unit upgrade status and asset audit + +`VoxelForgeAssetVersion::GUID` is `A76D5E31-4B2C-4F9A-B8C1-D2046E9F7738`; current version is 2 +(version 1 retains the tunnel-floor slope conversion). Loading assets with version below 2 converts +old integer layout counts once. The version is carried through Unreal's custom-version archive. The +five unmodified BuildHost copies loaded successfully in the clean audit, commandlet peak private +memory 1,608,806,400 bytes. `Saved/BuildHost/AssetMigrationAudit.final.log` contains the values. + +| BuildHost asset | Old serialized value | In-memory metre value | Physical delta | +|---|---|---|---:| +| DA_Settings | gap 0 layout chunks; layout chunk 8 m | gap 0 m | 0 m | +| DA_Strate1 | height 64 chunks; transition 1 chunk | height 512 m; transition 8 m | 0 m | +| DA_Strate2 | height 30 chunks; transition 3 chunks | height 240 m; transition 24 m | 0 m | +| DA_Strate3 | height 16 chunks; transition 2 chunks | height 128 m; transition 16 m | 0 m | +| DA_Strate4 | height 16 chunks; transition 2 chunks | height 128 m; transition 16 m | 0 m | + +Every conversion is exact at the saved 8 m layout width. The old and new numeric representations +differ because the units differ; the resulting physical dimensions change by 0 m. The settings +PostLoad also sees DA_Strate4 through its fixed-strata reference; the direct audit then loads all +four definitions. No packages were saved. + +BuildHost asset SHA-256 after audit (same values enforced by the BuildHost script before the audit): + +| Asset | SHA-256 | +|---|---| +| DA_Settings.uasset | `8ABE8243104980260FECA0A6F4CF4927768E8E568221A6F6A2C2694F6D15D454` | +| DA_Strate1.uasset | `76063D691D5CE26D2014B587E1972A6724787BE1204E4652CA863BB8D5001BD0` | +| DA_Strate2.uasset | `02F56A18CED991BAF93DFB2F69438152275E8353B5E279C1CB7DD6E079E352E2` | +| DA_Strate3.uasset | `F47437F8D156C608AA06E40C5B76716ED4A19FA0F057688E7FECC04DC976428C` | +| DA_Strate4.uasset | `7C0DE7D06876035AC7BC4F117B9166AFD77402021843BA4900B470F4B0D0185B` | + +### Unit upgrade limits + +The Research8 inventory is broader than the U1 migration. Automatic conversion is not yet wired for +legacy streaming/render/content chunk distances; decoration, brush, generator, biome, passage, shaft, +island, disturbance, terrain-op and landmark owner-voxel lengths/frequencies; SurfaceWorld/Core-voxel +lengths/frequencies; placement vectors, centimetre offsets, and other reflected spatial fields. The +new CoreGeometry fields themselves are metre-valued. The legacy fields continue to work in their +current units. Completing this inventory needs additional versioned fields/converters and consumer +updates; U1 does not claim that those settings now author metres. + +## Async safety review + +The diff leaves the existing safeguards intact: + +- Generation `Epoch` travels with worker results and stale epochs are discarded before apply. +- All four result queues remain `TQueue<..., EQueueMode::Mpsc>`. +- `EndPlay` signals shutdown, waits for active tasks, then drains each queue. +- Workers check `bShuttingDown` before work and before publishing results. +- `UVoxelDiffLayer::ModsLock` still guards all concurrent modification-map access. +- Core layout resolution and content-anchor setup read UObjects on initialization; landmark updates + and RMC mutation are game-thread work. No new worker-side UObject or RMC mutation was introduced. + +## Failed and retried runs + +- Earlier SurfaceWorld harness attempts exposed missing owner-to-Core length scale in imported JSON, + using world origin instead of `viewer_start`, and a collision reference centered on the wrong + anchor. The harness now supplies owner/Core scale and uses the manifest viewer and collision + center; the final SurfaceWorld gate passes. +- The first Floating Islands fixture anchor audit exceeded its 0.25 density residual because the + runtime uses finite-step column refinement. The diagnostic-only bound is now 0.5 Core voxel and + reports residual, Core-voxel and local-centimetre coordinates. Runtime generation was not changed; + the final island audit passes. +- An additional enforced-collision streaming run reached 900 seconds waiting on support tile + `(-5,0,-31)` with 2,917/2,918 desired and 2,914/2,917 loaded. It was stopped at the time box and + not repeated. The normal SurfaceWorld gate and the requested 40 m/s trace passed. +- Standalone fast verification was attempted with + `py Standalone/Tools/verify/verify.py fast --out Saved/BuildHost/StandaloneVerifyFast --overall-timeout 900`. + It exited before the tier started because Windows Python has no `signal.SIGALRM`; no standalone + tests ran, and no verify output was written outside BuildHost. +- First asset-audit launch exited 3 because the DDC graph had no writable node. A retry loaded and + reported all five assets, but Unreal returned exit 1 due the default user-profile DDC diagnostic. + Those two logs show default-cache write probes failing with `WriteError: 3` (path not found); no + write succeeded. The clean third launch isolated `LOCALAPPDATA` under BuildHost and exited 0. No + new `UECC-*` folder appeared; the only folder is the pre-existing 2026-09-17 crash folder. +- No run exited with `0xC0000142`; no such retry was needed. + +## Likely compile spots for the next Core/API merge + +- `VoxelForgeCoreAdapter.cpp`: Core `ResolvedLayoutSnapshotV3`, stamp V1/V3, cavern graph resolver, + profile field names, stable-ID conversion, and passage/resolver/finalize ordering. +- `VoxelStrateTypes.h`: UHT support for nested CoreGeometry structs, `FVector` metre metadata, and + polyline array metadata (the array itself cannot carry `Units`). +- `VoxelSettings.cpp`, `VoxelStrateDefinition.cpp`, and `VoxelForgeAssetVersion.h`: custom-version + registration, ordering with the existing version-1 tunnel-floor migration, and serialization. +- `VoxelForgeExploreCommandlet.cpp`: BuildHost-only asset audit command and editor-module access to + the runtime types. +- `VoxelClipmapDesiredTiles.h`: signed/64-bit coordinate shifts and clamped LOD bounds. +- `VoxelStrateManager.cpp`: keep layout chunk Z as the lookup input and convert physical bounds + only once at the layout boundary. + +Large compiler, Unreal, and trace outputs remain under `Saved/BuildHost`.