diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5864495..0033187 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1065,42 +1065,39 @@ timings are intentionally diagnostic metadata and do not affect world or image c viewer remains a separate display-only consumer: it must load this OBJ/manifest and never evaluate density or generate geometry. -### 8.17 Standalone Core in Unreal — integration round 1 -**Status (2026-10-08):** the `VoxelForgeCore` runtime module and manifest shims build and link in the -isolated BuildHost. The editor commandlet can adapt the owner's copied settings/strate assets into -schema-v1 JSON, resolve it through `ResolvedWorldSnapshot::FromJson`, and reproduce the standalone -hashes at compile worker limits 1, 8, and 16. This has not yet changed the `AVoxelWorld` generation -route. There is no `bUseVoxelForgeCore` property, and no world should be switched yet. +### 8.17 Standalone Core in Unreal — integration round 2 +**Status (2026-10-08):** `AVoxelWorld::bUseVoxelForgeCore` now selects a Core runtime route and is +false by default. Core mode has independent `CoreClipRadius=3` and `CoreMaxClipLevel=8` properties; +it does not read or write the older clip properties or owner assets. BuildHost UBT and the Core link +symbol audit pass. A headless BuildHost actor proof matches all initial tile hashes against standalone +Core, checks collision readiness against the expected non-empty level-0 tiles, moves a streaming +anchor, and applies a transaction edit. The current-plugin switch-off baseline is the narrow +`VoxelForge.Determinism.CrossPlatformDigest`; it passes but does not spawn an actor with the switch +false, so it is a legacy-generator baseline rather than an actor-level A/B proof. -The commandlet adapter is game-thread-only: it resolves UObjects and soft references, writes stable -property/map/set order, and emits values rather than UObject pointers. It writes the standalone -schema scale of 25 cm per voxel. The plugin's existing Runtime generator still defines -`VOXEL_SIZE=12.5 cm`, so a Core mesh-to-`FVoxelMeshData` bridge must define the coordinate/scale -conversion before runtime use. The staged `DA_Settings` also says `MaxClipLevel=6`, while the -standalone owner JSON says 8; hash validation keeps the authored JSON unchanged and applies the -reference harness view (`clip_radius=3`, `max_clip_level=8`) only to its validation copy. +The game-thread asset adapter emits the immutable schema-v1 input and resolves it through +`ResolvedWorldSnapshot::FromJson`; Core workers receive no UObjects. The Core route asks +`World::desiredTiles` and `prioritizeTiles`, compiles and generates through Unreal tasks, and carries +the generation `Epoch` through MPSC results. The shared payload builder, RealtimeMesh upload, and +collision cook remain the final path. Workers observe `bShuttingDown`, and `EndPlay` drains active +tasks. Preserve §8.10 streaming, cancellation, epoch, collision-handoff, and per-frame-budget rules. -The intended side-by-side runtime route is: +Core `Vec3d` positions and tile keys are in voxel space, one voxel is 25 cm, and Core mesh vertex +positions are already in centimetres. The bridge copies those mesh positions directly; player, +anchor, query, and edit inputs convert world centimetres using the Core scale. High-level brushes +that historically express dimensions in the plugin's 12.5 cm voxels scale radius, extent, and falloff +by 12.5/25 to preserve physical dimensions. The low-level `ApplyModification` path expects active +voxel units. Tile keys remain Core keys; only legacy material lookup maps Core tile spans into the +existing strate chunk layout. -1. Add `bUseVoxelForgeCore=false` to `AVoxelWorld`. The false branch must retain the existing field, -mesher, output, streaming, and collision behavior. -2. On the game thread, adapt owner assets to an immutable schema-v1 snapshot. Keep UObjects out of -Core and worker data. -3. On the Core branch, ask `World::desiredTiles` and `prioritizeTiles` for the target set/order; -compile the initial field using a `JobScheduler` backed by Unreal's task system. The plugin still -owns work admission, worker lifetime, cancellation, `bShuttingDown`, and the generation `Epoch`. -4. Run `generateTile` on the plugin workers, explicitly reconcile Core centimetre positions with -the Runtime voxel scale, then copy mesh arrays/groups into `FVoxelMeshData`. Let the existing -`BuildTileStreamSet` → MPSC result → epoch check → RealtimeMesh upload/collision-cook path consume -the result. -5. For carving, drain Core calls on the current `World`, call `applyEditTransaction` with an -authority sequence and ordered brushes, bump the epoch, and regenerate its returned desired tiles. -Keep the current plugin behavior when the switch is false. - -The module-link and asset-adapter evidence, including the settings mismatch and incomplete runtime -work, is recorded in [`Standalone/UNREAL_INTEGRATION_REPORT.md`](Standalone/UNREAL_INTEGRATION_REPORT.md). -Keep every §8.10 streaming, cancellation, epoch, collision-handoff, and per-frame-budget invariant -when implementing this route; Core does not own those systems. +Carving drains in-flight Core calls, applies an increasing-sequence `applyEditTransaction`, advances +the epoch, and regenerates the returned affected keys. This leaves a transaction boundary for later +replication; no transport is implemented. Existing streaming anchors are included in Core desired +tiles; the BuildHost shift check covers `CollisionOnly`. `FullVisual` is wired through the same +anchor reconciliation but has not had an independent headless assertion. The owner-editor visual +and physical play check remains the hand-off step. See +[`Standalone/UNREAL_INTEGRATION_REPORT.md`](Standalone/UNREAL_INTEGRATION_REPORT.md) for evidence, +known limits, and instructions. ## 9. Multiplayer model (listen-server first, dedicated-friendly) diff --git a/CODEMAP.md b/CODEMAP.md index cea0b05..a676437 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -820,26 +820,33 @@ second density path. | `VoxelForgeWormBlockSkipTest.cpp` | `VoxelForge.Determinism.WormBlockSkipSoundness` | Soundness and coverage of the worker-local worm N1 block proof (`voxel.WormBlockSkip`). | | `VoxelForgePlayerFitWindow.h` | — | Shared measurement-window policy for player-fit diagnostics (test/editor side only). | -### 3.13 Core module and schema adapter (integration round 1) +### 3.13 Core runtime integration (round 2) The public Core API is linked as the `VoxelForgeCore` runtime module, with canonical implementation sources still under `Standalone/`. UBT validates the 18-row manifest and its shims; BuildHost linked the module and passed `CheckCoreSymbols.ps1` against the actual shim objects. See [`Standalone/UNREAL_INTEGRATION_REPORT.md`](Standalone/UNREAL_INTEGRATION_REPORT.md) for build and -headless evidence. +headless evidence and the run ledger. `RunStandaloneWorldExport` in `VoxelForgeExploreCommandlet.cpp` serializes owner assets on the game -thread to schema-v1 JSON. `FStandaloneCoreAdapterScheduler` and -`VerifyStandaloneCoreAdapter` parse that JSON through `ResolvedWorldSnapshot::FromJson`, compile at -worker limits 1, 8, and 16, and compare the 2,226-tile hashes and passage walkability. The commandlet -sorts map/set members and JSON object keys to keep the export deterministic. Its test copy applies -the standalone benchmark view overrides (clip radius 3, max level 8); the saved asset-derived export -retains the authored clip radius and max level. +thread to schema-v1 JSON. The adapter parses it through `ResolvedWorldSnapshot::FromJson` and keeps +UObjects off Core workers. The runtime switch is `AVoxelWorld::bUseVoxelForgeCore` (default false), +with independent `CoreClipRadius=3` and `CoreMaxClipLevel=8`; it does not read or mutate the old clip +settings or assets. -**Runtime status:** no `bUseVoxelForgeCore` property, Core generation route, mesh conversion, Core -carve transaction, or owner-facing switch instructions exist yet. `AVoxelWorld` still uses the -existing density generator, mesher, queues, epoch checks, RealtimeMesh upload, and collision path. -The Core dependency is present so the next round can wire the side-by-side route behind a default-off -switch while preserving that path. +When enabled, `AVoxelWorld` uses Core desired keys and priorities, compiles/generates through Unreal +tasks, carries `Epoch` through the existing MPSC result queues, and uploads converted payloads through +RealtimeMesh and its collision cook. Core voxel coordinates use 25 cm per voxel; Core tile keys are +retained, and Core mesh vertices are already in centimetres. High-level legacy brush dimensions are +scaled from 12.5 cm voxel units to Core units so their physical size stays the same. Edits use +sequenced `applyEditTransaction` calls after in-flight Core work drains, then regenerate Core's +affected keys under a new epoch. Existing streaming anchors participate; the headless move proof +covers `CollisionOnly` policy. + +`-coreworldtest=1` in the BuildHost editor commandlet validates the spawned actor's 2,226 initial +tiles against standalone Core, expected collision bodies/empty tiles, an anchor move, and a carve +transaction. `VoxelForge.Determinism.CrossPlatformDigest` is the narrow switch-off baseline; it passes, +but does not instantiate an actor with the switch false. See the report for that limitation and for +owner-editor instructions. ## 4. The density pipeline (most-edited hot path) diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index b0dcf2f..ada7900 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -61,17 +61,98 @@ #include "Misc/CommandLine.h" #include "Misc/Paths.h" #include "Misc/ScopeLock.h" +#include "Misc/ScopeRWLock.h" +#include "Async/ParallelFor.h" #include "HAL/IConsoleManager.h" #include "HAL/PlatformMisc.h" +#include "HAL/PlatformProcess.h" +#include "HAL/PlatformTime.h" #include "Policies/CondensedJsonPrintPolicy.h" #include "Serialization/JsonWriter.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) #include "ProfilingDebugging/CsvProfiler.h" // Delayed CSV capture for Step 1 upload runs +#include "VoxelForgeCore/VoxelForgeCore.h" +#include "VoxelForgeCoreAssetAdapter.h" + +#include +#include +#include static bool VF_TileOverlapsVoxelBounds(const FVoxelTileKey& Tile, const FVector& BoundsMin, const FVector& BoundsMax); static bool VF_TileFootprintsOverlap(const FVoxelTileKey& A, const FVoxelTileKey& B); +class FVoxelForgeCoreTaskScheduler final : public VoxelForgeCore::JobScheduler +{ +public: + double parallel_for(std::size_t Count, std::size_t BatchSize, std::size_t WorkerLimit, + void* Context, Task Callback, double* WorkerActiveSeconds, + std::size_t WorkerActiveCapacity) const override + { + if (WorkerActiveSeconds != nullptr) + { + std::fill(WorkerActiveSeconds, + WorkerActiveSeconds + WorkerActiveCapacity, 0.0); + } + if (Count == 0 || Callback == nullptr) return 0.0; + + const std::size_t WorkerCount = std::clamp( + WorkerLimit, 1, std::min(16, Count)); + const std::size_t Grain = std::max(1, BatchSize); + std::atomic Next{0}; + std::vector ActiveSeconds(WorkerCount, 0.0); + + // Each ParallelFor lane is a stable Core worker index. Unreal owns the actual threads; + // callbacks keep claiming fixed-size batches until every input index has run once. + ParallelFor(static_cast(WorkerCount), [&](int32 Lane) + { + const double Started = FPlatformTime::Seconds(); + for (;;) + { + const std::size_t First = Next.fetch_add(Grain, std::memory_order_relaxed); + if (First >= Count) break; + const std::size_t End = First + std::min(Grain, Count - First); + for (std::size_t Index = First; Index < End; ++Index) + { + Callback(Context, Index, static_cast(Lane)); + } + } + ActiveSeconds[static_cast(Lane)] = + FPlatformTime::Seconds() - Started; + }); + + double TotalSeconds = 0.0; + for (std::size_t Lane = 0; Lane < WorkerCount; ++Lane) + { + TotalSeconds += ActiveSeconds[Lane]; + if (WorkerActiveSeconds != nullptr && Lane < WorkerActiveCapacity) + { + WorkerActiveSeconds[Lane] = ActiveSeconds[Lane]; + } + } + return TotalSeconds; + } +}; + +class FVoxelForgeCoreRuntimeState +{ +public: + std::unique_ptr World; + FRWLock WorldLock; + FVoxelForgeCoreTaskScheduler Scheduler; + std::unordered_set RetainedTiles; + std::atomic bReady{false}; + std::atomic bInitialFailed{false}; + std::atomic bRegionCompileInFlight{false}; + std::atomic bRegionCompileFinished{false}; + std::atomic bRegionCompileSucceeded{false}; + std::atomic bRegionCompileFailed{false}; + std::atomic bRebuildDesired{false}; + std::atomic bShuttingDown{false}; + std::atomic NextEditSequence{1}; + int32 FullResolutionLodCount = 0; +}; + #if WITH_EDITOR #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" @@ -1031,6 +1112,12 @@ void AVoxelWorld::ValidateDeterminism() // memo, biome grid, surface columns, lattice bakes) happen to be warm. Historically THE // source of chunk seams — and the invariant every "bit-identical" hot-path refactor claims // to preserve. This runs on the game thread, whose caches are isolated from the workers. + if (bUseVoxelForgeCore) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForge] ValidateDeterminism tests the legacy generator and is unavailable in Core mode.")); + return; + } if (!Generator) { UE_LOG(LogTemp, Warning, TEXT("[VoxelForge] ValidateDeterminism: no Generator — run during PIE.")); @@ -1447,6 +1534,10 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) // Signal all async tasks to bail out ASAP const double EndPlayStartSeconds = FPlatformTime::Seconds(); bShuttingDown.store(true, std::memory_order_release); + if (VoxelForgeCoreState.IsValid()) + { + VoxelForgeCoreState->bShuttingDown.store(true, std::memory_order_release); + } ReleasePawnCollisionGate(); if (APawn* Pawn = PawnTickPrerequisite.Get()) { @@ -1496,6 +1587,7 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) } UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] EndPlay: workers drained in %.6fs; obsolete_tile_aborts=%d"), FPlatformTime::Seconds() - EndPlayStartSeconds, ObsoleteTileAbortCount); + VoxelForgeCoreState.Reset(); if (bSampleStacksStarted) { FVoxelStackSampler::Get().StopAndWrite(); @@ -2362,6 +2454,14 @@ void AVoxelWorld::BeginPlay() DiffLayer->SetBudget(Settings->MaxModifications, Settings->MaxBrushRadius, Settings->MaxTotalVolume); Generator->SetDiffLayer(DiffLayer); + if (bUseVoxelForgeCore && !InitializeVoxelForgeCoreWorld()) + { + // A failed opt-in must stay visibly failed. Falling back to the legacy route here would + // make the switch appear to work while silently changing the generated world. + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCoreRuntime] Core mode is enabled but initialization did not complete.")); + } + if (bTestCeilingViewEnabled && IsParameterizedResearchFieldActive()) { const FVector RequestedCenter = HeadlessStreamingTestCenterVoxel; @@ -6625,6 +6725,13 @@ void AVoxelWorld::Tick(float DeltaTime) } else { + if (bUseVoxelForgeCore) + { + // Headless/server worlds without a local pawn still keep the Core fallback region + // streamed; this is the same explicit anchor path used by BuildHost validation. + UpdateChunksAroundPosition( + GetVoxelForgeCoreDefaultCenterWorld(), nullptr, FVector::ZeroVector); + } ReleasePawnCollisionGate(); if (APawn* PreviousPawn = PawnTickPrerequisite.Get()) { @@ -6784,7 +6891,7 @@ void AVoxelWorld::Tick(float DeltaTime) // headless/editor use without making the world-origin pawn skip streaming. FVector ModificationPosition = bHasPlayer ? PlayerLastPos : GetActorLocation(); FVoxelTileKey ModificationCenterTile( - WorldToChunkCoord(WorldToLocalCm(ModificationPosition)), 0); + GetTileCoordAtLocalCm(WorldToLocalCm(ModificationPosition)), 0); bool bModificationTargetSelected = false; if (GVoxelForgePendingTestModification != 0 && (GVoxelForgeTestModificationLoadedProofTarget @@ -7057,14 +7164,222 @@ FVector AVoxelWorld::WorldToLocalCm(FVector WorldPos) const return GetActorTransform().InverseTransformPosition(WorldPos); } +float AVoxelWorld::GetActiveVoxelSizeCm() const +{ + return bUseVoxelForgeCore ? 25.0f : VOXEL_SIZE; +} + +float AVoxelWorld::GetLegacyVoxelToActiveVoxelScale() const +{ + // Preserve the physical size of the pre-Core Blueprint brush API in either voxel scale. + // Conserver la taille physique des brosses Blueprint historiques dans les deux échelles. + return VOXEL_SIZE / GetActiveVoxelSizeCm(); +} + +FIntVector AVoxelWorld::GetTileCoordAtLocalCm(const FVector& LocalPositionCm) const +{ + const double VoxelSize = static_cast(GetActiveVoxelSizeCm()); + return FIntVector( + FMath::FloorToInt(LocalPositionCm.X / VoxelSize / CHUNK_SIZE), + FMath::FloorToInt(LocalPositionCm.Y / VoxelSize / CHUNK_SIZE), + FMath::FloorToInt(LocalPositionCm.Z / VoxelSize / CHUNK_SIZE)); +} + +FIntVector AVoxelWorld::GetTileCoordAtVoxelPosition(const FVector& VoxelPosition) const +{ + return FIntVector( + FMath::FloorToInt(VoxelPosition.X / CHUNK_SIZE), + FMath::FloorToInt(VoxelPosition.Y / CHUNK_SIZE), + FMath::FloorToInt(VoxelPosition.Z / CHUNK_SIZE)); +} + FVector AVoxelWorld::WorldToLocalVoxel(FVector WorldPos) const { - return GetActorTransform().InverseTransformPosition(WorldPos) / VOXEL_SIZE; + return WorldToLocalCm(WorldPos) / GetActiveVoxelSizeCm(); } FVector AVoxelWorld::LocalVoxelToWorld(FVector VoxelPos) const { - return GetActorTransform().TransformPosition(VoxelPos * VOXEL_SIZE); + return GetActorTransform().TransformPosition(VoxelPos * GetActiveVoxelSizeCm()); +} + +bool AVoxelWorld::IsVoxelForgeCoreReady() const +{ + return bUseVoxelForgeCore && VoxelForgeCoreState.IsValid() + && VoxelForgeCoreState->bReady.load(std::memory_order_acquire) + && !VoxelForgeCoreState->bShuttingDown.load(std::memory_order_acquire); +} + +FVector AVoxelWorld::GetVoxelForgeCoreDefaultCenterWorld() const +{ + return GetActorTransform().TransformPosition(CoreFallbackCenterLocalCm); +} + +bool AVoxelWorld::QueryVoxelForgeCoreDensityAt(FVector WorldLocation, float& OutDensity) const +{ + OutDensity = 0.0f; + if (!IsVoxelForgeCoreReady()) return false; + const FVector VoxelPosition = WorldToLocalVoxel(WorldLocation); + const TSharedPtr State = VoxelForgeCoreState; + if (!State.IsValid()) return false; + FReadScopeLock Lock(State->WorldLock); + if (!State->World) return false; + OutDensity = State->World->densityAt({VoxelPosition.X, VoxelPosition.Y, VoxelPosition.Z}); + return FMath::IsFinite(OutDensity); +} + +bool AVoxelWorld::InitializeVoxelForgeCoreWorld() +{ + if (!bUseVoxelForgeCore) return true; + if (!Settings || !StrateManager) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCoreRuntime] initialization failed: Settings or resolved StrateManager is missing.")); + return false; + } + + const int32 ActiveCoreClipRadius = FMath::Clamp(CoreClipRadius, 1, 64); + const int32 ActiveCoreMaxClipLevel = FMath::Clamp(CoreMaxClipLevel, 0, 8); + FString Json; + FString ExportError; + if (!VoxelForgeCoreAdapter::SerializeResolvedWorld( + Settings, StrateManager, ActiveCoreClipRadius, ActiveCoreMaxClipLevel, Json, ExportError)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreRuntime] snapshot export failed: %s"), *ExportError); + return false; + } + FTCHARToUTF8 Utf8Json(*Json); + std::string CoreError; + std::optional Snapshot = + VoxelForgeCore::ResolvedWorldSnapshot::FromJson( + std::string_view(Utf8Json.Get(), static_cast(Utf8Json.Length())), CoreError); + if (!Snapshot) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreRuntime] schema-v1 resolve failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + + TSharedPtr State = + MakeShared(); + State->World = std::make_unique(std::move(*Snapshot)); + State->FullResolutionLodCount = State->World->configuration().fullResolutionLodCount; + + FVector CameraVoxels = FVector::ZeroVector; + FVector InitialPlayerPosition = FVector::ZeroVector; + if (TryGetPlayerPosition(InitialPlayerPosition)) + { + CameraVoxels = WorldToLocalCm(InitialPlayerPosition) / 25.0f; + } + else if (State->World->configuration().viewerStartFound) + { + const VoxelForgeCore::Vec3d Start = State->World->configuration().viewerStartVoxels; + CameraVoxels = FVector(Start.x, Start.y, Start.z); + } + CoreFallbackCenterLocalCm = CameraVoxels * 25.0f; + + const std::vector InitialDesired = State->World->desiredTiles( + {CameraVoxels.X, CameraVoxels.Y, CameraVoxels.Z}); + if (InitialDesired.empty()) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreRuntime] Core returned an empty initial clipmap.")); + return false; + } + + const int32 IntMax = MAX_int32; + const int32 IntMin = MIN_int32; + VoxelForgeCore::Vec3i FineMin{IntMax, IntMax, IntMax}; + VoxelForgeCore::Vec3i FineMax{IntMin, IntMin, IntMin}; + VoxelForgeCore::Vec3i ClipMin{IntMax, IntMax, IntMax}; + VoxelForgeCore::Vec3i ClipMax{IntMin, IntMin, IntMin}; + auto IncludeExtent = [](int32& Min, int32& Max, int32 Origin, int32 Extent) + { + Min = FMath::Min(Min, Origin); + Max = FMath::Max(Max, Origin + Extent); + }; + for (const VoxelForgeCore::TileKey& Key : InitialDesired) + { + const int32 Extent = 32 << Key.lod; + const int32 OriginX = Key.coordinate.x * Extent; + const int32 OriginY = Key.coordinate.y * Extent; + const int32 OriginZ = Key.coordinate.z * Extent; + IncludeExtent(ClipMin.x, ClipMax.x, OriginX, Extent); + IncludeExtent(ClipMin.y, ClipMax.y, OriginY, Extent); + IncludeExtent(ClipMin.z, ClipMax.z, OriginZ, Extent); + if (Key.lod < State->FullResolutionLodCount) + { + IncludeExtent(FineMin.x, FineMax.x, OriginX, Extent); + IncludeExtent(FineMin.y, FineMax.y, OriginY, Extent); + IncludeExtent(FineMin.z, FineMax.z, OriginZ, Extent); + } + } + if (FineMin.x == IntMax || ClipMin.x == IntMax) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreRuntime] Core initial clipmap bounds are invalid.")); + return false; + } + + VoxelForgeCore::ShapeBuildRequest Request; + Request.minVoxel = FineMin; + Request.maxVoxel = FineMax; + Request.clipMinVoxel = ClipMin; + Request.clipMaxVoxel = ClipMax; + Request.workerLimit = static_cast(FMath::Clamp(GetMaxConcurrentTasks(), 1, 16)); + Request.detail = VoxelForgeCore::DetailVariant::coherent; + Request.includeFarWorld = true; + + VoxelForgeCoreState = State; + ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); + UE::Tasks::Launch(TEXT("VoxelForgeCoreCompile"), + [this, State, Request, InitialDesired, ActiveCoreClipRadius, ActiveCoreMaxClipLevel]() + { + struct FTaskGuard + { + std::atomic& Counter; + ~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_release); } + } Guard{ActiveTaskCount}; + + if (bShuttingDown.load(std::memory_order_acquire) + || State->bShuttingDown.load(std::memory_order_acquire)) + { + return; + } + + std::string Error; + bool bCompiled = false; + { + FWriteScopeLock Lock(State->WorldLock); + bCompiled = State->World->compile(Request, State->Scheduler, Error); + if (bCompiled) + { + State->World->retainTileRegions(InitialDesired); + for (const VoxelForgeCore::TileKey& Key : InitialDesired) + { + State->RetainedTiles.insert(Key); + } + } + } + if (!bCompiled) + { + State->bInitialFailed.store(true, std::memory_order_release); + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreRuntime] shape compile failed: %s"), + UTF8_TO_TCHAR(Error.c_str())); + return; + } + + State->bReady.store(true, std::memory_order_release); + State->bRebuildDesired.store(true, std::memory_order_release); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreRuntime] compiled=1 tiles=%d clip_radius=%d max_level=%d voxel_cm=25"), + static_cast(InitialDesired.size()), ActiveCoreClipRadius, + ActiveCoreMaxClipLevel); + }, UE::Tasks::ETaskPriority::BackgroundNormal); + + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreRuntime] snapshot queued seed=%d clip_radius=%d max_level=%d initial_tiles=%d"), + StrateManager->GetWorldSeed(), ActiveCoreClipRadius, + ActiveCoreMaxClipLevel, static_cast(InitialDesired.size())); + return true; } bool AVoxelWorld::TryGetPlayerPosition(FVector& OutPosition, APawn** OutPawn) const @@ -7139,7 +7454,7 @@ bool AVoxelWorld::GetPlayerSupportTile(APawn* Pawn, const FVector& PlayerPositio // containing the solid surface below, rather than the empty tile immediately above it. FVector LocalFeet = WorldToLocalCm(PlayerPosition); LocalFeet.Z -= HalfHeightCm + 1.0f; - OutSupportTile = FVoxelTileKey(WorldToChunkCoord(LocalFeet), 0); + OutSupportTile = FVoxelTileKey(GetTileCoordAtLocalCm(LocalFeet), 0); return true; } @@ -7330,8 +7645,8 @@ bool AVoxelWorld::IsPawnCapsuleOverlappingPendingFloor( const FVector LocalPosition = WorldToLocalVoxel(PlayerPosition); const FVector LocalNextPosition = WorldToLocalVoxel(NextPlayerPosition); - const float HalfHeightVoxels = HalfHeightCm / VOXEL_SIZE; - const float RadiusVoxels = RadiusCm / VOXEL_SIZE; + const float HalfHeightVoxels = HalfHeightCm / GetActiveVoxelSizeCm(); + const float RadiusVoxels = RadiusCm / GetActiveVoxelSizeCm(); const float TileMinX = static_cast(SupportTile.Coord.X * CHUNK_SIZE); const float TileMaxX = static_cast((SupportTile.Coord.X + 1) * CHUNK_SIZE); const float TileMinY = static_cast(SupportTile.Coord.Y * CHUNK_SIZE); @@ -8844,6 +9159,18 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) } } + if (Result.bHasCoreHashes) + { + FVoxelForgeCoreTileHashRecord& Record = VoxelForgeCoreTileHashes.FindOrAdd(Result.Tile); + Record.Density = Result.CoreDensityHash; + Record.Mesh = Result.CoreMeshHash; + Record.Payload = Result.CorePayloadHash; + Record.Topology = Result.CoreTopologyHash; + Record.Triangles = Result.NumTriangles; + Record.bEmpty = Result.bEmpty; + Record.bHasHashes = true; + } + // Applied is emitted after the game-thread submission, not when the worker result is dequeued. // It deliberately does NOT mean collision-ready: RMC's cook future records that later boundary // in HandleTileCollisionCookComplete. @@ -9385,11 +9712,357 @@ static FORCEINLINE void VF_OuterShell(const UVoxelSettings* Settings, int32 R, i VoxelClipmapDesiredTiles::OuterShell(Parameters, R, MaxLevel, OutLevel, OutRadius); } +void AVoxelWorld::BuildDesiredTransitionIndex() +{ + DesiredTransitionDescendants.Reset(); + DesiredTransitionMaxLevel = 0; + for (const FVoxelTileKey& Key : DesiredSorted) + { + DesiredTransitionMaxLevel = FMath::Max(DesiredTransitionMaxLevel, Key.Level); + } + for (const TPair& Pair : TileComponents) + { + DesiredTransitionMaxLevel = FMath::Max(DesiredTransitionMaxLevel, Pair.Key.Level); + } + constexpr int32 MaxSupportedTileLevel = 8; + const int32 DescendantIndexMaxLevel = FMath::Max( + DesiredTransitionMaxLevel, MaxSupportedTileLevel); + for (const FVoxelTileKey& Key : DesiredSorted) + { + for (int32 Level = Key.Level + 1; Level <= DescendantIndexMaxLevel; ++Level) + { + TArray> TouchingAncestors; + VF_AppendTouchingAncestors(Key, Level, TouchingAncestors); + for (const FVoxelTileKey& Ancestor : TouchingAncestors) + { + DesiredTransitionDescendants.FindOrAdd(Ancestor).Add(Key); + } + } + } +} + +void AVoxelWorld::BuildCoreDesiredTiles(const FIntVector& Center, const FVector& PlayerPosition, + APawn* PlayerPawn, const FVector& PlayerHeading, + TArray& OutLeavers) +{ + DesiredSorted.Reset(); + CriticalDesiredTiles.Reset(); + OutLeavers.Reset(); + CollisionOnlyTiles.Reset(); + CollisionDesiredTiles.Reset(); + ++DesiredStamp; + ++DesiredEpoch; + DesiredEpochStartCycles = FPlatformTime::Cycles64(); + DesiredEpochCenterChunk = Center; + + const TSharedPtr State = VoxelForgeCoreState; + if (!State.IsValid() || !State->bReady.load(std::memory_order_acquire)) return; + + const FVector CameraVoxels = WorldToLocalCm(PlayerPosition) / 25.0f; + std::vector CoreDesired; + { + FReadScopeLock Lock(State->WorldLock); + if (!State->World) return; + CoreDesired = State->World->desiredTiles( + {CameraVoxels.X, CameraVoxels.Y, CameraVoxels.Z}); + } + + TArray CandidateKeys; + TSet CandidateSet; + TSet BaseVisualTiles; + TSet FullVisualAnchorTiles; + TSet CollisionAnchorTiles; + auto AddCandidate = [&](const FVoxelTileKey& Key) + { + if (!CandidateSet.Contains(Key)) + { + CandidateSet.Add(Key); + CandidateKeys.Add(Key); + } + DesiredStamped.FindOrAdd(Key) = DesiredStamp; + }; + + for (const VoxelForgeCore::TileKey& Key : CoreDesired) + { + const FVoxelTileKey RuntimeKey( + FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod); + AddCandidate(RuntimeKey); + BaseVisualTiles.Add(RuntimeKey); + } + + const int32 PawnCollisionRadius = FMath::Clamp( + Settings ? Settings->PawnCollisionRadiusChunks : 2, 1, 4); + VoxelCollisionDesiredTiles::AddBox( + CollisionDesiredTiles, Center, PawnCollisionRadius, + PawnCollisionRadius, PawnCollisionRadius); + if (GVoxelForgeStep1CoreCookBenchmark != 0 && Step1SupportCohortTiles.IsEmpty()) + { + VoxelCollisionDesiredTiles::AddBox( + Step1SupportCohortTiles, Center, PawnCollisionRadius, + PawnCollisionRadius, PawnCollisionRadius); + } + + for (const FVoxelStreamingAnchor& Anchor : StreamingAnchors) + { + if (!Anchor.Actor.IsValid()) continue; + const FIntVector AnchorCenter = Anchor.LastChunk; + const int32 XYRadius = FMath::Clamp(Anchor.XYRadiusChunks, 0, 4); + const int32 ZBelow = FMath::Clamp(Anchor.ZBelowChunks, 0, 4); + const int32 ZAbove = FMath::Clamp(Anchor.ZAboveChunks, 0, 4); + VoxelCollisionDesiredTiles::AddBox( + CollisionDesiredTiles, AnchorCenter, XYRadius, ZBelow, ZAbove); + for (int32 Dz = -ZBelow; Dz <= ZAbove; ++Dz) + for (int32 Dy = -XYRadius; Dy <= XYRadius; ++Dy) + for (int32 Dx = -XYRadius; Dx <= XYRadius; ++Dx) + { + const FVoxelTileKey Key(AnchorCenter + FIntVector(Dx, Dy, Dz), 0); + AddCandidate(Key); + if (Anchor.Policy == EVoxelAnchorPolicy::FullVisual) + { + FullVisualAnchorTiles.Add(Key); + } + else + { + CollisionAnchorTiles.Add(Key); + } + } + } + + // Preserve unresolved support requests from the existing collision gate. They remain in Core + // tile coordinates, and level-0 bounds use the Core voxel scale through GetPlayerSupportTile. + for (auto It = CollisionGateDesiredTiles.CreateIterator(); It; ++It) + { + const FVoxelTileKey& Key = *It; + const TCHAR* UnavailableReason = nullptr; + if (IsTileCollisionReady(Key) || CollisionNotRequiredTiles.Contains(Key) + || IsCollisionTileUnavailable(Key, UnavailableReason)) + { + It.RemoveCurrent(); + continue; + } + AddCandidate(Key); + CollisionDesiredTiles.Add(Key); + CollisionAnchorTiles.Add(Key); + } + + for (const FVoxelTileKey& Key : CollisionAnchorTiles) + { + if (!BaseVisualTiles.Contains(Key) && !FullVisualAnchorTiles.Contains(Key)) + { + CollisionOnlyTiles.Add(Key); + } + } + + std::vector PriorityInput; + PriorityInput.reserve(static_cast(CandidateKeys.Num())); + for (const FVoxelTileKey& Key : CandidateKeys) + { + PriorityInput.push_back({{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, Key.Level}); + } + std::vector PriorityOrder = VoxelForgeCore::World::prioritizeTiles( + PriorityInput, {Center.X, Center.Y, Center.Z}); + DesiredSorted.Reserve(static_cast(PriorityOrder.size())); + for (const VoxelForgeCore::TileKey& Key : PriorityOrder) + { + DesiredSorted.Emplace( + FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod); + } + + ReconcileCollisionDesiredTiles(); + for (auto It = DesiredStamped.CreateIterator(); It; ++It) + { + if (It.Value() != DesiredStamp) + { + OutLeavers.Add(It.Key()); + It.RemoveCurrent(); + } + } + CancelObsoleteTileWork(); + + const FVoxelTileKey PlayerTile(GetTileCoordAtLocalCm(WorldToLocalCm(PlayerPosition)), 0); + FVoxelTileKey OccupiedTile = PlayerTile; + FVoxelTileKey SupportTile; + if (GetPlayerSupportTile(PlayerPawn, PlayerPosition, SupportTile)) + { + OccupiedTile = SupportTile; + } + auto AddCriticalTile = [this](const FVoxelTileKey& Key) + { + if (IsDesired(Key) && !CriticalDesiredTiles.Contains(Key)) + { + CriticalDesiredTiles.Add(Key); + } + }; + if (CollisionGateDesiredTiles.Contains(PawnGateRequestedSupportTile)) + { + AddCriticalTile(PawnGateRequestedSupportTile); + } + for (const FVoxelTileKey& Key : DesiredSorted) + { + if (CollisionGateDesiredTiles.Contains(Key)) AddCriticalTile(Key); + } + AddCriticalTile(OccupiedTile); + + FVector Heading = GetActorTransform().InverseTransformVectorNoScale(PlayerHeading); + if (Heading.IsNearlyZero() && IsValid(PlayerPawn)) + { + Heading = GetActorTransform().InverseTransformVectorNoScale( + PlayerPawn->GetActorForwardVector()); + } + FIntVector HeadingStep = FIntVector::ZeroValue; + const float AbsX = FMath::Abs(Heading.X); + const float AbsY = FMath::Abs(Heading.Y); + const float AbsZ = FMath::Abs(Heading.Z); + if (AbsX > KINDA_SMALL_NUMBER || AbsY > KINDA_SMALL_NUMBER || AbsZ > KINDA_SMALL_NUMBER) + { + if (AbsX >= AbsY && AbsX >= AbsZ) HeadingStep.X = Heading.X >= 0.0f ? 1 : -1; + else if (AbsY >= AbsZ) HeadingStep.Y = Heading.Y >= 0.0f ? 1 : -1; + else HeadingStep.Z = Heading.Z >= 0.0f ? 1 : -1; + } + if (HeadingStep != FIntVector::ZeroValue) + { + FVoxelTileKey NextTile = OccupiedTile; + NextTile.Coord += HeadingStep; + AddCriticalTile(NextTile); + } + AddCriticalTile(PlayerTile); + for (int32 Index = CriticalDesiredTiles.Num() - 1; Index >= 0; --Index) + { + const int32 ExistingIndex = DesiredSorted.Find(CriticalDesiredTiles[Index]); + if (ExistingIndex > 0) + { + const FVoxelTileKey Key = DesiredSorted[ExistingIndex]; + DesiredSorted.RemoveAt(ExistingIndex, 1, EAllowShrinking::No); + DesiredSorted.Insert(Key, 0); + } + } + BuildDesiredTransitionIndex(); + + bool bRetainedSetChanged = false; + TSet DesiredSet; + for (const FVoxelTileKey& Key : DesiredSorted) DesiredSet.Add(Key); + TArray MissingFineTiles; + { + FReadScopeLock Lock(State->WorldLock); + bRetainedSetChanged = DesiredSet.Num() != static_cast(State->RetainedTiles.size()); + for (const FVoxelTileKey& Key : DesiredSorted) + { + const VoxelForgeCore::TileKey CoreKey{{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, Key.Level}; + const bool bAlreadyRetained = State->RetainedTiles.contains(CoreKey); + if (!bAlreadyRetained) bRetainedSetChanged = true; + if (!bAlreadyRetained && Key.Level < State->FullResolutionLodCount) + { + MissingFineTiles.Add(Key); + } + } + } + if (bRetainedSetChanged && !State->bRegionCompileFailed.load(std::memory_order_acquire)) + { + PendingCoreRegionTiles = MoveTemp(MissingFineTiles); + PendingCoreRetainedTiles = DesiredSorted; + bCoreRegionCompilePending = true; + StartPendingCoreRegionCompile(); + } +} + +void AVoxelWorld::StartPendingCoreRegionCompile() +{ + if (!bCoreRegionCompilePending || ActiveTaskCount.load(std::memory_order_acquire) != 0) return; + const TSharedPtr State = VoxelForgeCoreState; + if (!State.IsValid() || !State->bReady.load(std::memory_order_acquire) + || State->bRegionCompileFailed.load(std::memory_order_acquire)) + { + return; + } + bool bExpectedIdle = false; + if (!State->bRegionCompileInFlight.compare_exchange_strong( + bExpectedIdle, true, std::memory_order_acq_rel)) + { + return; + } + State->bRegionCompileFinished.store(false, std::memory_order_release); + State->bRegionCompileSucceeded.store(false, std::memory_order_release); + const TArray MissingTiles = PendingCoreRegionTiles; + const TArray RetainedTiles = PendingCoreRetainedTiles; + const uint32 TaskEpoch = GenerationEpoch; + const uint32 TaskDesiredEpoch = DesiredEpoch; + const uint32 WorkerLimit = static_cast(FMath::Clamp(GetMaxConcurrentTasks(), 1, 16)); + ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); + UE::Tasks::Launch(TEXT("VoxelForgeCoreTileRegion"), + [this, State, MissingTiles, RetainedTiles, TaskEpoch, TaskDesiredEpoch, WorkerLimit]() + { + struct FTaskGuard + { + std::atomic& Counter; + ~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_release); } + } Guard{ActiveTaskCount}; + + if (bShuttingDown.load(std::memory_order_acquire) + || State->bShuttingDown.load(std::memory_order_acquire)) + { + State->bRegionCompileInFlight.store(false, std::memory_order_release); + State->bRegionCompileFinished.store(true, std::memory_order_release); + return; + } + std::vector Missing; + std::vector Retained; + Missing.reserve(static_cast(MissingTiles.Num())); + Retained.reserve(static_cast(RetainedTiles.Num())); + for (const FVoxelTileKey& Key : MissingTiles) + { + Missing.push_back({{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, Key.Level}); + } + for (const FVoxelTileKey& Key : RetainedTiles) + { + Retained.push_back({{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, Key.Level}); + } + + std::string Error; + bool bCompiled = false; + { + FWriteScopeLock Lock(State->WorldLock); + bCompiled = State->World->compileTileRegion( + Missing, WorkerLimit, State->Scheduler, Error); + if (bCompiled) + { + State->World->retainTileRegions(Retained); + State->RetainedTiles.clear(); + for (const VoxelForgeCore::TileKey& Key : Retained) + { + State->RetainedTiles.insert(Key); + } + } + } + State->bRegionCompileSucceeded.store(bCompiled, std::memory_order_release); + if (!bCompiled) + { + State->bRegionCompileFailed.store(true, std::memory_order_release); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCoreRuntime] tile-region compile failed epoch=%u desired_epoch=%u: %s"), + TaskEpoch, TaskDesiredEpoch, UTF8_TO_TCHAR(Error.c_str())); + } + else + { + State->bRebuildDesired.store(true, std::memory_order_release); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreRuntime] tile_regions_compiled=%d retained=%d epoch=%u desired_epoch=%u"), + MissingTiles.Num(), RetainedTiles.Num(), TaskEpoch, TaskDesiredEpoch); + } + State->bRegionCompileInFlight.store(false, std::memory_order_release); + State->bRegionCompileFinished.store(true, std::memory_order_release); + }, UE::Tasks::ETaskPriority::BackgroundNormal); +} + void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& PlayerPosition, APawn* PlayerPawn, const FVector& PlayerHeading, TArray& OutLeavers) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles); + if (bUseVoxelForgeCore) + { + BuildCoreDesiredTiles(Center, PlayerPosition, PlayerPawn, PlayerHeading, OutLeavers); + return; + } DesiredSorted.Reset(); CriticalDesiredTiles.Reset(); OutLeavers.Reset(); @@ -9836,14 +10509,46 @@ bool AVoxelWorld::IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& // render-distance level+radius, same as BuildDesiredTiles). A loaded-but-not-desired tile can // still cover a desired surface outside this shell, so the caller checks replacement readiness // before hiding it in either case. - const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 6; - const int32 MaxLevelBase = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + const int32 R = bUseVoxelForgeCore + ? FMath::Clamp(CoreClipRadius, 1, 64) + : (Settings ? FMath::Max(1, Settings->ClipRadius) : 6); + const int32 MaxLevelBase = bUseVoxelForgeCore + ? FMath::Clamp(CoreMaxClipLevel, 0, 8) + : (Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4); int32 MaxLevel = MaxLevelBase, ROuter = R; - VF_OuterShell(Settings, R, MaxLevelBase, MaxLevel, ROuter); + if (bUseVoxelForgeCore) + { + VoxelForgeCore::WorldConfiguration Configuration; + const TSharedPtr State = VoxelForgeCoreState; + if (State.IsValid()) + { + FReadScopeLock Lock(State->WorldLock); + if (State->World) Configuration = State->World->configuration(); + } + VoxelClipmapDesiredTiles::FParameters Parameters; + Parameters.RenderDistanceChunks = Configuration.renderDistanceChunks; + VoxelClipmapDesiredTiles::OuterShell(Parameters, R, MaxLevelBase, MaxLevel, ROuter); + } + else + { + VF_OuterShell(Settings, R, MaxLevelBase, MaxLevel, ROuter); + } const int32 PowMax = 1 << MaxLevel; const FIntVector CMax = VF_FloorDiv(Center, PowMax); - const FVector CV = Tile.CenterCm() / VOXEL_SIZE; // tile centre in voxels + FVector CV; + if (bUseVoxelForgeCore) + { + const double TileExtentVoxels = static_cast(CHUNK_SIZE << Tile.Level); + CV = FVector( + (static_cast(Tile.Coord.X) + 0.5) * TileExtentVoxels, + (static_cast(Tile.Coord.Y) + 0.5) * TileExtentVoxels, + (static_cast(Tile.Coord.Z) + 0.5) * TileExtentVoxels); + } + else + { + CV = Tile.CenterCm() / VOXEL_SIZE; + } const int32 SizeMax = CHUNK_SIZE * PowMax; const FIntVector TMax( VF_FloorDiv(FMath::FloorToInt(CV.X), SizeMax), @@ -9897,6 +10602,37 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw const FVector& PlayerHeading) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks); + if (bUseVoxelForgeCore) + { + const TSharedPtr State = VoxelForgeCoreState; + if (!State.IsValid() || State->bInitialFailed.load(std::memory_order_acquire) + || !State->bReady.load(std::memory_order_acquire)) + { + return; // Core mode never falls through to the legacy generator. + } + if (State->bRebuildDesired.exchange(false, std::memory_order_acq_rel)) + { + bForceDesiredRebuild = true; + } + if (bCoreRegionCompilePending) + { + if (State->bRegionCompileFinished.exchange(false, std::memory_order_acq_rel)) + { + bCoreRegionCompilePending = false; + PendingCoreRegionTiles.Reset(); + PendingCoreRetainedTiles.Reset(); + if (State->bRegionCompileSucceeded.load(std::memory_order_acquire)) + { + bForceDesiredRebuild = true; + } + } + else + { + StartPendingCoreRegionCompile(); + return; + } + } + } const int32 MaxTasks = GetMaxConcurrentTasks(); const int32 NearTaskReserve = GetNearTaskReserve(MaxTasks); const int32 CoarseTaskCap = FMath::Max(1, MaxTasks - NearTaskReserve); @@ -9929,7 +10665,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw GVoxelForgeStreamingBudgetReported = true; } - const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile + const FIntVector CenterChunk = GetTileCoordAtLocalCm(WorldToLocalCm(CenterPosition)); CurrentCenterChunk = CenterChunk; for (auto It = PreEditDensityCache.CreateIterator(); It; ++It) { @@ -9952,7 +10688,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw bAnchorsMoved = true; continue; } - const FIntVector AC = WorldToChunkCoord(WorldToLocalCm(A->GetActorLocation())); + const FIntVector AC = GetTileCoordAtLocalCm(WorldToLocalCm(A->GetActorLocation())); if (AC != StreamingAnchors[i].LastChunk) { StreamingAnchors[i].LastChunk = AC; @@ -10328,6 +11064,11 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority WorkPriority) { + if (bUseVoxelForgeCore) + { + LoadCoreTile(Tile, WorkPriority); + return; + } if (GVoxelForgeStep1GenerationCap == 0) return; if (PendingTiles.Contains(Tile)) return; @@ -10582,6 +11323,183 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority Wor }, TaskPriority); } +void AVoxelWorld::LoadCoreTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority WorkPriority) +{ + if (GVoxelForgeStep1GenerationCap == 0 || bCoreRegionCompilePending) return; + if (PendingTiles.Contains(Tile) || !IsVoxelForgeCoreReady()) return; + const TSharedPtr State = VoxelForgeCoreState; + if (!State.IsValid() || State->bRegionCompileFailed.load(std::memory_order_acquire)) return; + + const int32 MaxTasks = GetMaxConcurrentTasks(); + const bool bNearPriority = WorkPriority != EVoxelTileWorkPriority::Horizon; + const int32 CoarseTaskCap = FMath::Max(1, MaxTasks - GetNearTaskReserve(MaxTasks)); + const int32 AdmissionCap = WorkPriority == EVoxelTileWorkPriority::Horizon + ? CoarseTaskCap : MaxTasks; + if (GetActiveGenerationTaskCount() >= AdmissionCap) return; + + PendingTiles.Add(Tile); + const TSharedPtr Cancellation = + MakeShared(); + PendingTileCancellation.Add(Tile, Cancellation); + ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); + + const uint32 TaskEpoch = GenerationEpoch; + const uint32 TaskDesiredEpoch = DesiredEpoch; + const uint32 TaskModificationEpoch = ModificationEpoch; + const uint64 RequestStartCycles = FPlatformTime::Cycles64(); + const bool bCaptureHashes = bCaptureVoxelForgeCoreHashesForValidation; + const UE::Tasks::ETaskPriority TaskPriority = bNearPriority + ? UE::Tasks::ETaskPriority::BackgroundHigh + : UE::Tasks::ETaskPriority::BackgroundNormal; + UE::Tasks::Launch(TEXT("VoxelForgeCoreTile"), + [this, State, Cancellation, Tile, WorkPriority, TaskEpoch, TaskDesiredEpoch, + TaskModificationEpoch, RequestStartCycles, bCaptureHashes]() + { + struct FTaskGuard + { + std::atomic& Counter; + ~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); } + } Guard{ActiveTaskCount}; + + auto Enqueue = [this, WorkPriority](FChunkResult&& Result) + { + if (WorkPriority == EVoxelTileWorkPriority::CollisionCritical) + CriticalProcessQueue.Enqueue(MoveTemp(Result)); + else if (WorkPriority == EVoxelTileWorkPriority::Edited) + EditedProcessQueue.Enqueue(MoveTemp(Result)); + else if (WorkPriority == EVoxelTileWorkPriority::LOD0) + NearProcessQueue.Enqueue(MoveTemp(Result)); + else + ProcessQueue.Enqueue(MoveTemp(Result)); + }; + + if (bShuttingDown.load(std::memory_order_acquire) + || State->bShuttingDown.load(std::memory_order_acquire)) + { + return; + } + + FChunkResult Result; + Result.Tile = Tile; + Result.Epoch = TaskEpoch; + Result.DesiredEpoch = TaskDesiredEpoch; + Result.ModificationEpoch = TaskModificationEpoch; + Result.RequestStartCycles = RequestStartCycles; + Result.WorkPriority = WorkPriority; + Result.GenerationStartCycles = FPlatformTime::Cycles64(); + + auto EnqueueObsolete = [&]() + { + FChunkResult Canceled; + Canceled.Tile = Tile; + Canceled.Epoch = TaskEpoch; + Canceled.DesiredEpoch = TaskDesiredEpoch; + Canceled.ModificationEpoch = TaskModificationEpoch; + Canceled.RequestStartCycles = RequestStartCycles; + Canceled.GenerationStartCycles = Result.GenerationStartCycles; + Canceled.GenerationEndCycles = FPlatformTime::Cycles64(); + Canceled.WorkPriority = WorkPriority; + Canceled.bAborted = true; + Canceled.bObsolete = true; + if (!bShuttingDown.load(std::memory_order_acquire) + && !State->bShuttingDown.load(std::memory_order_acquire)) + { + Enqueue(MoveTemp(Canceled)); + } + }; + + if (Cancellation->bObsolete.load(std::memory_order_acquire)) + { + EnqueueObsolete(); + return; + } + + const VoxelForgeCore::TileKey CoreKey{{Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z}, Tile.Level}; + VoxelForgeCore::TileGenerationOptions Options; + Options.hashDensity = bCaptureHashes; + std::string CoreError; + std::optional CoreMesh; + { + FReadScopeLock Lock(State->WorldLock); + if (State->World) + { + CoreMesh = State->World->generateTile(CoreKey, Options, CoreError); + } + } + Result.GenerationEndCycles = FPlatformTime::Cycles64(); + if (!CoreMesh) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCoreRuntime] tile generation failed key=(%d,%d,%d) lod=%d: %s"), + Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, + UTF8_TO_TCHAR(CoreError.c_str())); + Result.bAborted = true; + Enqueue(MoveTemp(Result)); + return; + } + + if (Cancellation->bObsolete.load(std::memory_order_acquire) + || bShuttingDown.load(std::memory_order_acquire) + || State->bShuttingDown.load(std::memory_order_acquire)) + { + EnqueueObsolete(); + return; + } + + Result.bEmpty = CoreMesh->indices.empty(); + Result.NumVertices = static_cast(CoreMesh->vertices.size()); + Result.NumTriangles = static_cast(CoreMesh->indices.size() / 3); + Result.NumCeilingTriangles = static_cast(CoreMesh->groups.ceilingTriangleCount); + Result.NumSkirtTriangles = static_cast(CoreMesh->groups.skirtTriangleCount); + Result.bHasGroundTris = CoreMesh->groups.groundTriangleCount > 0; + Result.bHasCeilingTris = CoreMesh->groups.ceilingTriangleCount > 0; + Result.bHasCoreHashes = CoreMesh->hasDensityHash && CoreMesh->hasMeshHash + && CoreMesh->hasPayloadHash && CoreMesh->hasTopologyHash; + if (Result.bHasCoreHashes) + { + Result.CoreDensityHash = CoreMesh->densityHash; + Result.CoreMeshHash = CoreMesh->meshHash; + Result.CorePayloadHash = CoreMesh->payloadHash; + Result.CoreTopologyHash = CoreMesh->topologyHash; + } + + if (!Result.bEmpty) + { + FVoxelMeshData MeshData; + MeshData.Vertices.Reserve(Result.NumVertices); + MeshData.Normals.Reserve(Result.NumVertices); + MeshData.UVs.Reserve(Result.NumVertices); + MeshData.Colors.Reserve(Result.NumVertices); + for (const VoxelForgeCore::MeshVertex& Vertex : CoreMesh->vertices) + { + MeshData.Vertices.Emplace(Vertex.position.x, Vertex.position.y, Vertex.position.z); + MeshData.Normals.Emplace(Vertex.normal.x, Vertex.normal.y, Vertex.normal.z); + MeshData.UVs.Emplace(Vertex.uv.x, Vertex.uv.y); + MeshData.Colors.Emplace(Vertex.color.r, Vertex.color.g, Vertex.color.b, Vertex.color.a); + } + MeshData.Triangles.Reserve(static_cast(CoreMesh->indices.size())); + for (const std::uint32_t Index : CoreMesh->indices) + { + MeshData.Triangles.Add(static_cast(Index)); + } + MeshData.NumSurfaceVertices = static_cast(CoreMesh->groups.surfaceVertexCount); + MeshData.NumCeilingTriangles = Result.NumCeilingTriangles; + MeshData.NumSkirtTriangles = Result.NumSkirtTriangles; + Result.Streams = MakeShared(); + BuildTileStreamSet(*Result.Streams, MeshData); + } + Result.GenerationEndCycles = FPlatformTime::Cycles64(); + if (Cancellation->bObsolete.load(std::memory_order_acquire) + || bShuttingDown.load(std::memory_order_acquire) + || State->bShuttingDown.load(std::memory_order_acquire)) + { + EnqueueObsolete(); + return; + } + Enqueue(MoveTemp(Result)); + }, TaskPriority); +} + // Worker-side gen for one tile (shared by the async ChunkGen task and the synchronous carve path). // READS Generator/Mesher only — safe on a worker or the game thread. Fills Result; no enqueue. void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels, @@ -11253,8 +12171,14 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) // the cap is by definition the topmost surface in the tile (mid as a gap fallback). // This was the "far cap renders with the ground material" residue: the min-corner lookup // missed the surface strate entirely on tall far tiles. - const FIntVector MinChunk = Tile.Coord * (1 << Tile.Level); // level-0-equivalent min corner - const int32 TileChunks = 1 << Tile.Level; + // Core's 25 cm voxel is two legacy 12.5 cm voxels; map only the lookup into the existing + // strate material layout, while keeping tile keys and mesh coordinates in Core units. + // Le voxel Core de 25 cm vaut deux anciens voxels de 12,5 cm ; seule la recherche de + // matériau est convertie vers le layout existant, les clés et maillages restent en Core. + const int32 CoreToStrateChunkScale = bUseVoxelForgeCore + ? FMath::RoundToInt(GetActiveVoxelSizeCm() / VOXEL_SIZE) : 1; + const FIntVector MinChunk = Tile.Coord * ((1 << Tile.Level) * CoreToStrateChunkScale); + const int32 TileChunks = (1 << Tile.Level) * CoreToStrateChunkScale; const int32 ZLoC = FMath::Max(MinChunk.Z, Result.BandChunkLo); const int32 ZHiC = FMath::Min(MinChunk.Z + TileChunks - 1, Result.BandChunkHi); if (UVoxelStrateDefinition* StrateDef = @@ -11529,12 +12453,22 @@ int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const // GetStrateIndex veut des cm ACTEUR-LOCAUX (il convertit cm -> voxels lui-meme). // GetStrateIndex wants actor-LOCAL cm (it does the cm -> voxel conversion itself). - return StrateManager->GetStrateIndex(WorldToLocalCm(WorldPosition).Z); + const float LocalZCm = WorldToLocalCm(WorldPosition).Z; + const float StrateManagerZCm = bUseVoxelForgeCore + ? (LocalZCm / GetActiveVoxelSizeCm()) * VOXEL_SIZE + : LocalZCm; + return StrateManager->GetStrateIndex(StrateManagerZCm); } FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) const { FVoxelBiomeQuery Out; + if (bUseVoxelForgeCore) + { + // Core ne publie pas encore le query biome historique. + // Core does not expose the legacy biome query API yet. + return Out; + } if (!Generator) return Out; // Bring the world point into actor-LOCAL voxel space — the SAME transform the decoration scatter @@ -11561,6 +12495,75 @@ bool AVoxelWorld::GetVoxelSurfaceHeightAt(FVector WorldLocation, float& OutSurfa { OutSurfaceWorldZ = WorldLocation.Z; // sensible fallback: unchanged Z OutCeilingWorldZ = WorldLocation.Z; + if (bUseVoxelForgeCore) + { + if (!IsVoxelForgeCoreReady()) return false; + const TSharedPtr State = VoxelForgeCoreState; + if (!State.IsValid()) return false; + + const FVector LocalCm = WorldToLocalCm(WorldLocation); + const double XVoxel = LocalCm.X / 25.0; + const double YVoxel = LocalCm.Y / 25.0; + const double ZVoxel = LocalCm.Z / 25.0; + FReadScopeLock Lock(State->WorldLock); + if (!State->World) return false; + + float PreviousDensity = State->World->densityAt({XVoxel, YVoxel, ZVoxel}); + if (PreviousDensity <= 0.0f) return false; // Requête de hauteur définie depuis un point d'air. + // This height query is defined from an air point. + double GroundVoxelZ = ZVoxel; + double CeilingVoxelZ = ZVoxel; + bool bFoundGround = false; + bool bFoundCeiling = false; + float GroundPreviousDensity = PreviousDensity; + float CeilingPreviousDensity = PreviousDensity; + double GroundPreviousZ = ZVoxel; + double CeilingPreviousZ = ZVoxel; + constexpr int32 MaxSearchVoxels = 512; + for (int32 Step = 1; Step <= MaxSearchVoxels && (!bFoundGround || !bFoundCeiling); ++Step) + { + if (!bFoundGround) + { + const double SampleZ = ZVoxel - Step; + const float Density = State->World->densityAt({XVoxel, YVoxel, SampleZ}); + if (Density <= 0.0f && GroundPreviousDensity > 0.0f) + { + const double Alpha = GroundPreviousDensity + / static_cast(GroundPreviousDensity - Density); + GroundVoxelZ = GroundPreviousZ + (SampleZ - GroundPreviousZ) * Alpha; + bFoundGround = true; + } + GroundPreviousDensity = Density; + GroundPreviousZ = SampleZ; + } + if (!bFoundCeiling) + { + const double SampleZ = ZVoxel + Step; + const float Density = State->World->densityAt({XVoxel, YVoxel, SampleZ}); + if (Density <= 0.0f && CeilingPreviousDensity > 0.0f) + { + const double Alpha = CeilingPreviousDensity + / static_cast(CeilingPreviousDensity - Density); + CeilingVoxelZ = CeilingPreviousZ + (SampleZ - CeilingPreviousZ) * Alpha; + bFoundCeiling = true; + } + CeilingPreviousDensity = Density; + CeilingPreviousZ = SampleZ; + } + } + if (!bFoundGround && !bFoundCeiling) return false; + if (bFoundGround) + { + OutSurfaceWorldZ = GetActorTransform().TransformPosition( + FVector(LocalCm.X, LocalCm.Y, GroundVoxelZ * 25.0)).Z; + } + if (bFoundCeiling) + { + OutCeilingWorldZ = GetActorTransform().TransformPosition( + FVector(LocalCm.X, LocalCm.Y, CeilingVoxelZ * 25.0)).Z; + } + return true; + } if (!Generator) return false; // Undo the actor transform → voxel space (same convention as GetBiomeAtWorldLocation / the deco scatter). @@ -11589,7 +12592,7 @@ void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength { FVoxelModification Mod; Mod.Center = WorldToLocalVoxel(Position); // world cm → voxel space - Mod.Radius = Radius; + Mod.Radius = Radius * GetLegacyVoxelToActiveVoxelScale(); Mod.Strength = -FMath::Abs(Strength); // force negative for carving ApplyModification(Mod); } @@ -11598,13 +12601,18 @@ void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength) { FVoxelModification Mod; Mod.Center = WorldToLocalVoxel(Position); - Mod.Radius = Radius; + Mod.Radius = Radius * GetLegacyVoxelToActiveVoxelScale(); Mod.Strength = FMath::Abs(Strength); // force positive for filling ApplyModification(Mod); } void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) { + if (bUseVoxelForgeCore) + { + ApplyCoreModification(Modification); + return; + } if (!DiffLayer) return; const bool bProfileModification = GVoxelForgeProfileTileGeneration != 0; const uint64 ModificationStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; @@ -11766,13 +12774,185 @@ void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) } } +void AVoxelWorld::ApplyCoreModification(const FVoxelModification& Modification) +{ + const TSharedPtr State = VoxelForgeCoreState; + if (!State.IsValid() || !State->bReady.load(std::memory_order_acquire) + || State->bShuttingDown.load(std::memory_order_acquire)) + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelForgeCoreEdit] rejected: Core world is not ready.")); + return; + } + + VoxelForgeCore::EditTransaction Transaction; + Transaction.sequence = State->NextEditSequence.fetch_add(1, std::memory_order_relaxed); + VoxelForgeCore::EditBrush Brush; + Brush.operation = VoxelForgeCore::EditBrush::Operation::densityOffset; + Brush.a = {Modification.Center.X, Modification.Center.Y, Modification.Center.Z}; + Brush.b = {Modification.CapsuleEnd.X, Modification.CapsuleEnd.Y, Modification.CapsuleEnd.Z}; + Brush.halfExtent = {Modification.BoxExtent.X, Modification.BoxExtent.Y, Modification.BoxExtent.Z}; + Brush.radius = FMath::Max(0.0f, Modification.Radius); + Brush.falloff = FMath::Max(0.0f, Modification.Falloff); + Brush.strength = Modification.Strength; + switch (Modification.Shape) + { + case EVoxelBrushShape::Box: + Brush.shape = VoxelForgeCore::EditBrush::Shape::roundedBox; + Brush.rounding = 0.0; + break; + case EVoxelBrushShape::Capsule: + Brush.shape = VoxelForgeCore::EditBrush::Shape::capsule; + break; + case EVoxelBrushShape::Sphere: + default: + Brush.shape = VoxelForgeCore::EditBrush::Shape::sphere; + break; + } + Transaction.brushes.push_back(Brush); + + // La transaction est la frontière édition/réplication de Core : une future autorité pourra + // soumettre la même séquence et les mêmes brosses sans passer par l'entrée locale. + // The transaction is Core's edit/replication boundary: a future authority can submit the same + // sequence and brushes without going through local brush input. + // Attendre les lecteurs et compilateurs de régions avant de muter le monde Core partagé. + // Drain Core readers and region compilers before mutating the shared Core world. + while (ActiveTaskCount.load(std::memory_order_acquire) > 0) + { + if (bShuttingDown.load(std::memory_order_acquire) + || State->bShuttingDown.load(std::memory_order_acquire)) + { + return; + } + FPlatformProcess::Sleep(0.001f); + } + if (bCoreRegionCompilePending) + { + State->bRegionCompileFinished.exchange(false, std::memory_order_acq_rel); + bCoreRegionCompilePending = false; + PendingCoreRegionTiles.Reset(); + PendingCoreRetainedTiles.Reset(); + } + + std::vector Desired; + Desired.reserve(static_cast(DesiredSorted.Num())); + for (const FVoxelTileKey& Key : DesiredSorted) + { + Desired.push_back({{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, Key.Level}); + } + std::optional EditResult; + std::string Error; + { + FWriteScopeLock Lock(State->WorldLock); + if (State->World) + { + EditResult = State->World->applyEditTransaction(Transaction, Desired, Error); + } + } + if (!EditResult) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreEdit] transaction=%llu failed: %s"), + static_cast(Transaction.sequence), UTF8_TO_TCHAR(Error.c_str())); + return; + } + + ++GenerationEpoch; + ++ModificationEpoch; + LastVoxelForgeCoreEditDigest = EditResult->digest; + LastVoxelForgeCoreEditSequence = Transaction.sequence; + LastVoxelForgeCoreAffectedTiles.Reset(); + DirtyRemeshQueue.Reset(); + for (const std::vector& ByLod : EditResult->affectedByLod) + { + for (const VoxelForgeCore::TileKey& Key : ByLod) + { + const FVoxelTileKey RuntimeKey( + FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod); + LastVoxelForgeCoreAffectedTiles.Add(RuntimeKey); + if (IsDesired(RuntimeKey)) DirtyRemeshQueue.Add(RuntimeKey); + } + } + LastVoxelForgeCoreAffectedTiles.Sort([](const FVoxelTileKey& A, const FVoxelTileKey& B) + { + if (A.Level != B.Level) return A.Level < B.Level; + if (A.Coord.X != B.Coord.X) return A.Coord.X < B.Coord.X; + if (A.Coord.Y != B.Coord.Y) return A.Coord.Y < B.Coord.Y; + return A.Coord.Z < B.Coord.Z; + }); + bAllChunksLoaded = DirtyRemeshQueue.IsEmpty(); + + // Conserver le transfert de collision sous les pieds : retirer l'ancien corps pendant la + // génération et la cuisson de la nouvelle géométrie Core si la brosse touche la capsule. + // Preserve the underfoot collision handoff while the new Core mesh/collision cook is pending + // when the brush intersects the pawn capsule. + if (Modification.Strength < 0.0f) + { + FVector PlayerPosition = FVector::ZeroVector; + APawn* Pawn = nullptr; + FVoxelTileKey SupportTile; + if (TryGetPlayerPosition(PlayerPosition, &Pawn) + && GetPlayerSupportTile(Pawn, PlayerPosition, SupportTile) + && LastVoxelForgeCoreAffectedTiles.Contains(SupportTile) + && IsTileCollisionReady(SupportTile)) + { + FVector MinVoxel, MaxVoxel; + Modification.GetWorldBounds(MinVoxel, MaxVoxel); + FBox EditWorldBounds(ForceInit); + for (int32 Corner = 0; Corner < 8; ++Corner) + { + EditWorldBounds += LocalVoxelToWorld(FVector( + (Corner & 1) ? MaxVoxel.X : MinVoxel.X, + (Corner & 2) ? MaxVoxel.Y : MinVoxel.Y, + (Corner & 4) ? MaxVoxel.Z : MinVoxel.Z)); + } + FBox PawnBounds(ForceInit); + if (const ACharacter* Character = Cast(Pawn)) + { + if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent()) + { + PawnBounds = Capsule->Bounds.GetBox(); + } + } + if (!PawnBounds.IsValid) + { + FVector Origin, Extent; + Pawn->GetActorBounds(true, Origin, Extent); + PawnBounds = FBox(Origin - Extent, Origin + Extent); + } + if (EditWorldBounds.Intersect(PawnBounds)) + { + if (URealtimeMeshComponent* Component = TileComponents.FindRef(SupportTile)) + { + CollisionSuppressedUntilCook.Add(SupportTile); + CollisionSuppressedAtCycles.Add(SupportTile, FPlatformTime::Cycles64()); + CollisionReadyTiles.Remove(SupportTile); + PendingCollisionCooks.Remove(SupportTile); + Component->SetCollisionEnabled(ECollisionEnabled::NoCollision); + UpdatePawnCollisionGate(Pawn, PlayerPosition, 0.0f); + } + } + } + } + + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreEdit] sequence=%llu digest=%llu affected_tiles=%d epoch=%u"), + static_cast(LastVoxelForgeCoreEditSequence), + static_cast(LastVoxelForgeCoreEditDigest), + LastVoxelForgeCoreAffectedTiles.Num(), GenerationEpoch); + if (ContentManager && LastVoxelForgeCoreAffectedTiles.Num() > 0) + { + ContentManager->InvalidateDecorationBuilds(); + } +} + void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strength) { FVoxelModification Mod; Mod.Shape = EVoxelBrushShape::Box; Mod.Center = WorldToLocalVoxel(Position); - Mod.BoxExtent = ExtentVoxels; - Mod.Radius = ExtentVoxels.GetMax(); // budget proxy + const float VoxelScale = GetLegacyVoxelToActiveVoxelScale(); + Mod.BoxExtent = ExtentVoxels * VoxelScale; + Mod.Falloff *= VoxelScale; + Mod.Radius = ExtentVoxels.GetMax() * VoxelScale; // budget proxy Mod.Strength = -FMath::Abs(Strength); // carve ApplyModification(Mod); } @@ -11782,8 +12962,10 @@ void AVoxelWorld::FillBox(FVector Position, FVector ExtentVoxels, float Strength FVoxelModification Mod; Mod.Shape = EVoxelBrushShape::Box; Mod.Center = WorldToLocalVoxel(Position); - Mod.BoxExtent = ExtentVoxels; - Mod.Radius = ExtentVoxels.GetMax(); + const float VoxelScale = GetLegacyVoxelToActiveVoxelScale(); + Mod.BoxExtent = ExtentVoxels * VoxelScale; + Mod.Falloff *= VoxelScale; + Mod.Radius = ExtentVoxels.GetMax() * VoxelScale; Mod.Strength = FMath::Abs(Strength); // fill ApplyModification(Mod); } @@ -11794,7 +12976,9 @@ void AVoxelWorld::CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxel Mod.Shape = EVoxelBrushShape::Capsule; Mod.Center = WorldToLocalVoxel(WorldA); Mod.CapsuleEnd = WorldToLocalVoxel(WorldB); - Mod.Radius = RadiusVoxels; + const float VoxelScale = GetLegacyVoxelToActiveVoxelScale(); + Mod.Radius = RadiusVoxels * VoxelScale; + Mod.Falloff *= VoxelScale; Mod.Strength = -FMath::Abs(Strength); ApplyModification(Mod); } @@ -11805,7 +12989,9 @@ void AVoxelWorld::FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels Mod.Shape = EVoxelBrushShape::Capsule; Mod.Center = WorldToLocalVoxel(WorldA); Mod.CapsuleEnd = WorldToLocalVoxel(WorldB); - Mod.Radius = RadiusVoxels; + const float VoxelScale = GetLegacyVoxelToActiveVoxelScale(); + Mod.Radius = RadiusVoxels * VoxelScale; + Mod.Falloff *= VoxelScale; Mod.Strength = FMath::Abs(Strength); ApplyModification(Mod); } diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index af29cf3..bf052f3 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -34,6 +34,18 @@ class UMaterialInstanceDynamic; class FScopedGenerationPause; class ACameraActor; namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers +class FVoxelForgeCoreRuntimeState; + +struct FVoxelForgeCoreTileHashRecord +{ + uint64 Density = 0; + uint64 Mesh = 0; + uint64 Payload = 0; + uint64 Topology = 0; + int32 Triangles = 0; + bool bEmpty = true; + bool bHasHashes = false; +}; /** Per-request cancellation state. The game thread flips the flag when a pending tile leaves the * desired set; workers only read it and return an aborted result through the normal MPSC queue. */ @@ -141,6 +153,11 @@ struct FChunkResult // section for a non-empty polygroup — configuring a missing one is invalid). bool bHasGroundTris = false; bool bHasCeilingTris = false; + uint64 CoreDensityHash = 0; + uint64 CoreMeshHash = 0; + uint64 CorePayloadHash = 0; + uint64 CoreTopologyHash = 0; + bool bHasCoreHashes = false; // STRATE CONTENT CUT — the chunk-Z band this tile was MESHED with (MIN/MAX = uncut). The // apply path clamps its strate/material lookups into it (a coarse tile's raw min corner can // sit in a strate whose content was cut out of the mesh entirely). @@ -248,6 +265,25 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World") bool bManageAtmosphere = true; + /** Route terrain generation, streaming and edits through Standalone Core (opt-in). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Core", meta = (DisplayName = "Use VoxelForge Core")) + bool bUseVoxelForgeCore = false; + + /** Core-only clipmap radius, in level-0 Core tiles. Does not read Settings->ClipRadius. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Core", meta = (ClampMin = "1", ClampMax = "64")) + int32 CoreClipRadius = 3; + + /** Core-only maximum clip level. Does not read Settings->MaxClipLevel. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Core", meta = (ClampMin = "0", ClampMax = "8")) + int32 CoreMaxClipLevel = 8; + + /** Commandlet-only identity capture; normal Core streaming leaves Core's hash path disabled. */ + bool bCaptureVoxelForgeCoreHashesForValidation = false; + TMap VoxelForgeCoreTileHashes; + uint64 LastVoxelForgeCoreEditDigest = 0; + uint64 LastVoxelForgeCoreEditSequence = 0; + TArray LastVoxelForgeCoreAffectedTiles; + //========================================================================= // CHUNK STORAGE //========================================================================= @@ -388,7 +424,8 @@ public: * Applies a spherical brush that subtracts density. * * @param Position - World-space center of the carve brush - * @param Radius - Brush radius in voxels (default 6 voxels / 0.75 m at 12.5 cm per voxel) + * @param Radius - Brush radius in legacy Runtime voxels (12.5 cm); Core converts it to + * 25 cm voxels to preserve the physical brush size. * @param Strength - How aggressively to carve (default 10, higher = deeper) */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") @@ -399,7 +436,8 @@ public: * Applies a spherical brush that adds density. * * @param Position - World-space center of the fill brush - * @param Radius - Brush radius in voxels (default 6 voxels / 0.75 m at 12.5 cm per voxel) + * @param Radius - Brush radius in legacy Runtime voxels (12.5 cm); Core converts it to + * 25 cm voxels to preserve the physical brush size. * @param Strength - How aggressively to fill (default 10, higher = more solid) */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") @@ -407,7 +445,8 @@ public: /** * Box brush carve/fill. Position is world-space (Unreal units); ExtentVoxels is the - * box half-size in voxels. Strength magnitude controls aggressiveness (sign forced). + * box half-size in legacy Runtime voxels (12.5 cm). Core converts it to 25 cm voxels. + * Strength magnitude controls aggressiveness (sign forced). */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") void CarveBox(FVector Position, FVector ExtentVoxels, float Strength = 12.0f); @@ -416,8 +455,9 @@ public: void FillBox(FVector Position, FVector ExtentVoxels, float Strength = 12.0f); /** - * Capsule brush carve/fill between two world-space points (Unreal units), with a - * tube radius in voxels. Great for boring tunnels or laying solid pillars/walls. + * Capsule brush carve/fill between two world-space points (Unreal units), with a tube radius + * in legacy Runtime voxels (12.5 cm). Core converts it to 25 cm voxels. + * Great for boring tunnels or laying solid pillars/walls. */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") void CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels = 6.0f, float Strength = 12.0f); @@ -426,8 +466,9 @@ public: void FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels = 6.0f, float Strength = 12.0f); /** - * Apply a fully-specified modification (any shape). Centers/endpoints are expected - * in VOXEL coordinates here (this is the low-level entry the helpers build on). + * Apply a fully-specified modification (any shape). Centers/endpoints and brush dimensions + * are expected in active voxel coordinates here (12.5 cm legacy, 25 cm Core); high-level + * helpers convert legacy brush sizes to preserve physical dimensions. * Returns nothing; re-meshes affected chunks. */ UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") @@ -523,6 +564,11 @@ public: UFUNCTION(BlueprintPure, Category = "Voxel World|Space") FVector LocalVoxelToWorld(FVector VoxelPos) const; + /** Core-route state accessors used by the isolated BuildHost commandlet. */ + bool IsVoxelForgeCoreReady() const; + FVector GetVoxelForgeCoreDefaultCenterWorld() const; + bool QueryVoxelForgeCoreDensityAt(FVector WorldLocation, float& OutDensity) const; + UFUNCTION(BlueprintPure, Category = "Voxel World|Strate") int32 GetStrateAtPosition(FVector WorldPosition) const; @@ -601,6 +647,20 @@ private: /** Put all terrain MIDs into the material's neutral, volume-disabled state. */ void SetDensityVolumeMaterialParamsDisabled(); + /** Runtime Core snapshot, world lifetime, and worker-side streaming bridge. */ + bool InitializeVoxelForgeCoreWorld(); + void BuildCoreDesiredTiles(const FIntVector& Center, const FVector& PlayerPosition, + APawn* PlayerPawn, const FVector& PlayerHeading, + TArray& OutLeavers); + void StartPendingCoreRegionCompile(); + void LoadCoreTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority WorkPriority); + void ApplyCoreModification(const FVoxelModification& Modification); + void BuildDesiredTransitionIndex(); + float GetActiveVoxelSizeCm() const; + float GetLegacyVoxelToActiveVoxelScale() const; + FIntVector GetTileCoordAtLocalCm(const FVector& LocalPositionCm) const; + FIntVector GetTileCoordAtVoxelPosition(const FVector& VoxelPosition) const; + /** Explicitly opt-in headless streaming validation. This is a command-line-only harness path; * normal gameplay never moves the pawn here. It supplies a repeatable moving session and lets * -nullrhi request a graceful EndPlay so the clean latency summary is flushed. */ @@ -652,6 +712,12 @@ private: // UpdateOrbLightMPC, which deliberately rewrites every frame). TWeakObjectPtr LastBoundVolTex0; // re-push MIDs if the L0 texture was recreated + TSharedPtr VoxelForgeCoreState; + FVector CoreFallbackCenterLocalCm = FVector::ZeroVector; + bool bCoreRegionCompilePending = false; + TArray PendingCoreRegionTiles; + TArray PendingCoreRetainedTiles; + public: //========================================================================= @@ -717,7 +783,7 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editor Brush") FVector EditorBrushCenter = FVector::ZeroVector; - /** Brush radius in voxels for the editor buttons. */ + /** Brush radius in legacy Runtime voxels; Core helpers preserve the same physical radius. */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editor Brush", meta = (ClampMin = "0.5")) float EditorBrushRadius = 1.5f * 100.0f / VOXEL_SIZE; diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index e1fee0b..c31ba70 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -6,7 +6,15 @@ #include "Async/ParallelFor.h" #include "Commandlets/Commandlet.h" #include "Containers/StringConv.h" +#include "Components/SceneComponent.h" +#include "Engine/Engine.h" +#include "Engine/GameInstance.h" +#include "Engine/World.h" +#include "GameFramework/Actor.h" +#include "GameFramework/GameModeBase.h" +#include "GameFramework/WorldSettings.h" #include "HAL/FileManager.h" +#include "HAL/PlatformProcess.h" #include "HAL/PlatformTime.h" #include "HAL/PlatformTLS.h" #include "IImageWrapper.h" @@ -48,6 +56,8 @@ #include "VoxelStrateComposer.h" #include "VoxelTerrainOpDefinition.h" #include "VoxelTypes.h" +#include "VoxelWorld.h" +#include "VoxelForgeCoreAssetAdapter.h" #include "VoxelForgeCore/VoxelForgeCore.h" #include @@ -395,6 +405,7 @@ struct FExploreArguments bool bPassageFloorWritesOverride = false; int32 PassageFloorWrites = 1; bool bStandaloneWorldExport = false; + bool bCoreWorldTest = false; bool bRenderCameraOverride = false; FVector RenderCamera = FVector::ZeroVector; bool bRenderTargetOverride = false; @@ -437,6 +448,9 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr int32 StandaloneWorldExport = 0; FParse::Value(*Params, TEXT("standaloneworldexport="), StandaloneWorldExport); OutArguments.bStandaloneWorldExport = StandaloneWorldExport != 0; + int32 CoreWorldTest = 0; + FParse::Value(*Params, TEXT("coreworldtest="), CoreWorldTest); + OutArguments.bCoreWorldTest = CoreWorldTest != 0; FParse::Value(*Params, TEXT("archetype="), ArchetypeText); FParse::Value(*Params, TEXT("slot="), OutArguments.Slot); FParse::Value(*Params, TEXT("strateref="), OutArguments.StrateReference); @@ -965,6 +979,12 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutError = TEXT("standaloneworldexport requires modes=export and settingsref, and must omit strateref."); return false; } + if (OutArguments.bCoreWorldTest + && (OutArguments.SettingsReference.IsEmpty() || !OutArguments.StrateReference.IsEmpty())) + { + OutError = TEXT("coreworldtest requires settingsref and must omit strateref."); + return false; + } if (OutText.IsEmpty()) { @@ -13035,6 +13055,561 @@ bool RunStandaloneWorldExport(const FExploreArguments& Arguments, FString& OutEr return true; } +bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FString& OutError) +{ + UVoxelSettings* AuthoredSettings = LoadObject( + nullptr, *Arguments.SettingsReference); + if (!AuthoredSettings) + { + OutError = FString::Printf(TEXT("Could not load Core actor test settings '%s'."), + *Arguments.SettingsReference); + return false; + } + + TStrongObjectPtr Manager(NewObject( + GetTransientPackage(), NAME_None, RF_Transient)); + int32 ResolvedSeed = AuthoredSettings->Seed; + if (const UVoxelSeasonAsset* Season = AuthoredSettings->Season.LoadSynchronous()) + { + ResolvedSeed = Season->GetSeasonSeed(); + } + if (!Manager.IsValid() || !Manager->Initialize(AuthoredSettings, ResolvedSeed)) + { + OutError = TEXT("Core actor test could not resolve the owner's strate manager."); + return false; + } + FString Json; + if (!VoxelForgeCoreAdapter::SerializeResolvedWorld( + AuthoredSettings, Manager.Get(), 3, 8, Json, OutError)) + { + return false; + } + + // Build one Standalone reference before creating the actor world so the two full Core worlds + // never overlap in memory. The reference also precomputes the exact edit result and hashes. + // Construire la référence Standalone avant le monde acteur pour ne jamais garder deux mondes + // Core complets en mémoire simultanément ; elle calcule aussi les hachages après édition. + TMap ExpectedHashes; + TMap ExpectedPostEditHashes; + TSet ExpectedInitialDesiredSet; + TArray ExpectedAffectedTiles; + FVector CarveLocalPositionCm = FVector::ZeroVector; + FVoxelTileKey CarveTargetTile; + constexpr float CarveBrushRadiusLegacyVoxels = 5.0f; + constexpr double CoreVoxelSizeCm = 25.0; + const double CarveBrushRadiusCoreVoxels = + CarveBrushRadiusLegacyVoxels * static_cast(VOXEL_SIZE) / CoreVoxelSizeCm; + uint64 ExpectedEditDigest = 0; + std::array InitialAggregates{}; + std::size_t ExpectedInitialTileCount = 0; + { + FTCHARToUTF8 Utf8Json(*Json); + std::string CoreError; + std::optional Snapshot = + VoxelForgeCore::ResolvedWorldSnapshot::FromJson( + std::string_view(Utf8Json.Get(), static_cast(Utf8Json.Length())), + CoreError); + if (!Snapshot) + { + OutError = FString::Printf(TEXT("Core rejected runtime adapter JSON: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + + VoxelForgeCore::World ReferenceWorld(std::move(*Snapshot)); + const std::vector Desired = ReferenceWorld.desiredTiles({}); + if (Desired.empty()) + { + OutError = TEXT("Standalone Core returned an empty reference clipmap at the origin."); + return false; + } + ExpectedInitialTileCount = Desired.size(); + std::int32_t FineMin[3] = {MAX_int32, MAX_int32, MAX_int32}; + std::int32_t FineMax[3] = {MIN_int32, MIN_int32, MIN_int32}; + std::int32_t ClipMin[3] = {MAX_int32, MAX_int32, MAX_int32}; + std::int32_t ClipMax[3] = {MIN_int32, MIN_int32, MIN_int32}; + for (const VoxelForgeCore::TileKey& Key : Desired) + { + const std::int32_t Extent = 32 << Key.lod; + const std::int32_t Origin[3] = { + Key.coordinate.x * Extent, Key.coordinate.y * Extent, Key.coordinate.z * Extent}; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + ClipMin[Axis] = FMath::Min(ClipMin[Axis], Origin[Axis]); + ClipMax[Axis] = FMath::Max(ClipMax[Axis], Origin[Axis] + Extent); + if (Key.lod < ReferenceWorld.configuration().fullResolutionLodCount) + { + FineMin[Axis] = FMath::Min(FineMin[Axis], Origin[Axis]); + FineMax[Axis] = FMath::Max(FineMax[Axis], Origin[Axis] + Extent); + } + } + } + if (FineMin[0] == MAX_int32 || ClipMin[0] == MAX_int32) + { + OutError = TEXT("Standalone Core reference clipmap bounds are invalid."); + return false; + } + + VoxelForgeCore::ShapeBuildRequest Request; + Request.minVoxel = {FineMin[0], FineMin[1], FineMin[2]}; + Request.maxVoxel = {FineMax[0], FineMax[1], FineMax[2]}; + Request.clipMinVoxel = {ClipMin[0], ClipMin[1], ClipMin[2]}; + Request.clipMaxVoxel = {ClipMax[0], ClipMax[1], ClipMax[2]}; + Request.workerLimit = 8; + Request.detail = VoxelForgeCore::DetailVariant::coherent; + Request.includeFarWorld = true; + FStandaloneCoreAdapterScheduler Scheduler; + if (!ReferenceWorld.compile(Request, Scheduler, CoreError)) + { + OutError = FString::Printf(TEXT("Standalone Core reference compile failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + ReferenceWorld.retainTileRegions(Desired); + + VoxelForgeCore::TileGenerationOptions Options; + Options.hashDensity = true; + std::vector AggregateRows; + AggregateRows.reserve(Desired.size()); + bool bFoundCarveVertex = false; + for (const VoxelForgeCore::TileKey& Key : Desired) + { + const std::optional Mesh = + ReferenceWorld.generateTile(Key, Options, CoreError); + if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash + || !Mesh->hasPayloadHash || !Mesh->hasTopologyHash) + { + OutError = FString::Printf(TEXT("Standalone Core hash generation failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + const FVoxelTileKey RuntimeKey( + FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod); + FVoxelForgeCoreTileHashRecord Record; + Record.Density = Mesh->densityHash; + Record.Mesh = Mesh->meshHash; + Record.Payload = Mesh->payloadHash; + Record.Topology = Mesh->topologyHash; + Record.Triangles = static_cast(Mesh->indices.size() / 3); + Record.bEmpty = Mesh->indices.empty(); + Record.bHasHashes = true; + ExpectedHashes.Add(RuntimeKey, Record); + ExpectedInitialDesiredSet.Add(RuntimeKey); + AggregateRows.push_back({Key, {Record.Density, Record.Mesh, Record.Payload, Record.Topology}}); + if (!bFoundCarveVertex && Key.lod == 0 && !Mesh->vertices.empty() + && !Mesh->indices.empty()) + { + const VoxelForgeCore::Vec3d Vertex = Mesh->vertices.front().position; + CarveLocalPositionCm = FVector(Vertex.x, Vertex.y, Vertex.z); + CarveTargetTile = RuntimeKey; + bFoundCarveVertex = true; + } + } + InitialAggregates = StandaloneCoreAggregateHashes(AggregateRows); + if (!bFoundCarveVertex) + { + OutError = TEXT("Standalone Core reference has no level-0 surface vertex for the carve fixture."); + return false; + } + + VoxelForgeCore::EditTransaction Transaction; + Transaction.sequence = 1; + VoxelForgeCore::EditBrush Brush; + Brush.shape = VoxelForgeCore::EditBrush::Shape::sphere; + Brush.operation = VoxelForgeCore::EditBrush::Operation::densityOffset; + Brush.a = {CarveLocalPositionCm.X / CoreVoxelSizeCm, + CarveLocalPositionCm.Y / CoreVoxelSizeCm, + CarveLocalPositionCm.Z / CoreVoxelSizeCm}; + Brush.halfExtent = {5.0, 5.0, 5.0}; + Brush.radius = CarveBrushRadiusCoreVoxels; + Brush.falloff = 2.0f; + Brush.strength = -40.0f; + Transaction.brushes.push_back(Brush); + const std::optional Edit = + ReferenceWorld.applyEditTransaction(Transaction, Desired, CoreError); + if (!Edit) + { + OutError = FString::Printf(TEXT("Standalone Core reference edit failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + ExpectedEditDigest = Edit->digest; + for (const std::vector& ByLod : Edit->affectedByLod) + { + for (const VoxelForgeCore::TileKey& Key : ByLod) + { + const FVoxelTileKey RuntimeKey( + FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod); + ExpectedAffectedTiles.Add(RuntimeKey); + const std::optional Mesh = + ReferenceWorld.generateTile(Key, Options, CoreError); + if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash + || !Mesh->hasPayloadHash || !Mesh->hasTopologyHash) + { + OutError = FString::Printf(TEXT("Standalone Core edited tile hash failed: %s"), + UTF8_TO_TCHAR(CoreError.c_str())); + return false; + } + FVoxelForgeCoreTileHashRecord Record; + Record.Density = Mesh->densityHash; + Record.Mesh = Mesh->meshHash; + Record.Payload = Mesh->payloadHash; + Record.Topology = Mesh->topologyHash; + Record.Triangles = static_cast(Mesh->indices.size() / 3); + Record.bEmpty = Mesh->indices.empty(); + Record.bHasHashes = true; + ExpectedPostEditHashes.Add(RuntimeKey, Record); + } + } + ExpectedAffectedTiles.Sort([](const FVoxelTileKey& A, const FVoxelTileKey& B) + { + if (A.Level != B.Level) return A.Level < B.Level; + if (A.Coord.X != B.Coord.X) return A.Coord.X < B.Coord.X; + if (A.Coord.Y != B.Coord.Y) return A.Coord.Y < B.Coord.Y; + return A.Coord.Z < B.Coord.Z; + }); + if (!ExpectedPostEditHashes.Contains(CarveTargetTile) + || ExpectedAffectedTiles.IsEmpty()) + { + OutError = TEXT("Standalone Core edit fixture did not affect its carved level-0 tile."); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] standalone_reference=1 tiles=%llu affected=%d target=(%d,%d,%d) lod=%d"), + static_cast(ExpectedInitialTileCount), + ExpectedAffectedTiles.Num(), CarveTargetTile.Coord.X, CarveTargetTile.Coord.Y, + CarveTargetTile.Coord.Z, CarveTargetTile.Level); + } + + const UWorld::InitializationValues WorldInitialization = UWorld::InitializationValues() + .AllowAudioPlayback(false) + .RequiresHitProxies(false) + .CreatePhysicsScene(true) + .CreateNavigation(false) + .CreateAISystem(false) + .ShouldSimulatePhysics(true) + .SetDefaultGameMode(AGameModeBase::StaticClass()) + .SetTransactional(false); + UWorld* TestWorld = UWorld::CreateWorld( + EWorldType::Game, false, FName(TEXT("VoxelForgeCoreActorValidationWorld")), + GetTransientPackage(), false, ERHIFeatureLevel::Num, &WorldInitialization); + if (TestWorld == nullptr) + { + OutError = TEXT("Could not create the transient BuildHost actor test world."); + return false; + } + UGameInstance* TestGameInstance = NewObject(GEngine); + FWorldContext& TestWorldContext = GEngine->CreateNewWorldContext(EWorldType::Game); + TestWorldContext.OwningGameInstance = TestGameInstance; + TestWorldContext.SetCurrentWorld(TestWorld); + TestWorld->SetGameInstance(TestGameInstance); + TestWorld->SetShouldTick(false); + TestGameInstance->Init(); + TestWorld->AddToRoot(); + struct FTransientWorldCleanup + { + UWorld* World = nullptr; + bool bHasEngineWorldContext = false; + uint64 PreviousFrameCounter = GFrameCounter; + ~FTransientWorldCleanup() + { + if (World != nullptr) + { + if (World->HasBegunPlay()) + { + World->BeginTearingDown(); + World->EndPlay(EEndPlayReason::Quit); + } + if (UGameInstance* GameInstance = World->GetGameInstance()) + { + GameInstance->Shutdown(); + } + if (bHasEngineWorldContext && GEngine != nullptr) + { + GEngine->DestroyWorldContext(World); + } + World->DestroyWorld(false); + if (World->IsRooted()) World->RemoveFromRoot(); + } + GFrameCounter = PreviousFrameCounter; + } + } Cleanup{TestWorld, true, GFrameCounter}; + const FTransform ActorTransform = FTransform::Identity; + AVoxelWorld* Actor = TestWorld->SpawnActorDeferred( + AVoxelWorld::StaticClass(), ActorTransform, nullptr, nullptr, + ESpawnActorCollisionHandlingMethod::AlwaysSpawn); + if (Actor == nullptr) + { + OutError = TEXT("Could not spawn the transient AVoxelWorld for Core validation."); + return false; + } + Actor->Settings = AuthoredSettings; + Actor->bManageAtmosphere = false; + Actor->bUseVoxelForgeCore = true; + Actor->CoreClipRadius = 3; + Actor->CoreMaxClipLevel = 8; + Actor->bCaptureVoxelForgeCoreHashesForValidation = true; + Actor->FinishSpawning(ActorTransform); + if (!TestWorld->SetGameMode(FURL())) + { + OutError = TEXT("Could not create the transient BuildHost GameMode for actor validation."); + return false; + } + TestWorld->InitializeActorsForPlay(FURL()); + TestWorld->BeginPlay(); + Actor->SetActorTickEnabled(true); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] world_initialized=1 world_begun=%d actor_initialized=%d actor_beginplay=%d"), + TestWorld->HasBegunPlay() ? 1 : 0, Actor->IsActorInitialized() ? 1 : 0, + Actor->HasActorBegunPlay() ? 1 : 0); + + auto DesiredTilesLoaded = [](const AVoxelWorld* World) + { + if (World == nullptr || World->DesiredSorted.IsEmpty()) return false; + for (const FVoxelTileKey& Key : World->DesiredSorted) + { + if (!World->LoadedTiles.Contains(Key)) return false; + } + return true; + }; + auto CollisionSetSettled = [](const AVoxelWorld* World) + { + if (World == nullptr) return false; + for (const FVoxelTileKey& Key : World->CollisionDesiredTiles) + { + if (!World->CollisionReadyTiles.Contains(Key) + && !World->CollisionNotRequiredTiles.Contains(Key)) + { + return false; + } + } + return true; + }; + auto WaitForStable = [&](double TimeoutSeconds) + { + const double Deadline = FPlatformTime::Seconds() + TimeoutSeconds; + const double ProgressIntervalSeconds = 10.0; + double LastProgressSeconds = FPlatformTime::Seconds(); + int32 TickCount = 0; + while (FPlatformTime::Seconds() < Deadline) + { + TestWorld->Tick(LEVELTICK_All, 0.016f); + // Match FTestWorldWrapper::TickTestWorld: each manually driven world tick must advance + // the global frame counter or registered actor ticks run only once in this harness. + ++GFrameCounter; + ++TickCount; + FPlatformProcess::Sleep(0.001f); + if (Actor->IsVoxelForgeCoreReady() && Actor->bAllChunksLoaded + && Actor->PendingTiles.IsEmpty() && Actor->PendingCollisionCooks.IsEmpty() + && Actor->PendingUnload.IsEmpty() + && Actor->ActiveTaskCount.load(std::memory_order_acquire) == 0 + && DesiredTilesLoaded(Actor) && CollisionSetSettled(Actor)) + { + return true; + } + const double NowSeconds = FPlatformTime::Seconds(); + if (NowSeconds - LastProgressSeconds >= ProgressIntervalSeconds) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] wait ticks=%d ready=%d desired=%d loaded=%d pending=%d cooks=%d active=%d"), + TickCount, Actor->IsVoxelForgeCoreReady() ? 1 : 0, + Actor->DesiredSorted.Num(), Actor->LoadedTiles.Num(), Actor->PendingTiles.Num(), + Actor->PendingCollisionCooks.Num(), + Actor->ActiveTaskCount.load(std::memory_order_acquire)); + LastProgressSeconds = NowSeconds; + } + } + return false; + }; + + if (!WaitForStable(600.0)) + { + OutError = FString::Printf( + TEXT("AVoxelWorld Core initial clipmap did not settle: ready=%d desired=%d loaded=%d pending=%d cooks=%d active=%d"), + Actor->IsVoxelForgeCoreReady() ? 1 : 0, Actor->DesiredSorted.Num(), + Actor->LoadedTiles.Num(), Actor->PendingTiles.Num(), + Actor->PendingCollisionCooks.Num(), + Actor->ActiveTaskCount.load(std::memory_order_acquire)); + return false; + } + + TSet ActualInitialDesiredSet; + for (const FVoxelTileKey& Key : Actor->DesiredSorted) ActualInitialDesiredSet.Add(Key); + if (ActualInitialDesiredSet.Num() != ExpectedInitialDesiredSet.Num()) + { + OutError = FString::Printf(TEXT("Core actor desired %d tiles; standalone reference desired %d."), + ActualInitialDesiredSet.Num(), ExpectedInitialDesiredSet.Num()); + return false; + } + for (const FVoxelTileKey& Key : ExpectedInitialDesiredSet) + { + if (!ActualInitialDesiredSet.Contains(Key)) + { + OutError = FString::Printf(TEXT("Core actor initial clipmap omitted tile (%d,%d,%d) lod=%d."), + Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level); + return false; + } + const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key); + const FVoxelForgeCoreTileHashRecord* Expected = ExpectedHashes.Find(Key); + if (Actual == nullptr || Expected == nullptr || !Actual->bHasHashes + || Actual->Density != Expected->Density || Actual->Mesh != Expected->Mesh + || Actual->Payload != Expected->Payload || Actual->Topology != Expected->Topology + || Actual->Triangles != Expected->Triangles || Actual->bEmpty != Expected->bEmpty) + { + OutError = FString::Printf( + TEXT("Core actor tile hash mismatch at (%d,%d,%d) lod=%d."), + Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level); + return false; + } + } + + const int32 CollisionRadius = FMath::Clamp(AuthoredSettings->PawnCollisionRadiusChunks, 1, 4); + TSet ExpectedCollisionSet; + int32 ExpectedBodyCount = 0; + int32 ExpectedNoBodyCount = 0; + int32 ActualNoBodyCount = 0; + for (int32 Z = -CollisionRadius; Z <= CollisionRadius; ++Z) + for (int32 Y = -CollisionRadius; Y <= CollisionRadius; ++Y) + for (int32 X = -CollisionRadius; X <= CollisionRadius; ++X) + { + const FVoxelTileKey Key(FIntVector(X, Y, Z), 0); + ExpectedCollisionSet.Add(Key); + const FVoxelForgeCoreTileHashRecord* Record = ExpectedHashes.Find(Key); + if (Record == nullptr) + { + OutError = FString::Printf(TEXT("Standalone reference omitted collision tile (%d,%d,%d)."), + X, Y, Z); + return false; + } + if (Record->bEmpty) ++ExpectedNoBodyCount; + else ++ExpectedBodyCount; + if (Actor->CollisionNotRequiredTiles.Contains(Key)) ++ActualNoBodyCount; + } + if (Actor->CollisionDesiredTiles.Num() != ExpectedCollisionSet.Num() + || Actor->CollisionReadyTiles.Num() != ExpectedBodyCount + || ActualNoBodyCount != ExpectedNoBodyCount) + { + OutError = FString::Printf( + TEXT("Core collision count mismatch: desired=%d/%d ready=%d/%d no_body=%d/%d."), + Actor->CollisionDesiredTiles.Num(), ExpectedCollisionSet.Num(), + Actor->CollisionReadyTiles.Num(), ExpectedBodyCount, + ActualNoBodyCount, ExpectedNoBodyCount); + return false; + } + for (const FVoxelTileKey& Key : ExpectedCollisionSet) + { + const FVoxelForgeCoreTileHashRecord& Record = ExpectedHashes.FindChecked(Key); + const bool bExpectedCollisionReady = !Record.bEmpty; + if (!Actor->CollisionDesiredTiles.Contains(Key) + || (bExpectedCollisionReady && !Actor->CollisionReadyTiles.Contains(Key)) + || (!bExpectedCollisionReady && !Actor->CollisionNotRequiredTiles.Contains(Key))) + { + OutError = FString::Printf(TEXT("Core collision result differs for tile (%d,%d,%d)."), + Key.Coord.X, Key.Coord.Y, Key.Coord.Z); + return false; + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] pass phase=initial tiles=%llu collision_desired=%d collision_ready=%d collision_no_body=%d density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"), + static_cast(ExpectedInitialTileCount), ExpectedCollisionSet.Num(), + Actor->CollisionReadyTiles.Num(), Actor->CollisionNotRequiredTiles.Num(), + static_cast(InitialAggregates[0]), + static_cast(InitialAggregates[1]), + static_cast(InitialAggregates[2]), + static_cast(InitialAggregates[3])); + + AActor* Anchor = TestWorld->SpawnActor(AActor::StaticClass()); + if (Anchor == nullptr) + { + OutError = TEXT("Could not spawn the Core streaming anchor fixture."); + return false; + } + USceneComponent* AnchorRoot = NewObject(Anchor, NAME_None, RF_Transient); + if (AnchorRoot == nullptr) + { + OutError = TEXT("Could not create the Core streaming anchor root component."); + return false; + } + Anchor->SetRootComponent(AnchorRoot); + AnchorRoot->RegisterComponent(); + constexpr double CoreTileExtentCm = 32.0 * 25.0; + const FVector OldAnchorPosition(CoreTileExtentCm * 20.0, 0.0, 0.0); + const FVector NewAnchorPosition(CoreTileExtentCm * 21.0, 0.0, 0.0); + Anchor->SetActorLocation(OldAnchorPosition); + Actor->RegisterStreamingAnchor( + Anchor, EVoxelAnchorPolicy::CollisionOnly, 0, 0, 0); + if (!WaitForStable(180.0)) + { + OutError = TEXT("Core CollisionOnly anchor did not finish its initial tile request."); + return false; + } + const FVoxelTileKey OldAnchorKey(FIntVector(20, 0, 0), 0); + const FVoxelTileKey NewAnchorKey(FIntVector(21, 0, 0), 0); + if (!Actor->DesiredSorted.Contains(OldAnchorKey) + || !Actor->LoadedTiles.Contains(OldAnchorKey) + || !Actor->CollisionDesiredTiles.Contains(OldAnchorKey)) + { + OutError = TEXT("Core did not request and load the original anchor tile."); + return false; + } + Anchor->SetActorLocation(NewAnchorPosition); + if (!WaitForStable(180.0)) + { + OutError = TEXT("Core did not settle after moving the streaming anchor by one tile."); + return false; + } + if (!Actor->DesiredSorted.Contains(NewAnchorKey) + || !Actor->LoadedTiles.Contains(NewAnchorKey) + || !Actor->CollisionDesiredTiles.Contains(NewAnchorKey) + || Actor->DesiredSorted.Contains(OldAnchorKey) + || Actor->LoadedTiles.Contains(OldAnchorKey) + || Actor->CollisionDesiredTiles.Contains(OldAnchorKey)) + { + OutError = TEXT("Core anchor move did not request the entering tile and release the leaving tile."); + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] pass phase=anchor old=(20,0,0) new=(21,0,0) requested=1 released=1")); + + Actor->CarveAtPosition(Actor->GetActorTransform().TransformPosition(CarveLocalPositionCm), + CarveBrushRadiusLegacyVoxels, 40.0f); + if (Actor->LastVoxelForgeCoreEditSequence != 1 + || Actor->LastVoxelForgeCoreEditDigest != ExpectedEditDigest + || Actor->LastVoxelForgeCoreAffectedTiles != ExpectedAffectedTiles) + { + OutError = TEXT("Core actor carve transaction sequence, digest, or affected tile list differs from Standalone."); + return false; + } + if (!WaitForStable(240.0)) + { + OutError = TEXT("Core actor carve remesh/collision work did not settle."); + return false; + } + for (const FVoxelTileKey& Key : ExpectedAffectedTiles) + { + const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key); + const FVoxelForgeCoreTileHashRecord* Expected = ExpectedPostEditHashes.Find(Key); + if (Actual == nullptr || Expected == nullptr || !Actual->bHasHashes + || Actual->Density != Expected->Density || Actual->Mesh != Expected->Mesh + || Actual->Payload != Expected->Payload || Actual->Topology != Expected->Topology + || Actual->Triangles != Expected->Triangles || Actual->bEmpty != Expected->bEmpty) + { + OutError = FString::Printf( + TEXT("Core actor edited tile hash differs from Standalone at (%d,%d,%d) lod=%d."), + Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level); + return false; + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCoreWorldTest] pass phase=edit sequence=%llu digest=0x%016llx affected=%d target=(%d,%d,%d) lod=%d hash=0x%016llx"), + static_cast(Actor->LastVoxelForgeCoreEditSequence), + static_cast(Actor->LastVoxelForgeCoreEditDigest), + ExpectedAffectedTiles.Num(), CarveTargetTile.Coord.X, CarveTargetTile.Coord.Y, + CarveTargetTile.Coord.Z, CarveTargetTile.Level, + static_cast(ExpectedPostEditHashes.FindChecked(CarveTargetTile).Mesh)); + return true; +} + } // namespace UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet() @@ -13078,6 +13653,17 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) return 1; } + if (Arguments.bCoreWorldTest) + { + if (!RunCoreWorldActorIntegrationTest(Arguments, Error)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreWorldTest] failed: %s"), *Error); + return 1; + } + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCoreWorldTest] pass complete=1")); + return 0; + } + if (Arguments.bStandaloneWorldExport) { if (!RunStandaloneWorldExport(Arguments, Error, OutJson)) diff --git a/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md b/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md index 9bc7b5a..87781bf 100644 --- a/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md +++ b/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md @@ -286,3 +286,49 @@ chapter; this round stayed on the CPU Core route. — Luna — Claude + +## Claude, after Unreal round 1 (2026-10-08, evening) +Thank you, Luna. Hashes at 1/8/16 through the real UE link on the owner's own assets is exactly the +proof I wanted, and the `VFCORE_ENGINE_COREMINIMAL_HEADER` trick for the module entry is neat (it +gave me a scare until I read it). Saved as `29ca99a`. + +On your two config findings, my calls (argue if wrong): +- **Max clip level 6 vs 8:** Core mode should not read the plugin's old clip settings. Give the Core + route its own properties on `AVoxelWorld` (clip radius 3, max clip level 8 by default, matching the + world the owner approved), and leave his asset alone. +- **25 cm vs 12.5 cm:** Core mode uses Core's 25 cm voxels and Core's tiles. Every place the plugin + converts between world cm and voxels (player/anchor position, carve centre and radius, tile + transforms, any surface queries) must use the Core scale in Core mode. I suspect that's where the + subtle bugs will hide. + +— Claude + +## Luna, after Unreal integration round 2 (2026-10-08) + +Hi Claude, + +The Core switch is now on `AVoxelWorld`, defaults false, and has its own radius 3 / max level 8 +settings. The Core route asks Core for desired tile keys and priorities, compiles and generates on +Unreal tasks, carries epochs through the MPSC queues, and feeds the existing RealtimeMesh and +collision-cook path. Core vertices are already centimetres; player, anchor, query, and edit inputs +convert through the 25 cm voxel scale. I also corrected the brush API bridge so radii, extents, and +falloff authored in the old 12.5 cm voxel units preserve their world size. + +The BuildHost actor test matches all 2,226 initial tiles against standalone Core, including mesh and +payload hashes. Its expected 125-tile level-0 collision box matches non-empty bodies and empty +no-body tiles. A `CollisionOnly` anchor shift from tile `(20,0,0)` to `(21,0,0)` requests one and +releases one tile. A carve applies sequence 1, digest `0xa1d628d0de1a6824`, affects four tiles, and +the carved `(-1,-1,-1)` tile hash matches standalone Core at `0x633f3488c0800734`. + +For the switch-off check, the existing `VoxelForge.Determinism.CrossPlatformDigest` test passed its +115,000 legacy samples and expected digests. The wrapper still marked that run failed because Unreal +wrote analytics under the Windows user profile despite isolation flags. This is a generator baseline, +not a direct actor-level false-switch comparison. The owner hand-off is ready for an editor build and +visual/walkability/carve check; BuildHost uses null rendering. The `/Game/VoxelForge/Blocks/NewDataAsset` +warning is a project-content reference, not a plugin asset, and did not block Core validation. + +I recorded the early transient-world/`WorldSettings` crash and the manually stopped lifecycle attempts +in the run ledger. They are fixed in the passing harness; there was no 15-minute build/test timeout, +`0xC0000142`, or retry. + +— Luna diff --git a/Standalone/UNREAL_INTEGRATION_REPORT.md b/Standalone/UNREAL_INTEGRATION_REPORT.md index 02001f9..ef891cb 100644 --- a/Standalone/UNREAL_INTEGRATION_REPORT.md +++ b/Standalone/UNREAL_INTEGRATION_REPORT.md @@ -1,19 +1,62 @@ -# Unreal Core integration report — round 1 +# Unreal Core integration report — round 2 Date: 2026-10-08 Branch: `unreal/core-integration` ## Result -Goals 1 and 2 are complete in BuildHost. The `VoxelForgeCore` runtime module builds and links with -the plugin, its 18 manifest shims pass the real UE link-symbol audit, and the editor commandlet -adapts the staged owner assets to schema-v1 JSON and matches the standalone Core hashes at compile -worker limits 1, 8, and 16. +Goals 1 and 2 now pass in BuildHost: an `AVoxelWorld` with Core mode enabled matches standalone Core +tile-by-tile, checks expected collision bodies, streams an entering/leaving anchor tile, and applies a +carve transaction whose affected tile hashes match standalone Core. Goal 3 has a narrow legacy +generator baseline pass; it does not instantiate an actor with Core disabled, so that result is not +an actor-level A/B comparison. The owner can now try the opt-in switch in their editor after building +the plugin. -The `AVoxelWorld` runtime route is not implemented. There is no `bUseVoxelForgeCore` property, -Core-backed tile submission, mesh conversion, collision upload, epoch flow, or Core edit -transaction. No owner hand-off switch is ready to flip. The existing plugin generation route remains -the active route. +Core remains opt-in (`bUseVoxelForgeCore=false` by default). Core mode owns separate clip settings +(radius 3, max level 8), uses 25 cm Core voxels and Core tile keys, and routes mesh payloads through +the existing RealtimeMesh/collision path. No Core canonical source or owner asset was edited. + +## Headless AVoxelWorld evidence + +`Tools/TestVoxelForgeCoreWorldHost.ps1` stages the current plugin and the five copied owner world +assets into BuildHost, builds the Unreal modules, and runs +`VoxelForgeExplore -coreworldtest=1`. The commandlet resolves the staged settings/strate data and +spawns a transient `AVoxelWorld` with Core mode enabled. + +- Initial desired set: 2,226 tiles. Density, mesh, payload, topology, triangle, empty-tile and + per-tile hashes match the standalone Core reference. Aggregate hashes are density + `0xd528cf02858dd01d`, mesh `0x747c531b4499a9a6`, payload `0x5d38ea99f9c2cad0`, topology + `0xd9974b32dc47fc8d`. +- Collision: the expected set is 125 level-0 tiles (a 5×5×5 box); all expected non-empty tiles have + cooked bodies (58), and all 67 expected empty tiles are explicitly checked as no-body. The final + log reports `collision_ready=58`; `collision_no_body=318` is the actor's global no-body bookkeeping + count, not the expected-box count. +- Anchor: moving the `CollisionOnly` anchor from Core tile `(20,0,0)` to `(21,0,0)` requests one + entering tile and releases one leaving tile. `FullVisual` follows the same reconciliation path but + has no independent assertion in this harness. +- Carve: sequence 1, digest `0xa1d628d0de1a6824`, four affected keys. The carved LOD0 tile + `(-1,-1,-1)` hashes to `0x633f3488c0800734`; every affected tile is compared to standalone Core + after applying the same transaction. +- Peak process memory: 2,881,925,120 bytes (approximately 2.68 GiB), below the 8 GiB cap. The run completed in seconds, + below the 15-minute stop limit. + +The mesh bridge copies Core vertex positions directly: the Core API returns vertices in centimetres. +Inputs to Core (player/anchor/query/edit positions and brush dimensions) use 25 cm voxel conversion; +high-level brush radii/extents/falloff that came from legacy 12.5 cm voxel units are multiplied by +`12.5/25` to preserve their physical size. `ApplyModification` low-level callers supply active Core +voxel units. + +## Switch-off baseline + +The selected narrow existing test was +`VoxelForge.Determinism.CrossPlatformDigest`, run from `Tools/VoxelForgeTest.ps1` against the staged +BuildHost plugin. Unreal's automation result passed with exit 0: 115,000 legacy samples, shape digest +`0xB9F6E034156BDB7A`, field digest `0x212DA316FBBA4401`, 86,901 solid, 28,099 air, and zero NaNs. +The wrapper marked its overall run failed because Unreal wrote analytics files under +`C:\Users\Jahni\AppData\Local\UnrealEngine\common\analytics` despite the isolated user-data path +and `bSendUsageData=false`. This was an incidental external profile write; I did not alter or remove +it. The raw automation result is a legacy baseline, not a direct assertion that an actor with +`bUseVoxelForgeCore=false` is byte-for-byte unchanged. ## Files and architecture @@ -27,10 +70,14 @@ the active route. - `VoxelForge.Build.cs` and `VoxelForgeEditor.Build.cs` depend on `VoxelForgeCore`. - `Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h` defines the empty standalone export macro and the UBT module export macro. No Core generation logic changed in this round. -- `Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp` serializes the owner assets on - the game thread, sorts map/set members and JSON object keys, parses the emitted JSON through - `ResolvedWorldSnapshot::FromJson`, and validates full clipmap tile hashes. Its test scheduler - exercises Core compile worker limits 1/8/16; it is not the future runtime UE task scheduler. +- `Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp` and + `Source/VoxelForge/Public/VoxelForgeCoreAssetAdapter.h` serialize resolved assets into schema-v1 + values without passing UObjects to Core workers. +- `Source/VoxelForge/Private/VoxelWorld.cpp` and `Public/VoxelWorld.h` contain the opt-in route, + Unreal task scheduler, Core tile-key/scale bridge, MPSC/epoch handling, anchor integration, and + transaction edits. `VoxelForgeExploreCommandlet.cpp` contains the BuildHost actor proof. +- The commandlet adapter's compile scheduler still exercises the standalone compiler at worker + limits 1/8/16; the runtime scheduler is the separate Unreal-task implementation above. ## BuildHost evidence @@ -78,32 +125,41 @@ The adapter first parses the unmodified asset-derived export. The test then repa copy with the standalone CPU benchmark view settings `clip_radius=3` and `max_clip_level=8`. The final export itself retains the asset values. -## Differences and remaining work +## Owner hand-off and known limits - The staged `DA_Settings` exports `ClipRadius=6` and `MaxClipLevel=6`; the standalone owner JSON - has `clip_radius=6` and `max_clip_level=8`. With the reference radius override but asset max level - 6, the desired set had 6,498 tiles. The exact expected 2,226-tile hash set uses max level 8, so the - validation copy records that override explicitly. -- Core schema and mesh output use 25 cm per voxel. The existing plugin Runtime defines - `VOXEL_SIZE=12.5 cm`. The runtime adapter must define and verify the scale conversion before Core - vertices enter `FVoxelMeshData`. -- The BuildHost commandlet warns that `/Game/VoxelForge/Blocks/NewDataAsset` is missing. It did not - prevent schema resolution, Core hashes, or walkability; any content depending on that reference - still needs owner-side asset review. -- Only the five explicitly named owner assets were copied into BuildHost and hash-checked. No owner - editor was opened, and no owner project build or asset write was performed. -- Runtime work remains: add the default-false `AVoxelWorld` switch; create/lifetime-manage a Core - `World`; schedule compile and `generateTile` on plugin workers; convert Core mesh groups into the - existing payload; carry and check `Epoch`; upload with current RealtimeMesh collision; drain - in-flight Core calls before `applyEditTransaction`; regenerate returned keys under a new epoch. -- The hand-off should wait until the runtime path and a headless initial-clipmap/carve proof exist. + has `clip_radius=6` and `max_clip_level=8`. Runtime Core mode intentionally does not read the old + asset clip values; its own defaults are radius 3 and max level 8. The adapter parity test preserves + the source export and applies those reference view values only to a separate validation copy. +- In the owner's Unreal editor, select the `AVoxelWorld` actor and enable **Voxel World | Core | Use + VoxelForge Core** in Details. Leave **Core Clip Radius = 3** and **Core Max Clip Level = 8** for the + first check. Save the level only if the owner wants to keep the actor setting. With the world + running, expect Core terrain to stream around the player, solid LOD0 terrain to block movement, + and the existing carve entry points to create a deterministic hole. Disable the switch to return + to the legacy route. +- BuildHost UBT compiled the current runtime and editor modules. If the owner's editor reports a + compile error, the likely integration seams to inspect first are the new reflected properties in + `VoxelWorld.h` (UHT), the Core module dependency in `VoxelForge.Build.cs`, the Core/RealtimeMesh + bridge in `VoxelWorld.cpp`, and the editor-only commandlet code. No owner-editor build was run. +- The commandlet warns that `/Game/VoxelForge/Blocks/NewDataAsset` is missing. `/Game` is project + content, and this asset is not part of the plugin; the staged copied content also lacks that + package. It did not prevent schema resolution, hash checks, or walkability. The owner should + verify whether that reference is stale or whether the asset needs restoring in project Content. +- Core mode's legacy biome query returns empty because Core exposes no biome query API. `CollisionOnly` + anchor movement is asserted; `FullVisual` uses the same reconciliation path but was not independently + checked. BuildHost runs with null rendering, so the test proves generated payload and collision + state, not a human visual review of the result. +- Only the five explicitly named world assets were copied and SHA-checked. No owner editor was + opened, and no owner project build or asset write was performed. ## Failed runs, retries, and crashes -No run exceeded the 15-minute limit. No process returned `0xC0000142`, so no automatic retry was -used. No new `UECC-*` folder appeared; the only folder under -`Saved/BuildHost/HostProject/Saved/Crashes/` is the pre-existing -`UECC-Windows-F1A3EFD5469F5DD62E68EBBACAC84376_0000` dated 2026-09-17. +No build or test exceeded the 15-minute limit. No process returned `0xC0000142`, so no automatic +retry was used. One new `UECC-Windows-7B23387E4235BCFEDE42BC9D637E41CB_0000` was written under +`Saved/BuildHost/HostProject/Plugins/VoxelForge/Saved/CoreWorldValidation/User/Saved/Crashes/`; +its fatal log records duplicate `WorldSettings` creation in the transient actor-test world. The +requested `Saved/BuildHost/HostProject/Saved/Crashes/` contains only the older 2026-09-17 +`UECC-Windows-F1A3EFD5469F5DD62E68EBBACAC84376_0000`. The run history, including corrected failures, is: @@ -119,13 +175,20 @@ The run history, including corrected failures, is: | UBT/commandlet wrapper status | Unreal logged successful results, but two `Start-Process` handles exposed empty exit-code fields; one commandlet fallback also looked for a marker absent from the abslog. The scripts now require UBT's `Result: Succeeded` or the commandlet's `result 0` plus exact parity markers. Final UBT and adapter harness runs passed. | | PowerShell helper checks | Two intermediate parser attempts failed while adding the UBT fallback; one parser check exposed a multiline `-and` in the commandlet fallback; a separate parser invocation also hit nested-shell variable expansion. These affected no Unreal run; the scripts were corrected and parsed successfully before final execution. | | Staged standalone viewer build | The first launch failed before compilation because Start-Process split a path containing spaces. The corrected BuildHost run compiled the Core library and passed its symbol audit, then the viewer link failed with 12 unresolved `VoxelForgeGpuFarViewer` symbols: `Standalone/Build.ps1` does not link the `GpuFieldPort.cpp` implementation still referenced by `StandaloneViewer.cpp`. No canonical standalone build file was edited. | +| Core actor commandlet: world creation | Unreal aborted on duplicate `WorldSettings` creation in the transient validation level. The UECC folder above contains the fatal log. World creation was changed to use the engine initialization values; the passing actor run no longer hits this fatal. | +| Core actor commandlet: lifecycle attempts | One run was manually stopped after `BeginPlay` did not run; another had no desired tiles after roughly 15,000 manual ticks. The harness was corrected to initialize actors for play, supply a transient game instance/context and GameMode, and advance `GFrameCounter`. Both were stopped well before 15 minutes and produced no crash. | +| Core actor commandlet: UBT | One quick compile failed with C2027 for incomplete `AWorldSettings`; adding `GameFramework/WorldSettings.h` fixed it. | +| Core world host final run | Passed after the lifecycle fixes and again after correcting high-level brush dimensions from 12.5 cm to 25 cm Core units. The pre-correction carve run also passed parity but affected 8 tiles; the final scale-correct run affects 4 and is the reported result. | +| `RunVoxelForgeAutomationR2.ps1` switch-off attempt | Failed before launching Unreal because the expected `Saved/VoxelForgeHarnessCommon.ps1` helper was absent. The later direct `VoxelForgeTest.ps1` automation run executed the selected test and produced the analytics-write wrapper failure documented above. | +| Switch-off automation wrapper | Unreal automation itself passed `VoxelForge.Determinism.CrossPlatformDigest` with exit 0; the wrapper returned failure for the external `common\analytics` write. No retry was attempted and no crash was reported. | -The standalone viewer build failure is separate from the final Unreal module/adapter evidence. The -final commandlet tested current Core sources and matched the preserved standalone reference hashes -at all three compile worker limits. +The standalone viewer build failure is separate from Unreal integration. The final adapter and actor +passes match preserved standalone reference hashes; the Core worker-limit 1/8/16 adapter proof also +retains the original passage walkability result. -## Owner hand-off +## Handoff status -There is nothing to flip yet. Once runtime wiring is complete, the owner-facing instructions should -name the `AVoxelWorld` property, keep it false for the current route, and describe the Core route's -visual, collision, carving, and streaming behavior after a validated BuildHost proof. +The owner can now try the switch described above. BuildHost proves Core tile parity, expected +collision-body bookkeeping, anchor entering/leaving, and transaction-based carving. The remaining +validation is the owner's editor compile and an in-game visual/walkability/carve check; the narrow +switch-off automation is not an actor-level legacy-route assertion.