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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
This commit is contained in:
1 parent
d5ce5d675d
commit
874c796c65
20 files changed
+2879
-11
No files matched your search
@@ -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<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>;
|
||||
constexpr double CoreVoxelSizeCm = 25.0;
|
||||
|
||||
TSharedPtr<FJsonValue> SerializeCoreStruct(
|
||||
const UStruct* Type, const void* Data, TSet<const UObject*>& ActiveObjects,
|
||||
const UObject* Object = nullptr);
|
||||
|
||||
TSharedPtr<FJsonValue> SerializeCoreProperty(
|
||||
FProperty* Property, const void* Data, TSet<const UObject*>& ActiveObjects)
|
||||
{
|
||||
if (Property == nullptr || Data == nullptr) return MakeShared<FJsonValueNull>();
|
||||
if (const FBoolProperty* Bool = CastField<FBoolProperty>(Property))
|
||||
{
|
||||
return MakeShared<FJsonValueBoolean>(Bool->GetPropertyValue(Data));
|
||||
}
|
||||
if (const FEnumProperty* Enum = CastField<FEnumProperty>(Property))
|
||||
{
|
||||
return MakeShared<FJsonValueNumber>(static_cast<double>(
|
||||
Enum->GetUnderlyingProperty()->GetSignedIntPropertyValue(Data)));
|
||||
}
|
||||
if (const FNumericProperty* Numeric = CastField<FNumericProperty>(Property))
|
||||
{
|
||||
const double Value = Numeric->IsFloatingPoint()
|
||||
? Numeric->GetFloatingPointPropertyValue(Data)
|
||||
: static_cast<double>(Numeric->GetSignedIntPropertyValue(Data));
|
||||
return MakeShared<FJsonValueNumber>(Value);
|
||||
}
|
||||
if (const FStrProperty* String = CastField<FStrProperty>(Property))
|
||||
{
|
||||
return MakeShared<FJsonValueString>(String->GetPropertyValue(Data));
|
||||
}
|
||||
if (const FNameProperty* Name = CastField<FNameProperty>(Property))
|
||||
{
|
||||
return MakeShared<FJsonValueString>(Name->GetPropertyValue(Data).ToString());
|
||||
}
|
||||
if (const FTextProperty* Text = CastField<FTextProperty>(Property))
|
||||
{
|
||||
return MakeShared<FJsonValueString>(Text->GetPropertyValue(Data).ToString());
|
||||
}
|
||||
if (const FSoftObjectProperty* Soft = CastField<FSoftObjectProperty>(Property))
|
||||
{
|
||||
return MakeShared<FJsonValueString>(
|
||||
Soft->GetPropertyValue(Data).ToSoftObjectPath().ToString());
|
||||
}
|
||||
if (const FArrayProperty* Array = CastField<FArrayProperty>(Property))
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> 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<FJsonValueArray>(MoveTemp(Values));
|
||||
}
|
||||
if (const FSetProperty* Set = CastField<FSetProperty>(Property))
|
||||
{
|
||||
TArray<TPair<FString, TSharedPtr<FJsonValue>>> 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<TSharedPtr<FJsonValue>> Values;
|
||||
Values.Reserve(OrderedValues.Num());
|
||||
for (const auto& Entry : OrderedValues) Values.Add(Entry.Value);
|
||||
return MakeShared<FJsonValueArray>(MoveTemp(Values));
|
||||
}
|
||||
if (const FMapProperty* Map = CastField<FMapProperty>(Property))
|
||||
{
|
||||
TArray<TPair<FString, TSharedPtr<FJsonValue>>> OrderedValues;
|
||||
FScriptMapHelper Helper(Map, Data);
|
||||
for (int32 Index = 0; Index < Helper.GetMaxIndex(); ++Index)
|
||||
{
|
||||
if (!Helper.IsValidIndex(Index)) continue;
|
||||
TSharedRef<FJsonObject> Pair = MakeShared<FJsonObject>();
|
||||
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<FJsonValueObject>(Pair));
|
||||
}
|
||||
OrderedValues.Sort([](const auto& A, const auto& B) { return A.Key < B.Key; });
|
||||
TArray<TSharedPtr<FJsonValue>> Values;
|
||||
Values.Reserve(OrderedValues.Num());
|
||||
for (const auto& Entry : OrderedValues) Values.Add(Entry.Value);
|
||||
return MakeShared<FJsonValueArray>(MoveTemp(Values));
|
||||
}
|
||||
if (const FStructProperty* Struct = CastField<FStructProperty>(Property))
|
||||
{
|
||||
return SerializeCoreStruct(Struct->Struct, Data, ActiveObjects);
|
||||
}
|
||||
if (const FObjectPropertyBase* ObjectProperty = CastField<FObjectPropertyBase>(Property))
|
||||
{
|
||||
const UObject* Object = ObjectProperty->GetObjectPropertyValue(Data);
|
||||
if (Object == nullptr) return MakeShared<FJsonValueNull>();
|
||||
if (Object->GetClass()->GetName().StartsWith(TEXT("Voxel"), ESearchCase::CaseSensitive))
|
||||
{
|
||||
return SerializeCoreStruct(Object->GetClass(), Object, ActiveObjects, Object);
|
||||
}
|
||||
return MakeShared<FJsonValueString>(Object->GetPathName());
|
||||
}
|
||||
return MakeShared<FJsonValueNull>();
|
||||
}
|
||||
|
||||
TSharedPtr<FJsonValue> SerializeCoreStruct(
|
||||
const UStruct* Type, const void* Data, TSet<const UObject*>& ActiveObjects,
|
||||
const UObject* Object)
|
||||
{
|
||||
if (Type == nullptr || Data == nullptr) return MakeShared<FJsonValueNull>();
|
||||
if (Object != nullptr && ActiveObjects.Contains(Object))
|
||||
{
|
||||
return MakeShared<FJsonValueString>(Object->GetPathName());
|
||||
}
|
||||
if (Object != nullptr) ActiveObjects.Add(Object);
|
||||
|
||||
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
||||
for (TFieldIterator<FProperty> It(Type); It; ++It)
|
||||
{
|
||||
FProperty* Property = *It;
|
||||
Result->SetField(Property->GetName(), SerializeCoreProperty(
|
||||
Property, Property->ContainerPtrToValuePtr<void>(const_cast<void*>(Data)), ActiveObjects));
|
||||
}
|
||||
if (Object != nullptr) ActiveObjects.Remove(Object);
|
||||
return MakeShared<FJsonValueObject>(Result);
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> CoreVectorJson(const FVector& Value)
|
||||
{
|
||||
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
||||
Result->SetNumberField(TEXT("x"), Value.X);
|
||||
Result->SetNumberField(TEXT("y"), Value.Y);
|
||||
Result->SetNumberField(TEXT("z"), Value.Z);
|
||||
return Result;
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> CoreLandingJson(const FVoxelPassageLanding& Landing)
|
||||
{
|
||||
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
||||
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<FJsonObject> CorePassageJson(const FVoxelPassage& Passage)
|
||||
{
|
||||
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
||||
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<int32>(Passage.PassageType));
|
||||
auto WriteVectors = [](const TCHAR* Key, const TArray<FVector>& Source,
|
||||
TSharedRef<FJsonObject> Out)
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> Values;
|
||||
Values.Reserve(Source.Num());
|
||||
for (const FVector& Value : Source)
|
||||
{
|
||||
Values.Add(MakeShared<FJsonValueObject>(CoreVectorJson(Value)));
|
||||
}
|
||||
Out->SetArrayField(Key, MoveTemp(Values));
|
||||
};
|
||||
auto WriteFloats = [](const TCHAR* Key, const TArray<float>& Source,
|
||||
TSharedRef<FJsonObject> Out)
|
||||
{
|
||||
TArray<TSharedPtr<FJsonValue>> Values;
|
||||
Values.Reserve(Source.Num());
|
||||
for (float Value : Source) Values.Add(MakeShared<FJsonValueNumber>(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<int64>(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<FJsonValue>& 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<FJsonValue>& Element : Value->AsArray())
|
||||
{
|
||||
WriteCoreJsonValue(Writer, nullptr, Element);
|
||||
}
|
||||
Writer.WriteArrayEnd();
|
||||
}
|
||||
else if (Value->Type == EJson::Object)
|
||||
{
|
||||
Writer.WriteObjectStart();
|
||||
TArray<FString> 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<const UObject*> ActiveObjects;
|
||||
TSharedPtr<FJsonValue> 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<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
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<TSharedPtr<FJsonValue>> 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<FJsonObject> Strate = MakeShared<FJsonObject>();
|
||||
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<TSharedPtr<FJsonValue>> Operations;
|
||||
Operations.Reserve(Slot.Definition->TerrainOperations.Num());
|
||||
for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations)
|
||||
{
|
||||
TSharedRef<FJsonObject> Operation = MakeShared<FJsonObject>();
|
||||
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<FJsonValueObject>(Operation));
|
||||
}
|
||||
Strate->SetArrayField(TEXT("terrain_operation_assets"), MoveTemp(Operations));
|
||||
Strates.Add(MakeShared<FJsonValueObject>(Strate));
|
||||
}
|
||||
Root->SetArrayField(TEXT("strates"), MoveTemp(Strates));
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Passages;
|
||||
Passages.Reserve(StrateManager->GetPassages().Num());
|
||||
for (const FVoxelPassage& Passage : StrateManager->GetPassages())
|
||||
{
|
||||
Passages.Add(MakeShared<FJsonValueObject>(CorePassageJson(Passage)));
|
||||
}
|
||||
Root->SetArrayField(TEXT("passages"), MoveTemp(Passages));
|
||||
Root->SetArrayField(TEXT("density_samples"), {});
|
||||
TSharedRef<FJsonObject> ViewerStart = MakeShared<FJsonObject>();
|
||||
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<FCoreJsonWriter> Writer = TJsonWriterFactory<TCHAR,
|
||||
TCondensedJsonPrintPolicy<TCHAR>>::Create(&WriterBuffer);
|
||||
WriteCoreJsonValue(*Writer, nullptr, MakeShared<FJsonValueObject>(Root));
|
||||
if (!Writer->Close() || WriterBuffer.IsEmpty())
|
||||
{
|
||||
OutError = TEXT("Core snapshot export failed while writing schema-v1 JSON.");
|
||||
return false;
|
||||
}
|
||||
OutJson = MoveTemp(WriterBuffer);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#include "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);
|
||||
}
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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<std::uint32_t>(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<std::uint32_t>(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<WorldAssemblyBiomeProfileV1> biomes;
|
||||
std::uint64_t contentHash = 0;
|
||||
};
|
||||
|
||||
struct WorldAssemblyRoutePointV1
|
||||
{
|
||||
std::uint64_t stableFeatureId = 0;
|
||||
std::array<std::int64_t, 3> 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<std::int64_t, 3> minimumSubvoxels{};
|
||||
std::array<std::int64_t, 3> maximumSubvoxels{};
|
||||
std::array<std::int64_t, 4> 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<WorldAssemblyFeatureV1> features;
|
||||
std::vector<WorldAssemblyRoutePointV1> route;
|
||||
std::uint64_t contentHash = 0;
|
||||
};
|
||||
|
||||
struct WorldAssemblyPlanV1
|
||||
{
|
||||
std::uint32_t schemaVersion = WorldAssemblySchemaVersion;
|
||||
std::vector<WorldAssemblyStratumPlanV1> 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<WorldAssemblyManifest> 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<bool>(data_); }
|
||||
const WorldConfiguration& configuration() const;
|
||||
const WorldAssemblyManifest* worldAssembly() const noexcept;
|
||||
|
||||
private:
|
||||
struct Data;
|
||||
|
||||
@@ -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"
|
||||
@@ -281,11 +281,13 @@ public:
|
||||
bool bInRoundTunnels=false,bool bInProfileDetailBand=false,
|
||||
bool bValidatePassageRoutes=true,
|
||||
const std::vector<VoxelForgeCore::DetailBandTileRegion>& InGpuVisualDetailTiles={},
|
||||
const std::vector<VoxelForgeCore::DetailBandTileRegion>& InCpuVisualDetailTiles={})
|
||||
const std::vector<VoxelForgeCore::DetailBandTileRegion>& 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<float>(X),static_cast<float>(Y),
|
||||
static_cast<float>(Z),NativeDensity))
|
||||
return ApplyEditBrushes(NativeDensity,X,Y,Z);
|
||||
if(UsesLegacySurfaceField(static_cast<float>(X),static_cast<float>(Y),
|
||||
static_cast<float>(Z)))
|
||||
return LegacySurfaceDensityAt(static_cast<float>(X),static_cast<float>(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<float>(BMin[2])
|
||||
@@ -1130,10 +1143,24 @@ public:
|
||||
const int32 LastChunk=FMath::FloorToInt(static_cast<float>(BMax[2])
|
||||
/static_cast<float>(CHUNK_SIZE));
|
||||
for(int32 ChunkZ=FirstChunk;ChunkZ<=LastChunk;++ChunkZ)
|
||||
{
|
||||
if(Owner->Manager->GetGeneratorTypeForChunk(FIntVector(0,0,ChunkZ))
|
||||
==ECaveGeneratorType::SurfaceWorld)
|
||||
{
|
||||
bSurfaceOverlap=true;
|
||||
const int32 StrateIndex=Owner->Manager->GetStrateIndex(
|
||||
static_cast<float>((static_cast<double>(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<int64>(BMin[0])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel,
|
||||
static_cast<int64>(BMin[1])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel,
|
||||
static_cast<int64>(BMin[2])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel,
|
||||
static_cast<int64>(BMax[0])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel,
|
||||
static_cast<int64>(BMax[1])*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel,
|
||||
static_cast<int64>(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<float>(CHUNK_SIZE));
|
||||
if(Owner->Manager->GetGeneratorTypeForChunk(FIntVector(0,0,ChunkZ))
|
||||
!=ECaveGeneratorType::SurfaceWorld)return false;
|
||||
const int32 StrateIndex=Owner->Manager->GetStrateIndex(
|
||||
static_cast<float>((static_cast<double>(ChunkZ*CHUNK_SIZE+CHUNK_SIZE/2))*VOXEL_SIZE));
|
||||
const auto* Plan=FindWorldAssemblyStratum(StrateIndex,ChunkZ);
|
||||
if(!Plan)return false;
|
||||
constexpr double Scale=static_cast<double>(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel);
|
||||
const double MaxVoxel=static_cast<double>(std::numeric_limits<int64>::max())/Scale;
|
||||
if(std::abs(static_cast<double>(X))>MaxVoxel||std::abs(static_cast<double>(Y))>MaxVoxel
|
||||
||std::abs(static_cast<double>(Z))>MaxVoxel)return false;
|
||||
const auto ToFixed=[](float Value)
|
||||
{return static_cast<int64>(std::llround(static_cast<double>(Value)
|
||||
*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel));};
|
||||
const VoxelForgeCore::Heightfield2D Block(WorldAssembly->recipe,*Plan);
|
||||
if(!Block.isValid())return false;
|
||||
OutDensity=static_cast<float>(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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "CoreNamespacePreamble.h"
|
||||
#include "StandaloneSnapshotAdapter.h"
|
||||
#include "VoxelForgeCore/VoxelForgeCore.h"
|
||||
#include "WorldAssemblerInternal.h"
|
||||
|
||||
#include <shared_mutex>
|
||||
#include <mutex>
|
||||
@@ -23,6 +24,7 @@ namespace VoxelForgeCore
|
||||
struct ResolvedWorldSnapshot::Data
|
||||
{
|
||||
std::shared_ptr<FStandaloneOwnerWorld> owner;
|
||||
std::shared_ptr<const WorldAssemblyManifest> worldAssembly;
|
||||
WorldConfiguration configuration;
|
||||
};
|
||||
|
||||
@@ -216,12 +218,14 @@ namespace VoxelForgeCore
|
||||
|
||||
struct detail_StandaloneSnapshotAdapter
|
||||
{
|
||||
static ResolvedWorldSnapshot FromOwner(std::shared_ptr<FStandaloneOwnerWorld> owner)
|
||||
static ResolvedWorldSnapshot FromOwner(std::shared_ptr<FStandaloneOwnerWorld> owner,
|
||||
std::shared_ptr<const WorldAssemblyManifest> worldAssembly = {})
|
||||
{
|
||||
if(!owner||!owner->Settings||!owner->Manager||!owner->Generator)return {};
|
||||
auto data=std::make_shared<ResolvedWorldSnapshot::Data>();
|
||||
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<FStandaloneOwnerWorld>();
|
||||
if(!StandaloneOwnerLoader::Resolve(jsonRoot,*owner,error))return {};
|
||||
std::shared_ptr<const WorldAssemblyManifest> worldAssembly;
|
||||
if(jsonRoot.Get("world_assembly").IsObject())
|
||||
{
|
||||
auto parsed=detail_world_assembly::ParseNestedManifest(jsonRoot,error);
|
||||
if(!parsed)return {};
|
||||
worldAssembly=std::make_shared<WorldAssemblyManifest>(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> ResolvedWorldSnapshot::FromJson(
|
||||
@@ -262,8 +273,20 @@ namespace VoxelForgeCore
|
||||
{
|
||||
auto owner=std::make_shared<FStandaloneOwnerWorld>();
|
||||
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<const WorldAssemblyManifest> worldAssembly;
|
||||
if(root.Get("world_assembly").IsObject())
|
||||
{
|
||||
auto parsed=detail_world_assembly::ParseNestedManifest(root,error);
|
||||
if(!parsed)return std::nullopt;
|
||||
worldAssembly=std::make_shared<WorldAssemblyManifest>(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
|
||||
{
|
||||
@@ -274,6 +297,7 @@ namespace VoxelForgeCore
|
||||
struct World::Impl
|
||||
{
|
||||
std::shared_ptr<FStandaloneOwnerWorld> owner;
|
||||
std::shared_ptr<const WorldAssemblyManifest> worldAssembly;
|
||||
WorldConfiguration configuration;
|
||||
std::unique_ptr<FStandaloneShapeWorld> 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<int32>(std::clamp<std::uint32_t>(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<FStandaloneShapeWorld>();
|
||||
if(!region->Build(*impl_->owner,fineMin,fineMax,regionMin,regionMax,
|
||||
static_cast<int32>(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;
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
#pragma once
|
||||
|
||||
#include "Json.h"
|
||||
#include "VoxelForgeCore/VoxelForgeCore.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
|
||||
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<std::uint8_t>(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<std::int64_t>(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<std::uint32_t>::max()) return false;
|
||||
out = static_cast<std::uint32_t>(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<std::int32_t>::min()
|
||||
|| parsed > std::numeric_limits<std::int32_t>::max()) return false;
|
||||
out = static_cast<std::int32_t>(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<std::size_t>(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<std::uint32_t> 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<std::int64_t, 7> 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<std::uint64_t> 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<std::uint64_t>(recipe.biomeCellSizeSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(recipe.biomeWarpSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(recipe.biomeWarpWavelengthSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(recipe.biomeBorderBlendSubvoxels), 8);
|
||||
HashWord(hash, recipe.biomes.size(), 4);
|
||||
for (const auto& biome : recipe.biomes)
|
||||
{
|
||||
HashWord(hash, biome.stableId, 4);
|
||||
HashWord(hash, static_cast<std::uint64_t>(biome.baseHeightSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(biome.continentAmplitudeSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(biome.continentWavelengthSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(biome.mountainAmplitudeSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(biome.mountainWavelengthSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(biome.detailAmplitudeSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(biome.detailWavelengthSubvoxels), 8);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
inline std::uint64_t HashStratum(const WorldAssemblyStratumPlanV1& stratum)
|
||||
{
|
||||
std::uint64_t hash = StartHash();
|
||||
HashWord(hash, static_cast<std::uint32_t>(stratum.strateIndex), 4);
|
||||
HashWord(hash, static_cast<std::uint32_t>(stratum.topChunkZ), 4);
|
||||
HashWord(hash, static_cast<std::uint32_t>(stratum.bottomChunkZ), 4);
|
||||
HashWord(hash, static_cast<std::uint64_t>(stratum.capHeightSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(stratum.capUndulationSubvoxels), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(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<std::uint64_t>(value), 8);
|
||||
for (const auto value : feature.maximumSubvoxels) HashWord(hash, static_cast<std::uint64_t>(value), 8);
|
||||
for (const auto value : feature.parametersSubvoxels) HashWord(hash, static_cast<std::uint64_t>(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<std::uint64_t>(value), 8);
|
||||
HashWord(hash, static_cast<std::uint64_t>(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<WorldAssemblyManifest> 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<std::uint64_t>(seed) ^ salt);
|
||||
hash ^= Mix64(static_cast<std::uint64_t>(x) + 0x517cc1b727220a95ull);
|
||||
hash ^= Mix64(static_cast<std::uint64_t>(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<std::uint32_t>(CoordinateHash(seed, x, y, salt) >> 48) & 0x1ffffu;
|
||||
return static_cast<std::int32_t>(bits) - 65536;
|
||||
}
|
||||
|
||||
inline std::int32_t LerpQ16(std::int32_t a, std::int32_t b, std::uint32_t t)
|
||||
{ return static_cast<std::int32_t>(a + (static_cast<std::int64_t>(b - a) * t) / Q16); }
|
||||
|
||||
inline std::uint32_t SmoothQ16(std::uint32_t t)
|
||||
{
|
||||
const std::int64_t t2 = (static_cast<std::int64_t>(t) * t) / Q16;
|
||||
return static_cast<std::uint32_t>((t2 * (3 * Q16 - 2 * static_cast<std::int64_t>(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<std::uint32_t>((FloorMod(x, wavelength) * Q16) / wavelength);
|
||||
const std::uint32_t fractionY = static_cast<std::uint32_t>((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<WorldAssemblyManifest> 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> 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<std::int32_t> 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<std::uint64_t> 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<std::uint64_t>::max();
|
||||
std::uint64_t secondDistance = std::numeric_limits<std::uint64_t>::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<std::int64_t>((h & 0xffffu)) * (cellSize / 2) / 65535 - cellSize / 4;
|
||||
const std::int64_t jitterY = static_cast<std::int64_t>(((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<std::uint64_t>(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<std::uint64_t>(2 * cellSize)
|
||||
* static_cast<std::uint64_t>(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<std::int64_t>::max();
|
||||
std::int64_t groundMax = std::numeric_limits<std::int64_t>::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<std::int64_t>(plan_->bottomChunkZ) * 32 * WorldAssemblySubvoxelsPerVoxel;
|
||||
out.maximumZSubvoxels = (static_cast<std::int64_t>(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<std::int64_t>::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<std::int64_t>(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<std::uint32_t>(boxCount, 1, 100000);
|
||||
const auto random = [&seed]()
|
||||
{
|
||||
seed = world_assembly_detail::Mix64(seed);
|
||||
return seed;
|
||||
};
|
||||
const auto signedVoxel = [&random]()
|
||||
{ return static_cast<std::int64_t>(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<std::int64_t>(random() % 257) - 128) * WorldAssemblySubvoxelsPerVoxel;
|
||||
const std::int64_t halfX = static_cast<std::int64_t>(1 + random() % 64) * WorldAssemblySubvoxelsPerVoxel;
|
||||
const std::int64_t halfY = static_cast<std::int64_t>(1 + random() % 64) * WorldAssemblySubvoxelsPerVoxel;
|
||||
const std::int64_t halfZ = static_cast<std::int64_t>(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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -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":[]}]}}
|
||||
@@ -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::int64_t>(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<std::int64_t>(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<std::int64_t>(bottomChunkZ)*CHUNK_SIZE;
|
||||
const std::int64_t heightVoxels=static_cast<std::int64_t>(topChunkZ-bottomChunkZ+1)*CHUNK_SIZE;
|
||||
const double baseHeight=static_cast<double>(bottomVoxel)
|
||||
+static_cast<double>(heightVoxels)*surface.BaseGroundRelative;
|
||||
const double elevation=std::max(16.0,static_cast<double>(surface.ElevationRange));
|
||||
const FBiomeMapParams& map=biomeContext.Map;
|
||||
|
||||
VoxelForgeCore::WorldAssemblyManifest manifest;
|
||||
auto& recipe=manifest.recipe;
|
||||
recipe.seed=static_cast<uint32>(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<double>(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<double>(surface.MountainStrength)));
|
||||
upland.mountainWavelengthSubvoxels=WorldAssemblyWavelength(surface.MountainFrequency*0.75,256.0);
|
||||
upland.detailAmplitudeSubvoxels=WorldAssemblyFixed(std::max(2.0,static_cast<double>(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<float>((static_cast<double>(surfaceChunkZ*CHUNK_SIZE+CHUNK_SIZE/2))*VOXEL_SIZE));
|
||||
stratum.topChunkZ=topChunkZ;
|
||||
stratum.bottomChunkZ=bottomChunkZ;
|
||||
stratum.capHeightSubvoxels=WorldAssemblyFixed(static_cast<double>(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<std::streamsize>(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<VFJson::Value::Object>(&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<unsigned long long>(manifest.recipe.contentHash),
|
||||
static_cast<unsigned long long>(manifest.plan.contentHash),
|
||||
static_cast<unsigned long long>(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<long long>(influence.minimumZSubvoxels),
|
||||
static_cast<long long>(influence.maximumZSubvoxels),
|
||||
static_cast<long long>(detail.maximumAbsoluteDisplacementSubvoxels),
|
||||
static_cast<long long>(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<unsigned long long>(bounds.boxesTested),
|
||||
static_cast<unsigned long long>(bounds.pointSamples),
|
||||
static_cast<unsigned long long>(bounds.missedBounds),
|
||||
static_cast<unsigned long long>(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<Query> queries;
|
||||
queries.reserve(side*side);
|
||||
for(int y=0;y<side;++y)
|
||||
for(int x=0;x<side;++x)
|
||||
{
|
||||
const std::int64_t xq=(static_cast<std::int64_t>(x)-side/2)*128
|
||||
*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||||
const std::int64_t yq=(static_cast<std::int64_t>(y)-side/2)*128
|
||||
*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||||
const auto column=block.point(xq,yq,0);
|
||||
const std::int64_t offset=(static_cast<std::int64_t>((x+3*y)%9)-4)
|
||||
*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||||
queries.push_back({xq,yq,column.groundHeightSubvoxels+offset});
|
||||
}
|
||||
std::vector<VoxelForgeCore::HeightfieldPointSample> oneWorker(queries.size());
|
||||
const auto evaluate=[&](std::uint32_t workers,
|
||||
std::vector<VoxelForgeCore::HeightfieldPointSample>& output)
|
||||
{
|
||||
const auto begin=std::chrono::steady_clock::now();
|
||||
std::vector<std::thread> threads;
|
||||
for(std::uint32_t worker=0;worker<workers;++worker)
|
||||
{
|
||||
const std::size_t first=queries.size()*worker/workers;
|
||||
const std::size_t last=queries.size()*(worker+1)/workers;
|
||||
auto task=[&,first,last]
|
||||
{
|
||||
for(std::size_t i=first;i<last;++i)
|
||||
output[i]=block.point(queries[i].x,queries[i].y,queries[i].z);
|
||||
};
|
||||
if(workers==1)task();
|
||||
else threads.emplace_back(std::move(task));
|
||||
}
|
||||
for(auto& thread:threads)thread.join();
|
||||
return std::chrono::duration<double,std::milli>(
|
||||
std::chrono::steady_clock::now()-begin).count();
|
||||
};
|
||||
const auto hashSamples=[](const std::vector<VoxelForgeCore::HeightfieldPointSample>& samples)
|
||||
{
|
||||
std::uint64_t hash=14695981039346656037ull;
|
||||
const auto add=[&](std::uint64_t value,int bytes)
|
||||
{
|
||||
for(int i=0;i<bytes;++i){hash^=static_cast<uint8>(value>>(8*i));hash*=1099511628211ull;}
|
||||
};
|
||||
for(const auto& sample:samples)
|
||||
{
|
||||
add(static_cast<std::uint64_t>(sample.groundHeightSubvoxels),8);
|
||||
add(static_cast<std::uint64_t>(sample.capHeightSubvoxels),8);
|
||||
add(sample.biomeId,4);add(sample.neighborBiomeId,4);add(sample.biomeWeightQ16,4);
|
||||
add(static_cast<std::uint64_t>(sample.densitySubvoxels),8);
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
const double time1=evaluate(1,oneWorker);
|
||||
const std::uint64_t hash1=hashSamples(oneWorker);
|
||||
std::vector<VoxelForgeCore::HeightfieldPointSample> eightWorkers(queries.size());
|
||||
const double time8=evaluate(8,eightWorkers);
|
||||
const std::uint64_t hash8=hashSamples(eightWorkers);
|
||||
std::vector<VoxelForgeCore::HeightfieldPointSample> 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<unsigned long long>(hash1),
|
||||
static_cast<unsigned long long>(hash8),static_cast<unsigned long long>(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.size();++i)
|
||||
{
|
||||
const float x=static_cast<float>(queries[i].x
|
||||
/static_cast<double>(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel));
|
||||
const float y=static_cast<float>(queries[i].y
|
||||
/static_cast<double>(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel));
|
||||
const float z=static_cast<float>(queries[i].z
|
||||
/static_cast<double>(VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel));
|
||||
const float legacyDensity=owner.Generator->GetDensityAt(x,y,z);
|
||||
const double error=std::abs(static_cast<double>(oneWorker[i].densitySubvoxels
|
||||
/static_cast<double>(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<double,std::milli>(
|
||||
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<unsigned long long>(signMismatches),legacyMs);
|
||||
const auto centerColumn=block.point(0,0,0);
|
||||
const int32 centerGroundVoxel=static_cast<int32>(std::llround(
|
||||
static_cast<double>(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<double,std::milli>(
|
||||
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<double>(block.point(xq,yq,zq).densitySubvoxels)
|
||||
/VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||||
const double actual=coreWorld.densityAt({static_cast<double>(xq)
|
||||
/VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel,
|
||||
static_cast<double>(yq)/VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel,
|
||||
static_cast<double>(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<unsigned long long>(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<std::size_t>(width)*height;
|
||||
std::vector<std::uint8_t> ownerPixels(columnCount*3,0);
|
||||
std::vector<std::uint8_t> nativePixels(ownerPixels.size(),0);
|
||||
std::vector<double> ownerHeights(columnCount,std::numeric_limits<double>::quiet_NaN());
|
||||
std::vector<double> nativeHeights(columnCount,0.0);
|
||||
double minimumHeight=std::numeric_limits<double>::max();
|
||||
double maximumHeight=std::numeric_limits<double>::lowest();
|
||||
std::uint64_t ownerColumns=0;
|
||||
for(int y=0;y<height;++y)
|
||||
for(int x=0;x<width;++x)
|
||||
{
|
||||
const double xVoxel=(static_cast<double>(x)/(width-1)-0.5)*spanVoxels;
|
||||
const double yVoxel=(static_cast<double>(y)/(height-1)-0.5)*spanVoxels;
|
||||
const std::size_t column=static_cast<std::size_t>(y)*width+x;
|
||||
float ownerGround=0.0f,ownerCap=0.0f;
|
||||
if(owner.Generator->GetSurfaceHeightAt(static_cast<float>(xVoxel),
|
||||
static_cast<float>(yVoxel),plan.topChunkZ,ownerGround,ownerCap))
|
||||
{
|
||||
if(std::isfinite(ownerGround))
|
||||
{
|
||||
ownerHeights[column]=ownerGround;
|
||||
minimumHeight=std::min(minimumHeight,static_cast<double>(ownerGround));
|
||||
maximumHeight=std::max(maximumHeight,static_cast<double>(ownerGround));
|
||||
++ownerColumns;
|
||||
}
|
||||
}
|
||||
const auto native=block.point(
|
||||
static_cast<std::int64_t>(std::llround(xVoxel*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel)),
|
||||
static_cast<std::int64_t>(std::llround(yVoxel*VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel)),0);
|
||||
nativeHeights[column]=static_cast<double>(native.groundHeightSubvoxels)
|
||||
/VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
||||
minimumHeight=std::min(minimumHeight,nativeHeights[column]);
|
||||
maximumHeight=std::max(maximumHeight,nativeHeights[column]);
|
||||
}
|
||||
const auto colorHeight=[&](double heightValue,std::vector<std::uint8_t>& 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<std::array<double,3>,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<int>(scaled));
|
||||
const double blend=scaled-segment;
|
||||
for(int channel=0;channel<3;++channel)
|
||||
pixels[offset+channel]=static_cast<std::uint8_t>(std::clamp(std::lround(
|
||||
stops[segment][channel]*(1.0-blend)+stops[segment+1][channel]*blend),0l,255l));
|
||||
};
|
||||
for(std::size_t column=0;column<columnCount;++column)
|
||||
{
|
||||
const std::size_t offset=column*3;
|
||||
if(std::isfinite(ownerHeights[column]))
|
||||
colorHeight(ownerHeights[column],ownerPixels,offset);
|
||||
colorHeight(nativeHeights[column],nativePixels,offset);
|
||||
}
|
||||
std::error_code directoryError;
|
||||
std::filesystem::create_directories(captureDirectory,directoryError);
|
||||
const auto writePpm=[&](const std::filesystem::path& path,const std::vector<std::uint8_t>& pixels)
|
||||
{
|
||||
std::ofstream output(path,std::ios::binary);
|
||||
if(!output)return false;
|
||||
output<<"P6\n"<<width<<' '<<height<<"\n255\n";
|
||||
output.write(reinterpret_cast<const char*>(pixels.data()),
|
||||
static_cast<std::streamsize>(pixels.size()));
|
||||
return static_cast<bool>(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<unsigned long long>(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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user