From 874c796c65c184f038ca8cafda059860713f077d Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Fri, 9 Oct 2026 00:04:30 +0200 Subject: [PATCH] wip(assembler): M1 round 1 -- manifest format, block contract, bound audit, native Heightfield2D Luna, M1 round 1 of 4 (plan in Standalone/ASSEMBLER_M1_REPORT.md): - Versioned fixed-scale world manifest (the shipped plan format) and the Core block contract: point evaluation, interval-or-unknown, influence bound, detail envelope, channels. - Interval audit: 256 boxes / 6,912 samples, 0 missed, 0 unknown; a deliberately false bound is detected (negative control). - Native Heightfield2D with biome input: 4,096-point hash fae2721c052aec5c identical at W1/W8/W16; point queries 2.059 ms vs 62.115 ms legacy -- but NOT a match yet (mean density error 274 voxels, 2,261 sign mismatches vs the legacy surface). 45 mesh-free Core queries match direct evaluation exactly. Existing cave world unchanged (walkability, 64/64 tiles). - Open: collision readiness, a valid full-view timing (viewer captures were occluded/empty; top-down height maps are the only usable pair). Also carries the design doc's owner decisions (fixed water; player edits unrestricted, route law for automatic changes only) and three plugin files + BuildHost scripts that earlier round commits had missed (also fixed on unreal/core-integration as 2574b56). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- .../Private/VoxelForgeCoreAdapter.cpp | 402 +++++++++ .../Private/VoxelForgeCoreAdapter.h | 3 + .../Public/VoxelForgeCoreAssetAdapter.h | 21 + Standalone/ASSEMBLER_M1_REPORT.md | 181 +++++ Standalone/Core/API.md | 39 + .../include/VoxelForgeCore/VoxelForgeCore.h | 177 ++++ Standalone/Core/src/CoreNamespacePreamble.h | 1 + Standalone/Core/src/ShapeWorldInternal.h | 93 ++- Standalone/Core/src/VoxelForgeCore.cpp | 35 +- Standalone/Core/src/WorldAssemblerInternal.h | 766 ++++++++++++++++++ .../RunPassageWalkabilityAudit.ps1 | 112 +++ Standalone/DESIGN_WORLD_ASSEMBLER.md | 15 + .../gpu_field_port/LUNA_DIALOGUE.md | 54 ++ .../native-prototype-height.jpg | Bin 0 -> 2359 bytes .../owner-reference-height.jpg | Bin 0 -> 5149 bytes .../WorldAssemblerM1/round1-manifest.json | 1 + Standalone/Source/StandaloneViewer.cpp | 379 +++++++++ Tools/BuildVoxelForgeCoreHost.ps1 | 213 +++++ Tools/TestVoxelForgeCoreAdapterHost.ps1 | 203 +++++ Tools/TestVoxelForgeCoreWorldHost.ps1 | 197 +++++ 20 files changed, 2880 insertions(+), 12 deletions(-) create mode 100644 Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp create mode 100644 Source/VoxelForge/Private/VoxelForgeCoreAdapter.h create mode 100644 Source/VoxelForge/Public/VoxelForgeCoreAssetAdapter.h create mode 100644 Standalone/ASSEMBLER_M1_REPORT.md create mode 100644 Standalone/Core/src/WorldAssemblerInternal.h create mode 100644 Standalone/Core/verification/RunPassageWalkabilityAudit.ps1 create mode 100644 Standalone/Research/WorldAssemblerM1/native-prototype-height.jpg create mode 100644 Standalone/Research/WorldAssemblerM1/owner-reference-height.jpg create mode 100644 Standalone/Research/WorldAssemblerM1/round1-manifest.json create mode 100644 Tools/BuildVoxelForgeCoreHost.ps1 create mode 100644 Tools/TestVoxelForgeCoreAdapterHost.ps1 create mode 100644 Tools/TestVoxelForgeCoreWorldHost.ps1 diff --git a/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp b/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp new file mode 100644 index 0000000..31640bc --- /dev/null +++ b/Source/VoxelForge/Private/VoxelForgeCoreAdapter.cpp @@ -0,0 +1,402 @@ +#include "VoxelForgeCoreAdapter.h" + +#include "Dom/JsonObject.h" +#include "Policies/CondensedJsonPrintPolicy.h" +#include "Serialization/JsonWriter.h" +#include "UObject/UnrealType.h" +#include "UObject/TextProperty.h" +#include "VoxelSeasonAsset.h" +#include "VoxelSettings.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.h" +#include "VoxelTerrainOpDefinition.h" + +namespace +{ +using FCoreJsonWriter = TJsonWriter>; +constexpr double CoreVoxelSizeCm = 25.0; + +TSharedPtr SerializeCoreStruct( + const UStruct* Type, const void* Data, TSet& ActiveObjects, + const UObject* Object = nullptr); + +TSharedPtr SerializeCoreProperty( + FProperty* Property, const void* Data, TSet& ActiveObjects) +{ + if (Property == nullptr || Data == nullptr) return MakeShared(); + if (const FBoolProperty* Bool = CastField(Property)) + { + return MakeShared(Bool->GetPropertyValue(Data)); + } + if (const FEnumProperty* Enum = CastField(Property)) + { + return MakeShared(static_cast( + Enum->GetUnderlyingProperty()->GetSignedIntPropertyValue(Data))); + } + if (const FNumericProperty* Numeric = CastField(Property)) + { + const double Value = Numeric->IsFloatingPoint() + ? Numeric->GetFloatingPointPropertyValue(Data) + : static_cast(Numeric->GetSignedIntPropertyValue(Data)); + return MakeShared(Value); + } + if (const FStrProperty* String = CastField(Property)) + { + return MakeShared(String->GetPropertyValue(Data)); + } + if (const FNameProperty* Name = CastField(Property)) + { + return MakeShared(Name->GetPropertyValue(Data).ToString()); + } + if (const FTextProperty* Text = CastField(Property)) + { + return MakeShared(Text->GetPropertyValue(Data).ToString()); + } + if (const FSoftObjectProperty* Soft = CastField(Property)) + { + return MakeShared( + Soft->GetPropertyValue(Data).ToSoftObjectPath().ToString()); + } + if (const FArrayProperty* Array = CastField(Property)) + { + TArray> Values; + FScriptArrayHelper Helper(Array, Data); + Values.Reserve(Helper.Num()); + for (int32 Index = 0; Index < Helper.Num(); ++Index) + { + Values.Add(SerializeCoreProperty(Array->Inner, Helper.GetRawPtr(Index), ActiveObjects)); + } + return MakeShared(MoveTemp(Values)); + } + if (const FSetProperty* Set = CastField(Property)) + { + TArray>> OrderedValues; + FScriptSetHelper Helper(Set, Data); + for (int32 Index = 0; Index < Helper.GetMaxIndex(); ++Index) + { + if (!Helper.IsValidIndex(Index)) continue; + FString StableKey; + Set->ElementProp->ExportTextItem_Direct( + StableKey, Helper.GetElementPtr(Index), nullptr, nullptr, PPF_None); + OrderedValues.Emplace(StableKey, SerializeCoreProperty( + Set->ElementProp, Helper.GetElementPtr(Index), ActiveObjects)); + } + OrderedValues.Sort([](const auto& A, const auto& B) { return A.Key < B.Key; }); + TArray> Values; + Values.Reserve(OrderedValues.Num()); + for (const auto& Entry : OrderedValues) Values.Add(Entry.Value); + return MakeShared(MoveTemp(Values)); + } + if (const FMapProperty* Map = CastField(Property)) + { + TArray>> OrderedValues; + FScriptMapHelper Helper(Map, Data); + for (int32 Index = 0; Index < Helper.GetMaxIndex(); ++Index) + { + if (!Helper.IsValidIndex(Index)) continue; + TSharedRef Pair = MakeShared(); + Pair->SetField(TEXT("key"), SerializeCoreProperty( + Map->KeyProp, Helper.GetKeyPtr(Index), ActiveObjects)); + Pair->SetField(TEXT("value"), SerializeCoreProperty( + Map->ValueProp, Helper.GetValuePtr(Index), ActiveObjects)); + FString StableKey; + Map->KeyProp->ExportTextItem_Direct( + StableKey, Helper.GetKeyPtr(Index), nullptr, nullptr, PPF_None); + OrderedValues.Emplace(StableKey, MakeShared(Pair)); + } + OrderedValues.Sort([](const auto& A, const auto& B) { return A.Key < B.Key; }); + TArray> Values; + Values.Reserve(OrderedValues.Num()); + for (const auto& Entry : OrderedValues) Values.Add(Entry.Value); + return MakeShared(MoveTemp(Values)); + } + if (const FStructProperty* Struct = CastField(Property)) + { + return SerializeCoreStruct(Struct->Struct, Data, ActiveObjects); + } + if (const FObjectPropertyBase* ObjectProperty = CastField(Property)) + { + const UObject* Object = ObjectProperty->GetObjectPropertyValue(Data); + if (Object == nullptr) return MakeShared(); + if (Object->GetClass()->GetName().StartsWith(TEXT("Voxel"), ESearchCase::CaseSensitive)) + { + return SerializeCoreStruct(Object->GetClass(), Object, ActiveObjects, Object); + } + return MakeShared(Object->GetPathName()); + } + return MakeShared(); +} + +TSharedPtr SerializeCoreStruct( + const UStruct* Type, const void* Data, TSet& ActiveObjects, + const UObject* Object) +{ + if (Type == nullptr || Data == nullptr) return MakeShared(); + if (Object != nullptr && ActiveObjects.Contains(Object)) + { + return MakeShared(Object->GetPathName()); + } + if (Object != nullptr) ActiveObjects.Add(Object); + + TSharedRef Result = MakeShared(); + for (TFieldIterator It(Type); It; ++It) + { + FProperty* Property = *It; + Result->SetField(Property->GetName(), SerializeCoreProperty( + Property, Property->ContainerPtrToValuePtr(const_cast(Data)), ActiveObjects)); + } + if (Object != nullptr) ActiveObjects.Remove(Object); + return MakeShared(Result); +} + +TSharedRef CoreVectorJson(const FVector& Value) +{ + TSharedRef Result = MakeShared(); + Result->SetNumberField(TEXT("x"), Value.X); + Result->SetNumberField(TEXT("y"), Value.Y); + Result->SetNumberField(TEXT("z"), Value.Z); + return Result; +} + +TSharedRef CoreLandingJson(const FVoxelPassageLanding& Landing) +{ + TSharedRef Result = MakeShared(); + Result->SetObjectField(TEXT("standing_point"), CoreVectorJson(Landing.StandingPoint)); + Result->SetObjectField(TEXT("door_point"), CoreVectorJson(Landing.DoorPoint)); + Result->SetObjectField(TEXT("door_direction"), CoreVectorJson(Landing.DoorDirection)); + Result->SetNumberField(TEXT("floor_z"), Landing.FloorZ); + Result->SetNumberField(TEXT("ceiling_z"), Landing.CeilingZ); + Result->SetNumberField(TEXT("half_width"), Landing.HalfWidth); + Result->SetNumberField(TEXT("floor_thickness"), Landing.FloorThickness); + Result->SetBoolField(TEXT("source_player_fit"), Landing.bSourcePlayerFit); + return Result; +} + +TSharedRef CorePassageJson(const FVoxelPassage& Passage) +{ + TSharedRef Result = MakeShared(); + Result->SetNumberField(TEXT("upper_strate_index"), Passage.UpperStrateIndex); + Result->SetNumberField(TEXT("lower_strate_index"), Passage.LowerStrateIndex); + Result->SetObjectField(TEXT("upper_point"), CoreVectorJson(Passage.UpperPoint)); + Result->SetObjectField(TEXT("lower_point"), CoreVectorJson(Passage.LowerPoint)); + Result->SetObjectField(TEXT("requested_upper_point"), CoreVectorJson(Passage.RequestedUpperPoint)); + Result->SetObjectField(TEXT("requested_lower_point"), CoreVectorJson(Passage.RequestedLowerPoint)); + Result->SetNumberField(TEXT("radius"), Passage.Radius); + Result->SetNumberField(TEXT("passage_type"), static_cast(Passage.PassageType)); + auto WriteVectors = [](const TCHAR* Key, const TArray& Source, + TSharedRef Out) + { + TArray> Values; + Values.Reserve(Source.Num()); + for (const FVector& Value : Source) + { + Values.Add(MakeShared(CoreVectorJson(Value))); + } + Out->SetArrayField(Key, MoveTemp(Values)); + }; + auto WriteFloats = [](const TCHAR* Key, const TArray& Source, + TSharedRef Out) + { + TArray> Values; + Values.Reserve(Source.Num()); + for (float Value : Source) Values.Add(MakeShared(Value)); + Out->SetArrayField(Key, MoveTemp(Values)); + }; + WriteVectors(TEXT("control_points"), Passage.ControlPoints, Result); + WriteFloats(TEXT("control_radii"), Passage.ControlRadii, Result); + Result->SetObjectField(TEXT("upper_landing"), CoreLandingJson(Passage.UpperLanding)); + Result->SetObjectField(TEXT("lower_landing"), CoreLandingJson(Passage.LowerLanding)); + Result->SetBoolField(TEXT("walkable_tunnel_contract"), Passage.bWalkableTunnelContract); + Result->SetBoolField(TEXT("native_floor_enabled"), Passage.bNativeFloorEnabled); + WriteFloats(TEXT("native_floor_profile_z"), Passage.NativeFloorProfileZ, Result); + WriteFloats(TEXT("native_floor_relief_scales"), Passage.NativeFloorReliefScales, Result); + Result->SetNumberField(TEXT("native_floor_relief_strength"), Passage.NativeFloorReliefStrength); + Result->SetNumberField(TEXT("native_floor_relief_frequency"), Passage.NativeFloorReliefFrequency); + Result->SetNumberField(TEXT("native_floor_seed"), static_cast(Passage.NativeFloorSeed)); + Result->SetNumberField(TEXT("tunnel_max_gradient_degrees"), Passage.TunnelMaxGradientDegrees); + Result->SetNumberField(TEXT("tunnel_vertical_drop_voxels"), Passage.TunnelVerticalDropVoxels); + Result->SetNumberField(TEXT("tunnel_required_horizontal_run_voxels"), Passage.TunnelRequiredHorizontalRunVoxels); + Result->SetNumberField(TEXT("tunnel_horizontal_path_length_voxels"), Passage.TunnelHorizontalPathLengthVoxels); + Result->SetNumberField(TEXT("tunnel_minimum_clear_width_voxels"), Passage.TunnelMinimumClearWidthVoxels); + Result->SetNumberField(TEXT("tunnel_clear_height_voxels"), Passage.TunnelClearHeightVoxels); + Result->SetObjectField(TEXT("bound_center"), CoreVectorJson(Passage.BoundCenter)); + Result->SetNumberField(TEXT("bound_radius"), Passage.BoundRadius); + Result->SetNumberField(TEXT("bound_radius_sq"), Passage.BoundRadiusSq); + return Result; +} + +void WriteCoreJsonValue(FCoreJsonWriter& Writer, const TCHAR* Identifier, + const TSharedPtr& Value) +{ + if (Identifier != nullptr) Writer.WriteIdentifierPrefix(Identifier); + if (!Value.IsValid() || Value->Type == EJson::Null) + { + Writer.WriteNull(); + } + else if (Value->Type == EJson::Boolean) + { + Writer.WriteValue(Value->AsBool()); + } + else if (Value->Type == EJson::Number) + { + Writer.WriteValue(Value->AsNumber()); + } + else if (Value->Type == EJson::String) + { + Writer.WriteValue(Value->AsString()); + } + else if (Value->Type == EJson::Array) + { + Writer.WriteArrayStart(); + for (const TSharedPtr& Element : Value->AsArray()) + { + WriteCoreJsonValue(Writer, nullptr, Element); + } + Writer.WriteArrayEnd(); + } + else if (Value->Type == EJson::Object) + { + Writer.WriteObjectStart(); + TArray Keys; + Value->AsObject()->Values.GetKeys(Keys); + Keys.Sort(); + for (const FString& Key : Keys) + { + WriteCoreJsonValue(Writer, *Key, Value->AsObject()->Values.FindRef(Key)); + } + Writer.WriteObjectEnd(); + } + else + { + Writer.WriteNull(); + } +} +} + +bool VoxelForgeCoreAdapter::SerializeResolvedWorld( + const UVoxelSettings* Settings, + const UVoxelStrateManager* StrateManager, + int32 ClipRadius, + int32 MaxClipLevel, + FString& OutJson, + FString& OutError) +{ + OutJson.Reset(); + OutError.Reset(); + if (Settings == nullptr || StrateManager == nullptr || StrateManager->GetLayout().IsEmpty()) + { + OutError = TEXT("Core snapshot export requires initialized settings and a resolved strate layout."); + return false; + } + + const int32 Seed = StrateManager->GetWorldSeed(); + const float OriginSpineRadius = Settings->GetEffectiveOriginSpineRadius(); + const float WorldRadiusVoxels = Settings->GetEffectiveWorldRadiusVoxels(); + const int32 CoreClipRadius = FMath::Clamp(ClipRadius, 1, 64); + const int32 CoreMaxClipLevel = FMath::Clamp(MaxClipLevel, 0, 8); + + TSet ActiveObjects; + TSharedPtr SettingsValue = SerializeCoreStruct( + Settings->GetClass(), Settings, ActiveObjects, Settings); + if (!SettingsValue.IsValid() || SettingsValue->Type != EJson::Object) + { + OutError = TEXT("Core snapshot export could not serialize the settings asset."); + return false; + } + // Override only the temporary schema copy. Clipmap tuning on the owner's asset is preserved. + SettingsValue->AsObject()->SetNumberField(TEXT("ClipRadius"), CoreClipRadius); + SettingsValue->AsObject()->SetNumberField(TEXT("MaxClipLevel"), CoreMaxClipLevel); + + TSharedRef Root = MakeShared(); + Root->SetNumberField(TEXT("schema_version"), 1); + Root->SetStringField(TEXT("source"), TEXT("VoxelForge Runtime Core snapshot")); + Root->SetNumberField(TEXT("voxel_size_cm"), CoreVoxelSizeCm); + Root->SetNumberField(TEXT("seed"), Seed); + Root->SetBoolField(TEXT("open_surface_entry"), Settings->bOpenSurfaceEntry); + Root->SetNumberField(TEXT("origin_spine_radius"), OriginSpineRadius); + Root->SetNumberField(TEXT("world_radius_voxels"), WorldRadiusVoxels); + Root->SetNumberField(TEXT("edge_seal_thickness"), Settings->EdgeSealThickness); + Root->SetNumberField(TEXT("total_strates"), StrateManager->GetNumStrates()); + Root->SetNumberField(TEXT("inter_strate_gap_chunks"), Settings->InterStrateGapChunks); + Root->SetNumberField(TEXT("clip_radius"), CoreClipRadius); + Root->SetNumberField(TEXT("max_clip_level"), CoreMaxClipLevel); + Root->SetNumberField(TEXT("full_res_clip_levels"), Settings->FullResClipLevels); + Root->SetNumberField(TEXT("coarse_tile_cells"), Settings->CoarseTileCells); + Root->SetNumberField(TEXT("render_distance_chunks"), Settings->RenderDistanceChunks); + Root->SetBoolField(TEXT("generate_skirts"), Settings->bGenerateSkirts); + Root->SetNumberField(TEXT("skirt_cells"), Settings->SkirtCells); + Root->SetNumberField(TEXT("lod_octave_drop"), Settings->LODOctaveDrop); + Root->SetNumberField(TEXT("strate_content_cut_min_level"), + Settings->GetEffectiveStrateContentCutMinLevel()); + Root->SetField(TEXT("settings_asset"), SettingsValue); + + TArray> Strates; + Strates.Reserve(StrateManager->GetLayout().Num()); + for (const FStrateSlot& Slot : StrateManager->GetLayout()) + { + if (Slot.Definition == nullptr) + { + OutError = FString::Printf(TEXT("Resolved strate %d has no definition asset."), Slot.StrateIndex); + return false; + } + TSharedRef Strate = MakeShared(); + Strate->SetNumberField(TEXT("index"), Slot.StrateIndex); + Strate->SetNumberField(TEXT("top_chunk_z"), Slot.TopChunkZ); + Strate->SetNumberField(TEXT("bottom_chunk_z"), Slot.BottomChunkZ); + Strate->SetNumberField(TEXT("height_chunks"), Slot.HeightInChunks); + Strate->SetField(TEXT("asset"), SerializeCoreStruct( + Slot.Definition->GetClass(), Slot.Definition, ActiveObjects, Slot.Definition)); + Strate->SetField(TEXT("generation_params"), SerializeCoreStruct( + FStrateGenerationParams::StaticStruct(), &Slot.Definition->GenerationParams, ActiveObjects)); + Strate->SetField(TEXT("disturbances"), SerializeCoreStruct( + FStrateDisturbanceParams::StaticStruct(), &Slot.Definition->Disturbances, ActiveObjects)); + Strate->SetField(TEXT("passage_config"), SerializeCoreStruct( + FStratePassageConfig::StaticStruct(), &Slot.Definition->PassageConfig, ActiveObjects)); + + TArray> Operations; + Operations.Reserve(Slot.Definition->TerrainOperations.Num()); + for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations) + { + TSharedRef Operation = MakeShared(); + Operation->SetNumberField(TEXT("weight"), Entry.Weight); + Operation->SetNumberField(TEXT("probability"), Entry.Probability); + Operation->SetStringField(TEXT("asset_path"), Entry.Operation.ToString()); + if (const UVoxelTerrainOpDefinition* Definition = Entry.Operation.LoadSynchronous()) + { + Operation->SetField(TEXT("asset"), SerializeCoreStruct( + Definition->GetClass(), Definition, ActiveObjects, Definition)); + } + Operations.Add(MakeShared(Operation)); + } + Strate->SetArrayField(TEXT("terrain_operation_assets"), MoveTemp(Operations)); + Strates.Add(MakeShared(Strate)); + } + Root->SetArrayField(TEXT("strates"), MoveTemp(Strates)); + + TArray> Passages; + Passages.Reserve(StrateManager->GetPassages().Num()); + for (const FVoxelPassage& Passage : StrateManager->GetPassages()) + { + Passages.Add(MakeShared(CorePassageJson(Passage))); + } + Root->SetArrayField(TEXT("passages"), MoveTemp(Passages)); + Root->SetArrayField(TEXT("density_samples"), {}); + TSharedRef ViewerStart = MakeShared(); + ViewerStart->SetBoolField(TEXT("found_open_space"), false); + ViewerStart->SetNumberField(TEXT("x"), 0.0); + ViewerStart->SetNumberField(TEXT("y"), 0.0); + ViewerStart->SetNumberField(TEXT("z"), 0.0); + Root->SetObjectField(TEXT("viewer_start"), ViewerStart); + + FString WriterBuffer; + TSharedRef Writer = TJsonWriterFactory>::Create(&WriterBuffer); + WriteCoreJsonValue(*Writer, nullptr, MakeShared(Root)); + if (!Writer->Close() || WriterBuffer.IsEmpty()) + { + OutError = TEXT("Core snapshot export failed while writing schema-v1 JSON."); + return false; + } + OutJson = MoveTemp(WriterBuffer); + return true; +} diff --git a/Source/VoxelForge/Private/VoxelForgeCoreAdapter.h b/Source/VoxelForge/Private/VoxelForgeCoreAdapter.h new file mode 100644 index 0000000..0df5e12 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelForgeCoreAdapter.h @@ -0,0 +1,3 @@ +#pragma once + +#include "VoxelForgeCoreAssetAdapter.h" diff --git a/Source/VoxelForge/Public/VoxelForgeCoreAssetAdapter.h b/Source/VoxelForge/Public/VoxelForgeCoreAssetAdapter.h new file mode 100644 index 0000000..f1494bd --- /dev/null +++ b/Source/VoxelForge/Public/VoxelForgeCoreAssetAdapter.h @@ -0,0 +1,21 @@ +#pragma once + +#include "CoreMinimal.h" + +class UVoxelSettings; +class UVoxelStrateManager; + +namespace VoxelForgeCoreAdapter +{ + /** + * Export the already-resolved Runtime assets through the shared schema-v1 boundary. + * The override values are written into the JSON copy only; authored UObject properties stay + * untouched. This runs on the game thread because it walks UObjects and soft references. + */ + VOXELFORGE_API bool SerializeResolvedWorld(const UVoxelSettings* Settings, + const UVoxelStrateManager* StrateManager, + int32 ClipRadius, + int32 MaxClipLevel, + FString& OutJson, + FString& OutError); +} diff --git a/Standalone/ASSEMBLER_M1_REPORT.md b/Standalone/ASSEMBLER_M1_REPORT.md new file mode 100644 index 0000000..0a360c5 --- /dev/null +++ b/Standalone/ASSEMBLER_M1_REPORT.md @@ -0,0 +1,181 @@ +# World assembler M1 report + +M1 has four implementation rounds. The gates at the end of the milestone remain the agreed +two-biome SurfaceWorld in the owner's game, including coast, cliff/overhang, cap and a walkable +route; conservative bound audits; route sweep; identical evaluator hashes at W1/W8/W16; separate +local query/collision readiness and full-view timings; faster matched workload than the 23.085 s +legacy bridge; and server field queries that do not depend on render meshes. The approximately +1.57 s whole-view aim (2 × the 0.785 s cave-only Core measurement) is an aim, not an acceptance gate. + +## Round 1 of 4 — native foundations + +- Define the fixed-scale, versioned, hashed recipe and shipped-plan payload, including the Core + field-block contract and explicit field channels. +- Add a brute-force interval audit with a deliberately false bound as a negative control. +- Implement the first native `Heightfield2D` block with biome-map input and compare it with the + SurfaceWorld legacy bridge in the standalone viewer. +- Record current hashes, timings, audit results and a small owner/native capture pair. This round + establishes a heightfield baseline; it does not claim the final two-biome/coast/cliff/route gate. + +## Round 2 of 4 — complete the surface recipe + +- Add output-space biome blending, coast and beach shaping, cap detail, cliffs and bounded 3D + overhang exceptions with stable feature IDs and certified influence bounds. +- Keep unknown or intersecting exception intervals on the evaluation path. Expand the brute-force + audit across biome borders, cliffs, caps, negative coordinates and exception bounds. +- Measure surface field query cost separately from cave compilation and full-view rendering; tune + caches only after the workload and cache identity are fixed. + +## Round 3 of 4 — route and query readiness + +- Ship the finite-strate plan with route points, support constraints and stable IDs. Sweep the actual + player capsule over the final composed field and repair/reject any failing candidate. +- Verify W1/W8/W16 evaluator hashes, route clearance/support and deterministic plan serialization. +- Keep the capsule-swept, supported route valid through generation and automatic/scripted changes. + Player carve/fill edits are unrestricted and may break or plug the route. Seas and rivers stay fixed + for the season, with shore behavior under edits measured separately. +- Report local field-query and collision readiness separately from render mesh readiness, and show + a server-side field query path that does not build or require render meshes. + +## Round 4 of 4 — Unreal delivery and owner review + +- Import the reviewed manifest into the season asset/content hash path and connect the native Core + evaluator behind `bUseVoxelForgeCore`, preserving the legacy path when the flag is off. +- Build only the isolated BuildHost target here; the owner builds the project. Review the two-biome + surface and route in-game, then publish matched workload timings, hashes, audit logs and captures. +- Close every M1 gate or name the failed gate, its evidence and the additional repair round needed. + +## Round 1 results + +### Deliverables + +- Added the public recipe, shipped-plan, hash, channel, block-contract, interval, influence and + detail-envelope types in `Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h`. Recipe and + evaluator schemas are version 1. All recipe, plan, feature, route and field coordinates use signed + 1/256-voxel integers; the plugin's Core voxel remains 25 cm. +- Added manifest parse/finalize/validate/serialize and canonical FNV-1a 64 hashing in + `Standalone/Core/src/WorldAssemblerInternal.h`. Fields hash in declared order as little-endian + 4/8-byte words. Hashes are 16 lowercase hex digits; JSON key order and whitespace are ignored. + The optional owner-snapshot `world_assembly` field matches the payload shape planned for the + season manifest. The current review artifact is + [`round1-manifest.json`](Research/WorldAssemblerM1/round1-manifest.json); season asset/hash-path + integration remains for round 4. +- Added native `Heightfield2D`: deterministic warped Voronoi biome selection with Q16 border blend, + fixed integer profile noise, ground and cap heights, signed density (`negative = solid`), declared + channels, a conservative global interval, unbounded XY/finite-stratum-Z influence and an explicit + detail envelope. The current block reads `biome` and writes `height` and `rock`. Water, route and + surface-tag channels are declared for later blocks, not yet emitted by this block. +- Connected that block to native Core density queries and added the standalone audit/viewer switches. + `API.md` documents the current data format and its limits. + +### Sample manifest and hashes + +The viewer builds a sample from the owner's `type_4_SurfaceWorld.json` probe: one finite strate +(index 1, chunk Z -16 through -79), two synthetic stable biome IDs (4097 and 4098), no planned +features, and no route points yet. The compact manifest is 972 bytes. + +| Content | Hash | +|---|---| +| Recipe | `5cc6cfb49073d366` | +| Plan | `9aa8b4c29a69233a` | +| Manifest | `c87ae279b0e26f53` | +| Heightfield sample stream, 4096 points | `fae2721c052aec5c` | + +The sample block reports influence Z `[-2528, -480]` voxels, maximum displacement envelope +`247` voxels, and minimum feature size `25` voxels. These prototype ranges are not the final +composer-supported recipe envelope. + +### Bounds and determinism audit + +The brute-force audit tested 256 randomized boxes with a 3×3×3 sample grid per box (6,912 points). +It found zero missed bounds and zero unknown bounds. Its deliberately false one-sided interval was +rejected, so the negative control passed. This is evidence for the current Heightfield2D +implementation and audit path, not a proof for future field blocks or the final cliff/overhang +composition. + +The 4,096-point evaluator output hash matched at W1, W8 and W16 (`fae2721c052aec5c`). The timing +scope was point evaluation only: W1 2.059 ms, W8 6.851 ms, W16 12.626 ms. W8 and W16 were slower +on this small batch, likely from thread-startup overhead; no parallel speedup was measured. These +numbers are not a full view or tile benchmark. + +### Legacy comparison and captures + +For the same 4,096 points, the direct legacy density query took 62.115 ms. The prototype's mean +absolute density error was 274.0383 voxels, maximum error 841.4257 voxels, with 2,261 sign +mismatches (55.2%). The prototype is therefore not yet a parity replacement for the legacy field. +The current biome map and two-profile output need further shaping against the reference. + +The small top-down pair uses the same 3200-voxel XY span and one color ramp fitted to the observed +owner and native heights together (range `[-2273.12, -1410.26]` voxels): owner `GetSurfaceHeightAt` +reference and native Heightfield2D ground height. Each JPEG is below 6 KB. + +| Owner reference | Native prototype | +|---|---| +| ![Owner SurfaceWorld height reference](Research/WorldAssemblerM1/owner-reference-height.jpg) | ![Native Heightfield2D prototype](Research/WorldAssemblerM1/native-prototype-height.jpg) | + +The pair shows a large visible mismatch; it is a height-map comparison, not an in-game render. A +separate D3D window-backbuffer capture attempt was invalid: both viewer windows reported +`DXGI_STATUS_OCCLUDED`, the saved backbuffers were black, and the native prototype produced zero +mesh vertices/indices. The reduced diagnostic images were inspected only after downscaling. Do not +use those D3D files as visual evidence. + +### Query, collision and view readiness + +The audit compiled a Core world around the sample heightfield, queried 45 positions through +`World::densityAt`, and compared them with direct Heightfield2D evaluation. Maximum error was zero; +the sample Core compile took 35.608 ms; no tile mesh was generated +(`render_meshes_generated=0`). This verifies the local Core field-query path without render meshes +for this sample. It does not yet verify a dedicated-server build or the Unreal adapter. + +The no-manifest cave Core path also passed a constrained regression smoke on the existing +`type_3_Maze.json` fixture: the 5,642-point walkability audit passed, all 27 player-neighborhood +tiles and all 64 requested LOD0 tiles generated, and full-detail readiness was 0.157 s at W16. +The shape compile was 0.100 s. This small 64-tile LOD0 run confirms the additive no-plan path still +works; it is not the 23.085 s matched workload or the 0.785 s cave-only whole-view baseline. + +Collision cooking is not available in the standalone audit and was not measured. Full-view time was +also not measured by audit mode. Earlier small-view D3D attempt timings (owner 0.236 s, native +0.309 s) are not a valid full-view comparison because presentation was occluded and the native +view had no geometry. The 23.085 s matched-workload gate remains open. The ~1.57 s aim remains +reasonable, but round 1 has no valid full-view evidence to support or reject it. + +### Remaining work by round + +- **Round 2:** Match the legacy reference more closely; add output-space biome blending, coast/beach, + cap detail, cliffs and bounded 3D overhang features with stable IDs and certified influence + bounds. Expand bound audits across borders, caps, negative coordinates and exceptions. +- **Round 3:** Add the finite-strate capsule route and support constraints, sweep it against the final + composed field, and reject or repair any failing automatic plan. Verify full-evaluator W1/W8/W16 + hashes. Measure collision readiness separately from query and mesh readiness; demonstrate a + headless/server field query without render meshes. Keep the route valid during all automatic + changes, leave player edits unrestricted, and keep seasonal water fixed. +- **Round 4:** Wire the reviewed manifest into the season asset/content-hash path and connect Core + behind `bUseVoxelForgeCore`; prove the flag-off legacy path is unchanged. Build only the isolated + BuildHost target here. The owner builds and reviews the in-game surface and walkable route. + Re-run the matched 23.085 s workload, report full-view and local query/collision readiness + separately, and assess the ~1.57 s aim with valid captures. + +### Run log + +- First isolated full build compiled Core and passed the 199,209-line Core link-safety scan, then + failed at viewer link: the new inline API symbols were not emitted and the standalone build lacked + the GPU Far viewer implementation. No GPU Far source file exists in the standalone set. +- Retry adding the assumed `GpuFarViewer.cpp` failed at compile with `C1083` because that source file + is absent. The build was changed to use local CPU-viewer link stubs under ignored + `Standalone/Build/AssemblerM1/` output; Core remains GPU-free. +- The next full standalone build succeeded after moving the new API definitions out of line and + isolating the GPU viewer stubs. Another full build succeeded after adding the height-map capture; + a further build succeeded after adding manifest output; the final full rebuild after marking the + manifest API for Core export also succeeded. The latest full build succeeded after changing the + capture ramp to the observed shared height range. Each successful build took under one minute, + used `/MP16`, Normal priority and all 16 allowed CPUs; sampled compiler processes remained well + below 8 GB. +- Successful builds still print the environment's `'vswhere.exe' is not recognized` message and + existing C4838 narrowing warnings in `VoxelStrateManager.cpp:3974-3975`. These did not stop the + build. No `0xC0000142` retry occurred. A separate Git Bash `tail` inspection command failed with + access denied while opening a file; it made no changes. +- No BuildHost or owner-project build was run this round. The checked + `Saved/BuildHost/HostProject/Saved/Crashes/` folder contains one older `UECC-Windows-*` directory + from 2026-09-17; there was no new crash folder from this round. + +No Unreal route or asset was changed in this round, so there is no owner rebuild handoff yet. diff --git a/Standalone/Core/API.md b/Standalone/Core/API.md index c4560ad..963ac56 100644 --- a/Standalone/Core/API.md +++ b/Standalone/Core/API.md @@ -79,6 +79,45 @@ Pass `analyticOnly = false` to retain the reference packed-page compiler. Packed parent-cell coordinates and an 8-voxel apron; each covers a 128-voxel cube or one larger coarse tile. This path remains available for parity checks and workloads that need the packed lattice. +## World assembler manifest v1 (prototype) + +`ResolvedWorldSnapshot::FromJson` accepts an optional `world_assembly` object alongside the owner +snapshot. The object is the shipped-plan payload shape: a fixed-scale recipe and finite-strate plan +with hashes, no generated mesh or voxel samples. The reviewed sample payload is +[`round1-manifest.json`](../Research/WorldAssemblerM1/round1-manifest.json). Round 4 still needs to +connect this payload to the season asset/hash path. + +Every coordinate and scalar uses signed integer units of 1/256 voxel (`units_per_voxel: 256`), +independent of the plugin's 25 cm Core voxel and of tile size. Schema and evaluator versions are +both 1. JSON integer fields are canonicalized in declared field order and hashed as 4- or 8-byte +little-endian words with FNV-1a 64. Hashes are emitted as 16 lowercase hexadecimal characters; +JSON key order and whitespace do not affect them. The root, recipe, plan and each stratum carry a +content hash. The recipe stores its seed, warped biome-map settings and one to eight biome profiles; +the plan stores finite-strate extents, cap settings, stable-ID features and route points. Bounds, +feature count and route count are validated before a manifest is accepted. + +```cpp +std::string error; +auto manifest = VoxelForgeCore::WorldAssemblyManifest::FromJson(payload, error); +if (!manifest || !manifest->validate(error)) { /* reject the payload */ } +const auto* loaded = snapshot.worldAssembly(); // null when the owner snapshot has no plan +``` + +`Heightfield2D` is the first field block. `point(xQ, yQ, zQ)` evaluates the biome map and blended +height profiles at fixed-scale coordinates, returning the ground and cap heights, primary and +neighbor biome IDs, Q16 blend weight, and signed density. Negative density is solid. Its block +contract reads `biome` and writes `height` plus `rock`; `water`, `waterSurface`, `route` and +`surfaceTags` are declared channels for later blocks. The detail envelope reports maximum absolute +vertical displacement and minimum feature size. Influence is global in XY and limited to the +finite stratum's Z extent. + +`interval(minXQ, minYQ, minZQ, maxXQ, maxYQ, maxZQ)` returns a conservative density range or +`known == false`; callers must keep evaluating when it is unknown. The current heightfield range +uses the maximum recipe-profile amplitude and cap undulation over the whole biome set. The public +`AuditHeightfieldBounds` samples points inside randomized boxes and includes a deliberately false +one-sided range as a negative control. A pass means no sampled point escaped and the negative +control was rejected; it is an audit of this block implementation, not a proof for future blocks. + ## Queries and tiles `densityAt(position)` returns signed density; negative is solid and nonnegative is air. diff --git a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h index 93d5adf..92f90b9 100644 --- a/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h +++ b/Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h @@ -98,6 +98,182 @@ namespace VoxelForgeCore Vec3d viewerStartVoxels{}; }; + // World assembler v1 serializes every coordinate and scalar in 1/256 voxel units. + // This is independent of tile size and is the fixed input scale for Core field blocks. + // L'assembleur v1 encode coordonnees et scalaires en 1/256 de voxel, independamment des tuiles. + inline constexpr std::int64_t WorldAssemblySubvoxelsPerVoxel = 256; + inline constexpr std::uint32_t WorldAssemblySchemaVersion = 1; + inline constexpr std::uint32_t WorldAssemblyEvaluatorVersion = 1; + + enum class FieldChannel : std::uint32_t + { + rock = 1u << 0, height = 1u << 1, biome = 1u << 2, water = 1u << 3, + waterSurface = 1u << 4, route = 1u << 5, surfaceTags = 1u << 6 + }; + + constexpr std::uint32_t ChannelBit(FieldChannel channel) noexcept + { return static_cast(channel); } + + struct WorldAssemblyBiomeProfileV1 + { + std::uint32_t stableId = 0; + std::int64_t baseHeightSubvoxels = 0; + std::int64_t continentAmplitudeSubvoxels = 0; + std::int64_t continentWavelengthSubvoxels = 256 * 256; + std::int64_t mountainAmplitudeSubvoxels = 0; + std::int64_t mountainWavelengthSubvoxels = 256 * 128; + std::int64_t detailAmplitudeSubvoxels = 0; + std::int64_t detailWavelengthSubvoxels = 256 * 16; + }; + + struct WorldAssemblyRecipeV1 + { + std::uint32_t schemaVersion = WorldAssemblySchemaVersion; + std::uint32_t evaluatorVersion = WorldAssemblyEvaluatorVersion; + std::uint32_t unitsPerVoxel = static_cast(WorldAssemblySubvoxelsPerVoxel); + std::uint32_t seed = 0; + std::int64_t biomeCellSizeSubvoxels = 256 * 800; + std::int64_t biomeWarpSubvoxels = 256 * 250; + std::int64_t biomeWarpWavelengthSubvoxels = 256 * 556; + std::int64_t biomeBorderBlendSubvoxels = 256 * 120; + std::vector biomes; + std::uint64_t contentHash = 0; + }; + + struct WorldAssemblyRoutePointV1 + { + std::uint64_t stableFeatureId = 0; + std::array positionSubvoxels{}; + std::int64_t radiusSubvoxels = 0; + }; + + struct WorldAssemblyFeatureV1 + { + // Stable numeric type IDs are part of the plan contract; v1 reserves 1 for cliff planes, + // 2 for bounded overhang shelves and 3 for authored cap features. + // Les types numeriques stables font partie du plan; v1 reserve 1 aux falaises, 2 aux surplombs bornes et 3 aux caps. + std::uint64_t stableId = 0; + std::uint32_t type = 0; + std::array minimumSubvoxels{}; + std::array maximumSubvoxels{}; + std::array parametersSubvoxels{}; + }; + + struct WorldAssemblyStratumPlanV1 + { + std::int32_t strateIndex = -1; + std::int32_t topChunkZ = 0; + std::int32_t bottomChunkZ = 0; + std::int64_t capHeightSubvoxels = 0; + std::int64_t capUndulationSubvoxels = 0; + std::int64_t capWavelengthSubvoxels = 256 * 512; + std::vector features; + std::vector route; + std::uint64_t contentHash = 0; + }; + + struct WorldAssemblyPlanV1 + { + std::uint32_t schemaVersion = WorldAssemblySchemaVersion; + std::vector strates; + std::uint64_t contentHash = 0; + }; + + struct VFCORE_API WorldAssemblyManifest + { + std::uint32_t schemaVersion = WorldAssemblySchemaVersion; + WorldAssemblyRecipeV1 recipe; + WorldAssemblyPlanV1 plan; + std::uint64_t contentHash = 0; + + // JSON is the reviewed season-manifest payload. Hashes use canonical ordered fields, + // little-endian integers and FNV-1a 64; textual key order and whitespace do not matter. + // Le JSON est le payload de saison valide; les entiers little-endian sont haches en FNV-1a 64, sans dependance a la mise en forme. + static std::optional FromJson( + std::string_view json, std::string& error); + bool finalize(std::string& error); + bool validate(std::string& error) const; + std::string ToJson() const; + }; + + struct HeightfieldPointSample + { + std::int64_t groundHeightSubvoxels = 0; + std::int64_t capHeightSubvoxels = 0; + std::uint32_t biomeId = 0; + std::uint32_t neighborBiomeId = 0; + std::uint32_t biomeWeightQ16 = 0; + std::int64_t densitySubvoxels = 0; + }; + + struct FieldInterval + { + bool known = false; + std::int64_t minimumDensitySubvoxels = 0; + std::int64_t maximumDensitySubvoxels = 0; + }; + + struct FieldInfluenceBounds + { + bool unboundedXY = true; + std::int64_t minimumXSubvoxels = 0, maximumXSubvoxels = 0; + std::int64_t minimumYSubvoxels = 0, maximumYSubvoxels = 0; + std::int64_t minimumZSubvoxels = 0, maximumZSubvoxels = 0; + }; + + struct FieldDetailEnvelope + { + std::int64_t maximumAbsoluteDisplacementSubvoxels = 0; + std::int64_t minimumFeatureSizeSubvoxels = 1; + }; + + struct FieldBlockContract + { + std::uint32_t readsChannels = 0; + std::uint32_t writesChannels = 0; + std::uint32_t evaluatorVersion = WorldAssemblyEvaluatorVersion; + }; + + // Point evaluation is canonical at 1/256 voxel. Bounds fail closed when the block or input + // extent is invalid; callers must evaluate when interval() returns unknown. + // L'evaluation est canonique au 1/256 de voxel. Toute borne invalide echoue prudemment; une borne inconnue impose l'evaluation. + class VFCORE_API Heightfield2D + { + public: + Heightfield2D(const WorldAssemblyRecipeV1& recipe, + const WorldAssemblyStratumPlanV1& plan) noexcept; + bool isValid() const noexcept; + HeightfieldPointSample point(std::int64_t xSubvoxels, std::int64_t ySubvoxels, + std::int64_t zSubvoxels) const noexcept; + FieldInterval interval(std::int64_t minimumXSubvoxels, + std::int64_t minimumYSubvoxels, + std::int64_t minimumZSubvoxels, + std::int64_t maximumXSubvoxels, + std::int64_t maximumYSubvoxels, + std::int64_t maximumZSubvoxels) const noexcept; + FieldInfluenceBounds influenceBounds() const noexcept; + FieldDetailEnvelope detailEnvelope() const noexcept; + FieldBlockContract contract() const noexcept; + + private: + const WorldAssemblyRecipeV1* recipe_ = nullptr; + const WorldAssemblyStratumPlanV1* plan_ = nullptr; + }; + + struct WorldAssemblyBoundsAuditResult + { + std::uint64_t boxesTested = 0; + std::uint64_t pointSamples = 0; + std::uint64_t missedBounds = 0; + std::uint64_t unknownBounds = 0; + bool negativeControlDetected = false; + bool passed = false; + }; + + VFCORE_API WorldAssemblyBoundsAuditResult AuditHeightfieldBounds( + const Heightfield2D& block, std::uint32_t boxCount = 256, + std::uint64_t seed = 0x574f524c44415331ull); + class VFCORE_API ResolvedWorldSnapshot { public: @@ -110,6 +286,7 @@ namespace VoxelForgeCore explicit operator bool() const noexcept { return static_cast(data_); } const WorldConfiguration& configuration() const; + const WorldAssemblyManifest* worldAssembly() const noexcept; private: struct Data; diff --git a/Standalone/Core/src/CoreNamespacePreamble.h b/Standalone/Core/src/CoreNamespacePreamble.h index 1bac805..275655b 100644 --- a/Standalone/Core/src/CoreNamespacePreamble.h +++ b/Standalone/Core/src/CoreNamespacePreamble.h @@ -52,3 +52,4 @@ // VFJson is a small, stateless standalone input parser with no Unreal-shaped names. Keeping its // value tree shared lets the viewer resolve its reference world and Core snapshot from one parse. #include "Json.h" +#include "VoxelForgeCore/VoxelForgeCore.h" diff --git a/Standalone/Core/src/ShapeWorldInternal.h b/Standalone/Core/src/ShapeWorldInternal.h index cdd04fe..43257ec 100644 --- a/Standalone/Core/src/ShapeWorldInternal.h +++ b/Standalone/Core/src/ShapeWorldInternal.h @@ -281,11 +281,13 @@ public: bool bInRoundTunnels=false,bool bInProfileDetailBand=false, bool bValidatePassageRoutes=true, const std::vector& InGpuVisualDetailTiles={}, - const std::vector& InCpuVisualDetailTiles={}) + const std::vector& InCpuVisualDetailTiles={}, + const VoxelForgeCore::WorldAssemblyManifest* InWorldAssembly=nullptr) { using Clock=std::chrono::steady_clock; const auto TotalStart=Clock::now(); - Owner=&World;WorkerCount=std::clamp(Workers,1,16);DetailVariant=Variant; + Owner=&World;WorldAssembly=InWorldAssembly; + WorkerCount=std::clamp(Workers,1,16);DetailVariant=Variant; Scheduler=InScheduler;RoundTunnelsEnabled=bInRoundTunnels; bAnalyticOnly=false; bProfileDetailBand=bInProfileDetailBand; @@ -919,6 +921,9 @@ public: // edge interpolation and normals to the existing mesher. float DensityAt(float X,float Y,float Z) const { + float NativeDensity=0.0f; + if(NativeSurfaceDensityAt(X,Y,Z,NativeDensity)) + return ApplyEditBrushes(NativeDensity,X,Y,Z); if(UsesLegacySurfaceField(X,Y,Z)) return LegacySurfaceDensityAt(X,Y,Z); int32 IX=0,IY=0,IZ=0; @@ -931,6 +936,10 @@ public: float DensityAtLattice(int32 X,int32 Y,int32 Z) const { + float NativeDensity=0.0f; + if(NativeSurfaceDensityAt(static_cast(X),static_cast(Y), + static_cast(Z),NativeDensity)) + return ApplyEditBrushes(NativeDensity,X,Y,Z); if(UsesLegacySurfaceField(static_cast(X),static_cast(Y), static_cast(Z))) return LegacySurfaceDensityAt(static_cast(X),static_cast(Y), @@ -945,6 +954,9 @@ public: float DensityAtUnclamped(float X,float Y,float Z) const { + float NativeDensity=0.0f; + if(NativeSurfaceDensityAt(X,Y,Z,NativeDensity)) + return ApplyEditBrushes(NativeDensity,X,Y,Z); if(UsesLegacySurfaceField(X,Y,Z)) return LegacySurfaceDensityAt(X,Y,Z); return AnalyticDensityAt(X,Y,Z,false); @@ -1066,6 +1078,9 @@ private: if(OutFloorInfluence)*OutFloorInfluence=0.0f; if(UsesLegacySurfaceField(X,Y,Z)) { + float NativeDensity=0.0f; + if(NativeSurfaceDensityAt(X,Y,Z,NativeDensity)) + return bApplyEdits?ApplyEditBrushes(NativeDensity,X,Y,Z):NativeDensity; const float Density=Owner->Generator->GetDensityAt(X,Y,Z); return bApplyEdits?ApplyEditBrushes(Density,X,Y,Z):Density; } @@ -1119,10 +1134,8 @@ public: const int32 Pad=std::max(Step,1); int32 BMin[3]={Origin.X-Pad,Origin.Y-Pad,Origin.Z-Pad}; int32 BMax[3]={Origin.X+(Cells+1)*Step,Origin.Y+(Cells+1)*Step,Origin.Z+(Cells+1)*Step}; - // A primitive-only proof cannot classify the biome heightfield. Keep overlapping - // SurfaceWorld boxes mixed so generation samples the exact copied evaluator. - // Une preuve fondee sur les primitives ne classe pas la hauteur par biomes; garder les - // boites SurfaceWorld en Mixed force l'evaluation exacte lors du maillage. + const VoxelForgeCore::WorldAssemblyStratumPlanV1* SurfacePlan=nullptr; + bool bSurfaceOverlap=false,bOtherStrateOverlap=false; if(Owner&&Owner->Manager) { const int32 FirstChunk=FMath::FloorToInt(static_cast(BMin[2]) @@ -1130,10 +1143,24 @@ public: const int32 LastChunk=FMath::FloorToInt(static_cast(BMax[2]) /static_cast(CHUNK_SIZE)); for(int32 ChunkZ=FirstChunk;ChunkZ<=LastChunk;++ChunkZ) + { if(Owner->Manager->GetGeneratorTypeForChunk(FIntVector(0,0,ChunkZ)) ==ECaveGeneratorType::SurfaceWorld) - return EVoxelTileClass::Mixed; + { + bSurfaceOverlap=true; + const int32 StrateIndex=Owner->Manager->GetStrateIndex( + static_cast((static_cast(ChunkZ*CHUNK_SIZE+CHUNK_SIZE/2))*VOXEL_SIZE)); + const auto* Candidate=FindWorldAssemblyStratum(StrateIndex,ChunkZ); + if(!Candidate||(SurfacePlan&&SurfacePlan!=Candidate)) + return EVoxelTileClass::Mixed; + SurfacePlan=Candidate; + } + else bOtherStrateOverlap=true; + } } + // A box crossing recipes cannot use one field interval; unknown composition stays Mixed. + // Une boite qui traverse plusieurs recettes reste Mixed, car l'intervalle d'un seul champ ne suffit pas. + if(bSurfaceOverlap&&bOtherStrateOverlap)return EVoxelTileClass::Mixed; if(!EditBrushes.empty()&&EditBoundsMin.X<=BMax[0]&&EditBoundsMax.X>=BMin[0] &&EditBoundsMin.Y<=BMax[1]&&EditBoundsMax.Y>=BMin[1] &&EditBoundsMin.Z<=BMax[2]&&EditBoundsMax.Z>=BMin[2]) @@ -1155,6 +1182,22 @@ public: if(R.Lo<=BMax[2]&&R.Hi>=BMin[2] &&(R.Lo>=BMin[2]||R.Hi<=BMax[2])) return EVoxelTileClass::Mixed; + if(bSurfaceOverlap) + { + if(!WorldAssembly||!SurfacePlan)return EVoxelTileClass::Mixed; + const VoxelForgeCore::Heightfield2D Block(WorldAssembly->recipe,*SurfacePlan); + const auto Bounds=Block.interval( + static_cast(BMin[0])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel, + static_cast(BMin[1])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel, + static_cast(BMin[2])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel, + static_cast(BMax[0])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel, + static_cast(BMax[1])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel, + static_cast(BMax[2])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel); + if(!Bounds.known)return EVoxelTileClass::Mixed; + if(Bounds.minimumDensitySubvoxels>0)return EVoxelTileClass::AllAir; + if(Bounds.maximumDensitySubvoxels<0)return EVoxelTileClass::AllSolid; + return EVoxelTileClass::Mixed; + } const bool bRock=EvaluateFast((BMin[0]+BMax[0])*0.5, (BMin[1]+BMax[1])*0.5,(BMin[2]+BMax[2])*0.5); return bRock?EVoxelTileClass::AllSolid:EVoxelTileClass::AllAir; @@ -1619,6 +1662,41 @@ public: } private: + const VoxelForgeCore::WorldAssemblyStratumPlanV1* FindWorldAssemblyStratum( + int32 StrateIndex,int32 ChunkZ) const + { + if(!WorldAssembly||StrateIndex<0)return nullptr; + const auto It=std::find_if(WorldAssembly->plan.strates.begin(),WorldAssembly->plan.strates.end(), + [StrateIndex,ChunkZ](const auto& Entry) + {return Entry.strateIndex==StrateIndex&&ChunkZ<=Entry.topChunkZ&&ChunkZ>=Entry.bottomChunkZ;}); + return It==WorldAssembly->plan.strates.end()?nullptr:&*It; + } + + bool NativeSurfaceDensityAt(float X,float Y,float Z,float& OutDensity) const + { + if(!WorldAssembly||!Owner||!Owner->Manager||!Owner->Generator + ||!std::isfinite(X)||!std::isfinite(Y)||!std::isfinite(Z))return false; + const int32 ChunkZ=FMath::FloorToInt(Z/static_cast(CHUNK_SIZE)); + if(Owner->Manager->GetGeneratorTypeForChunk(FIntVector(0,0,ChunkZ)) + !=ECaveGeneratorType::SurfaceWorld)return false; + const int32 StrateIndex=Owner->Manager->GetStrateIndex( + static_cast((static_cast(ChunkZ*CHUNK_SIZE+CHUNK_SIZE/2))*VOXEL_SIZE)); + const auto* Plan=FindWorldAssemblyStratum(StrateIndex,ChunkZ); + if(!Plan)return false; + constexpr double Scale=static_cast(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel); + const double MaxVoxel=static_cast(std::numeric_limits::max())/Scale; + if(std::abs(static_cast(X))>MaxVoxel||std::abs(static_cast(Y))>MaxVoxel + ||std::abs(static_cast(Z))>MaxVoxel)return false; + const auto ToFixed=[](float Value) + {return static_cast(std::llround(static_cast(Value) + *VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel));}; + const VoxelForgeCore::Heightfield2D Block(WorldAssembly->recipe,*Plan); + if(!Block.isValid())return false; + OutDensity=static_cast(Block.point(ToFixed(X),ToFixed(Y),ToFixed(Z)).densitySubvoxels + /Scale); + return true; + } + bool UsesLegacySurfaceField(float X,float Y,float Z) const { if(!Owner||!Owner->Manager||!Owner->Generator)return false; @@ -4850,6 +4928,7 @@ private: } FStandaloneOwnerWorld* Owner=nullptr; + const VoxelForgeCore::WorldAssemblyManifest* WorldAssembly=nullptr; int32 WorkerCount=6; const VoxelForgeCore::JobScheduler* Scheduler=nullptr; bool RoundTunnelsEnabled=false; diff --git a/Standalone/Core/src/VoxelForgeCore.cpp b/Standalone/Core/src/VoxelForgeCore.cpp index 6f29cb4..e723583 100644 --- a/Standalone/Core/src/VoxelForgeCore.cpp +++ b/Standalone/Core/src/VoxelForgeCore.cpp @@ -1,6 +1,7 @@ #include "CoreNamespacePreamble.h" #include "StandaloneSnapshotAdapter.h" #include "VoxelForgeCore/VoxelForgeCore.h" +#include "WorldAssemblerInternal.h" #include #include @@ -23,6 +24,7 @@ namespace VoxelForgeCore struct ResolvedWorldSnapshot::Data { std::shared_ptr owner; + std::shared_ptr worldAssembly; WorldConfiguration configuration; }; @@ -216,12 +218,14 @@ namespace VoxelForgeCore struct detail_StandaloneSnapshotAdapter { - static ResolvedWorldSnapshot FromOwner(std::shared_ptr owner) + static ResolvedWorldSnapshot FromOwner(std::shared_ptr owner, + std::shared_ptr worldAssembly = {}) { if(!owner||!owner->Settings||!owner->Manager||!owner->Generator)return {}; auto data=std::make_shared(); data->configuration=MakeConfiguration(*owner); data->owner=std::move(owner); + data->worldAssembly=std::move(worldAssembly); return ResolvedWorldSnapshot(std::move(data)); } }; @@ -232,6 +236,13 @@ namespace VoxelForgeCore { auto owner=std::make_shared(); if(!StandaloneOwnerLoader::Resolve(jsonRoot,*owner,error))return {}; + std::shared_ptr worldAssembly; + if(jsonRoot.Get("world_assembly").IsObject()) + { + auto parsed=detail_world_assembly::ParseNestedManifest(jsonRoot,error); + if(!parsed)return {}; + worldAssembly=std::make_shared(std::move(*parsed)); + } const bool seedChanged=owner->Seed!=seed; owner->Settings->Seed=seed; @@ -254,7 +265,7 @@ namespace VoxelForgeCore owner->MaxClipLevel=maxLodOverride; owner->Settings->MaxClipLevel=maxLodOverride; } - return detail_StandaloneSnapshotAdapter::FromOwner(std::move(owner)); + return detail_StandaloneSnapshotAdapter::FromOwner(std::move(owner),std::move(worldAssembly)); } std::optional ResolvedWorldSnapshot::FromJson( @@ -262,9 +273,21 @@ namespace VoxelForgeCore { auto owner=std::make_shared(); if(!StandaloneOwnerLoader::LoadJson(json,*owner,error))return std::nullopt; - return detail_StandaloneSnapshotAdapter::FromOwner(std::move(owner)); + VFJson::Value root; + if(!VFJson::Parser(std::string(json)).Parse(root,error))return std::nullopt; + std::shared_ptr worldAssembly; + if(root.Get("world_assembly").IsObject()) + { + auto parsed=detail_world_assembly::ParseNestedManifest(root,error); + if(!parsed)return std::nullopt; + worldAssembly=std::make_shared(std::move(*parsed)); + } + return detail_StandaloneSnapshotAdapter::FromOwner(std::move(owner),std::move(worldAssembly)); } + const WorldAssemblyManifest* ResolvedWorldSnapshot::worldAssembly() const noexcept + { return data_?data_->worldAssembly.get():nullptr; } + const WorldConfiguration& ResolvedWorldSnapshot::configuration() const { static const WorldConfiguration empty{}; @@ -274,6 +297,7 @@ namespace VoxelForgeCore struct World::Impl { std::shared_ptr owner; + std::shared_ptr worldAssembly; WorldConfiguration configuration; std::unique_ptr shape; ShapeBuildRequest buildRequest; @@ -291,6 +315,7 @@ namespace VoxelForgeCore if(snapshot.data_) { impl_->owner=snapshot.data_->owner; + impl_->worldAssembly=snapshot.data_->worldAssembly; impl_->configuration=snapshot.data_->configuration; } } @@ -307,7 +332,7 @@ namespace VoxelForgeCore static_cast(std::clamp(request.workerLimit,1,16)),ToNative(request.detail), request.includeFarWorld,error,&scheduler,request.roundTunnels,request.profileDetailBand, request.validatePassageRoutes,request.gpuVisualDetailTiles, - request.cpuVisualDetailTiles); + request.cpuVisualDetailTiles,impl_->worldAssembly.get()); if(ok) { impl_->shape=std::move(shape); @@ -443,7 +468,7 @@ namespace VoxelForgeCore auto region=std::make_shared(); if(!region->Build(*impl_->owner,fineMin,fineMax,regionMin,regionMax, static_cast(clampedWorkers),ToNative(base.detail),false,error,&scheduler, - base.roundTunnels,base.profileDetailBand,false))return false; + base.roundTunnels,base.profileDetailBand,false,{}, {},impl_->worldAssembly.get()))return false; if(!impl_->editLog.empty())region->SetEditTransaction(impl_->editLog); const auto& build=region->GetStats(); ++stats.regionsBuilt; diff --git a/Standalone/Core/src/WorldAssemblerInternal.h b/Standalone/Core/src/WorldAssemblerInternal.h new file mode 100644 index 0000000..e642782 --- /dev/null +++ b/Standalone/Core/src/WorldAssemblerInternal.h @@ -0,0 +1,766 @@ +#pragma once + +#include "Json.h" +#include "VoxelForgeCore/VoxelForgeCore.h" + +#include +#include +#include +#include +#include + +namespace VoxelForgeCore +{ +namespace world_assembly_detail +{ + constexpr std::int64_t Q16 = 1ll << 16; + constexpr std::int64_t MaxSerializedMagnitude = 1000000000000ll; + constexpr std::size_t MaxBiomeCount = 8; + constexpr std::size_t MaxStrateCount = 64; + constexpr std::size_t MaxFeatureCount = 4096; + constexpr std::size_t MaxRoutePointCount = 4096; + + inline void HashWord(std::uint64_t& hash, std::uint64_t word, int bytes) + { + for (int i = 0; i < bytes; ++i) + { + hash ^= static_cast(word >> (i * 8)); + hash *= 1099511628211ull; + } + } + + inline std::uint64_t StartHash() { return 14695981039346656037ull; } + + inline bool ReadInteger(const VFJson::Value& value, std::int64_t& out) + { + if (!value.IsNumber()) return false; + const double number = value.Number(); + constexpr double ExactJsonIntegerLimit = 9007199254740991.0; + if (!std::isfinite(number) || std::trunc(number) != number + || std::abs(number) > ExactJsonIntegerLimit) return false; + out = static_cast(number); + return true; + } + + inline bool ReadU32(const VFJson::Value& value, std::uint32_t& out) + { + std::int64_t parsed = 0; + if (!ReadInteger(value, parsed) || parsed < 0 + || parsed > std::numeric_limits::max()) return false; + out = static_cast(parsed); + return true; + } + + inline bool ReadI32(const VFJson::Value& value, std::int32_t& out) + { + std::int64_t parsed = 0; + if (!ReadInteger(value, parsed) || parsed < std::numeric_limits::min() + || parsed > std::numeric_limits::max()) return false; + out = static_cast(parsed); + return true; + } + + inline bool ReadArray(const VFJson::Value& value, std::int64_t* out, std::size_t count) + { + if (!value.IsArray() || value.AsArray().size() != count) return false; + for (std::size_t i = 0; i < count; ++i) + if (!ReadInteger(value.AsArray()[i], out[i])) return false; + return true; + } + + inline bool ReadHash(const VFJson::Value& value, std::uint64_t& out) + { + if (!value.IsString()) return false; + std::string_view text = value.String(); + if (text.starts_with("0x") || text.starts_with("0X")) text.remove_prefix(2); + if (text.empty() || text.size() > 16) return false; + const auto result = std::from_chars(text.data(), text.data() + text.size(), out, 16); + return result.ec == std::errc{} && result.ptr == text.data() + text.size(); + } + + inline std::string HashText(std::uint64_t hash) + { + constexpr char Hex[] = "0123456789abcdef"; + std::string out(16, '0'); + for (int i = 15; i >= 0; --i) + { + out[static_cast(i)] = Hex[hash & 0xfu]; + hash >>= 4; + } + return out; + } + + inline bool ValidScalar(std::int64_t value) + { return value >= -MaxSerializedMagnitude && value <= MaxSerializedMagnitude; } + + inline bool OutsideRange(std::int64_t value) + { return value < -MaxSerializedMagnitude || value > MaxSerializedMagnitude; } + + inline bool ValidateRecipe(const WorldAssemblyRecipeV1& recipe, std::string& error) + { + if (recipe.schemaVersion != WorldAssemblySchemaVersion + || recipe.evaluatorVersion != WorldAssemblyEvaluatorVersion + || recipe.unitsPerVoxel != WorldAssemblySubvoxelsPerVoxel) + { error = "world assembly recipe version, evaluator or fixed scale is unsupported"; return false; } + if (recipe.biomes.empty() || recipe.biomes.size() > MaxBiomeCount) + { error = "world assembly recipe must contain one to eight biome profiles"; return false; } + if (recipe.biomeCellSizeSubvoxels <= 0 || recipe.biomeCellSizeSubvoxels > 100000000ll + || recipe.biomeWarpSubvoxels < 0 || recipe.biomeWarpSubvoxels > recipe.biomeCellSizeSubvoxels / 2 + || recipe.biomeWarpWavelengthSubvoxels <= 0 + || recipe.biomeWarpWavelengthSubvoxels > 100000000ll + || recipe.biomeBorderBlendSubvoxels < 0 + || recipe.biomeBorderBlendSubvoxels > recipe.biomeCellSizeSubvoxels) + { error = "world assembly biome-map parameters are outside the supported fixed-scale range"; return false; } + std::vector ids; + ids.reserve(recipe.biomes.size()); + for (const auto& biome : recipe.biomes) + { + if (biome.stableId == 0 || std::find(ids.begin(), ids.end(), biome.stableId) != ids.end()) + { error = "world assembly biome IDs must be nonzero and unique"; return false; } + ids.push_back(biome.stableId); + const std::array values = {biome.baseHeightSubvoxels, + biome.continentAmplitudeSubvoxels, biome.continentWavelengthSubvoxels, + biome.mountainAmplitudeSubvoxels, biome.mountainWavelengthSubvoxels, + biome.detailAmplitudeSubvoxels, biome.detailWavelengthSubvoxels}; + if (!std::all_of(values.begin(), values.end(), ValidScalar) + || biome.continentAmplitudeSubvoxels < 0 || biome.mountainAmplitudeSubvoxels < 0 + || biome.detailAmplitudeSubvoxels < 0 || biome.continentWavelengthSubvoxels <= 0 + || biome.mountainWavelengthSubvoxels <= 0 || biome.detailWavelengthSubvoxels <= 0) + { error = "world assembly biome profile has an invalid fixed-scale value"; return false; } + } + return true; + } + + inline bool ValidateStratum(const WorldAssemblyStratumPlanV1& stratum, std::string& error) + { + if (stratum.strateIndex < 0 || stratum.topChunkZ < stratum.bottomChunkZ + || stratum.capWavelengthSubvoxels <= 0 || stratum.capUndulationSubvoxels < 0 + || stratum.features.size() > MaxFeatureCount || stratum.route.size() > MaxRoutePointCount) + { error = "world assembly stratum plan has an invalid extent or exceeds its bounded payload"; return false; } + if (!ValidScalar(stratum.capHeightSubvoxels) || !ValidScalar(stratum.capUndulationSubvoxels) + || !ValidScalar(stratum.capWavelengthSubvoxels)) + { error = "world assembly cap contains a value outside the fixed-scale range"; return false; } + std::vector ids; + ids.reserve(stratum.features.size()); + for (const auto& feature : stratum.features) + { + if (feature.stableId == 0 || feature.type < 1 || feature.type > 3 + || std::find(ids.begin(), ids.end(), feature.stableId) != ids.end()) + { error = "world assembly feature IDs and types must be stable, unique and supported"; return false; } + ids.push_back(feature.stableId); + for (std::size_t axis = 0; axis < 3; ++axis) + if (!ValidScalar(feature.minimumSubvoxels[axis]) + || !ValidScalar(feature.maximumSubvoxels[axis]) + || feature.minimumSubvoxels[axis] > feature.maximumSubvoxels[axis]) + { error = "world assembly feature has an invalid influence bound"; return false; } + if (!std::all_of(feature.parametersSubvoxels.begin(), feature.parametersSubvoxels.end(), ValidScalar)) + { error = "world assembly feature parameter is outside the fixed-scale range"; return false; } + } + for (const auto& point : stratum.route) + { + if (point.stableFeatureId == 0 || point.radiusSubvoxels <= 0 + || !ValidScalar(point.radiusSubvoxels) + || !std::all_of(point.positionSubvoxels.begin(), point.positionSubvoxels.end(), ValidScalar)) + { error = "world assembly route point has an invalid feature ID, position or radius"; return false; } + } + return true; + } + + inline std::uint64_t HashRecipe(const WorldAssemblyRecipeV1& recipe) + { + std::uint64_t hash = StartHash(); + HashWord(hash, recipe.schemaVersion, 4); HashWord(hash, recipe.evaluatorVersion, 4); + HashWord(hash, recipe.unitsPerVoxel, 4); HashWord(hash, recipe.seed, 4); + HashWord(hash, static_cast(recipe.biomeCellSizeSubvoxels), 8); + HashWord(hash, static_cast(recipe.biomeWarpSubvoxels), 8); + HashWord(hash, static_cast(recipe.biomeWarpWavelengthSubvoxels), 8); + HashWord(hash, static_cast(recipe.biomeBorderBlendSubvoxels), 8); + HashWord(hash, recipe.biomes.size(), 4); + for (const auto& biome : recipe.biomes) + { + HashWord(hash, biome.stableId, 4); + HashWord(hash, static_cast(biome.baseHeightSubvoxels), 8); + HashWord(hash, static_cast(biome.continentAmplitudeSubvoxels), 8); + HashWord(hash, static_cast(biome.continentWavelengthSubvoxels), 8); + HashWord(hash, static_cast(biome.mountainAmplitudeSubvoxels), 8); + HashWord(hash, static_cast(biome.mountainWavelengthSubvoxels), 8); + HashWord(hash, static_cast(biome.detailAmplitudeSubvoxels), 8); + HashWord(hash, static_cast(biome.detailWavelengthSubvoxels), 8); + } + return hash; + } + + inline std::uint64_t HashStratum(const WorldAssemblyStratumPlanV1& stratum) + { + std::uint64_t hash = StartHash(); + HashWord(hash, static_cast(stratum.strateIndex), 4); + HashWord(hash, static_cast(stratum.topChunkZ), 4); + HashWord(hash, static_cast(stratum.bottomChunkZ), 4); + HashWord(hash, static_cast(stratum.capHeightSubvoxels), 8); + HashWord(hash, static_cast(stratum.capUndulationSubvoxels), 8); + HashWord(hash, static_cast(stratum.capWavelengthSubvoxels), 8); + HashWord(hash, stratum.features.size(), 4); + for (const auto& feature : stratum.features) + { + HashWord(hash, feature.stableId, 8); HashWord(hash, feature.type, 4); + for (const auto value : feature.minimumSubvoxels) HashWord(hash, static_cast(value), 8); + for (const auto value : feature.maximumSubvoxels) HashWord(hash, static_cast(value), 8); + for (const auto value : feature.parametersSubvoxels) HashWord(hash, static_cast(value), 8); + } + HashWord(hash, stratum.route.size(), 4); + for (const auto& point : stratum.route) + { + HashWord(hash, point.stableFeatureId, 8); + for (const auto value : point.positionSubvoxels) HashWord(hash, static_cast(value), 8); + HashWord(hash, static_cast(point.radiusSubvoxels), 8); + } + return hash; + } + + inline std::uint64_t HashPlan(const WorldAssemblyPlanV1& plan) + { + std::uint64_t hash = StartHash(); + HashWord(hash, plan.schemaVersion, 4); HashWord(hash, plan.strates.size(), 4); + for (const auto& stratum : plan.strates) HashWord(hash, stratum.contentHash, 8); + return hash; + } + + inline std::uint64_t HashManifest(const WorldAssemblyManifest& manifest) + { + std::uint64_t hash = StartHash(); + HashWord(hash, manifest.schemaVersion, 4); + HashWord(hash, manifest.recipe.contentHash, 8); + HashWord(hash, manifest.plan.contentHash, 8); + return hash; + } + + inline bool ParseRecipe(const VFJson::Value& value, WorldAssemblyRecipeV1& recipe, + std::string& error) + { + if (!value.IsObject() || !ReadU32(value.Get("schema_version"), recipe.schemaVersion) + || !ReadU32(value.Get("evaluator_version"), recipe.evaluatorVersion) + || !ReadU32(value.Get("units_per_voxel"), recipe.unitsPerVoxel) + || !ReadU32(value.Get("seed"), recipe.seed) + || !ReadInteger(value.Get("biome_cell_size_q"), recipe.biomeCellSizeSubvoxels) + || !ReadInteger(value.Get("biome_warp_q"), recipe.biomeWarpSubvoxels) + || !ReadInteger(value.Get("biome_warp_wavelength_q"), recipe.biomeWarpWavelengthSubvoxels) + || !ReadInteger(value.Get("biome_border_blend_q"), recipe.biomeBorderBlendSubvoxels) + || !ReadHash(value.Get("content_hash"), recipe.contentHash) + || !value.Get("biomes").IsArray()) + { error = "world assembly recipe is missing a required versioned fixed-scale field"; return false; } + for (const auto& item : value.Get("biomes").AsArray()) + { + WorldAssemblyBiomeProfileV1 biome; + if (!item.IsObject() || !ReadU32(item.Get("id"), biome.stableId) + || !ReadInteger(item.Get("base_height_q"), biome.baseHeightSubvoxels) + || !ReadInteger(item.Get("continent_amplitude_q"), biome.continentAmplitudeSubvoxels) + || !ReadInteger(item.Get("continent_wavelength_q"), biome.continentWavelengthSubvoxels) + || !ReadInteger(item.Get("mountain_amplitude_q"), biome.mountainAmplitudeSubvoxels) + || !ReadInteger(item.Get("mountain_wavelength_q"), biome.mountainWavelengthSubvoxels) + || !ReadInteger(item.Get("detail_amplitude_q"), biome.detailAmplitudeSubvoxels) + || !ReadInteger(item.Get("detail_wavelength_q"), biome.detailWavelengthSubvoxels)) + { error = "world assembly biome profile is missing a required fixed-scale field"; return false; } + recipe.biomes.push_back(biome); + } + return true; + } + + inline bool ParsePlan(const VFJson::Value& value, WorldAssemblyPlanV1& plan, + std::string& error) + { + if (!value.IsObject() || !ReadU32(value.Get("schema_version"), plan.schemaVersion) + || !ReadHash(value.Get("content_hash"), plan.contentHash) + || !value.Get("strates").IsArray()) + { error = "world assembly plan is missing a required versioned field"; return false; } + for (const auto& item : value.Get("strates").AsArray()) + { + WorldAssemblyStratumPlanV1 stratum; + if (!item.IsObject() || !ReadI32(item.Get("strate_index"), stratum.strateIndex) + || !ReadI32(item.Get("top_chunk_z"), stratum.topChunkZ) + || !ReadI32(item.Get("bottom_chunk_z"), stratum.bottomChunkZ) + || !ReadInteger(item.Get("cap_height_q"), stratum.capHeightSubvoxels) + || !ReadInteger(item.Get("cap_undulation_q"), stratum.capUndulationSubvoxels) + || !ReadInteger(item.Get("cap_wavelength_q"), stratum.capWavelengthSubvoxels) + || !ReadHash(item.Get("content_hash"), stratum.contentHash) + || !item.Get("features").IsArray() || !item.Get("route").IsArray()) + { error = "world assembly stratum is missing a required fixed-scale field"; return false; } + for (const auto& featureValue : item.Get("features").AsArray()) + { + WorldAssemblyFeatureV1 feature; + std::uint32_t type = 0; + if (!featureValue.IsObject() || !ReadHash(featureValue.Get("stable_id"), feature.stableId) + || !ReadU32(featureValue.Get("type"), type) + || !ReadArray(featureValue.Get("min_q"), feature.minimumSubvoxels.data(), 3) + || !ReadArray(featureValue.Get("max_q"), feature.maximumSubvoxels.data(), 3) + || !ReadArray(featureValue.Get("parameters_q"), feature.parametersSubvoxels.data(), 4)) + { error = "world assembly feature is missing a fixed-scale field"; return false; } + feature.type = type; + stratum.features.push_back(feature); + } + for (const auto& pointValue : item.Get("route").AsArray()) + { + WorldAssemblyRoutePointV1 point; + if (!pointValue.IsObject() || !ReadHash(pointValue.Get("feature_id"), point.stableFeatureId) + || !ReadArray(pointValue.Get("position_q"), point.positionSubvoxels.data(), 3) + || !ReadInteger(pointValue.Get("radius_q"), point.radiusSubvoxels)) + { error = "world assembly route point is missing a fixed-scale field"; return false; } + stratum.route.push_back(point); + } + plan.strates.push_back(std::move(stratum)); + } + return true; + } + + inline std::optional ParseManifestValue(const VFJson::Value& value, + std::string& error) + { + WorldAssemblyManifest manifest; + if (!value.IsObject() || !ReadU32(value.Get("schema_version"), manifest.schemaVersion) + || !ReadHash(value.Get("content_hash"), manifest.contentHash) + || !ParseRecipe(value.Get("recipe"), manifest.recipe, error) + || !ParsePlan(value.Get("plan"), manifest.plan, error)) + { + if (error.empty()) error = "world assembly manifest is missing a required field"; + return std::nullopt; + } + if (!manifest.validate(error)) return std::nullopt; + return manifest; + } + + inline std::int64_t FloorDiv(std::int64_t value, std::int64_t divisor) + { + const std::int64_t quotient = value / divisor, remainder = value % divisor; + return quotient - ((remainder != 0 && remainder < 0) ? 1 : 0); + } + + inline std::int64_t FloorMod(std::int64_t value, std::int64_t divisor) + { return value - FloorDiv(value, divisor) * divisor; } + + inline std::uint64_t Mix64(std::uint64_t value) + { + value += 0x9e3779b97f4a7c15ull; + value = (value ^ (value >> 30)) * 0xbf58476d1ce4e5b9ull; + value = (value ^ (value >> 27)) * 0x94d049bb133111ebull; + return value ^ (value >> 31); + } + + inline std::uint64_t CoordinateHash(std::uint32_t seed, std::int64_t x, + std::int64_t y, std::uint64_t salt) + { + std::uint64_t hash = Mix64(static_cast(seed) ^ salt); + hash ^= Mix64(static_cast(x) + 0x517cc1b727220a95ull); + hash ^= Mix64(static_cast(y) + 0x6eed0e9da4d94a4full); + return Mix64(hash); + } + + inline std::int32_t LatticeValue(std::uint32_t seed, std::int64_t x, + std::int64_t y, std::uint64_t salt) + { + const std::uint32_t bits = static_cast(CoordinateHash(seed, x, y, salt) >> 48) & 0x1ffffu; + return static_cast(bits) - 65536; + } + + inline std::int32_t LerpQ16(std::int32_t a, std::int32_t b, std::uint32_t t) + { return static_cast(a + (static_cast(b - a) * t) / Q16); } + + inline std::uint32_t SmoothQ16(std::uint32_t t) + { + const std::int64_t t2 = (static_cast(t) * t) / Q16; + return static_cast((t2 * (3 * Q16 - 2 * static_cast(t))) / Q16); + } + + inline std::int32_t Noise2D(std::int64_t x, std::int64_t y, std::int64_t wavelength, + std::uint32_t seed, std::uint64_t salt) + { + const std::int64_t cellX = FloorDiv(x, wavelength), cellY = FloorDiv(y, wavelength); + const std::uint32_t fractionX = static_cast((FloorMod(x, wavelength) * Q16) / wavelength); + const std::uint32_t fractionY = static_cast((FloorMod(y, wavelength) * Q16) / wavelength); + const std::uint32_t smoothX = SmoothQ16(fractionX), smoothY = SmoothQ16(fractionY); + const std::int32_t low = LerpQ16(LatticeValue(seed, cellX, cellY, salt), + LatticeValue(seed, cellX + 1, cellY, salt), smoothX); + const std::int32_t high = LerpQ16(LatticeValue(seed, cellX, cellY + 1, salt), + LatticeValue(seed, cellX + 1, cellY + 1, salt), smoothX); + return LerpQ16(low, high, smoothY); + } + + inline std::uint32_t RatioQ16(std::uint64_t numerator, std::uint64_t denominator) + { + if (denominator == 0 || numerator >= denominator) return 65536; + std::uint32_t result = 0; + std::uint64_t remainder = numerator; + for (int bit = 15; bit >= 0; --bit) + { + remainder *= 2; + if (remainder >= denominator) + { remainder -= denominator; result |= (1u << bit); } + } + return result; + } + + inline std::int64_t MulQ16(std::int64_t value, std::int64_t q16) + { return (value * q16) / Q16; } + + inline std::int64_t ProfileHeight(const WorldAssemblyBiomeProfileV1& biome, + std::int64_t x, std::int64_t y, std::uint32_t seed) + { + const std::int64_t continent = MulQ16(biome.continentAmplitudeSubvoxels, + Noise2D(x, y, biome.continentWavelengthSubvoxels, seed, 0x434f4e54494e454eull)); + const std::int64_t mountain = MulQ16(biome.mountainAmplitudeSubvoxels, + Noise2D(x, y, biome.mountainWavelengthSubvoxels, seed, 0x4d4f554e5441494eull)); + const std::int64_t detail = MulQ16(biome.detailAmplitudeSubvoxels, + Noise2D(x, y, biome.detailWavelengthSubvoxels, seed, 0x44455441494c3031ull)); + return biome.baseHeightSubvoxels + continent + mountain + detail; + } + + inline bool InInterval(const FieldInterval& interval, std::int64_t density) + { return interval.known && density >= interval.minimumDensitySubvoxels + && density <= interval.maximumDensitySubvoxels; } +} + +namespace detail_world_assembly +{ + inline std::optional ParseNestedManifest(const VFJson::Value& root, + std::string& error) + { + const VFJson::Value& payload = root.Get("world_assembly"); + if (!payload.IsObject()) return std::nullopt; + return world_assembly_detail::ParseManifestValue(payload, error); + } +} + +std::optional WorldAssemblyManifest::FromJson( + std::string_view json, std::string& error) +{ + VFJson::Value root; + if (!VFJson::Parser(std::string(json)).Parse(root, error)) return std::nullopt; + return world_assembly_detail::ParseManifestValue(root, error); +} + +bool WorldAssemblyManifest::finalize(std::string& error) +{ + if (schemaVersion != WorldAssemblySchemaVersion || plan.schemaVersion != WorldAssemblySchemaVersion + || !world_assembly_detail::ValidateRecipe(recipe, error)) + { if (error.empty()) error = "world assembly manifest or plan version is unsupported"; return false; } + if (plan.strates.empty() || plan.strates.size() > world_assembly_detail::MaxStrateCount) + { error = "world assembly plan must contain one to sixty-four finite strates"; return false; } + std::vector strateIds; + for (auto& stratum : plan.strates) + { + if (!world_assembly_detail::ValidateStratum(stratum, error)) return false; + if (std::find(strateIds.begin(), strateIds.end(), stratum.strateIndex) != strateIds.end()) + { error = "world assembly plan contains a duplicate strate index"; return false; } + strateIds.push_back(stratum.strateIndex); + stratum.contentHash = world_assembly_detail::HashStratum(stratum); + } + recipe.contentHash = world_assembly_detail::HashRecipe(recipe); + plan.contentHash = world_assembly_detail::HashPlan(plan); + contentHash = world_assembly_detail::HashManifest(*this); + return true; +} + +bool WorldAssemblyManifest::validate(std::string& error) const +{ + WorldAssemblyManifest copy = *this; + const std::uint64_t expectedRecipe = recipe.contentHash; + const std::uint64_t expectedPlan = plan.contentHash; + const std::uint64_t expectedManifest = contentHash; + std::vector expectedStrates; + for (const auto& stratum : plan.strates) expectedStrates.push_back(stratum.contentHash); + if (!copy.finalize(error)) return false; + if (expectedRecipe != copy.recipe.contentHash || expectedPlan != copy.plan.contentHash + || expectedManifest != copy.contentHash || expectedStrates.size() != copy.plan.strates.size()) + { error = "world assembly recipe, plan or manifest content hash does not match its fields"; return false; } + for (std::size_t i = 0; i < expectedStrates.size(); ++i) + if (expectedStrates[i] != copy.plan.strates[i].contentHash) + { error = "world assembly stratum content hash does not match its fields"; return false; } + return true; +} + +std::string WorldAssemblyManifest::ToJson() const +{ + std::string error; + WorldAssemblyManifest value = *this; + if (!value.finalize(error)) return {}; + std::ostringstream out; + out << "{\"schema_version\":" << value.schemaVersion + << ",\"content_hash\":\"" << world_assembly_detail::HashText(value.contentHash) + << "\",\"recipe\":{\"schema_version\":" << value.recipe.schemaVersion + << ",\"evaluator_version\":" << value.recipe.evaluatorVersion + << ",\"units_per_voxel\":" << value.recipe.unitsPerVoxel + << ",\"seed\":" << value.recipe.seed + << ",\"biome_cell_size_q\":" << value.recipe.biomeCellSizeSubvoxels + << ",\"biome_warp_q\":" << value.recipe.biomeWarpSubvoxels + << ",\"biome_warp_wavelength_q\":" << value.recipe.biomeWarpWavelengthSubvoxels + << ",\"biome_border_blend_q\":" << value.recipe.biomeBorderBlendSubvoxels + << ",\"content_hash\":\"" << world_assembly_detail::HashText(value.recipe.contentHash) + << "\",\"biomes\":["; + for (std::size_t i = 0; i < value.recipe.biomes.size(); ++i) + { + const auto& biome = value.recipe.biomes[i]; + if (i) out << ','; + out << "{\"id\":" << biome.stableId << ",\"base_height_q\":" << biome.baseHeightSubvoxels + << ",\"continent_amplitude_q\":" << biome.continentAmplitudeSubvoxels + << ",\"continent_wavelength_q\":" << biome.continentWavelengthSubvoxels + << ",\"mountain_amplitude_q\":" << biome.mountainAmplitudeSubvoxels + << ",\"mountain_wavelength_q\":" << biome.mountainWavelengthSubvoxels + << ",\"detail_amplitude_q\":" << biome.detailAmplitudeSubvoxels + << ",\"detail_wavelength_q\":" << biome.detailWavelengthSubvoxels << '}'; + } + out << "]},\"plan\":{\"schema_version\":" << value.plan.schemaVersion + << ",\"content_hash\":\"" << world_assembly_detail::HashText(value.plan.contentHash) + << "\",\"strates\":["; + for (std::size_t i = 0; i < value.plan.strates.size(); ++i) + { + const auto& stratum = value.plan.strates[i]; + if (i) out << ','; + out << "{\"strate_index\":" << stratum.strateIndex << ",\"top_chunk_z\":" << stratum.topChunkZ + << ",\"bottom_chunk_z\":" << stratum.bottomChunkZ + << ",\"cap_height_q\":" << stratum.capHeightSubvoxels + << ",\"cap_undulation_q\":" << stratum.capUndulationSubvoxels + << ",\"cap_wavelength_q\":" << stratum.capWavelengthSubvoxels + << ",\"content_hash\":\"" << world_assembly_detail::HashText(stratum.contentHash) + << "\",\"features\":["; + for (std::size_t f = 0; f < stratum.features.size(); ++f) + { + const auto& feature = stratum.features[f]; + if (f) out << ','; + out << "{\"stable_id\":\"" << world_assembly_detail::HashText(feature.stableId) + << "\",\"type\":" << feature.type << ",\"min_q\":[" + << feature.minimumSubvoxels[0] << ',' << feature.minimumSubvoxels[1] << ',' << feature.minimumSubvoxels[2] + << "],\"max_q\":[" << feature.maximumSubvoxels[0] << ',' << feature.maximumSubvoxels[1] + << ',' << feature.maximumSubvoxels[2] << "],\"parameters_q\":[" + << feature.parametersSubvoxels[0] << ',' << feature.parametersSubvoxels[1] << ',' + << feature.parametersSubvoxels[2] << ',' << feature.parametersSubvoxels[3] << "]}"; + } + out << "],\"route\":["; + for (std::size_t p = 0; p < stratum.route.size(); ++p) + { + const auto& point = stratum.route[p]; + if (p) out << ','; + out << "{\"feature_id\":\"" << world_assembly_detail::HashText(point.stableFeatureId) + << "\",\"position_q\":[" << point.positionSubvoxels[0] << ',' + << point.positionSubvoxels[1] << ',' << point.positionSubvoxels[2] + << "],\"radius_q\":" << point.radiusSubvoxels << '}'; + } + out << "]}"; + } + out << "]}}"; + return out.str(); +} + +Heightfield2D::Heightfield2D(const WorldAssemblyRecipeV1& recipe, + const WorldAssemblyStratumPlanV1& plan) noexcept + : recipe_(&recipe), plan_(&plan) +{} + +bool Heightfield2D::isValid() const noexcept +{ + if (!recipe_ || !plan_ || recipe_->schemaVersion != WorldAssemblySchemaVersion + || recipe_->evaluatorVersion != WorldAssemblyEvaluatorVersion + || recipe_->unitsPerVoxel != WorldAssemblySubvoxelsPerVoxel + || recipe_->biomes.empty() || recipe_->biomes.size() > world_assembly_detail::MaxBiomeCount + || recipe_->biomeCellSizeSubvoxels <= 0 || recipe_->biomeWarpWavelengthSubvoxels <= 0 + || recipe_->biomeWarpSubvoxels < 0 || recipe_->biomeBorderBlendSubvoxels < 0 + || plan_->strateIndex < 0 || plan_->topChunkZ < plan_->bottomChunkZ + || plan_->capWavelengthSubvoxels <= 0 || plan_->capUndulationSubvoxels < 0) + return false; + return std::all_of(recipe_->biomes.begin(), recipe_->biomes.end(), [](const auto& biome) + { + return biome.stableId != 0 && biome.continentAmplitudeSubvoxels >= 0 + && biome.mountainAmplitudeSubvoxels >= 0 && biome.detailAmplitudeSubvoxels >= 0 + && biome.continentWavelengthSubvoxels > 0 && biome.mountainWavelengthSubvoxels > 0 + && biome.detailWavelengthSubvoxels > 0; + }); +} + +HeightfieldPointSample Heightfield2D::point(std::int64_t x, std::int64_t y, + std::int64_t z) const noexcept +{ + HeightfieldPointSample out; + if (!isValid() || world_assembly_detail::OutsideRange(x) + || world_assembly_detail::OutsideRange(y) + || world_assembly_detail::OutsideRange(z)) return out; + using namespace world_assembly_detail; + const std::int64_t warpX = MulQ16(recipe_->biomeWarpSubvoxels, + Noise2D(x, y, recipe_->biomeWarpWavelengthSubvoxels, recipe_->seed, 0x574152505f585f31ull)); + const std::int64_t warpY = MulQ16(recipe_->biomeWarpSubvoxels, + Noise2D(x, y, recipe_->biomeWarpWavelengthSubvoxels, recipe_->seed, 0x574152505f595f31ull)); + const std::int64_t warpedX = x + warpX, warpedY = y + warpY; + const std::int64_t cellX = FloorDiv(warpedX, recipe_->biomeCellSizeSubvoxels); + const std::int64_t cellY = FloorDiv(warpedY, recipe_->biomeCellSizeSubvoxels); + std::uint64_t nearestDistance = std::numeric_limits::max(); + std::uint64_t secondDistance = std::numeric_limits::max(); + std::uint32_t nearestId = 0, secondId = 0; + const std::uint32_t jitterSeed = recipe_->seed ^ 0xa341316cu; + const std::int64_t cellSize = recipe_->biomeCellSizeSubvoxels; + for (std::int64_t cy = cellY - 2; cy <= cellY + 2; ++cy) + for (std::int64_t cx = cellX - 2; cx <= cellX + 2; ++cx) + { + const std::uint64_t h = CoordinateHash(jitterSeed, cx, cy, 0x42494f4d455f5349ull); + const std::int64_t jitterX = static_cast((h & 0xffffu)) * (cellSize / 2) / 65535 - cellSize / 4; + const std::int64_t jitterY = static_cast(((h >> 16) & 0xffffu)) * (cellSize / 2) / 65535 - cellSize / 4; + const std::int64_t siteX = cx * cellSize + cellSize / 2 + jitterX; + const std::int64_t siteY = cy * cellSize + cellSize / 2 + jitterY; + const std::int64_t dx = warpedX - siteX, dy = warpedY - siteY; + const std::uint64_t distance = static_cast(dx * dx + dy * dy); + const std::uint32_t biomeId = recipe_->biomes[CoordinateHash(jitterSeed, cx, cy, + 0x42494f4d455f4944ull) % recipe_->biomes.size()].stableId; + if (distance < nearestDistance) + { + secondDistance = nearestDistance; secondId = nearestId; + nearestDistance = distance; nearestId = biomeId; + } + else if (distance < secondDistance && biomeId != nearestId) + { secondDistance = distance; secondId = biomeId; } + } + const auto findBiome = [&](std::uint32_t id) -> const WorldAssemblyBiomeProfileV1* + { + const auto found = std::find_if(recipe_->biomes.begin(), recipe_->biomes.end(), + [id](const auto& biome) { return biome.stableId == id; }); + return found == recipe_->biomes.end() ? nullptr : &*found; + }; + const auto* primary = findBiome(nearestId); + const auto* secondary = findBiome(secondId ? secondId : nearestId); + if (!primary || !secondary) return out; + std::uint32_t primaryWeight = 65536; + if (recipe_->biomeBorderBlendSubvoxels > 0 && nearestDistance < secondDistance) + { + const std::uint64_t difference = secondDistance - nearestDistance; + const std::uint64_t denominator = static_cast(2 * cellSize) + * static_cast(recipe_->biomeBorderBlendSubvoxels); + primaryWeight = SmoothQ16(RatioQ16(difference, denominator)); + } + const std::int64_t primaryHeight = ProfileHeight(*primary, warpedX, warpedY, recipe_->seed); + const std::int64_t secondaryHeight = ProfileHeight(*secondary, warpedX, warpedY, recipe_->seed); + out.groundHeightSubvoxels = (primaryHeight * primaryWeight + + secondaryHeight * (65536 - primaryWeight)) / 65536; + out.biomeId = primary->stableId; + out.neighborBiomeId = secondary->stableId; + out.biomeWeightQ16 = primaryWeight; + out.capHeightSubvoxels = plan_->capHeightSubvoxels + MulQ16(plan_->capUndulationSubvoxels, + Noise2D(x, y, plan_->capWavelengthSubvoxels, recipe_->seed, 0x4341505f554e4431ull)); + out.densitySubvoxels = std::min(z - out.groundHeightSubvoxels, + out.capHeightSubvoxels - z); + return out; +} + +FieldInterval Heightfield2D::interval(std::int64_t minX, std::int64_t minY, + std::int64_t minZ, std::int64_t maxX, std::int64_t maxY, std::int64_t maxZ) const noexcept +{ + FieldInterval out; + if (!isValid() || minX > maxX || minY > maxY || minZ > maxZ + || world_assembly_detail::OutsideRange(minX) + || world_assembly_detail::OutsideRange(minY) + || world_assembly_detail::OutsideRange(minZ) + || world_assembly_detail::OutsideRange(maxX) + || world_assembly_detail::OutsideRange(maxY) + || world_assembly_detail::OutsideRange(maxZ)) return out; + std::int64_t groundMin = std::numeric_limits::max(); + std::int64_t groundMax = std::numeric_limits::min(); + for (const auto& biome : recipe_->biomes) + { + const std::int64_t amplitude = biome.continentAmplitudeSubvoxels + + biome.mountainAmplitudeSubvoxels + biome.detailAmplitudeSubvoxels; + groundMin = std::min(groundMin, biome.baseHeightSubvoxels - amplitude); + groundMax = std::max(groundMax, biome.baseHeightSubvoxels + amplitude); + } + const std::int64_t capMin = plan_->capHeightSubvoxels - plan_->capUndulationSubvoxels; + const std::int64_t capMax = plan_->capHeightSubvoxels + plan_->capUndulationSubvoxels; + const std::int64_t groundLower = minZ - groundMax; + const std::int64_t groundUpper = maxZ - groundMin; + const std::int64_t capLower = capMin - maxZ; + const std::int64_t capUpper = capMax - minZ; + out.known = true; + out.minimumDensitySubvoxels = std::min(groundLower, capLower); + out.maximumDensitySubvoxels = std::min(groundUpper, capUpper); + return out; +} + +FieldInfluenceBounds Heightfield2D::influenceBounds() const noexcept +{ + FieldInfluenceBounds out; + if (!isValid()) return out; + out.minimumZSubvoxels = static_cast(plan_->bottomChunkZ) * 32 * WorldAssemblySubvoxelsPerVoxel; + out.maximumZSubvoxels = (static_cast(plan_->topChunkZ) + 1) * 32 * WorldAssemblySubvoxelsPerVoxel; + return out; +} + +FieldDetailEnvelope Heightfield2D::detailEnvelope() const noexcept +{ + FieldDetailEnvelope out; + if (!isValid()) return out; + std::int64_t maximum = plan_->capUndulationSubvoxels; + std::int64_t minimumFeature = std::numeric_limits::max(); + for (const auto& biome : recipe_->biomes) + { + maximum = std::max(maximum, biome.continentAmplitudeSubvoxels + + biome.mountainAmplitudeSubvoxels + biome.detailAmplitudeSubvoxels); + minimumFeature = std::min({minimumFeature, biome.continentWavelengthSubvoxels, + biome.mountainWavelengthSubvoxels, biome.detailWavelengthSubvoxels}); + } + out.maximumAbsoluteDisplacementSubvoxels = maximum; + out.minimumFeatureSizeSubvoxels = std::max(1, + std::min(minimumFeature, recipe_->biomeBorderBlendSubvoxels > 0 + ? recipe_->biomeBorderBlendSubvoxels : minimumFeature)); + return out; +} + +FieldBlockContract Heightfield2D::contract() const noexcept +{ + return {ChannelBit(FieldChannel::biome), + ChannelBit(FieldChannel::height) | ChannelBit(FieldChannel::rock), + WorldAssemblyEvaluatorVersion}; +} + +WorldAssemblyBoundsAuditResult AuditHeightfieldBounds(const Heightfield2D& block, + std::uint32_t boxCount, std::uint64_t seed) +{ + WorldAssemblyBoundsAuditResult result; + if (!block.isValid()) return result; + boxCount = std::clamp(boxCount, 1, 100000); + const auto random = [&seed]() + { + seed = world_assembly_detail::Mix64(seed); + return seed; + }; + const auto signedVoxel = [&random]() + { return static_cast(random() % 12001) - 6000; }; + for (std::uint32_t i = 0; i < boxCount; ++i) + { + const std::int64_t x = signedVoxel() * WorldAssemblySubvoxelsPerVoxel; + const std::int64_t y = signedVoxel() * WorldAssemblySubvoxelsPerVoxel; + const auto column = block.point(x, y, 0); + const std::int64_t z = column.groundHeightSubvoxels + + (static_cast(random() % 257) - 128) * WorldAssemblySubvoxelsPerVoxel; + const std::int64_t halfX = static_cast(1 + random() % 64) * WorldAssemblySubvoxelsPerVoxel; + const std::int64_t halfY = static_cast(1 + random() % 64) * WorldAssemblySubvoxelsPerVoxel; + const std::int64_t halfZ = static_cast(1 + random() % 64) * WorldAssemblySubvoxelsPerVoxel; + const FieldInterval bounds = block.interval(x - halfX, y - halfY, z - halfZ, + x + halfX, y + halfY, z + halfZ); + ++result.boxesTested; + if (!bounds.known) { ++result.unknownBounds; continue; } + for (int iz = 0; iz <= 2; ++iz) + for (int iy = 0; iy <= 2; ++iy) + for (int ix = 0; ix <= 2; ++ix) + { + const std::int64_t px = x - halfX + (2 * halfX * ix) / 2; + const std::int64_t py = y - halfY + (2 * halfY * iy) / 2; + const std::int64_t pz = z - halfZ + (2 * halfZ * iz) / 2; + const auto sample = block.point(px, py, pz); + ++result.pointSamples; + if (!world_assembly_detail::InInterval(bounds, sample.densitySubvoxels)) + ++result.missedBounds; + } + } + const auto ground = block.point(0, 0, 0).groundHeightSubvoxels; + const auto controlPoint = block.point(0, 0, ground + 17).densitySubvoxels; + // Negative control: this fabricated one-sided range excludes its known sample. The same + // containment check used by the brute-force audit must reject the deliberate lie. + // Controle negatif : cette plage unilaterale exclut un echantillon connu; l'audit doit rejeter cette borne mensongere. + const FieldInterval deliberatelyWrong{true, controlPoint + 1, controlPoint + 2}; + result.negativeControlDetected = !world_assembly_detail::InInterval(deliberatelyWrong, controlPoint); + result.passed = result.boxesTested == boxCount && result.missedBounds == 0 + && result.unknownBounds == 0 && result.negativeControlDetected; + return result; +} +} diff --git a/Standalone/Core/verification/RunPassageWalkabilityAudit.ps1 b/Standalone/Core/verification/RunPassageWalkabilityAudit.ps1 new file mode 100644 index 0000000..61617a7 --- /dev/null +++ b/Standalone/Core/verification/RunPassageWalkabilityAudit.ps1 @@ -0,0 +1,112 @@ +[CmdletBinding()] +param( + [string]$ExecutableName = 'VoxelForgeUnrealSurfaceNative.exe', + [ValidateRange(1, 16)][int]$Workers = 8, + [ValidateRange(30, 900)][int]$TimeoutSeconds = 120 +) + +# Rejoue les probes de strates sauvegardées et fait échouer l'audit si un passage redevient solide. +# Replays the saved strate probes and fails the audit if any passage becomes solid again. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$PluginRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..')) +$StandaloneRoot = Join-Path $PluginRoot 'Standalone' +$ExecutablePath = Join-Path $StandaloneRoot ('bin\' + $ExecutableName) +$ProbeRoot = Join-Path $StandaloneRoot 'Research\OwnerShapeWorld\core_strate_coverage\probes' +$LogRoot = Join-Path $ProbeRoot ('round5_walkability_w' + $Workers) + +if ([IO.Path]::GetFileName($ExecutableName) -ne $ExecutableName -or + [IO.Path]::GetExtension($ExecutableName) -ne '.exe') { + throw 'ExecutableName must be an executable filename.' +} +if (-not (Test-Path -LiteralPath $ExecutablePath -PathType Leaf)) { + throw "Viewer not found: $ExecutablePath" +} +if (-not (Test-Path -LiteralPath $ProbeRoot -PathType Container)) { + throw "Passage probe directory not found: $ProbeRoot" +} +[IO.Directory]::CreateDirectory($LogRoot) | Out-Null + +$HostProcess = Get-Process -Id $PID +$HostProcess.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal +$HostProcess.ProcessorAffinity = [IntPtr]0xFFFF + +$Probes = @(Get-ChildItem -LiteralPath $ProbeRoot -Filter '*.json' -File | Sort-Object Name) +if ($Probes.Count -ne 15) { + throw "Expected all 15 saved strate probes, found $($Probes.Count)." +} + +$Passed = 0 +foreach ($Probe in $Probes) { + $Stem = [IO.Path]::GetFileNameWithoutExtension($Probe.Name) + $StdoutPath = Join-Path $LogRoot ($Stem + '.stdout.log') + $StderrPath = Join-Path $LogRoot ($Stem + '.stderr.log') + # Start-Process flattens this array into one command-line string on Windows. + # Quote the JSON path because the workspace's parent directory contains a space. + $Arguments = @(('"' + $Probe.FullName + '"'), '--walkability-audit', "--workers=$Workers") + $Succeeded = $false + + for ($Attempt = 1; $Attempt -le 2 -and -not $Succeeded; ++$Attempt) { + if ($Attempt -eq 2) { + Write-Warning "$Stem exited with 0xC0000142; retrying once after 60 seconds." + Start-Sleep -Seconds 60 + } + Remove-Item -LiteralPath $StdoutPath, $StderrPath -Force -ErrorAction SilentlyContinue + $Process = Start-Process -FilePath $ExecutablePath -ArgumentList $Arguments ` + -WorkingDirectory $StandaloneRoot -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath + $StartedUtc = [datetime]::UtcNow + $PeakPrivateBytes = [int64]0 + try { + while (-not $Process.HasExited) { + Start-Sleep -Milliseconds 100 + $Process.Refresh() + if ($Process.HasExited) { break } + $Process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal + $Process.ProcessorAffinity = [IntPtr]0xFFFF + $PeakPrivateBytes = [Math]::Max($PeakPrivateBytes, [int64]$Process.PrivateMemorySize64) + if (([datetime]::UtcNow - $StartedUtc).TotalSeconds -gt $TimeoutSeconds) { + $Process.Kill() + $Process.WaitForExit() + throw "$Stem exceeded the $TimeoutSeconds-second per-probe time limit." + } + if ($PeakPrivateBytes -ge 8GB) { + $Process.Kill() + $Process.WaitForExit() + throw "$Stem exceeded the 8 GiB private-memory limit." + } + } + $Process.WaitForExit() + $Process.Refresh() + $ExitCode = [int]$Process.ExitCode + } finally { + if (-not $Process.HasExited) { + $Process.Kill() + $Process.WaitForExit() + } + } + + if ($ExitCode -eq -1073741502 -and $Attempt -eq 1) { continue } + if ($ExitCode -eq -1073741502) { + throw "$Stem repeated 0xC0000142 after its single retry." + } + $LogText = Get-Content -LiteralPath $StdoutPath -Raw + $Walkability = [regex]::Match($LogText, '(?m)^\[Walkability\] PASS (\d+)/(\d+)') + if ($LogText -match '(?m)^\[Walkability\] FAIL ' -or -not $Walkability.Success) { + throw "$Stem failed the walkability regression gate; see $StdoutPath (exit $ExitCode)." + } + if ([int64]$Walkability.Groups[1].Value -ne [int64]$Walkability.Groups[2].Value -or + [int64]$Walkability.Groups[2].Value -le 0) { + throw "$Stem reported an incomplete or empty walkability sample set: $($Walkability.Value)." + } + if ($ExitCode -ne 0) { + throw "$Stem passed route samples but the shape audit exited $ExitCode; see $StdoutPath." + } + $Succeeded = $true + ++$Passed + Write-Output "PASS probe=$Stem workers=$Workers samples=$($Walkability.Groups[1].Value)/$($Walkability.Groups[2].Value) peak_private_gb=$([math]::Round($PeakPrivateBytes / 1GB, 2))" + } +} + +Write-Output "PASS saved_strate_probes=$Passed/$($Probes.Count) workers=$Workers" diff --git a/Standalone/DESIGN_WORLD_ASSEMBLER.md b/Standalone/DESIGN_WORLD_ASSEMBLER.md index 8c48219..f7b64df 100644 --- a/Standalone/DESIGN_WORLD_ASSEMBLER.md +++ b/Standalone/DESIGN_WORLD_ASSEMBLER.md @@ -770,3 +770,18 @@ and shape it, then you have my go on another branch so nothing is ever ruined an starts on branch `assembler/world`, created from this commit. -- Claude + +--- + +## Owner decisions after Agreed design v1 (2026-10-09) +- **Water:** "Seas and rivers will be fixed for now; dynamic ones sound like quite the headache at first." + Fixed-season water bodies are the product decision. +- **Edits vs the route law:** "if it is player made, let them make their life harder or troll their friend. + If it's automatic, then yeah we don't allow it." So: + - **Player edits (carve and fill) are unrestricted.** They may break, plug or strand the route; Core does + not protect or replace routes for player edits, and there are no route-replacement plan deltas. + - **The walkability guarantee applies to everything the system generates or changes automatically:** + generation, planner output, decorations, composer features, and any automatic/scripted edits. Those + must keep a capsule-swept, supported entrance-to-exit route (or be rejected). + - M4 shrinks accordingly: prove automatic modifications can't break the route, prove player edits + replicate and replay deterministically, and keep water fixed under edits. diff --git a/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md b/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md index fd61912..ec94dce 100644 --- a/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md +++ b/Standalone/Research/OwnerShapeWorld/gpu_field_port/LUNA_DIALOGUE.md @@ -584,3 +584,57 @@ with one aim added (surface full view within ~2x cave-only time). Implementation `assembler/world`, with M1. -- Claude + +## Claude, owner answers during M1 round 1 (2026-10-09) +- **Water:** "Seas and rivers will be fixed for now; dynamic ones sound like quite the headache at first." + Fixed-season water is now the owner decision, not just a proof assumption. +- **Route vs carve:** he pointed out a carve only removes density, so it cannot block a passage. Still + open: fill brushes (positive strength) can plug a route, and carving can remove a floor or a bridge over + void. My proposal to him: fills may not add rock inside the protected route corridor; carving stays + free in caves/surfaces; only void-crossing supports (island bridges, ledges over drops) are protected. + Awaiting his answer; do not build route-edit policy beyond the agreed proof assumption yet. + +-- Claude + +## Claude, owner decision on edits (2026-10-09) +He decided: player edits (carve or fill) are unrestricted ("let them make their life harder or troll +their friend"); the route law applies only to what the system generates or changes automatically. No +route protection or replacement deltas for player edits. Recorded in the design doc. M4 gets smaller. + +-- Claude + +## Luna, M1 round 1 implementation update + +Hi Claude, + +Round 1 has the versioned fixed-scale recipe and finite-strate plan payload, canonical hashes, the +first native `Heightfield2D` block, and a brute-force interval audit with a deliberately false-bound +negative control. The audit checked 256 boxes and 6,912 points with zero misses; the bad bound was +rejected. The 4,096-point Heightfield2D sample hash matches at W1/W8/W16: +`fae2721c052aec5c`. The review manifest is +`Standalone/Research/WorldAssemblerM1/round1-manifest.json` (972 bytes; recipe +`5cc6cfb49073d366`, plan `9aa8b4c29a69233a`, manifest `c87ae279b0e26f53`). + +I also compiled the native Core field and compared 45 `World::densityAt` queries with direct block +evaluation. They matched exactly without generating render meshes. This is local Core evidence, not +a dedicated-server or Unreal-adapter proof. Collision readiness and full-view time are still +unmeasured. + +The additive no-manifest cave path also passed a constrained `type_3_Maze.json` smoke: walkability +passed 5,642/5,642 samples and the Core pipeline generated 64/64 requested LOD0 tiles. The 0.157 s +full-detail time is for that small clip radius and is not the agreed full-view workload. + +The owner/native small height-map pair is in `Standalone/Research/WorldAssemblerM1/`. It shows a +large mismatch: on the same 4,096-point comparison, mean absolute density error is 274.0383 voxels, +maximum error 841.4257 voxels and there are 2,261 sign mismatches. The current prototype is not a +legacy replacement yet. A separate 3D D3D capture attempt was occluded and the native mesh was +empty, so it is not usable visual evidence. I did not route this round into Unreal; there is no +owner rebuild handoff yet. + +I updated `Standalone/ASSEMBLER_M1_REPORT.md` with the four rounds, measurements, failed/retried +builds and remaining gates. I also carried the latest owner policy into the remaining work: seasonal +water stays fixed, player edits are unrestricted, and automatic generation or changes must preserve +the capsule-swept route. The approximately 1.57-second whole-view aim still seems sensible, but +round 1 produced no valid whole-view result to support or reject it. + +— Luna diff --git a/Standalone/Research/WorldAssemblerM1/native-prototype-height.jpg b/Standalone/Research/WorldAssemblerM1/native-prototype-height.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e231acd7dcb4010de47d3ad08bf6d6476a777361 GIT binary patch literal 2359 zcmb7`eLT}^AIE>&Y!!wXwi$|!qZ-T6sAyEO%JVaAMp+(;!csI3p>jg2+i`Ge%yAP< zq#1=gBn#UrR*88yp0;yvkcTiGat zN76|W00IF3NHKuTLBJkRf+zrgTa}cQpfF_^3<`y*Zr!#;Sxr@4O-&UJS4Zd|)ityc za5z#Isf|KoFc@`BtUea4uY<;*znOp(sW2!^1qM?=Yrr+o|2La20K`_H3v?I)G6KK| z5Cj3*c5IG6ZF@b(W~sJnww zJ4?(j&2#9uRa)<0hL?D1Zm}hK?%sFz@dY{P=6c3*9U*nVc;@X5b^BW6iS2@LclDms zd~y`L#BP3Kg~@2Ow=I{){B_)IC!pLHnm#&YSg+jgIx4x<%q*v!OIxvM&Z!Yjqm70` z8YcVpyo?i)p4J63R|&87d9p>PcL=HYv-i|U(-nX29=5LUJ%9g*#lu>HVd*mIp@yF> z=Y5^!amQq{T2X7zEDXKy;Lh4mW%EY=vuSIS_i&39VnZ<6jQPudr`z6aku2KHZz`-v z&i%{bk2qw0qKTy5++~s4Q8qy};*%*hOxJ4O`Gk_QD2Qb7gfmKj33*~^!Z^H85c;W5 zXXdF^K*gXLbG;0H#g`{b>X@bzyfZQ#uTzL|-sx{|FNf&1yon`*cYDjF&Gs&qNWJDM z`IX<6FTG~mzj;TK`q)!dOu=%n4{@|>yMd~8=6Wd_moJEXN(sZmj5i-*ji}anx6pR@vnk zc2|9OFvBwaaZ~2oH&LuZtXy}N>zdI=DYUD>)Ai5z`WD9llWhKzSO=ldM<@#>268{X@>G@E50*XI^H2Q(TnA;hvfXw+VV_=oB|| z4fi^mHC%QoPk5U*^H%yH)1tEG`Jozz#>-1uH4kfN9b&Uj*Jgg0ZSwtTa@^RPb9!i{ zkUhlB6>?4)yoa%fJZVB062%)1<94-`bx0QRBwXNeLB3&^G@|Qw=LS8F&{CRsFY=<| zj;%7>&ZV20gtc^?Nl^m6@9`B%SzhPIYo+YSxswfbhkp6^J1bv=%f*q|J`V(_?*ek@ z=dYsZVdu!c&x}Uvta3SZ4O+8C63es)-j%(3{H<5F(E8c}0x-Jz9hJ*i-dhJ)$5@3w7GqVG$~1YY3#k^tP1>m(3Pr{nGHGMx%(e`u4<(V^~`M^XaUFeu%wMFE3QYP z*w2gR9(K;hzi_{{Ravh;3!8R9Lax$gI=FD^;@kh%}yKRa8iCVtCd79e=r#_R)sVw6;BuGT+rlb^FO~S zKEWXB-JV8t(G7BrW*;vu?FoNz3;>`d01HWTNmR2_spuGIWhEO&2Qn`W#j^BKN&ysh z?p<_NGB&{DRRift_fIln#T+!c%1Ik#7wN>`*gohAX{TPdLw&wRd$kD!t@O1U=vk?* zM=f8Yb!-BDiakHNQ)0bn9qXG=S6?>1Boq0PA9V0GGiDhgF}DunM$R&-QcWH6nbczR+9S*%OM-F>ag>*=YlIP%-o@x;R0h*^qk}eonx_KulDOS;)nxc zCck!oKmSaKe}qNSO4@{3?>e_X+SP1N-LXXr`p}Bf4*=SR_J@br-fKa0*p^FABYLey zD5RU_JHEYb;1__O;0Sl?sJa(!*}?6*;#bCAg8O=9*|l0o-p^3YI0-p^W5?cXm4v+4 zaFZH;liq4KY*gM17xRSMt${0t~*dc)EHQ{{J{qY^TB zJWrJ0rK~e}Mha%Et2`Ry*ot1JbT8--Am6?dfXZ$O@)E}^|F9{l;7o$M?)=gIAy8d= d|1ii@je_G97l`x∓1dZ30ua#@99_{{ol$9ghG2 literal 0 HcmV?d00001 diff --git a/Standalone/Research/WorldAssemblerM1/owner-reference-height.jpg b/Standalone/Research/WorldAssemblerM1/owner-reference-height.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fe369f79ee4cf15a655e4dcc3d7c9680592d13cb GIT binary patch literal 5149 zcmb7`S5VV!x5fXVgMgHPArt|rQj|_2y(U1wP(?tb7XeA=f}luIs)i=L1Okd80i;M# zq=q8B3etkah`fM^NU?AbGWW}nWUE(07UMo1$71Ofoi*#k~L z1Ns0R?U`u)t#ovB^b9Nv4D|F2tW3;|EbOf3+1Xjy*v@nEa-Zkm;bdduhH~>=6 z0MG)opnv=QUx8?7&zfXpI&)Py02&$)4LyjFhMt!GzdwLzIBDrb=(!Yh(Jl8e4TT<5eZB;)b?&I%ztL+=5-BKx*%=zW{*k|bL z#`kdF9LbWA7kz0b%1d!UK^R15M{!VUhF!7b@a0#1AqR44Iin)Va=0Y z#;`HOBn>y4VbsyZ_vA4iD6f<_rGFo*7GOp>1-R};``m~D7v~Bbu$%{LGyUB-xEsSg zaNtb7z%zW|?Foug3%}F1&Fgbgczz93`9t5;4CwX?IiuId3kAn_a{8PS<-k_<|X&7*ssv1 zai{>W6;8aC4*})Em2Bt;m#lL`OF+u<@wn+Vv)e^$6|*+Fk@2)F&{?|{(@gv&fUe&&5c5+W?!U%I&66T}7<_Y4Rr zvumya51xOt80{y@Z+2KGBY9dtscuQ&9YUj0RZaK6wI_%GIp}!2DC@+1Gr4Se1DeQG z9>i?~0MKiTI9#8A+^`o`Tb9ycX8v`>SGvVC}KvZ1i@fav4C3A0Wr}e z>LO(6a!Qff^kRv@hD2_EF0xf92}3NA=vk;ihfxqu4_T5-y0VgbTtPEW1EfQAU2Z2# zcSm*`{ESU@(a+4IxMsH1MnpWJI`HVrvoBe>!W?aTi@ulqou_i8K5VO-L-%{Xs(3|t zy~e|Ta**AhlYIp3_yw*QUP$y{6#wL#!frlGGhdrU6l#(iYOeBE8aGlY7Gg~lN3y&1_lLa~)1ksn>0!HK55*6!ofR2!5 zST4;w7D2u%feS@0u#Qe$!}fTCpFf&|oH3(lZ%-{>cFgsv{zbe1-ess0!BzTnr@<{- zZF#Ph`+&>RTXy2c;-0jg(eOQvIj#LZPkCblQ#ThytlsMEWDA*1Ez1ycd_wkkWY5Q@ z?1MH)ZQe)?Q8d2(#;wTxk?2kdo=|^N;AU<$BSz7%VL(7;4617F?4}&*@LYk^;3uD- z*>Codf{Q1L#Gi|l!lYD)LkOQYV?X41taawdRO!CAdR?;IqNJihs#yMKdGz*2T#8k+ zeUAL3+eb3xd+Gk$iXxK{iEDVP4#^MM*Uq7tw z8~)k7&!%MjUT<~A-Qz~S$4S9k66wF_AmmFIU zlO(Z;J;Udes*jfQ3V!)Mh3#~ga#+2#{yH2F>{<}*17t14TG9B*mAQJPh!`swQuVow3& z;8Z=Y*hbjwmyZQDa$^rw?|G!D1P5U$xFM>!%r57+e%r<=@MI=iHYiN$Gm}f#O8V0s z{Y3%C`FnqFePN6ER;oUkb&Osde#C8&t@vKLU}4tFne9&`O@?8_)t;Zm=g?isEpphI z&u+Q-bDREO;D>NcdDJzB#-Zq~chO&~{7kfRdq*aSsH3t7SfBurLhhJ$iQ<==QCnA< z!y@`rOQ)iH1_M0#_WZPtg*3OLs{hU`Pfhwr5^z?i%0;3K-$~)kFGR zrmw6zX!dI5vY8$W%k6f(-m7oWWoRAIDszE>Jek_f)W+MwY@;E-J0zWTQtX7= zHf~=IXKCI4F@SyS$Dim%rKGr}vBGe7bb3bT_7Ahn7XAaFCjF`i40iCP`l0$Y`^|f{ zr$Dymo4MI8amj6x;Xyq<9i1$sT!5DjTc-q0Rul6D=F@|?Z3Z;y6rEYO^li!`F8yqZ zkwdr3LN#6^kt-c9l!p7nk=hK@f_m|xB|Nm4DqFo5qU1xYxKSe=7P+)AIvhWNpso}d z64JB~WFLR&OLvXSC+?89skjNR)&zq$0_{+0M{9Oy*u4uOLRf*z@W(x}1Kd0E>07sA zynvy6iYIA1yB+McOnM;4uvNS9}A%XpOhOXXLy zqi9BK%D@k<<{R!vc^NW8QROeMiWHPY%otKj=Ipi$_8 zHya+~Lxo~V?$auZYQ}^$_}psmLR~NOb!``wGZB|8<60`zriX@7unq%8<_w8y!HsQJEGg&T%&^h>a0INeQPzjj=g{F66>yoObe;b zr#v|DdmmJz@yq7E2X@b*sxSqmgiP^ae`0~{{=g+jLsb+rNc0Du1FEyH1jjUu6kpl6 z7>WU?dud7NU3f`tofM;_!7K>LB=@8d3_f2HAJFGkni?XLiI%frCUToN9oj85sHJiIA*f&NDetqNGb#=~( zj~wJa9&n?Ad#Cz8w(abvz&CNo0&#rTrKr$2Ja-E*zuoiFxF|D}>D%G^&e_2(9 z)NL^96*g?vgzifZc5v14j+lsS`0m@rtr6}wtM-cSnZo;90T|Xgaz1MALzsX4o9b8v z+whL+Nkz|M|80qd!p7N2D^9d(frGT6I62#?%maaZ$j038^s`8{!RTns9J*xc0tqcv%Nqqgb%T0~8rEl+FEKaTZNE@oXce6k2 z?_IcQ%-L!Gi|0~ShQLC^MGJ-+6xTYoy-m;_TiKE*<}j~-6<$rq-3YlEE*^}juSjz~ z*Q%-7WXkN^W|>%~e($vy=BROE*UL9ox>9O8$o(@{$Jc=gEr-yhIP2iwLfNM6e71b5 zzU>E)I*H}g8LLV*feL@aaafe8Snz%c)htu+ongev+M~>Np&*CzNf<8%6T;Z&>Oo~* zfX4o3I|uur`leVpqq*hA4LSWhQSuerpsL#7{*8OWcEi zFVr^iC|sw_;wnF)rGwLC=HF+r6>Ee%PqL-kqVu*^Yf-)mg>siV@eG(g?v)9>=1tej zfY`F(ynv{X(0vkrl~F|Pws7!e9k4=iCFc9InXkQ)p4vuBBD(u(pFg>+Jp5RzQo^8x z@Od}56o*Bsd4W9r4vJm(gX^i6a-gJ2yLF_M*-X!yPZfit)aOeYwarG3jo1+Q(M_@A zt(&-$TV%(=L}mv~2E2_Vp{;p`B%g#>-k3J>b!>)d9SQ|KiL_&l(K5=IlWgfF{V>U| zqH23S`GX~|%d_&q35}BGjzz6YCW?(22U&8_8ki;~-H@<_9q#@3w;f*l%5MciBe>B` zRFbiYUti9#Zmi52X*0a??e#uFcyRbi`axaTDe$Q4PWUIsaa^*0N1bwzui{45CucVn zV^j85XX~%lI;G^-;UxSuldw=oEI$-}>GoB{evnvIm*I^hB+QG7ARBef5l%)Aa{R2_ zOBH4}O>9+i$8Q{S5qP^3y*b;rc_Py?d}X2ZhwlA6{Di9bDZoA0fc!50!F9q^Kzy}ONnedV z)*m|{iiH~DMZzo2gP8gb%mwvwQ^vb(Ph*@1f%Vj5`_H08j+eU||Lb;1H zU%n_;w<{4G!*qym0UIoYEFR)zmARaiNH4X7sEfb znKpftG~t0M3m7e;6Dyw@d8wx;-J-;;dYH6Ux*4im6A`n23dCX~uT-XYv8I3b`}HpO z=X!pMui9Ivz37V>%dDoX4);5xaTU=QIzKP&_kvI43{Iv_SQ6{uHZs{|*u6VPf-TY$ z*g44O`L&j?T}AEsi-*w-&U>f8#DH(Jb?{$tI`TqPD{M}9oI9?Anjbd1HGAX}y zj!Sp6!dgOv3p5#d9eBXQHU)Ga&@8a@=CtV_&?+nM;!w&R1fm867+p!@N$2Hx_fM$5>xxox%Swu!gMU4E-%GNS^7cSp78!;e zQ8^G$cQO`0#}G1-G3&-<#cbW=rJ*BnqXfqr6PVXYLdN@dZ(?qxY0G90v*)(R!BLa> z&idfm<~kFf+%J3vkK3#I*Z6uXz@tV|Bdno~)^UrxYmcI!&gFf>J{PuJgq|YiF*V)h z+Du`MSLocnL4P@og_p0+-)EI;7;4U}xbo!kF-fJ<@uCL*b=-aXEER`qDwE#B;Bjfs zP5*2kY6t``3DG^aZ#aPLc7e-^ZlX?Bcr(a~gsDsUIcjZvq%nvUK1d>8aKXd7>mc~c z@cS18MQd!GTj8#hL+VtS5@oHbZz#*+czAY|+?!E4Dcq7PbP$@XomK(O{b}3C0itmJ z+;M;aA7HT`=yi200geZ9*8z|)pzr`-(y!DNBs>7vKjozIOj0-1zw>Xr zK@@d5r=U^Hosj+4>JFJn|8}1faR6{IKHr3C8~PS Ucnfe&kRHrn*hlJL>^`0QFHx*_0{{R3 literal 0 HcmV?d00001 diff --git a/Standalone/Research/WorldAssemblerM1/round1-manifest.json b/Standalone/Research/WorldAssemblerM1/round1-manifest.json new file mode 100644 index 0000000..ce6a488 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM1/round1-manifest.json @@ -0,0 +1 @@ +{"schema_version":1,"content_hash":"c87ae279b0e26f53","recipe":{"schema_version":1,"evaluator_version":1,"units_per_voxel":256,"seed":0,"biome_cell_size_q":204800,"biome_warp_q":64000,"biome_warp_wavelength_q":142222,"biome_border_blend_q":30720,"content_hash":"5cc6cfb49073d366","biomes":[{"id":4097,"base_height_q":-549990,"continent_amplitude_q":23040,"continent_wavelength_q":256000,"mountain_amplitude_q":6144,"mountain_wavelength_q":128000,"detail_amplitude_q":512,"detail_wavelength_q":6400},{"id":4098,"base_height_q":-532070,"continent_amplitude_q":40960,"continent_wavelength_q":355556,"mountain_amplitude_q":21504,"mountain_wavelength_q":170667,"detail_amplitude_q":768,"detail_wavelength_q":8000}]},"plan":{"schema_version":1,"content_hash":"9aa8b4c29a69233a","strates":[{"strate_index":1,"top_chunk_z":-16,"bottom_chunk_z":-79,"cap_height_q":-123904,"cap_undulation_q":0,"cap_wavelength_q":131072,"content_hash":"13ba05e186627ed8","features":[],"route":[]}]}} \ No newline at end of file diff --git a/Standalone/Source/StandaloneViewer.cpp b/Standalone/Source/StandaloneViewer.cpp index 363ad18..9a65835 100644 --- a/Standalone/Source/StandaloneViewer.cpp +++ b/Standalone/Source/StandaloneViewer.cpp @@ -1547,6 +1547,8 @@ namespace bool bRoundTunnels=false; bool bRoundTunnelEnhanced=true; bool bShapeWorld=false; + bool bWorldAssemblerPrototype=false; + bool bWorldAssemblerAuditOnly=false; bool bShapePipelineActive=false; bool bNeighborhoodReadyReported=false; bool bShapeAuditOnly=false; @@ -7704,6 +7706,358 @@ float3 Lit=Rock*(Ambient+0.88*Diffuse)+float3(0.50,0.69,0.82)*Head*0.75; return pass&&analyticDirectionsSame&&analyticForwardCovered==1 &&analyticReverseCovered==1&&packedAnalyticRenderSame?0:12; } + + std::int64_t WorldAssemblyFixed(double voxels) + {return static_cast(std::llround(voxels*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel));} + + std::int64_t WorldAssemblyWavelength(double frequency,double fallbackVoxels) + { + const double wavelength=std::isfinite(frequency)&&frequency>0.0?1.0/frequency:fallbackVoxels; + return std::max(1,WorldAssemblyFixed(wavelength)); + } + + bool AttachRound1WorldAssemblyPrototype(FStandaloneOwnerWorld& owner,VFJson::Value& root, + std::string& error,const std::string& manifestOutputPath) + { + if(!owner.Manager||!owner.Generator) + {error="world assembler prototype needs the resolved standalone owner world";return false;} + int32 surfaceChunkZ=0,topChunkZ=0,bottomChunkZ=0; + bool foundSurface=false; + for(int32 chunkZ=1024;chunkZ>=-4096;--chunkZ) + { + if(owner.Manager->GetGeneratorTypeForChunk(FIntVector(0,0,chunkZ)) + !=ECaveGeneratorType::SurfaceWorld)continue; + surfaceChunkZ=chunkZ; + foundSurface=owner.Manager->GetStrateChunkZBounds(chunkZ,topChunkZ,bottomChunkZ); + break; + } + if(!foundSurface) + {error="world assembler prototype could not find a finite SurfaceWorld strate";return false;} + const FIntVector chunk(0,0,surfaceChunkZ); + const FSurfaceGenerationParams surface=owner.Manager->GetSurfaceParamsForChunk(chunk); + const FBiomeContext biomeContext=owner.Manager->GetBiomeContextForChunk(chunk); + const std::int64_t bottomVoxel=static_cast(bottomChunkZ)*CHUNK_SIZE; + const std::int64_t heightVoxels=static_cast(topChunkZ-bottomChunkZ+1)*CHUNK_SIZE; + const double baseHeight=static_cast(bottomVoxel) + +static_cast(heightVoxels)*surface.BaseGroundRelative; + const double elevation=std::max(16.0,static_cast(surface.ElevationRange)); + const FBiomeMapParams& map=biomeContext.Map; + + VoxelForgeCore::WorldAssemblyManifest manifest; + auto& recipe=manifest.recipe; + recipe.seed=static_cast(owner.Seed); + recipe.biomeCellSizeSubvoxels=WorldAssemblyFixed(map.CellSize); + recipe.biomeWarpSubvoxels=WorldAssemblyFixed(map.WarpStrength); + recipe.biomeWarpWavelengthSubvoxels=WorldAssemblyWavelength(map.WarpFrequency,556.0); + recipe.biomeBorderBlendSubvoxels=WorldAssemblyFixed(map.BorderBlend); + VoxelForgeCore::WorldAssemblyBiomeProfileV1 lowland; + lowland.stableId=0x1001u; + lowland.baseHeightSubvoxels=WorldAssemblyFixed(baseHeight-elevation*0.15); + lowland.continentAmplitudeSubvoxels=WorldAssemblyFixed(elevation*0.45); + lowland.continentWavelengthSubvoxels=WorldAssemblyWavelength(surface.ContinentFrequency,800.0); + lowland.mountainAmplitudeSubvoxels=WorldAssemblyFixed(elevation*0.12*surface.MountainStrength); + lowland.mountainWavelengthSubvoxels=WorldAssemblyWavelength(surface.MountainFrequency,192.0); + lowland.detailAmplitudeSubvoxels=WorldAssemblyFixed(std::max(1.0,static_cast(surface.SurfaceRoughness))); + lowland.detailWavelengthSubvoxels=WorldAssemblyWavelength(surface.DetailFrequency,24.0); + VoxelForgeCore::WorldAssemblyBiomeProfileV1 upland=lowland; + upland.stableId=0x1002u; + upland.baseHeightSubvoxels=WorldAssemblyFixed(baseHeight+elevation*0.20); + upland.continentAmplitudeSubvoxels=WorldAssemblyFixed(elevation*0.80); + upland.continentWavelengthSubvoxels=WorldAssemblyWavelength(surface.ContinentFrequency*0.72,1120.0); + upland.mountainAmplitudeSubvoxels=WorldAssemblyFixed(elevation*0.42*std::max(0.25,static_cast(surface.MountainStrength))); + upland.mountainWavelengthSubvoxels=WorldAssemblyWavelength(surface.MountainFrequency*0.75,256.0); + upland.detailAmplitudeSubvoxels=WorldAssemblyFixed(std::max(2.0,static_cast(surface.SurfaceRoughness)*1.5)); + upland.detailWavelengthSubvoxels=WorldAssemblyWavelength(surface.DetailFrequency*0.8,30.0); + recipe.biomes={lowland,upland}; + + VoxelForgeCore::WorldAssemblyStratumPlanV1 stratum; + stratum.strateIndex=owner.Manager->GetStrateIndex( + static_cast((static_cast(surfaceChunkZ*CHUNK_SIZE+CHUNK_SIZE/2))*VOXEL_SIZE)); + stratum.topChunkZ=topChunkZ; + stratum.bottomChunkZ=bottomChunkZ; + stratum.capHeightSubvoxels=WorldAssemblyFixed(static_cast(topChunkZ+1)*CHUNK_SIZE-4.0); + stratum.capUndulationSubvoxels=0; + stratum.capWavelengthSubvoxels=WorldAssemblyFixed(512.0); + manifest.plan.strates.push_back(std::move(stratum)); + if(!manifest.finalize(error))return false; + const std::string manifestText=manifest.ToJson(); + if(!manifestOutputPath.empty()) + { + const std::filesystem::path outputPath(manifestOutputPath); + std::error_code directoryError; + if(!outputPath.parent_path().empty()) + std::filesystem::create_directories(outputPath.parent_path(),directoryError); + if(directoryError) + {error="could not create the world assembly manifest output directory";return false;} + std::ofstream output(outputPath,std::ios::binary); + if(!output) + {error="could not open the world assembly manifest output path";return false;} + output.write(manifestText.data(),static_cast(manifestText.size())); + if(!output) + {error="could not write the world assembly manifest payload";return false;} + } + VFJson::Value manifestJson; + if(!VFJson::Parser(manifestText).Parse(manifestJson,error))return false; + auto* rootObject=std::get_if(&root.Data); + if(!rootObject) + {error="owner snapshot root is not a JSON object";return false;} + (*rootObject)["world_assembly"]=std::move(manifestJson); + std::printf("[WorldAssemblerPrototype] recipe_version=%u evaluator_version=%u strata=%zu biomes=%zu strate_index=%d chunk_z=%d..%d serialized_bytes=%zu recipe_hash=%016llx plan_hash=%016llx manifest_hash=%016llx source=owner_surface_params_and_biome_map route_points=0 features=0\n", + manifest.recipe.schemaVersion,manifest.recipe.evaluatorVersion,manifest.plan.strates.size(), + manifest.recipe.biomes.size(),manifest.plan.strates.front().strateIndex,topChunkZ,bottomChunkZ, + manifestText.size(),static_cast(manifest.recipe.contentHash), + static_cast(manifest.plan.contentHash), + static_cast(manifest.contentHash)); + return true; + } + + int RunWorldAssemblerAudit(FStandaloneOwnerWorld& owner, + const VoxelForgeCore::ResolvedWorldSnapshot& snapshot,const std::string& captureDirectory) + { + const auto* manifest=snapshot.worldAssembly(); + if(!manifest||manifest->plan.strates.empty()) + {std::fprintf(stderr,"[WorldAssemblerAudit] no shipped world_assembly payload was resolved.\n");return 10;} + const auto& plan=manifest->plan.strates.front(); + const VoxelForgeCore::Heightfield2D block(manifest->recipe,plan); + if(!block.isValid()) + {std::fprintf(stderr,"[WorldAssemblerAudit] Heightfield2D rejected its recipe or plan.\n");return 10;} + const auto contract=block.contract(); + const auto influence=block.influenceBounds(); + const auto detail=block.detailEnvelope(); + const auto bounds=VoxelForgeCore::AuditHeightfieldBounds(block,256, + 0x574f524c44415331ull^manifest->contentHash); + std::printf("[WorldAssemblerBlock] name=Heightfield2D evaluator=%u reads=biome writes=height,rock influence_xy=global influence_z_q=%lld..%lld detail_envelope_q=%lld minimum_feature_q=%lld status=%s\n", + contract.evaluatorVersion,static_cast(influence.minimumZSubvoxels), + static_cast(influence.maximumZSubvoxels), + static_cast(detail.maximumAbsoluteDisplacementSubvoxels), + static_cast(detail.minimumFeatureSizeSubvoxels),bounds.passed?"PASS":"FAIL"); + std::printf("[WorldAssemblerBoundAudit] boxes=%llu point_samples=%llu missed_bounds=%llu unknown_bounds=%llu deliberately_wrong_bound_detected=%s status=%s\n", + static_cast(bounds.boxesTested), + static_cast(bounds.pointSamples), + static_cast(bounds.missedBounds), + static_cast(bounds.unknownBounds), + bounds.negativeControlDetected?"true":"false",bounds.passed?"PASS":"FAIL"); + + struct Query {std::int64_t x=0,y=0,z=0;}; + constexpr int side=64; + std::vector queries; + queries.reserve(side*side); + for(int y=0;y(x)-side/2)*128 + *VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const std::int64_t yq=(static_cast(y)-side/2)*128 + *VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const auto column=block.point(xq,yq,0); + const std::int64_t offset=(static_cast((x+3*y)%9)-4) + *VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + queries.push_back({xq,yq,column.groundHeightSubvoxels+offset}); + } + std::vector oneWorker(queries.size()); + const auto evaluate=[&](std::uint32_t workers, + std::vector& output) + { + const auto begin=std::chrono::steady_clock::now(); + std::vector threads; + for(std::uint32_t worker=0;worker( + std::chrono::steady_clock::now()-begin).count(); + }; + const auto hashSamples=[](const std::vector& samples) + { + std::uint64_t hash=14695981039346656037ull; + const auto add=[&](std::uint64_t value,int bytes) + { + for(int i=0;i(value>>(8*i));hash*=1099511628211ull;} + }; + for(const auto& sample:samples) + { + add(static_cast(sample.groundHeightSubvoxels),8); + add(static_cast(sample.capHeightSubvoxels),8); + add(sample.biomeId,4);add(sample.neighborBiomeId,4);add(sample.biomeWeightQ16,4); + add(static_cast(sample.densitySubvoxels),8); + } + return hash; + }; + const double time1=evaluate(1,oneWorker); + const std::uint64_t hash1=hashSamples(oneWorker); + std::vector eightWorkers(queries.size()); + const double time8=evaluate(8,eightWorkers); + const std::uint64_t hash8=hashSamples(eightWorkers); + std::vector sixteenWorkers(queries.size()); + const double time16=evaluate(16,sixteenWorkers); + const std::uint64_t hash16=hashSamples(sixteenWorkers); + const bool deterministic=hash1==hash8&&hash1==hash16; + std::printf("[WorldAssemblerEvaluatorHash] samples=%zu W1=%016llx W8=%016llx W16=%016llx deterministic=%s\n", + queries.size(),static_cast(hash1), + static_cast(hash8),static_cast(hash16), + deterministic?"true":"false"); + std::printf("[WorldAssemblerQueryTiming] samples=%zu native_W1_ms=%.3f native_W8_ms=%.3f native_W16_ms=%.3f scope=point_evaluation_only\n", + queries.size(),time1,time8,time16); + + double absoluteSum=0.0,maximumError=0.0; + std::uint64_t signMismatches=0; + const auto legacyStart=std::chrono::steady_clock::now(); + for(std::size_t i=0;i(queries[i].x + /static_cast(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel)); + const float y=static_cast(queries[i].y + /static_cast(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel)); + const float z=static_cast(queries[i].z + /static_cast(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel)); + const float legacyDensity=owner.Generator->GetDensityAt(x,y,z); + const double error=std::abs(static_cast(oneWorker[i].densitySubvoxels + /static_cast(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel))-legacyDensity); + absoluteSum+=error;maximumError=std::max(maximumError,error); + if((oneWorker[i].densitySubvoxels<0)!=(legacyDensity<0.0f)) + ++signMismatches; + } + const double legacyMs=std::chrono::duration( + std::chrono::steady_clock::now()-legacyStart).count(); + std::printf("[WorldAssemblerLegacyCompare] samples=%zu mean_abs_density_error=%.4f max_abs_density_error=%.4f sign_mismatches=%llu legacy_point_queries_ms=%.3f status=MEASURED_NOT_PARITY_GATE\n", + queries.size(),absoluteSum/queries.size(),maximumError, + static_cast(signMismatches),legacyMs); + const auto centerColumn=block.point(0,0,0); + const int32 centerGroundVoxel=static_cast(std::llround( + static_cast(centerColumn.groundHeightSubvoxels) + /VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel)); + VoxelForgeCore::World coreWorld(snapshot); + VoxelForgeCore::ShapeBuildRequest coreBuild; + coreBuild.minVoxel={-32,-32,centerGroundVoxel-32}; + coreBuild.maxVoxel={32,32,centerGroundVoxel+32}; + coreBuild.clipMinVoxel=coreBuild.minVoxel; + coreBuild.clipMaxVoxel=coreBuild.maxVoxel; + coreBuild.workerLimit=1; + coreBuild.validatePassageRoutes=false; + FStandaloneCoreScheduler scheduler; + std::string coreError; + const auto coreCompileStart=std::chrono::steady_clock::now(); + const bool coreCompiled=coreWorld.compile(coreBuild,scheduler,coreError); + const double coreCompileMs=std::chrono::duration( + std::chrono::steady_clock::now()-coreCompileStart).count(); + double coreQueryMaximumError=0.0; + std::uint64_t coreQuerySamples=0; + if(coreCompiled) + { + for(const std::int64_t dx:{-24ll,0ll,24ll}) + for(const std::int64_t dy:{-24ll,0ll,24ll}) + for(const std::int64_t dz:{-8ll,-1ll,0ll,1ll,8ll}) + { + const std::int64_t xq=dx*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const std::int64_t yq=dy*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const std::int64_t zq=centerColumn.groundHeightSubvoxels + +dz*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const double expected=static_cast(block.point(xq,yq,zq).densitySubvoxels) + /VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + const double actual=coreWorld.densityAt({static_cast(xq) + /VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel, + static_cast(yq)/VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel, + static_cast(zq)/VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel}); + coreQueryMaximumError=std::max(coreQueryMaximumError,std::abs(expected-actual)); + ++coreQuerySamples; + } + } + const bool coreQueryPass=coreCompiled&&coreQuerySamples==45&&coreQueryMaximumError<=1.0/256.0; + std::printf("[WorldAssemblerCoreQuery] compiled=%s compile_ms=%.3f samples=%llu max_abs_error=%.8f render_meshes_generated=0 readiness=%s status=%s error=%s\n", + coreCompiled?"true":"false",coreCompileMs, + static_cast(coreQuerySamples),coreQueryMaximumError, + coreQueryPass?"field_ready_without_meshes":"unavailable",coreQueryPass?"PASS":"FAIL", + coreError.empty()?"none":coreError.c_str()); + if(!captureDirectory.empty()) + { + constexpr int width=320,height=200; + constexpr double spanVoxels=3200.0; + const std::size_t columnCount=static_cast(width)*height; + std::vector ownerPixels(columnCount*3,0); + std::vector nativePixels(ownerPixels.size(),0); + std::vector ownerHeights(columnCount,std::numeric_limits::quiet_NaN()); + std::vector nativeHeights(columnCount,0.0); + double minimumHeight=std::numeric_limits::max(); + double maximumHeight=std::numeric_limits::lowest(); + std::uint64_t ownerColumns=0; + for(int y=0;y(x)/(width-1)-0.5)*spanVoxels; + const double yVoxel=(static_cast(y)/(height-1)-0.5)*spanVoxels; + const std::size_t column=static_cast(y)*width+x; + float ownerGround=0.0f,ownerCap=0.0f; + if(owner.Generator->GetSurfaceHeightAt(static_cast(xVoxel), + static_cast(yVoxel),plan.topChunkZ,ownerGround,ownerCap)) + { + if(std::isfinite(ownerGround)) + { + ownerHeights[column]=ownerGround; + minimumHeight=std::min(minimumHeight,static_cast(ownerGround)); + maximumHeight=std::max(maximumHeight,static_cast(ownerGround)); + ++ownerColumns; + } + } + const auto native=block.point( + static_cast(std::llround(xVoxel*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel)), + static_cast(std::llround(yVoxel*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel)),0); + nativeHeights[column]=static_cast(native.groundHeightSubvoxels) + /VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel; + minimumHeight=std::min(minimumHeight,nativeHeights[column]); + maximumHeight=std::max(maximumHeight,nativeHeights[column]); + } + const auto colorHeight=[&](double heightValue,std::vector& pixels,std::size_t offset) + { + const double t=std::clamp((heightValue-minimumHeight) + /std::max(1.0,maximumHeight-minimumHeight),0.0,1.0); + const std::array,4> stops={{{24,68,78},{61,128,75}, + {166,143,83},{232,229,209}}}; + const double scaled=t*3.0; + const int segment=std::min(2,static_cast(scaled)); + const double blend=scaled-segment; + for(int channel=0;channel<3;++channel) + pixels[offset+channel]=static_cast(std::clamp(std::lround( + stops[segment][channel]*(1.0-blend)+stops[segment+1][channel]*blend),0l,255l)); + }; + for(std::size_t column=0;column& pixels) + { + std::ofstream output(path,std::ios::binary); + if(!output)return false; + output<<"P6\n"<(pixels.data()), + static_cast(pixels.size())); + return static_cast(output); + }; + const bool ownerSaved=!directoryError&&writePpm( + std::filesystem::path(captureDirectory)/"owner-reference-height.ppm",ownerPixels); + const bool nativeSaved=!directoryError&&writePpm( + std::filesystem::path(captureDirectory)/"native-prototype-height.ppm",nativePixels); + std::printf("[WorldAssemblerCapturePair] resolution=%dx%d xy_span_vox=%.0f height_range=[%.2f,%.2f] owner_columns=%llu owner_saved=%s native_saved=%s files=owner-reference-height.ppm,native-prototype-height.ppm status=%s\n", + width,height,spanVoxels,minimumHeight,maximumHeight,static_cast(ownerColumns), + ownerSaved?"true":"false",nativeSaved?"true":"false", + ownerSaved&&nativeSaved?"PASS":"FAIL"); + } + std::printf("[WorldAssemblerFullViewTiming] status=NOT_MEASURED_IN_AUDIT_MODE; capture_viewer_runs_report_separately\n"); + return bounds.passed&&deterministic&&coreQueryPass?0:11; + } } int main(int Argc,char** Argv) @@ -7805,6 +8159,8 @@ int main(int Argc,char** Argv) int32 MaxClipLevelOverride=-1; int32 ShapeCompareLevel=-1,ShapeCompareTileLimit=0; std::string ShapeCompareArea="unlabeled"; + std::string WorldAssemblyCaptureDir; + std::string WorldAssemblyManifestOutputPath; bool bStreamBenchmark=false; bool bStreamPipeline=false; bool bProfileDetailBand=false; @@ -7848,6 +8204,16 @@ int main(int Argc,char** Argv) else if(Arg=="--parity-only")App.bParityOnly=true; else if(Arg=="--density-hash")App.bDensityHash=true; else if(Arg=="--shape-world")App.bShapeWorld=true; + else if(Arg=="--world-assembler-prototype") + {App.bWorldAssemblerPrototype=true;App.bShapeWorld=true;} + else if(Arg=="--world-assembler-audit") + {App.bWorldAssemblerPrototype=true;App.bWorldAssemblerAuditOnly=true;} + else if(Arg.starts_with("--world-assembler-capture-dir=")) + WorldAssemblyCaptureDir=std::string(Arg.substr( + std::string_view("--world-assembler-capture-dir=").size())); + else if(Arg.starts_with("--world-assembler-manifest-output=")) + WorldAssemblyManifestOutputPath=std::string(Arg.substr( + std::string_view("--world-assembler-manifest-output=").size())); else if(Arg=="--gpu-far") App.bGpuFarEnabled=true,App.bGpuFarShaderPrewarm=true,App.bShapeWorld=true,App.bForceGenerate=true, App.bPresentCpuFrameBeforeGpu=true,App.bHoldCpuFrameDuringGpuWarmup=true; @@ -8057,6 +8423,19 @@ int main(int Argc,char** Argv) std::printf("Standalone max clip level override: exported=%d -> max_level=%d.\n", ExportMaxClipLevel,MaxClipLevelOverride); } + if(App.bWorldAssemblerPrototype + &&!AttachRound1WorldAssemblyPrototype(*App.World,App.OwnerJsonRoot,Error, + WorldAssemblyManifestOutputPath)) + {std::fprintf(stderr,"World assembler prototype setup failed: %s\n",Error.c_str());return 2;} + if(App.bWorldAssemblerAuditOnly) + { + App.Snapshot=VoxelForgeCore::MakeStandaloneSnapshotFromParsedJson(App.OwnerJsonRoot, + App.Seed,App.ClipRadius,App.MaxClipLevelOverride,App.bRoundTunnels, + App.bRoundTunnelEnhanced,Error); + if(!App.Snapshot) + {std::fprintf(stderr,"World assembler snapshot setup failed: %s\n",Error.c_str());return 2;} + return RunWorldAssemblerAudit(*App.World,App.Snapshot,WorldAssemblyCaptureDir); + } const bool bNeedFarShapeWorld=bStreamBenchmark ?(StreamBenchmarkMaxLevel<0||StreamBenchmarkMaxLevel>=App.World->FullResClipLevels) :(App.bShapeCompare?ShapeCompareLevel>=App.World->FullResClipLevels diff --git a/Tools/BuildVoxelForgeCoreHost.ps1 b/Tools/BuildVoxelForgeCoreHost.ps1 new file mode 100644 index 0000000..20fcf6f --- /dev/null +++ b/Tools/BuildVoxelForgeCoreHost.ps1 @@ -0,0 +1,213 @@ +[CmdletBinding()] +param( + [ValidateRange(1, 16)][int]$MaxParallelActions = 16, + [ValidateRange(60, 900)][int]$TimeoutSeconds = 900 +) + +# Stages only inside Saved/BuildHost, builds the isolated host, then audits the exact Core object +# files used by the UE module link. Le projet propriétaire et son éditeur ne sont jamais lancés. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$PluginRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$HostRoot = Join-Path $PluginRoot 'Saved\BuildHost\HostProject' +$HostProject = Join-Path $HostRoot 'HostProject.uproject' +$StagePlugin = Join-Path $HostRoot 'Plugins\VoxelForge' +$StageStandalone = Join-Path $StagePlugin 'Standalone' +$LogRoot = Join-Path $PluginRoot 'Saved\BuildHost' +$EngineRoot = 'E:\Program Files\Epic Games\UE_5.7' +$UbtDll = Join-Path $EngineRoot 'Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll' +$CoreManifest = Join-Path $PluginRoot 'Standalone\Core\core_sources.txt' +$BuildLog = Join-Path $LogRoot 'UBT-CoreIntegration.log' +$BuildStdout = Join-Path $LogRoot 'UBT-CoreIntegration.stdout.log' +$BuildStderr = Join-Path $LogRoot 'UBT-CoreIntegration.stderr.log' +$AuditLibrary = Join-Path $LogRoot 'VoxelForgeCore_ue_link_inputs.lib' +$AuditResult = Join-Path $LogRoot 'VoxelForgeCore_ue_link_safety.log' + +function Assert-UnderPath([string]$Candidate, [string]$Root) { + $candidateFull = [IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') + $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + if (-not ($candidateFull.Equals($rootFull, [StringComparison]::OrdinalIgnoreCase) -or + $candidateFull.StartsWith($rootFull + '\', [StringComparison]::OrdinalIgnoreCase))) { + throw "Refusing a BuildHost path outside its root: $candidateFull" + } +} + +function Copy-DirectoryContents([string]$Source, [string]$Destination) { + if (-not (Test-Path -LiteralPath $Source -PathType Container)) { + throw "Canonical Core source directory is missing: $Source" + } + [IO.Directory]::CreateDirectory($Destination) | Out-Null + foreach ($item in @(Get-ChildItem -LiteralPath $Source -Force)) { + $target = Join-Path $Destination $item.Name + if ($item.PSIsContainer) { + Copy-DirectoryContents $item.FullName $target + } else { + Copy-Item -LiteralPath $item.FullName -Destination $target -Force + } + } +} + +function Find-VsTool([string]$ToolName) { + $toolRoot = 'E:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC' + foreach ($version in @(Get-ChildItem -LiteralPath $toolRoot -Directory | Sort-Object Name -Descending)) { + $candidate = Join-Path $version.FullName "bin\Hostx64\x64\$ToolName" + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + } + throw "$ToolName was not found in the configured VS 2022 toolchain." +} + +foreach ($required in @($HostProject, $UbtDll, $CoreManifest)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Required file is missing: $required" } +} +Assert-UnderPath $StagePlugin $HostRoot +Assert-UnderPath $StageStandalone $StagePlugin +Assert-UnderPath $LogRoot $PluginRoot + +Write-Host 'Staging plugin source and canonical Core inputs into BuildHost.' +$stageSource = Join-Path $StagePlugin 'Source' +Assert-UnderPath $stageSource $StagePlugin +if (Test-Path -LiteralPath $stageSource -PathType Container) { + $sourceItem = Get-Item -LiteralPath $stageSource -Force + if (($sourceItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to replace a linked BuildHost source path: $stageSource" + } + $nestedLinks = @(Get-ChildItem -LiteralPath $stageSource -Force -Recurse | + Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 }) + if ($nestedLinks.Count -gt 0) { + throw "Refusing to replace BuildHost source containing a link: $($nestedLinks[0].FullName)" + } + Remove-Item -LiteralPath $stageSource -Recurse -Force +} +Copy-DirectoryContents (Join-Path $PluginRoot 'Source') $stageSource +Copy-DirectoryContents (Join-Path $PluginRoot 'Standalone\Core\include') (Join-Path $StageStandalone 'Core\include') +Copy-DirectoryContents (Join-Path $PluginRoot 'Standalone\Core\src') (Join-Path $StageStandalone 'Core\src') +Copy-DirectoryContents (Join-Path $PluginRoot 'Standalone\Compat') (Join-Path $StageStandalone 'Compat') +Copy-DirectoryContents (Join-Path $PluginRoot 'Standalone\Source') (Join-Path $StageStandalone 'Source') +Copy-Item -LiteralPath $CoreManifest -Destination (Join-Path $StageStandalone 'Core\core_sources.txt') -Force +Copy-Item -LiteralPath (Join-Path $PluginRoot 'VoxelForge.uplugin') ` + -Destination (Join-Path $StagePlugin 'VoxelForge.uplugin') -Force + +# The verified owner-asset copies are already staged under BuildHost. Read only those copies here; +# never resolve, open, copy, or compare against the owner's project Content directory. +$hostAssetRoot = Join-Path $HostRoot 'Content\VoxelForge' +$expectedAssetHashes = @{ + 'DA_Settings' = '5D2977F57E75A63B6B73499CD6E0AA7FBBF3718CE7F70318107BF922F0819AB7' + 'DA_Strate1' = 'CDA1F985E589F28CA6E93A068FECC3CA92487E4AD5700F2C301C4B563E40E5F9' + 'DA_Strate2' = '02F56A18CED991BAF93DFB2F69438152275E8353B5E279C1CB7DD6E079E352E2' + 'DA_Strate3' = 'F47437F8D156C608AA06E40C5B76716ED4A19FA0F057688E7FECC04DC976428C' + 'DA_Strate4' = '279570AE3CBA7B3507269C8209A7C931EEB7A15CC3241CE07B50120996B1C656' +} +foreach ($assetName in $expectedAssetHashes.Keys) { + $stagedAsset = Join-Path $hostAssetRoot "$assetName.uasset" + if (-not (Test-Path -LiteralPath $stagedAsset -PathType Leaf)) { + throw "Verified BuildHost asset copy is missing: $stagedAsset" + } + $stagedHash = (Get-FileHash -LiteralPath $stagedAsset -Algorithm SHA256).Hash + if ($stagedHash -ne $expectedAssetHashes[$assetName]) { + throw "BuildHost asset copy differs from the recorded round-2 hash: $assetName" + } +} + +$dotnet = (Get-Command dotnet.exe -ErrorAction Stop).Source +$argumentList = @( + ('"{0}"' -f $UbtDll), 'UnrealEditor', 'Win64', 'Development', + ('-Project="{0}"' -f $HostProject), '-WaitMutex', '-NoHotReloadFromIDE', + '-NoUBA', '-NoXGE', '-NoFASTBuild', '-NoSNDBS', + "-MaxParallelActions=$MaxParallelActions", ('-Log="{0}"' -f $BuildLog) +) +$crashRoots = @((Join-Path $HostRoot 'Saved\Crashes'), (Join-Path $StagePlugin 'Saved\Crashes')) +$crashBefore = @{} +foreach ($crashRoot in $crashRoots) { + if (Test-Path -LiteralPath $crashRoot -PathType Container) { + foreach ($crash in @(Get-ChildItem -LiteralPath $crashRoot -Directory -Filter 'UECC-*' -Recurse -Force)) { + $crashBefore[$crash.FullName.ToLowerInvariant()] = $crash.LastWriteTimeUtc.Ticks + } + } +} + +$buildSucceeded = $false +for ($attempt = 1; $attempt -le 2 -and -not $buildSucceeded; ++$attempt) { + $attemptStdout = if ($attempt -eq 1) { $BuildStdout } else { Join-Path $LogRoot 'UBT-CoreIntegration.retry.stdout.log' } + $attemptStderr = if ($attempt -eq 1) { $BuildStderr } else { Join-Path $LogRoot 'UBT-CoreIntegration.retry.stderr.log' } + Write-Host "BuildHost UBT attempt $attempt of 2; max parallel actions=$MaxParallelActions; normal priority." + $process = Start-Process -FilePath $dotnet -ArgumentList $argumentList -WorkingDirectory $PluginRoot ` + -RedirectStandardOutput $attemptStdout -RedirectStandardError $attemptStderr ` + -WindowStyle Hidden -PassThru + $process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal + $started = [datetime]::UtcNow + while (-not $process.HasExited) { + Start-Sleep -Seconds 5 + $process.Refresh() + if (([datetime]::UtcNow - $started).TotalSeconds -gt $TimeoutSeconds) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "BuildHost UBT exceeded the $TimeoutSeconds-second time box; stopped PID $($process.Id)." + } + if (-not $process.HasExited -and $process.PrivateMemorySize64 -gt 8GB) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "BuildHost UBT exceeded the 8 GiB private-memory limit; stopped PID $($process.Id)." + } + } + $process.WaitForExit() + $process.Refresh() + $buildExitCode = $process.ExitCode + if ([string]::IsNullOrEmpty([string]$buildExitCode) -and (Test-Path -LiteralPath $BuildLog -PathType Leaf) -and + ((Get-Content -LiteralPath $BuildLog -Raw) -match '(?m)^Result: Succeeded\s*$')) { + # Some Windows Start-Process handles lose the exit-code field after UBT's child exits; + # accept only UBT's explicit successful target result in that case. + $buildExitCode = 0 + } + if ($buildExitCode -eq 0) { + $buildSucceeded = $true + Write-Host "BuildHost UBT attempt $attempt passed." + } elseif ($buildExitCode -eq -1073741502 -and $attempt -eq 1) { + Write-Warning 'BuildHost UBT exited with 0xC0000142; retrying once as requested.' + } else { + throw "BuildHost UBT attempt $attempt failed with exit code '$buildExitCode'. See $BuildLog." + } +} + +if (-not $buildSucceeded) { throw 'BuildHost UBT did not complete successfully.' } + +$manifestRows = @(Get-Content -LiteralPath $CoreManifest | ForEach-Object { $_.Trim().Replace('\', '/') } | + Where-Object { $_ -and -not $_.StartsWith('#') }) +$expectedObjects = @($manifestRows | ForEach-Object { + 'VFCore_' + ($_ -replace '[^A-Za-z0-9]+', '_') + '.cpp.obj' +}) +$objectRoot = Join-Path $StagePlugin 'Intermediate\Build' +$coreObjects = @(Get-ChildItem -LiteralPath $objectRoot -Recurse -File -Filter 'VFCore_*.cpp.obj' | + Where-Object { $_.FullName -match '\\VoxelForgeCore\\' }) +$actualObjects = @($coreObjects | ForEach-Object { $_.Name } | Sort-Object -Unique) +$missingObjects = @($expectedObjects | Where-Object { $actualObjects -notcontains $_ }) +$unexpectedObjects = @($actualObjects | Where-Object { $expectedObjects -notcontains $_ }) +if ($missingObjects.Count -gt 0 -or $unexpectedObjects.Count -gt 0 -or $coreObjects.Count -ne $manifestRows.Count) { + throw "Core link input inventory mismatch: expected $($manifestRows.Count) shims, found $($coreObjects.Count); missing=[$($missingObjects -join ',')], extra=[$($unexpectedObjects -join ',')]." +} + +$libTool = Find-VsTool 'lib.exe' +if (Test-Path -LiteralPath $AuditLibrary -PathType Leaf) { Remove-Item -LiteralPath $AuditLibrary -Force } +& $libTool '/nologo' ("/OUT:{0}" -f $AuditLibrary) @($coreObjects.FullName) +if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $AuditLibrary -PathType Leaf)) { + throw "Could not archive the exact UBT Core link inputs for symbol inspection (exit $LASTEXITCODE)." +} +& (Join-Path $PluginRoot 'Standalone\Core\CheckCoreSymbols.ps1') ` + -LibraryPath $AuditLibrary -ResultPath $AuditResult + +$newCrashes = [Collections.Generic.List[string]]::new() +foreach ($crashRoot in $crashRoots) { + if (Test-Path -LiteralPath $crashRoot -PathType Container) { + foreach ($crash in @(Get-ChildItem -LiteralPath $crashRoot -Directory -Filter 'UECC-*' -Recurse -Force)) { + if (-not $crashBefore.ContainsKey($crash.FullName.ToLowerInvariant())) { + [void]$newCrashes.Add($crash.FullName) + } + } + } +} +if ($newCrashes.Count -gt 0) { + throw "New UECC crash folders appeared during the BuildHost run: $($newCrashes -join '; ')" +} + +Write-Host "Verified the five recorded asset hashes in the existing BuildHost Content copies." +Write-Host "Core manifest shims linked and audited: $($coreObjects.Count)" +Write-Host "Link-safety result: $AuditResult" +Write-Host "Build log: $BuildLog" diff --git a/Tools/TestVoxelForgeCoreAdapterHost.ps1 b/Tools/TestVoxelForgeCoreAdapterHost.ps1 new file mode 100644 index 0000000..aee9b13 --- /dev/null +++ b/Tools/TestVoxelForgeCoreAdapterHost.ps1 @@ -0,0 +1,203 @@ +[CmdletBinding()] +param( + [ValidateRange(60, 900)][int]$TimeoutSeconds = 900 +) + +# Run the owner-asset adapter and Core parity gate in the isolated BuildHost only. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$PluginRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$HostRoot = Join-Path $PluginRoot 'Saved\BuildHost\HostProject' +$HostProject = Join-Path $HostRoot 'HostProject.uproject' +$StagePlugin = Join-Path $HostRoot 'Plugins\VoxelForge' +$OwnerAssetRoot = Join-Path ([IO.Path]::GetFullPath((Join-Path $PluginRoot '..\..'))) 'Content\VoxelForge' +$OutputRoot = Join-Path $StagePlugin 'Saved\CoreAdapterValidation' +$OutputDirectory = Join-Path $OutputRoot 'Output' +$LogDirectory = Join-Path $OutputRoot 'Logs' +$LocalAppData = Join-Path $OutputRoot 'LocalAppData' +$UserDirectory = Join-Path $OutputRoot 'User' +$DerivedDataCache = Join-Path $PluginRoot 'Saved\BuildHost\VFCoreAdapterDDC' +$ZenData = Join-Path $OutputRoot 'ZenData' +$EditorCmd = 'E:\Program Files\Epic Games\UE_5.7\Engine\Binaries\Win64\UnrealEditor-Cmd.exe' +$LogPath = Join-Path $LogDirectory 'CoreAdapterValidation.log' +$StdoutPath = Join-Path $LogDirectory 'CoreAdapterValidation.stdout.log' +$StderrPath = Join-Path $LogDirectory 'CoreAdapterValidation.stderr.log' +$WorldJson = Join-Path $OutputDirectory 'owner_world.json' +$MemoryLimitBytes = 8GB + +function Assert-UnderPath([string]$Candidate, [string]$Root) { + $candidateFull = [IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') + $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + if (-not ($candidateFull.Equals($rootFull, [StringComparison]::OrdinalIgnoreCase) -or + $candidateFull.StartsWith($rootFull + '\', [StringComparison]::OrdinalIgnoreCase))) { + throw "Refusing a commandlet path outside BuildHost: $candidateFull" + } +} + +Assert-UnderPath $HostRoot (Join-Path $PluginRoot 'Saved\BuildHost') +Assert-UnderPath $OutputRoot (Join-Path $PluginRoot 'Saved\BuildHost') +foreach ($required in @($HostProject, $EditorCmd)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Required BuildHost commandlet input is missing: $required" + } +} + +$foreignEditors = @(Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.ProcessName -like 'UnrealEditor*' }) +if ($foreignEditors.Count -gt 0) { + $ids = ($foreignEditors | ForEach-Object { "$($_.ProcessName):$($_.Id)" }) -join ', ' + throw "An UnrealEditor process is already running; leaving it untouched: $ids" +} + +foreach ($assetName in @('DA_Settings', 'DA_Strate1', 'DA_Strate2', 'DA_Strate3', 'DA_Strate4')) { + $ownerAsset = Join-Path $OwnerAssetRoot "$assetName.uasset" + $stagedAsset = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset" + if (-not (Test-Path -LiteralPath $ownerAsset -PathType Leaf) -or + -not (Test-Path -LiteralPath $stagedAsset -PathType Leaf)) { + throw "Owner or BuildHost asset is missing: $assetName" + } + if ((Get-FileHash -LiteralPath $ownerAsset -Algorithm SHA256).Hash -ne + (Get-FileHash -LiteralPath $stagedAsset -Algorithm SHA256).Hash) { + throw "BuildHost asset copy differs from the owner asset: $assetName" + } +} + +foreach ($directory in @($OutputDirectory, $LogDirectory, $LocalAppData, + $UserDirectory, $DerivedDataCache, $ZenData)) { + [IO.Directory]::CreateDirectory($directory) | Out-Null +} + +$CrashRoots = @((Join-Path $HostRoot 'Saved\Crashes'), (Join-Path $StagePlugin 'Saved\Crashes')) +$CrashBefore = @{} +foreach ($CrashRoot in $CrashRoots) { + if (Test-Path -LiteralPath $CrashRoot -PathType Container) { + foreach ($Crash in @(Get-ChildItem -LiteralPath $CrashRoot -Directory -Filter 'UECC-*' -Recurse -Force)) { + $CrashBefore[$Crash.FullName.ToLowerInvariant()] = $Crash.LastWriteTimeUtc.Ticks + } + } +} + +function Get-NewCrashFolders { + $Found = [Collections.Generic.List[string]]::new() + foreach ($CrashRoot in $CrashRoots) { + if (Test-Path -LiteralPath $CrashRoot -PathType Container) { + foreach ($Crash in @(Get-ChildItem -LiteralPath $CrashRoot -Directory -Filter 'UECC-*' -Recurse -Force)) { + if (-not $CrashBefore.ContainsKey($Crash.FullName.ToLowerInvariant())) { + [void]$Found.Add($Crash.FullName) + } + } + } + } + return @($Found.ToArray()) +} + +$Arguments = @( + ('-project="{0}"' -f $HostProject), + '-nullrhi', '-unattended', '-nop4', '-nosplash', '-NoSound', + '-stdout', '-FullStdOutLogOutput', '-forcelogflush', '-NoZenAutoLaunch', + '-notraceserver', '-nocrashreports', '-ddc=NoZenLocalFallback', + ('-userdir="{0}"' -f $UserDirectory), + ('-ZenDataPath="{0}"' -f $ZenData), + ('-LocalDataCachePath="{0}"' -f $DerivedDataCache), + '-run=VoxelForgeExplore', '-seed=0', '-archetype=TunnelNetwork', '-slot=0', + '-modes=export', '-blockearlyout=0', '-gameconfig=1', '-standaloneworldexport=1', + '-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings', + ('-out="{0}"' -f $OutputDirectory) +) + +$OldLocalAppData = $env:LOCALAPPDATA +$env:LOCALAPPDATA = $LocalAppData +try { + $PeakPrivateBytes = [int64]0 + $Succeeded = $false + for ($Attempt = 1; $Attempt -le 2 -and -not $Succeeded; ++$Attempt) { + $AttemptLogPath = if ($Attempt -eq 1) { $LogPath } else { + Join-Path $LogDirectory 'CoreAdapterValidation.retry.log' + } + $AttemptStdoutPath = if ($Attempt -eq 1) { $StdoutPath } else { + Join-Path $LogDirectory 'CoreAdapterValidation.retry.stdout.log' + } + $AttemptStderrPath = if ($Attempt -eq 1) { $StderrPath } else { + Join-Path $LogDirectory 'CoreAdapterValidation.retry.stderr.log' + } + $AttemptArguments = @($Arguments) + ('-abslog="{0}"' -f $AttemptLogPath) + Write-Host "BuildHost adapter commandlet attempt $Attempt of 2; normal priority." + $Process = Start-Process -FilePath $EditorCmd -ArgumentList $AttemptArguments -WorkingDirectory $HostRoot ` + -RedirectStandardOutput $AttemptStdoutPath -RedirectStandardError $AttemptStderrPath ` + -WindowStyle Hidden -PassThru + $Process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal + $StartedUtc = [datetime]::UtcNow + while (-not $Process.HasExited) { + Start-Sleep -Seconds 2 + $Process.Refresh() + if (-not $Process.HasExited) { + $PeakPrivateBytes = [Math]::Max($PeakPrivateBytes, [int64]$Process.PrivateMemorySize64) + } + if (([datetime]::UtcNow - $StartedUtc).TotalSeconds -gt $TimeoutSeconds) { + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + throw "BuildHost adapter commandlet exceeded the $TimeoutSeconds-second time box." + } + if (-not $Process.HasExited -and $Process.PrivateMemorySize64 -gt $MemoryLimitBytes) { + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + throw 'BuildHost adapter commandlet exceeded the 8 GiB private-memory limit.' + } + } + $Process.WaitForExit() + $Process.Refresh() + $ProcessExitCode = $Process.ExitCode + if ([string]::IsNullOrEmpty([string]$ProcessExitCode) -and (Test-Path -LiteralPath $AttemptLogPath -PathType Leaf)) { + $CompletedLog = Get-Content -LiteralPath $AttemptLogPath -Raw + if ((($CompletedLog -match 'Commandlet->Main return this error code: 0') -or + ($CompletedLog -match 'Commandlet VoxelForgeExploreCommandlet_\d+ finished execution \(result 0\)')) -and + ($CompletedLog -match '\[VoxelForgeCoreAdapter\] pass schema=1')) { + # Unreal returned its commandlet result through the log, but Start-Process did not + # retain an ExitCode value for this editor process handle. + $ProcessExitCode = 0 + } + } + if ($ProcessExitCode -eq -1073741502 -and $Attempt -eq 1) { + Write-Warning 'BuildHost adapter commandlet exited with 0xC0000142; retrying once.' + continue + } + if ($ProcessExitCode -ne 0) { + $FailedRunCrashes = @(Get-NewCrashFolders) + $CrashDetail = if ($FailedRunCrashes.Count -gt 0) { + " New UECC folders: $($FailedRunCrashes -join '; ')." + } else { '' } + throw "BuildHost adapter commandlet attempt $Attempt failed with exit code '$ProcessExitCode'.$CrashDetail See $AttemptLogPath." + } + $LogPath = $AttemptLogPath + $Succeeded = $true + } + if (-not $Succeeded) { + throw 'BuildHost adapter commandlet did not complete successfully.' + } +} finally { + if ($null -eq $OldLocalAppData) { + Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue + } else { + $env:LOCALAPPDATA = $OldLocalAppData + } +} + +if (-not (Test-Path -LiteralPath $WorldJson -PathType Leaf)) { + throw "The adapter commandlet did not write $WorldJson." +} +$BuildLogText = Get-Content -LiteralPath $LogPath -Raw +foreach ($WorkerCount in @(1, 8, 16)) { + $ExpectedAdapterPass = "\[VoxelForgeCoreAdapter\] pass schema=1 workers=$WorkerCount asset_clip_radius=\d+ asset_max_level=\d+ reference_clip_radius=3 reference_max_level=8 tiles=2226 density=0xdb2991c73215af4c mesh=0x8236c5edd7fc8e4f payload=0x4c48fa75a543d58e topology=0xbea22e76b34a4dfc walk=5642/5642" + if ($BuildLogText -notmatch $ExpectedAdapterPass) { + throw "The BuildHost log is missing the exact Core adapter parity result for workers=$WorkerCount. See $LogPath." + } +} + +$NewCrashes = @(Get-NewCrashFolders) +if ($NewCrashes.Count -gt 0) { + throw "New UECC crash folders appeared during adapter validation: $($NewCrashes -join '; ')" +} + +Write-Host 'VOXELFORGE_CORE_ADAPTER_PASS tiles=2226 walkability=5642/5642' +Write-Host "Core adapter log: $LogPath" +Write-Host "Schema-v1 JSON: $WorldJson" +Write-Host "Peak commandlet private bytes sampled: $PeakPrivateBytes" diff --git a/Tools/TestVoxelForgeCoreWorldHost.ps1 b/Tools/TestVoxelForgeCoreWorldHost.ps1 new file mode 100644 index 0000000..dce316f --- /dev/null +++ b/Tools/TestVoxelForgeCoreWorldHost.ps1 @@ -0,0 +1,197 @@ +[CmdletBinding()] +param( + [ValidateRange(60, 900)][int]$TimeoutSeconds = 900 +) + +# Exercise AVoxelWorld Core streaming, collision, anchor movement, and carving in BuildHost only. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$PluginRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$HostRoot = Join-Path $PluginRoot 'Saved\BuildHost\HostProject' +$StagePlugin = Join-Path $HostRoot 'Plugins\VoxelForge' +$HostProject = Join-Path $HostRoot 'HostProject.uproject' +$OutputRoot = Join-Path $StagePlugin 'Saved\CoreWorldValidation' +$OutputDirectory = Join-Path $OutputRoot 'Output' +$LogDirectory = Join-Path $OutputRoot 'Logs' +$LocalAppData = Join-Path $OutputRoot 'LocalAppData' +$UserDirectory = Join-Path $OutputRoot 'User' +$DerivedDataCache = Join-Path $PluginRoot 'Saved\BuildHost\VFCoreWorldDDC' +$ZenData = Join-Path $OutputRoot 'ZenData' +$EditorCmd = 'E:\Program Files\Epic Games\UE_5.7\Engine\Binaries\Win64\UnrealEditor-Cmd.exe' +$MemoryLimitBytes = 8GB +$CrashRoots = @((Join-Path $HostRoot 'Saved\Crashes'), (Join-Path $StagePlugin 'Saved\Crashes')) + +function Assert-UnderPath([string]$Candidate, [string]$Root) { + $candidateFull = [IO.Path]::GetFullPath($Candidate).TrimEnd('\', '/') + $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + if (-not ($candidateFull.Equals($rootFull, [StringComparison]::OrdinalIgnoreCase) -or + $candidateFull.StartsWith($rootFull + '\', [StringComparison]::OrdinalIgnoreCase))) { + throw "Refusing a BuildHost path outside its root: $candidateFull" + } +} + +Assert-UnderPath $HostRoot (Join-Path $PluginRoot 'Saved\BuildHost') +Assert-UnderPath $OutputRoot (Join-Path $StagePlugin 'Saved') +foreach ($required in @($HostProject, $EditorCmd)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Required BuildHost commandlet input is missing: $required" + } +} + +$foreignEditors = @(Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.ProcessName -like 'UnrealEditor*' }) +if ($foreignEditors.Count -gt 0) { + $ids = ($foreignEditors | ForEach-Object { "$($_.ProcessName):$($_.Id)" }) -join ', ' + throw "An UnrealEditor process is already running; leaving it untouched: $ids" +} + +$ExpectedAssetHashes = @{ + 'DA_Settings' = '5D2977F57E75A63B6B73499CD6E0AA7FBBF3718CE7F70318107BF922F0819AB7' + 'DA_Strate1' = 'CDA1F985E589F28CA6E93A068FECC3CA92487E4AD5700F2C301C4B563E40E5F9' + 'DA_Strate2' = '02F56A18CED991BAF93DFB2F69438152275E8353B5E279C1CB7DD6E079E352E2' + 'DA_Strate3' = 'F47437F8D156C608AA06E40C5B76716ED4A19FA0F057688E7FECC04DC976428C' + 'DA_Strate4' = '279570AE3CBA7B3507269C8209A7C931EEB7A15CC3241CE07B50120996B1C656' +} +foreach ($assetName in $ExpectedAssetHashes.Keys) { + $stagedAsset = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset" + if (-not (Test-Path -LiteralPath $stagedAsset -PathType Leaf)) { + throw "Verified BuildHost asset copy is missing: $assetName" + } + if ((Get-FileHash -LiteralPath $stagedAsset -Algorithm SHA256).Hash -ne $ExpectedAssetHashes[$assetName]) { + throw "BuildHost asset copy differs from the recorded round-2 hash: $assetName" + } +} + +foreach ($directory in @($OutputDirectory, $LogDirectory, $LocalAppData, + $UserDirectory, $DerivedDataCache, $ZenData)) { + [IO.Directory]::CreateDirectory($directory) | Out-Null +} + +$CrashBefore = @{} +foreach ($CrashRoot in $CrashRoots) { + if (Test-Path -LiteralPath $CrashRoot -PathType Container) { + foreach ($Crash in @(Get-ChildItem -LiteralPath $CrashRoot -Directory -Filter 'UECC-*' -Recurse -Force)) { + $CrashBefore[$Crash.FullName.ToLowerInvariant()] = $Crash.LastWriteTimeUtc.Ticks + } + } +} + +function Get-NewCrashFolders { + $Found = [Collections.Generic.List[string]]::new() + foreach ($CrashRoot in $CrashRoots) { + if (Test-Path -LiteralPath $CrashRoot -PathType Container) { + foreach ($Crash in @(Get-ChildItem -LiteralPath $CrashRoot -Directory -Filter 'UECC-*' -Recurse -Force)) { + if (-not $CrashBefore.ContainsKey($Crash.FullName.ToLowerInvariant())) { + [void]$Found.Add($Crash.FullName) + } + } + } + } + return @($Found.ToArray()) +} + +$BaseArguments = @( + ('-project="{0}"' -f $HostProject), + '-nullrhi', '-unattended', '-nop4', '-nosplash', '-NoSound', + '-stdout', '-FullStdOutLogOutput', '-forcelogflush', '-NoZenAutoLaunch', + '-notraceserver', '-nocrashreports', '-ddc=NoZenLocalFallback', + ('-userdir="{0}"' -f $UserDirectory), + ('-ZenDataPath="{0}"' -f $ZenData), + ('-LocalDataCachePath="{0}"' -f $DerivedDataCache), + '-run=VoxelForgeExplore', '-seed=0', '-archetype=TunnelNetwork', '-slot=0', + '-modes=export', '-gameconfig=1', '-coreworldtest=1', + '-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings', + ('-out="{0}"' -f $OutputDirectory) +) + +$OldLocalAppData = $env:LOCALAPPDATA +$env:LOCALAPPDATA = $LocalAppData +try { + $Succeeded = $false + for ($Attempt = 1; $Attempt -le 2 -and -not $Succeeded; ++$Attempt) { + if ($Attempt -eq 1) { + $LogFileName = 'CoreWorldValidation.log' + $StdoutFileName = 'CoreWorldValidation.stdout.log' + $StderrFileName = 'CoreWorldValidation.stderr.log' + } else { + $LogFileName = 'CoreWorldValidation.retry.log' + $StdoutFileName = 'CoreWorldValidation.retry.stdout.log' + $StderrFileName = 'CoreWorldValidation.retry.stderr.log' + } + $LogPath = Join-Path $LogDirectory $LogFileName + $StdoutPath = Join-Path $LogDirectory $StdoutFileName + $StderrPath = Join-Path $LogDirectory $StderrFileName + $Arguments = @($BaseArguments) + ('-abslog="{0}"' -f $LogPath) + Write-Host "BuildHost Core actor commandlet attempt $Attempt of 2; normal priority." + $Process = Start-Process -FilePath $EditorCmd -ArgumentList $Arguments -WorkingDirectory $HostRoot ` + -RedirectStandardOutput $StdoutPath -RedirectStandardError $StderrPath ` + -WindowStyle Hidden -PassThru + $Process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal + $StartedUtc = [datetime]::UtcNow + $PeakPrivateBytes = [int64]0 + while (-not $Process.HasExited) { + Start-Sleep -Seconds 2 + $Process.Refresh() + if (-not $Process.HasExited) { + $PeakPrivateBytes = [Math]::Max($PeakPrivateBytes, [int64]$Process.PrivateMemorySize64) + } + if (([datetime]::UtcNow - $StartedUtc).TotalSeconds -gt $TimeoutSeconds) { + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + throw "BuildHost Core actor commandlet exceeded the $TimeoutSeconds-second time box." + } + if (-not $Process.HasExited -and $Process.PrivateMemorySize64 -gt $MemoryLimitBytes) { + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + throw 'BuildHost Core actor commandlet exceeded the 8 GiB private-memory limit.' + } + } + $Process.WaitForExit() + $Process.Refresh() + $ExitCode = $Process.ExitCode + if ([string]::IsNullOrEmpty([string]$ExitCode) -and (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + $CompletedLog = Get-Content -LiteralPath $LogPath -Raw + if ($CompletedLog -match '\[VoxelForgeCoreWorldTest\] pass complete=1' -and + $CompletedLog -match 'Commandlet VoxelForgeExploreCommandlet_\d+ finished execution \(result 0\)') { + $ExitCode = 0 + } + } + if ($ExitCode -eq -1073741502 -and $Attempt -eq 1) { + Write-Warning 'BuildHost Core actor commandlet exited with 0xC0000142; retrying once.' + continue + } + if ($ExitCode -ne 0) { + $NewCrashes = @(Get-NewCrashFolders) + $CrashDetail = if ($NewCrashes.Count -gt 0) { " New UECC folders: $($NewCrashes -join '; ')." } else { '' } + throw "BuildHost Core actor commandlet attempt $Attempt failed with exit code '$ExitCode'.$CrashDetail See $LogPath." + } + $Succeeded = $true + } + if (-not $Succeeded) { throw 'BuildHost Core actor commandlet did not complete successfully.' } +} finally { + if ($null -eq $OldLocalAppData) { + Remove-Item Env:LOCALAPPDATA -ErrorAction SilentlyContinue + } else { + $env:LOCALAPPDATA = $OldLocalAppData + } +} + +$FinalLog = Join-Path $LogDirectory 'CoreWorldValidation.log' +if (-not (Test-Path -LiteralPath $FinalLog -PathType Leaf)) { + $FinalLog = Join-Path $LogDirectory 'CoreWorldValidation.retry.log' +} +$Text = Get-Content -LiteralPath $FinalLog -Raw +foreach ($Phase in @('initial', 'anchor', 'edit', 'surface_travel', 'surface_strate_travel')) { + if ($Text -notmatch "\[VoxelForgeCoreWorldTest\] pass phase=$Phase") { + throw "BuildHost Core actor log is missing the '$Phase' validation marker. See $FinalLog." + } +} +if ($Text -notmatch '\[VoxelForgeCoreWorldTest\] worker_hash_matrix=1/8/16 stable=1') { + throw "BuildHost Core actor log is missing the W1/W8/W16 determinism marker. See $FinalLog." +} +$NewCrashes = @(Get-NewCrashFolders) +if ($NewCrashes.Count -gt 0) { + throw "New UECC crash folders appeared during Core actor validation: $($NewCrashes -join '; ')" +} +Write-Host 'VOXELFORGE_CORE_WORLD_PASS streaming=1 collision=1 anchor_move=1 carve=1' +Write-Host "Core actor log: $FinalLog" +Write-Host "Peak commandlet private bytes sampled: $PeakPrivateBytes"