feat(runtime): the composed world can BE the game world

Jahni: "can you not implement those settings? as in, have them be live in
game?" This is that. The composer stops being a lab tool.

THE ARCHITECTURAL LINE, which is the whole design (COMPOSER-NOTES §3.1):
COMPOSITION stays editor-only, EVALUATION becomes runtime. Rolling,
measuring, preview, selection, promotion, the corpus -- none of it reaches
a player's machine. What crosses into the runtime is exactly two things:
reading a season manifest, and materialising a recipe into an op stack.

  UVoxelSeasonAsset      a cookable UPrimaryDataAsset holding the manifest,
                         so a season ships in a packaged game. The JSON
                         stays the reviewable, git-diffable artefact.
  UVoxelSettings::Season  optional. Unset => behaviour is exactly as today.
  GetEffective*()         season seed / spine radius / world radius / season
                         number override the authored values when assigned.
                         The test deliberately sets DIFFERENT authored
                         values, so "the season is authoritative" is proved
                         rather than coincidental.

⭐ MULTIPLAYER DETERMINISM, PROVED: two independent runtime managers built
the same season and compared 393,216 density samples BIT-IDENTICALLY. This
is the property the whole multiplayer model rests on -- replicate seed +
layout + diff, never geometry.

⭐ AND A STALE SEASON IS DETECTABLE, NOT SILENT. Initialize() now returns
bool and an assigned-but-invalid season NEVER falls back to the authored
pool. Falling back would hand a client a world that looks fine and is not
the season -- silent divergence, the worst failure mode in a shared world.
GetCurrentSeasonContentHash() is exposed BlueprintPure to compare at join.

HasComposerRecipeOverride() is gone. It force-classified every custom recipe
as Mixed, which was correct while box verdicts were only proved for
hand-written stacks -- but 8e346a3 made EffectOverBox correct in ISOLATION,
proved on a stack no builder produces. Keeping the force would have cost
tile skipping on every composed world forever.

VF_RollStrateRegionCount moved behind WITH_EDITOR while the partition-seed
helpers stayed runtime: rolling out, evaluation in. Lateral regions remain
gated off.

⚠️ Sol hit its usage limit mid-task, so the edits arrived unvalidated. I
built and ran them myself rather than assume -- and they did not compile:
VoxelForgeComposerSeasonTest.cpp was missing UObject/Package.h, which every
other test picks up transitively through the shared fixture it does not use.
One include; every NewObject/GetTransientPackage in the file was failing.

Editor build clean. SHIPPING BUILD SUCCEEDS -- the invariant that proves no
composer roller is linked into a packaged game. 30 succeeded / 0 failed.

⚠️ UNVERIFIED: whether it looks right on screen. Nothing headless can tell
us that; it needs Jahni to assign a season asset and press play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-05 08:37:01 +02:00
1 parent 088b40216d
commit faa62d419e
21 files changed
+1268 -287

No files matched your search

@@ -5,10 +5,18 @@
#include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "UObject/Package.h" // GetTransientPackage() — this file does not use the shared
// fixture, which is where the other tests pick it up implicitly.
#include "UObject/StrongObjectPtr.h"
#include "VoxelDensityOpStack.h"
#include "VoxelGenerator.h"
#include "VoxelSeasonAsset.h"
#include "VoxelSeasonManifest.h"
#include "VoxelSettings.h"
#include "VoxelStrateComposer.h"
#include "VoxelStrateDefinition.h"
#include "VoxelStrateManager.h"
namespace
{
@@ -121,6 +129,11 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
}
TestEqual(TEXT("loaded manifest has the same number of selected strates"),
LoadedManifest.Strates.Num(), Manifest.Strates.Num());
TestEqual(TEXT("loaded manifest content hash matches the composed payload"),
LoadedManifest.ContentHash, Manifest.ContentHash);
TestEqual(TEXT("manifest recomputes the hash it was composed from"),
VF_ComputeVoxelSeasonManifestContentHash(LoadedManifest),
LoadedManifest.ContentHash);
int64 ComparedDensitySamples = 0;
bool bAllDensityBitsIdentical = true;
@@ -202,6 +215,190 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
ComparedDensitySamples));
TestTrue(TEXT("manifest round-trip density field is bit-identical"),
bAllDensityBitsIdentical);
// Runtime seam: embed the reviewed JSON in a cookable primary asset, then let two independent
// managers/generators materialise the complete selected layout. The authored settings seed and
// radius are deliberately different so the test proves the season is authoritative.
TStrongObjectPtr<UVoxelSeasonAsset> SeasonAsset(
NewObject<UVoxelSeasonAsset>(GetTransientPackage(), NAME_None, RF_Transient));
FString AssetReport;
TestTrue(TEXT("cookable season asset accepts the reviewed manifest"),
SeasonAsset->SetManifestJson(Manifest.SerializedJson, AssetReport));
TStrongObjectPtr<UVoxelSettings> RuntimeSettings(
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
RuntimeSettings->Seed = Manifest.Seed + 17;
RuntimeSettings->OriginSpineRadius = Manifest.OriginSpineRadius + 31.0f;
RuntimeSettings->WorldRadiusVoxels = 4096.0f;
RuntimeSettings->Season = TSoftObjectPtr<UVoxelSeasonAsset>(SeasonAsset.Get());
TStrongObjectPtr<UVoxelStrateManager> ManagerA(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelStrateManager> ManagerB(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
TestTrue(TEXT("first independent season manager initializes"),
ManagerA->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
TestTrue(TEXT("second independent season manager initializes"),
ManagerB->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed()));
TestEqual(TEXT("season overrides the authored settings seed"),
RuntimeSettings->GetEffectiveWorldSeed(), Manifest.Seed);
TestEqual(TEXT("season keeps WorldRadiusVoxels zero"),
RuntimeSettings->GetEffectiveWorldRadiusVoxels(), 0.0f);
TestEqual(TEXT("season owns the origin spine radius used by recipe stacks"),
RuntimeSettings->GetEffectiveOriginSpineRadius(), Manifest.OriginSpineRadius);
TestEqual(TEXT("first manager loads every season slot"),
ManagerA->GetNumStrates(), Manifest.Strates.Num());
TestEqual(TEXT("second manager loads every season slot"),
ManagerB->GetNumStrates(), Manifest.Strates.Num());
TestEqual(TEXT("first manager exposes the reviewed content hash"),
ManagerA->GetSeasonContentHash(), Manifest.ContentHash);
TestEqual(TEXT("second manager exposes the reviewed content hash"),
ManagerB->GetSeasonContentHash(), Manifest.ContentHash);
TStrongObjectPtr<UVoxelGenerator> GeneratorA(
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelGenerator> GeneratorB(
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
GeneratorA->InitializeSettings(RuntimeSettings.Get());
GeneratorB->InitializeSettings(RuntimeSettings.Get());
GeneratorA->SetStrateManager(ManagerA.Get());
GeneratorB->SetStrateManager(ManagerB.Get());
int64 ManagerDensitySamples = 0;
bool bManagersBitIdentical = true;
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{
for (int32 Z = Strate.BottomWorldZ;
Z < Strate.TopWorldZ && bManagersBitIdentical; Z += 2)
{
for (int32 Y = -32; Y < 32 && bManagersBitIdentical; Y += 2)
{
for (int32 X = -32; X < 32; X += 2)
{
const float A = GeneratorA->GetDensityAt((float)X, (float)Y, (float)Z);
const float B = GeneratorB->GetDensityAt((float)X, (float)Y, (float)Z);
++ManagerDensitySamples;
if (!SameDensityBits(A, B))
{
bManagersBitIdentical = false;
AddError(FString::Printf(
TEXT("independent season managers diverged at (%d,%d,%d): 0x%08x vs 0x%08x"),
X, Y, Z, *reinterpret_cast<const uint32*>(&A),
*reinterpret_cast<const uint32*>(&B)));
break;
}
}
}
}
}
AddInfo(FString::Printf(
TEXT("two independent runtime season managers compared %lld bit-identical density samples"),
ManagerDensitySamples));
TestTrue(TEXT("two independent runtime season managers produce a bit-identical field"),
bManagersBitIdentical);
// The former HasComposerRecipeOverride force made every one of these tiles Mixed (zero proved
// tiles). The new path asks the exact recipe stack and brute-forces every verdict it accepts.
int32 NumClassified = 0;
int32 NumMixed = 0;
int32 NumAllSolid = 0;
int32 NumAllAir = 0;
int32 NumVerdictsBruteForced = 0;
int32 NumFalseVerdicts = 0;
constexpr int32 TileCells = 16;
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{
for (int32 ZBand = 1; ZBand <= 3; ++ZBand)
{
const int32 Z = Strate.BottomWorldZ
+ (Strate.TopWorldZ - Strate.BottomWorldZ) * ZBand / 4;
for (int32 Y = -128; Y <= 128; Y += 32)
{
for (int32 X = -128; X <= 128; X += 32)
{
const FIntVector Origin(X, Y, Z);
const EVoxelTileClass Verdict = GeneratorA->ClassifyTile(
Origin, 1, TileCells);
++NumClassified;
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
if (Verdict == EVoxelTileClass::AllSolid) ++NumAllSolid;
else ++NumAllAir;
if (NumVerdictsBruteForced >= 64) continue;
++NumVerdictsBruteForced;
const bool bSolid = Verdict == EVoxelTileClass::AllSolid;
bool bBad = false;
for (int32 GZ = -1; GZ <= TileCells + 1 && !bBad; ++GZ)
for (int32 GY = -1; GY <= TileCells + 1 && !bBad; ++GY)
for (int32 GX = -1; GX <= TileCells + 1; ++GX)
{
const float D = GeneratorA->GetDensityAt(
(float)(X + GX), (float)(Y + GY), (float)(Z + GZ));
if (bSolid ? D >= 0.0f : D < 0.0f)
{
bBad = true;
++NumFalseVerdicts;
AddError(FString::Printf(
TEXT("composed recipe false %s tile at (%d,%d,%d), sample (%d,%d,%d)=%.9g"),
bSolid ? TEXT("AllSolid") : TEXT("AllAir"), X, Y, Z,
X + GX, Y + GY, Z + GZ, D));
break;
}
}
}
}
}
}
AddInfo(FString::Printf(
TEXT("composed recipe tile classification: before force=%d Mixed/0 proved; after=%d Mixed, %d AllSolid, %d AllAir; %d accepted verdicts brute-forced, %d violations"),
NumClassified, NumMixed, NumAllSolid, NumAllAir,
NumVerdictsBruteForced, NumFalseVerdicts));
TestTrue(TEXT("composed recipe classification proves at least one uniform tile"),
NumAllSolid + NumAllAir > 0);
TestEqual(TEXT("composed recipe classification has no false uniform verdict"),
NumFalseVerdicts, 0);
FString TamperedJson = Manifest.SerializedJson;
const FString OriginalSeedField = FString::Printf(TEXT("\"seed\": %d"), Manifest.Seed);
const FString TamperedSeedField = FString::Printf(TEXT("\"seed\": %d"), Manifest.Seed + 1);
TamperedJson = TamperedJson.Replace(*OriginalSeedField, *TamperedSeedField,
ESearchCase::CaseSensitive);
FVoxelSeasonManifest TamperedManifest;
FString TamperReport;
TestFalse(TEXT("stale/edited season JSON is rejected by its content hash"),
VF_DeserializeVoxelSeasonManifest(
TamperedJson, TamperedManifest, TamperReport));
TestTrue(TEXT("stale season failure names the content hash"),
TamperReport.Contains(TEXT("content hash")));
// Explicit unset regression gate: no season means the authored pointer, seed and radius still
// flow through the original pool/fixed path.
TStrongObjectPtr<UVoxelSettings> LegacySettings(
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelStrateDefinition> LegacyDefinition(
NewObject<UVoxelStrateDefinition>(GetTransientPackage(), NAME_None, RF_Transient));
LegacySettings->Seed = 77123;
LegacySettings->OriginSpineRadius = 23.0f;
LegacySettings->WorldRadiusVoxels = 987.0f;
LegacySettings->TotalStrates = 1;
LegacyDefinition->StrateHeightInChunks = 7;
LegacySettings->StratePool.Add(
TSoftObjectPtr<UVoxelStrateDefinition>(LegacyDefinition.Get()));
TStrongObjectPtr<UVoxelStrateManager> LegacyManager(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
TestTrue(TEXT("legacy manager still initializes with Season unset"),
LegacyManager->Initialize(LegacySettings.Get(), LegacySettings->Seed));
TestTrue(TEXT("season unset remains the authored-pool path"),
LegacySettings->Season.IsNull()
&& LegacyManager->GetNumStrates() == 1
&& LegacyManager->GetLayout()[0].Definition == LegacyDefinition.Get()
&& LegacyManager->GetLayout()[0].HeightInChunks == 7
&& LegacyManager->GetWorldSeed() == LegacySettings->Seed
&& LegacySettings->GetEffectiveWorldSeed() == LegacySettings->Seed
&& LegacySettings->GetEffectiveOriginSpineRadius()
== LegacySettings->OriginSpineRadius
&& LegacySettings->GetEffectiveWorldRadiusVoxels()
== LegacySettings->WorldRadiusVoxels);
return true;
}
@@ -4886,6 +4886,7 @@ namespace
uint32 SeedU;
};
#if WITH_EDITOR
//=============================================================================
// RÔLE 2 — COMBINER : RÉGIONS LATÉRALES
//=============================================================================
@@ -5008,6 +5009,7 @@ namespace
mutable FVoxelStrateRegionPartitionCache PartitionCache;
mutable bool bPrepared = false;
};
#endif
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
// Même piège que dans VoxelHeightOpStack.cpp : s'ancrer sur une bannière située plus bas
@@ -6144,6 +6146,7 @@ bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe,
return true;
}
#if WITH_EDITOR
namespace
{
static EVoxelStrateParamBlock VF_RegionParamBlock(ECaveGeneratorType Archetype)
@@ -6449,3 +6452,4 @@ bool VF_BuildStrateRegionStack(
OutStack = MoveTemp(Parent);
return true;
}
#endif // WITH_EDITOR — lateral regions remain behind the failed shippability gate
+168 -74
View File
@@ -659,9 +659,9 @@ void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
{
// Les paramètres globaux sont copiés ici une seule fois : le chemin voxel ne doit pas
// déréférencer l'asset de settings.
Seed = Settings ? Settings->Seed : 0;
OriginSpineRadius = Settings ? Settings->OriginSpineRadius : 14.0f;
WorldRadiusVoxels = Settings ? Settings->WorldRadiusVoxels : 0.0f;
Seed = Settings ? Settings->GetEffectiveWorldSeed() : 0;
OriginSpineRadius = Settings ? Settings->GetEffectiveOriginSpineRadius() : 14.0f;
WorldRadiusVoxels = Settings ? Settings->GetEffectiveWorldRadiusVoxels() : 0.0f;
EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.0f;
}
@@ -736,15 +736,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté.
thread_local FVoxelOpStack CP_OpStack;
thread_local bool CP_UseOpStack = false;
// Cooked-season recipes and editor candidate recipes share this immutable worker-local
// hand-off. Recipe materialisation is runtime; rolling/selection remains editor-only.
thread_local bool CP_UseCustomRecipe = false;
thread_local int32 CP_CustomRecipeSeed = 0;
thread_local FVoxelStrateArchetypeParams CP_CustomParams;
thread_local FVoxelOpStackRecipe CP_CustomRecipe;
#if WITH_EDITOR
// Editor-only composer override. The candidate recipe is copied into this worker-local
// cache on the same versioned refetch as the native params; no worker reads mutable editor
// state while the world is applying a new candidate.
thread_local bool CP_UseComposerRecipe = false;
thread_local bool CP_UseComposerRegions = false;
thread_local int32 CP_ComposerSeed = 0;
thread_local FVoxelStrateArchetypeParams CP_ComposerParams;
thread_local FVoxelOpStackRecipe CP_ComposerRecipe;
thread_local FVoxelStrateRegionManifest CP_ComposerRegions;
#endif
@@ -763,29 +766,29 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
CP_Version = LayoutVersion;
CP_Chunk = ChunkCoord;
CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
CP_UseCustomRecipe = false;
bool bAllowCustomRecipe = true;
#if WITH_EDITOR
CP_UseComposerRecipe = false;
CP_UseComposerRegions = false;
ECaveGeneratorType ComposerArchetype = ECaveGeneratorType::TunnelNetwork;
bool bComposerUseRecipe = false;
if (StrateManager->GetComposerRegionOverrideForChunk(
ChunkCoord, CP_ComposerRegions))
{
CP_UseComposerRegions = true;
bAllowCustomRecipe = false;
CP_ComposerSeed = CP_ComposerRegions.Seed;
if (CP_ComposerRegions.Regions.Num() > 0)
{
CP_GenType = CP_ComposerRegions.Regions[0].Archetype;
}
}
else if (StrateManager->GetComposerOverrideForChunk(
ChunkCoord, CP_ComposerSeed, ComposerArchetype, CP_ComposerParams,
bComposerUseRecipe, CP_ComposerRecipe))
{
CP_GenType = ComposerArchetype;
CP_UseComposerRecipe = bComposerUseRecipe;
}
#endif
if (bAllowCustomRecipe
&& StrateManager->GetRecipeForChunk(
ChunkCoord, CP_CustomRecipeSeed, CP_GenType,
CP_CustomParams, CP_CustomRecipe))
{
CP_UseCustomRecipe = true;
}
switch (CP_GenType)
{
case ECaveGeneratorType::FlatPlain:
@@ -817,19 +820,22 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// Une seule branche ajoutée au chemin densité, et elle est FROIDE : la construction est
// par chunk (comme le refetch de params juste au-dessus), jamais par voxel.
CP_UseOpStack = StrateManager->UsesOperatorStackForChunk(ChunkCoord);
CP_UseOpStack = CP_UseOpStack || CP_UseCustomRecipe;
#if WITH_EDITOR
// A composer override is already an explicit, validated stack description. It must
// be evaluated even when the authored definition had the normal operator-stack opt-in
// disabled; otherwise a multi-region candidate would be installed in the manager but
// silently fall back to the old one-archetype switch.
CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions || CP_UseComposerRecipe;
CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions;
#endif
if (CP_UseOpStack)
{
CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile
bool bBuiltSpecialStack = false;
#if WITH_EDITOR
if (CP_UseComposerRegions)
{
bBuiltSpecialStack = true;
FVoxelOpContext RegionContext;
if (VF_BuildStrateRegionStack(
CP_ComposerRegions, OriginSpineRadius, StrateManager,
@@ -847,70 +853,71 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
CP_UseOpStack = false;
}
}
else if (CP_UseComposerRecipe)
#endif
if (!bBuiltSpecialStack)
{
FVoxelOpContext ComposerContext;
if (VF_BuildStackFromRecipe(
CP_ComposerRecipe, CP_ComposerParams, Seed,
OriginSpineRadius, StrateManager, CP_OpStack, ComposerContext, nullptr))
if (CP_UseCustomRecipe)
{
ComposerContext.ChunkCoord = ChunkCoord;
ComposerContext.Step = 1;
ComposerContext.LayoutVersion = LayoutVersion;
// The recipe builder supplies the candidate's vertical seal to the
// structural post. The XY edge seal is global and uses live settings.
ComposerContext.WorldRadiusVoxels = WorldRadiusVoxels;
ComposerContext.EdgeSealThickness = EdgeSealThickness;
CP_OpStack.PrepareChunk(ComposerContext);
FVoxelOpContext RecipeContext;
if (VF_BuildStackFromRecipe(
CP_CustomRecipe, CP_CustomParams, CP_CustomRecipeSeed,
OriginSpineRadius, StrateManager, CP_OpStack, RecipeContext, nullptr))
{
RecipeContext.ChunkCoord = ChunkCoord;
RecipeContext.Step = 1;
RecipeContext.LayoutVersion = LayoutVersion;
// The recipe builder supplies the candidate's vertical seal to the
// structural post. The XY edge seal is global and uses live settings.
RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels;
RecipeContext.EdgeSealThickness = EdgeSealThickness;
CP_OpStack.PrepareChunk(RecipeContext);
}
else
{
// Import/load validates recipes before a season becomes active. Keep
// the fallback hole-safe if a future schema changes underneath it.
CP_UseOpStack = false;
}
}
else
{
// The editor action validates before installing the override. Keep this
// fallback safe if a future recipe changes underneath PIE: the native
// candidate params remain available instead of yielding an uninitialised
// stack or a hole.
CP_UseOpStack = false;
}
}
else
#endif
{
FVoxelOpContext OpCtx;
OpCtx.ChunkCoord = ChunkCoord;
OpCtx.Seed = (uint32)Seed;
OpCtx.LayoutVersion = LayoutVersion;
OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
OpCtx.EdgeSealThickness = EdgeSealThickness;
FVoxelOpContext OpCtx;
OpCtx.ChunkCoord = ChunkCoord;
OpCtx.Seed = (uint32)Seed;
OpCtx.LayoutVersion = LayoutVersion;
OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
OpCtx.EdgeSealThickness = EdgeSealThickness;
// ⚠️ LE MAPPING VIT DANS `VF_BuildOpStackForChunk` (haut de ce fichier) ET NULLE
// PART AILLEURS — `ClassifyTile` appelle la MÊME fabrique. Un verdict de tuile issu
// d'une pile construite autrement serait un trou. Ici on ne fait que fournir les
// params déjà cherchés juste au-dessus.
FVoxelStackParamRefs Refs;
Refs.Slab = &CP_Slab;
Refs.Maze = &CP_Maze;
Refs.Vert = &CP_Vert;
Refs.Float = &CP_Float;
Refs.Tunnel = &CP_Tunnel;
FVoxelStackParamRefs Refs;
Refs.Slab = &CP_Slab;
Refs.Maze = &CP_Maze;
Refs.Vert = &CP_Vert;
Refs.Float = &CP_Float;
Refs.Tunnel = &CP_Tunnel;
// SurfaceWorld : le champ de biomes est fabriqué ICI, du côté qui connaît le
// générateur, et TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField`,
// ce qui lui permet de devenir un asset en Phase 3 sans traîner le générateur.
TArray<FSurfaceGenerationParams> PerBiome;
if (CP_GenType == ECaveGeneratorType::SurfaceWorld)
{
Refs.Surface = &CP_Surface;
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
{
PerBiome = CP_SurfaceBiomeParams;
Refs.SurfaceBiomeParams = &PerBiome;
Refs.BiomeField = MakeUnique<FGeneratorBiomeField>(
this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z);
}
}
TArray<FSurfaceGenerationParams> PerBiome;
if (CP_GenType == ECaveGeneratorType::SurfaceWorld)
{
Refs.Surface = &CP_Surface;
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
{
PerBiome = CP_SurfaceBiomeParams;
Refs.SurfaceBiomeParams = &PerBiome;
Refs.BiomeField = MakeUnique<FGeneratorBiomeField>(
this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z);
}
}
CP_UseOpStack = VF_BuildOpStackForChunk(CP_GenType, Refs, Seed, OriginSpineRadius,
StrateManager, CP_OpStack, OpCtx);
CP_UseOpStack = VF_BuildOpStackForChunk(
CP_GenType, Refs, Seed, OriginSpineRadius,
StrateManager, CP_OpStack, OpCtx);
// Le test appelle PrepareChunk, pas la production : c'est exactement la divergence
// qui rend un opérateur vert en test et faux en jeu. Les sept `PrepareChunk`
@@ -922,7 +929,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// makes an op green in test and wrong in game. All seven bodies are empty today,
// which is the point: the first op that hoists real per-chunk work must find the
// call already here.
if (CP_UseOpStack) { CP_OpStack.PrepareChunk(OpCtx); }
if (CP_UseOpStack) { CP_OpStack.PrepareChunk(OpCtx); }
}
}
}
}
@@ -2935,6 +2943,99 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (!StrateManager) return EVoxelTileClass::Mixed;
// A selected recipe must be classified by the exact same materialised stack that supplies
// density. Search every touched chunk-Z first: the minimum corner may be in a gap or adjacent
// slot, and letting that fall through to the native factory would prove the wrong field.
int32 RecipeSeed = 0;
ECaveGeneratorType RecipeArchetype = ECaveGeneratorType::TunnelNetwork;
FVoxelStrateArchetypeParams RecipeParams;
FVoxelOpStackRecipe Recipe;
bool bTouchesRecipe = false;
const int32 RecipeMinChunkZ = FloorDivC(MinZ, CHUNK_SIZE);
const int32 RecipeMaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE);
for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ)
{
if (StrateManager->GetRecipeForChunk(
FIntVector(0, 0, CZ), RecipeSeed, RecipeArchetype,
RecipeParams, Recipe))
{
bTouchesRecipe = true;
break;
}
}
if (bTouchesRecipe)
{
int32 RecipeTopChunkZ = 0;
int32 RecipeBottomChunkZ = 0;
if (!StrateManager->GetStrateChunkZBounds(
RecipeMinChunkZ, RecipeTopChunkZ, RecipeBottomChunkZ)
|| RecipeMinChunkZ < RecipeBottomChunkZ
|| RecipeMaxChunkZ > RecipeTopChunkZ)
{
return EVoxelTileClass::Mixed;
}
const int32 RecipeMinChunkX = FloorDivC(MinX, CHUNK_SIZE);
const int32 RecipeMaxChunkX = FloorDivC(MaxX, CHUNK_SIZE);
const int32 RecipeMinChunkY = FloorDivC(MinY, CHUNK_SIZE);
const int32 RecipeMaxChunkY = FloorDivC(MaxY, CHUNK_SIZE);
for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ)
{
int32 TouchedTop = 0;
int32 TouchedBottom = 0;
if (!StrateManager->GetStrateChunkZBounds(CZ, TouchedTop, TouchedBottom)
|| TouchedTop != RecipeTopChunkZ || TouchedBottom != RecipeBottomChunkZ)
{
return EVoxelTileClass::Mixed;
}
for (int32 CY = RecipeMinChunkY; CY <= RecipeMaxChunkY; ++CY)
{
for (int32 CX = RecipeMinChunkX; CX <= RecipeMaxChunkX; ++CX)
{
int32 TouchedSeed = 0;
ECaveGeneratorType TouchedArchetype = ECaveGeneratorType::TunnelNetwork;
FVoxelStrateArchetypeParams TouchedParams;
FVoxelOpStackRecipe TouchedRecipe;
const FIntVector TouchedChunk(CX, CY, CZ);
if (!StrateManager->GetRecipeForChunk(
TouchedChunk, TouchedSeed, TouchedArchetype,
TouchedParams, TouchedRecipe)
|| TouchedSeed != RecipeSeed
|| TouchedArchetype != RecipeArchetype)
{
return EVoxelTileClass::Mixed;
}
const FStrateDisturbanceParams Disturbances =
StrateManager->GetDisturbanceParamsForChunk(TouchedChunk);
if (Disturbances.ChasmDensity > 0.0f
|| Disturbances.BridgeDensity > 0.0f
|| Disturbances.RidgeDensity > 0.0f)
{
return EVoxelTileClass::Mixed;
}
}
}
}
FVoxelOpStack RecipeStack;
FVoxelOpContext RecipeContext;
if (!VF_BuildStackFromRecipe(
Recipe, RecipeParams, RecipeSeed, OriginSpineRadius,
StrateManager, RecipeStack, RecipeContext, nullptr))
{
return EVoxelTileClass::Mixed;
}
RecipeContext.ChunkCoord = FIntVector(
RecipeMinChunkX, RecipeMinChunkY, RecipeMinChunkZ);
RecipeContext.Step = Step;
RecipeContext.LayoutVersion = StrateManager->GetLayoutVersion();
RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels;
RecipeContext.EdgeSealThickness = EdgeSealThickness;
RecipeStack.PrepareChunk(RecipeContext);
return RecipeStack.ClassifyBox(TileVoxelBox, RecipeContext);
}
#if WITH_EDITOR
// A multi-region composer override is already a complete parent stack: it owns the lateral
// blend and the four global structural posts. Classify it as one field only when the complete
@@ -3024,13 +3125,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
return RegionStack.ClassifyBox(TileVoxelBox, RegionContext);
}
// A custom recipe is deliberately not fed to the native ClassifyBox proof yet. Returning
// Mixed keeps the live walk-through hole-safe: GenerateMesh samples the exact recipe stack,
// while no optimistic AllAir/AllSolid result can skip a candidate tile.
if (StrateManager->HasComposerRecipeOverride())
{
return EVoxelTileClass::Mixed;
}
#endif
bool bCanSolid = true; // "tout le treillis est solide" encore prouvable
@@ -0,0 +1,135 @@
#include "VoxelSeasonAsset.h"
#include "VoxelSeasonManifest.h"
#include "VoxelDensityOpStack.h"
#if WITH_EDITOR
#include "Misc/FileHelper.h"
#endif
namespace
{
bool ValidateRuntimeRecipes(const FVoxelSeasonManifest& Manifest, FString& OutReport)
{
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{
if (!Strate.bUsesRecipe) continue;
FVoxelOpStack ValidationStack;
FVoxelOpContext ValidationContext;
FString BuildError;
if (!VF_BuildSeasonStrateStack(
Strate, Manifest.OriginSpineRadius, nullptr,
ValidationStack, ValidationContext, &BuildError))
{
OutReport = FString::Printf(
TEXT("season slot %d recipe is not materialisable: %s"),
Strate.DepthIndex, *BuildError);
return false;
}
}
return true;
}
}
FPrimaryAssetId UVoxelSeasonAsset::GetPrimaryAssetId() const
{
return FPrimaryAssetId(TEXT("VoxelSeason"), GetFName());
}
bool UVoxelSeasonAsset::LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const
{
if (ManifestJson.IsEmpty())
{
OutReport = TEXT("season asset contains no imported manifest JSON");
OutManifest = FVoxelSeasonManifest();
return false;
}
if (!VF_DeserializeVoxelSeasonManifest(ManifestJson, OutManifest, OutReport))
{
return false;
}
if (!OutManifest.ContentHash.Equals(ManifestContentHash, ESearchCase::IgnoreCase)
|| OutManifest.SchemaVersion != SchemaVersion
|| OutManifest.Season != SeasonNumber
|| OutManifest.Seed != SeasonSeed
|| FMemory::Memcmp(&OutManifest.OriginSpineRadius,
&OriginSpineRadius, sizeof(float)) != 0
|| FMemory::Memcmp(&OutManifest.WorldRadiusVoxels,
&WorldRadiusVoxels, sizeof(float)) != 0)
{
OutReport = TEXT("season asset metadata does not match its embedded manifest; re-import the JSON");
OutManifest.bValid = false;
OutManifest.Error = OutReport;
return false;
}
if (!ValidateRuntimeRecipes(OutManifest, OutReport))
{
OutManifest.bValid = false;
OutManifest.Error = OutReport;
return false;
}
return true;
}
bool UVoxelSeasonAsset::SetManifestJson(const FString& InManifestJson, FString& OutReport)
{
FVoxelSeasonManifest Parsed;
if (!VF_DeserializeVoxelSeasonManifest(InManifestJson, Parsed, OutReport))
{
return false;
}
if (!ValidateRuntimeRecipes(Parsed, OutReport))
{
return false;
}
Modify();
ManifestJson = InManifestJson;
SchemaVersion = Parsed.SchemaVersion;
SeasonNumber = Parsed.Season;
SeasonSeed = Parsed.Seed;
OriginSpineRadius = Parsed.OriginSpineRadius;
WorldRadiusVoxels = Parsed.WorldRadiusVoxels;
ManifestContentHash = Parsed.ContentHash;
ReferencedDefinitions.Reset();
TSet<FString> SeenDefinitionPaths;
for (const FVoxelSeasonStrate& Strate : Parsed.Strates)
{
if (!Strate.SourceDefinitionPath.IsEmpty()
&& !SeenDefinitionPaths.Contains(Strate.SourceDefinitionPath))
{
SeenDefinitionPaths.Add(Strate.SourceDefinitionPath);
ReferencedDefinitions.Add(TSoftObjectPtr<UVoxelStrateDefinition>(
FSoftObjectPath(Strate.SourceDefinitionPath)));
}
}
MarkPackageDirty();
OutReport = FString::Printf(
TEXT("imported season %d seed %d (%d strates, hash %s)"),
SeasonNumber, SeasonSeed, Parsed.Strates.Num(), *ManifestContentHash);
return true;
}
void UVoxelSeasonAsset::ImportSeasonManifestJson()
{
#if WITH_EDITOR
FString JsonText;
FString Report;
if (SourceManifestJson.FilePath.IsEmpty()
|| !FFileHelper::LoadFileToString(JsonText, *SourceManifestJson.FilePath))
{
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Could not read manifest JSON '%s'."),
*SourceManifestJson.FilePath);
return;
}
if (!SetManifestJson(JsonText, Report))
{
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Import failed: %s"), *Report);
return;
}
UE_LOG(LogTemp, Log, TEXT("[VoxelSeasonAsset] %s"), *Report);
#else
UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] JSON import is editor-only."));
#endif
}
+189 -68
View File
@@ -7,6 +7,7 @@
#include "HAL/FileManager.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Misc/SecureHash.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
@@ -1428,56 +1429,85 @@ const TCHAR* VF_GetVoxelSeasonSelectionReasonName(EVoxelSeasonSelectionReason Re
return TEXT("unknown");
}
FString VF_SerializeVoxelSeasonManifest(const FVoxelSeasonManifest& Manifest)
namespace
{
using namespace VoxelSeasonManifestPrivate;
TSharedPtr<FJsonObject> Root = MakeShared<FJsonObject>();
SetCommonManifestFields(Root, Manifest);
TArray<TSharedPtr<FJsonValue>> Strates;
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
FString SerializeVoxelSeasonManifestInternal(
const FVoxelSeasonManifest& Manifest, bool bIncludeContentHash)
{
Strates.Add(MakeShared<FJsonValueObject>(SerializeStrate(Strate)));
}
Root->SetArrayField(TEXT("strates"), MoveTemp(Strates));
using namespace VoxelSeasonManifestPrivate;
TSharedPtr<FJsonObject> Root = MakeShared<FJsonObject>();
SetCommonManifestFields(Root, Manifest);
TArray<TSharedPtr<FJsonValue>> Rejections;
for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts)
{
TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>();
Object->SetStringField(TEXT("reason"), Rejection.Reason);
Object->SetNumberField(TEXT("count"), Rejection.Count);
Rejections.Add(MakeShared<FJsonValueObject>(Object));
}
Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections));
TArray<TSharedPtr<FJsonValue>> Strates;
for (const FVoxelSeasonStrate& Strate : Manifest.Strates)
{
Strates.Add(MakeShared<FJsonValueObject>(SerializeStrate(Strate)));
}
Root->SetArrayField(TEXT("strates"), MoveTemp(Strates));
if (!Manifest.CandidateAudit.IsEmpty())
{
TArray<TSharedPtr<FJsonValue>> Audit;
for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit)
TArray<TSharedPtr<FJsonValue>> Rejections;
for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts)
{
TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>();
Object->SetNumberField(TEXT("candidate_index"), Row.CandidateIndex);
Object->SetNumberField(TEXT("seed"), Row.Seed);
Object->SetStringField(TEXT("archetype"), ArchetypeName(Row.Archetype));
Object->SetNumberField(TEXT("recipe_hash"), static_cast<double>(Row.RecipeHash));
Object->SetNumberField(TEXT("distance_from_corpus_centroid"), Row.DistanceFromCorpusCentroid);
Object->SetBoolField(TEXT("passed_hard_gates"), Row.bPassedHardGates);
Object->SetBoolField(TEXT("selected"), Row.bSelected);
Object->SetStringField(TEXT("rejection_reason"), Row.RejectionReason);
Audit.Add(MakeShared<FJsonValueObject>(Object));
Object->SetStringField(TEXT("reason"), Rejection.Reason);
Object->SetNumberField(TEXT("count"), Rejection.Count);
Rejections.Add(MakeShared<FJsonValueObject>(Object));
}
Root->SetArrayField(TEXT("candidate_audit"), MoveTemp(Audit));
}
Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections));
FString JsonText;
TSharedRef<TJsonWriter<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>> Writer =
TJsonWriterFactory<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>::Create(&JsonText);
if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close())
{
return FString();
if (!Manifest.CandidateAudit.IsEmpty())
{
TArray<TSharedPtr<FJsonValue>> Audit;
for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit)
{
TSharedPtr<FJsonObject> Object = MakeShared<FJsonObject>();
Object->SetNumberField(TEXT("candidate_index"), Row.CandidateIndex);
Object->SetNumberField(TEXT("seed"), Row.Seed);
Object->SetStringField(TEXT("archetype"), ArchetypeName(Row.Archetype));
Object->SetNumberField(TEXT("recipe_hash"), static_cast<double>(Row.RecipeHash));
Object->SetNumberField(TEXT("distance_from_corpus_centroid"), Row.DistanceFromCorpusCentroid);
Object->SetBoolField(TEXT("passed_hard_gates"), Row.bPassedHardGates);
Object->SetBoolField(TEXT("selected"), Row.bSelected);
Object->SetStringField(TEXT("rejection_reason"), Row.RejectionReason);
Audit.Add(MakeShared<FJsonValueObject>(Object));
}
Root->SetArrayField(TEXT("candidate_audit"), MoveTemp(Audit));
}
if (bIncludeContentHash)
{
const FString Payload = SerializeVoxelSeasonManifestInternal(Manifest, false);
if (Payload.IsEmpty()) return FString();
FTCHARToUTF8 Utf8(*Payload);
uint8 Digest[FSHA1::DigestSize];
FSHA1::HashBuffer(Utf8.Get(), Utf8.Length(), Digest);
Root->SetStringField(TEXT("content_hash"), BytesToHex(Digest, FSHA1::DigestSize));
}
FString JsonText;
TSharedRef<TJsonWriter<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>> Writer =
TJsonWriterFactory<TCHAR, TPrettyJsonPrintPolicy<TCHAR>>::Create(&JsonText);
if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close())
{
return FString();
}
return JsonText;
}
return JsonText;
}
FString VF_ComputeVoxelSeasonManifestContentHash(const FVoxelSeasonManifest& Manifest)
{
const FString Payload = SerializeVoxelSeasonManifestInternal(Manifest, false);
if (Payload.IsEmpty()) return FString();
FTCHARToUTF8 Utf8(*Payload);
uint8 Digest[FSHA1::DigestSize];
FSHA1::HashBuffer(Utf8.Get(), Utf8.Length(), Digest);
return BytesToHex(Digest, FSHA1::DigestSize);
}
FString VF_SerializeVoxelSeasonManifest(const FVoxelSeasonManifest& Manifest)
{
return SerializeVoxelSeasonManifestInternal(Manifest, true);
}
bool VF_SaveVoxelSeasonManifest(const FString& ManifestPath,
@@ -1511,24 +1541,25 @@ bool VF_SaveVoxelSeasonManifest(const FString& ManifestPath,
return true;
}
bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath,
FVoxelSeasonManifest& OutManifest,
FString& OutReport)
bool VF_DeserializeVoxelSeasonManifest(const FString& ManifestJson,
FVoxelSeasonManifest& OutManifest,
FString& OutReport)
{
using namespace VoxelSeasonManifestPrivate;
OutManifest = FVoxelSeasonManifest();
OutReport.Reset();
if (ManifestPath.IsEmpty())
if (ManifestJson.IsEmpty())
{
OutReport = TEXT("season manifest path is empty");
OutReport = TEXT("season manifest JSON is empty");
OutManifest.Error = OutReport;
return false;
}
TSharedPtr<FJsonObject> Root;
FString OriginalJson;
if (!LoadJson(ManifestPath, Root, OutReport, &OriginalJson))
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(ManifestJson);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
OutReport = TEXT("season manifest JSON is malformed");
OutManifest.Error = OutReport;
return false;
}
@@ -1556,6 +1587,8 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath,
bool bFieldsValid = true;
OutManifest.bValid = bValid;
OutManifest.SchemaVersion = SchemaVersion;
bFieldsValid = ReadJsonString(Root, TEXT("content_hash"), OutManifest.ContentHash)
&& bFieldsValid;
bFieldsValid = ReadJsonInt32(Root, TEXT("season"), OutManifest.Season) && bFieldsValid;
bFieldsValid = ReadJsonInt32(Root, TEXT("seed"), OutManifest.Seed) && bFieldsValid;
double CorpusHash = 0.0;
@@ -1669,7 +1702,7 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath,
const FVoxelSeasonStrate& Strate = OutManifest.Strates[Index];
const float StoredTop = static_cast<float>(Strate.TopWorldZ);
const float StoredBottom = static_cast<float>(Strate.BottomWorldZ);
if (Strate.DepthIndex != Index || !Strate.bPassedPrimordialLaw
if (Strate.DepthIndex != Index || Strate.bUsesRegions || !Strate.bPassedPrimordialLaw
|| Strate.PrimordialLawResult != EVoxelConnectivityResult::Connected)
{
bFieldsValid = false;
@@ -1695,17 +1728,99 @@ bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath,
}
if (!bFieldsValid)
{
OutReport = FString::Printf(TEXT("season manifest %s contains invalid fields"), *ManifestPath);
OutReport = TEXT("season manifest contains invalid fields");
OutManifest.Error = OutReport;
OutManifest.bValid = false;
return false;
}
OutManifest.ManifestPath = ManifestPath;
OutManifest.SerializedJson = MoveTemp(OriginalJson);
OutReport = FString::Printf(TEXT("loaded season manifest from %s"), *ManifestPath);
const FString ComputedHash = VF_ComputeVoxelSeasonManifestContentHash(OutManifest);
if (ComputedHash.IsEmpty()
|| !ComputedHash.Equals(OutManifest.ContentHash, ESearchCase::IgnoreCase))
{
OutReport = FString::Printf(
TEXT("season manifest content hash mismatch (stored %s, computed %s)"),
*OutManifest.ContentHash, *ComputedHash);
OutManifest.Error = OutReport;
OutManifest.bValid = false;
return false;
}
OutManifest.SerializedJson = ManifestJson;
OutReport = FString::Printf(TEXT("loaded season manifest hash %s"), *OutManifest.ContentHash);
return OutManifest.IsUsable();
}
bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath,
FVoxelSeasonManifest& OutManifest,
FString& OutReport)
{
FString JsonText;
if (ManifestPath.IsEmpty())
{
OutReport = TEXT("season manifest path is empty");
OutManifest = FVoxelSeasonManifest();
OutManifest.Error = OutReport;
return false;
}
if (!FFileHelper::LoadFileToString(JsonText, *ManifestPath))
{
OutReport = FString::Printf(TEXT("could not read season manifest %s"), *ManifestPath);
OutManifest = FVoxelSeasonManifest();
OutManifest.Error = OutReport;
return false;
}
if (!VF_DeserializeVoxelSeasonManifest(JsonText, OutManifest, OutReport))
{
OutReport = FString::Printf(TEXT("season manifest %s: %s"), *ManifestPath, *OutReport);
OutManifest.Error = OutReport;
return false;
}
OutManifest.ManifestPath = ManifestPath;
OutReport = FString::Printf(TEXT("loaded season manifest from %s (hash %s)"),
*ManifestPath, *OutManifest.ContentHash);
return true;
}
namespace VoxelSeasonRuntimePrivate
{
float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater: return Params.TunnelNetworkParams.StrateTopWorldZ;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber: return Params.SlabParams.StrateTopWorldZ;
case ECaveGeneratorType::Maze: return Params.MazeParams.StrateTopWorldZ;
case ECaveGeneratorType::SurfaceWorld: return Params.SurfaceParams.StrateTopWorldZ;
case ECaveGeneratorType::VerticalShafts: return Params.VerticalShaftParams.StrateTopWorldZ;
case ECaveGeneratorType::FloatingIslands: return Params.FloatingIslandParams.StrateTopWorldZ;
}
return 0.0f;
}
float ActiveBottom(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater: return Params.TunnelNetworkParams.StrateBottomWorldZ;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber: return Params.SlabParams.StrateBottomWorldZ;
case ECaveGeneratorType::Maze: return Params.MazeParams.StrateBottomWorldZ;
case ECaveGeneratorType::SurfaceWorld: return Params.SurfaceParams.StrateBottomWorldZ;
case ECaveGeneratorType::VerticalShafts: return Params.VerticalShaftParams.StrateBottomWorldZ;
case ECaveGeneratorType::FloatingIslands: return Params.FloatingIslandParams.StrateBottomWorldZ;
}
return 0.0f;
}
bool SameFloatBits(float A, float B)
{
return FMemory::Memcmp(&A, &B, sizeof(float)) == 0;
}
}
#if WITH_EDITOR
namespace VoxelSeasonCompositionPrivate
{
float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype);
@@ -1920,7 +2035,10 @@ namespace VoxelSeasonCompositionPrivate
OutStrate.Params = Candidate.ArchetypeParams;
OutStrate.Regions = Candidate.Regions;
OutStrate.bUsesRecipe = true;
OutStrate.bUsesRegions = Candidate.Regions.RegionCount > 1;
// Lateral composition remains a laboratory feature until its cross-seam law is perfect.
// The season artifact must therefore stay on the proved single-stack runtime contract.
OutStrate.bUsesRegions = VF_LateralRegionsAreShippable()
&& Candidate.Regions.RegionCount > 1;
SetRuntimeBounds(OutStrate.Params, static_cast<float>(TopWorldZ),
static_cast<float>(BottomWorldZ));
if (OutStrate.Regions.IsValid())
@@ -2235,6 +2353,7 @@ namespace VoxelSeasonCompositionPrivate
return VoxelSeasonManifestPrivate::ConnectivityName(Result);
}
}
#endif // WITH_EDITOR — composition, measurement and review helpers never ship
namespace VoxelSeasonCompositionPrivate
{
@@ -2791,6 +2910,11 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed,
Manifest.bValid = true;
Manifest.Error.Reset();
Manifest.ContentHash = VF_ComputeVoxelSeasonManifestContentHash(Manifest);
if (Manifest.ContentHash.IsEmpty())
{
return Fail(TEXT("could not hash composed season manifest"));
}
Manifest.SerializedJson = VF_SerializeVoxelSeasonManifest(Manifest);
if (Manifest.SerializedJson.IsEmpty())
{
@@ -2807,20 +2931,9 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed,
return Manifest;
}
#else
FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed,
const FVoxelSeasonCompositionSettings& Settings)
{
(void)SeasonSeed;
(void)Settings;
FVoxelSeasonManifest Manifest;
Manifest.Error = TEXT("season composition is editor/build-box only");
return Manifest;
}
#endif
#if WITH_EDITOR
FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* WorldSettings)
{
FVoxelSeasonCompositionSettings Settings;
@@ -2835,6 +2948,7 @@ FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* Wo
}
return VF_ComposeSeason(SeasonSeed, Settings);
}
#endif
bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate,
float SpineRadius,
@@ -2843,7 +2957,7 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate,
FVoxelOpContext& OutContext,
FString* OutError)
{
using namespace VoxelSeasonCompositionPrivate;
using namespace VoxelSeasonRuntimePrivate;
auto Fail = [&](const FString& Reason) -> bool
{
if (OutError != nullptr)
@@ -2890,8 +3004,12 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate,
bool bBuilt = false;
if (Strate.bUsesRegions)
{
#if WITH_EDITOR
bBuilt = VF_BuildStrateRegionStack(Strate.Regions, SpineRadius, StrateManager,
OutStack, OutContext, OutError);
#else
return Fail(TEXT("Lateral region manifests are not shippable."));
#endif
}
else if (Strate.bUsesRecipe)
{
@@ -2902,7 +3020,8 @@ bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate,
else
{
#if WITH_EDITOR
const float Seal = ActiveSeal(Strate.Params, Strate.Archetype);
const float Seal = VoxelSeasonCompositionPrivate::ActiveSeal(
Strate.Params, Strate.Archetype);
bBuilt = VF_BuildNativeStrateStackForCandidate(
Strate.Archetype, Strate.Params, Strate.Seed, SpineRadius,
0.0f, Seal, StrateManager, OutStack, OutContext);
@@ -2940,6 +3059,7 @@ bool VF_RebuildVoxelSeasonStrate(const FVoxelSeasonStrate& Strate,
OutStack, OutContext, OutError);
}
#if WITH_EDITOR
namespace VoxelSeasonCompositionPrivate
{
bool WriteReviewPage(const FVoxelSeasonManifest& Manifest,
@@ -3021,3 +3141,4 @@ namespace VoxelSeasonCompositionPrivate
return true;
}
}
#endif // WITH_EDITOR — review rendering never ships
@@ -0,0 +1,51 @@
#include "VoxelSettings.h"
#include "VoxelSeasonAsset.h"
int32 UVoxelSettings::GetEffectiveWorldSeed() const
{
if (!Season.IsNull())
{
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
{
return Asset->GetSeasonSeed();
}
}
return Seed;
}
float UVoxelSettings::GetEffectiveOriginSpineRadius() const
{
if (!Season.IsNull())
{
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
{
return Asset->GetOriginSpineRadius();
}
}
return OriginSpineRadius;
}
float UVoxelSettings::GetEffectiveWorldRadiusVoxels() const
{
if (!Season.IsNull())
{
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
{
return Asset->GetWorldRadiusVoxels();
}
}
return WorldRadiusVoxels;
}
int32 UVoxelSettings::GetEffectiveSeasonNumber() const
{
if (!Season.IsNull())
{
if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous())
{
return Asset->GetSeasonNumber();
}
}
return CurrentSeason;
}
@@ -28,6 +28,7 @@
#include <type_traits>
#include <utility>
#if WITH_EDITOR
namespace
{
constexpr float GJitterFraction = 0.15f;
@@ -2262,27 +2263,6 @@ namespace
}
}
bool FVoxelStrateMeasuredMetrics::IsUsable() const
{
return bValid
&& NumSampled > 0
&& NumAir > 0
&& NumSolid > 0
&& NumSampled == NumAir + NumSolid
&& NumAirComponents > 0
&& LargestComponentCells > 0
&& AirComponentCells.Num() == NumAirComponents
&& FMath::IsFinite(AirFraction)
&& FMath::IsFinite(LargestComponentShare)
&& FMath::IsFinite(WalkableFraction)
&& FMath::IsFinite(WalkableFloorAreaFraction)
&& FMath::IsFinite(LargestWalkableSurfaceShare)
&& FMath::IsFinite(MedianFeatureScale)
&& FMath::IsFinite(LargestComponentPoint.X)
&& FMath::IsFinite(LargestComponentPoint.Y)
&& FMath::IsFinite(LargestComponentPoint.Z);
}
FVoxelStrateMeasuredMetrics VF_SummarizeStrateMetrics(
const FVoxelStrateMetrics& Metrics)
{
@@ -3901,22 +3881,6 @@ uint32 FVoxelStrateCorpus::GetContentsHash() const
return Hash;
}
const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork");
case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain");
case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber");
case ECaveGeneratorType::Maze: return TEXT("Maze");
case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld");
case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts");
case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands");
case ECaveGeneratorType::Underwater: return TEXT("Underwater");
}
return TEXT("Unknown");
}
const TCHAR* VF_GetStrateCorpusProvenanceName(EVoxelStrateCorpusProvenance Provenance)
{
switch (Provenance)
@@ -4400,6 +4364,7 @@ bool FVoxelStrateCorpus::SetMeasuredMetrics(
}
return false;
}
#endif // WITH_EDITOR — corpus loading, promotion and parameter/structure roll implementation
void VF_SetStrateArchetypeRuntimeBounds(
FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ)
@@ -4589,11 +4554,13 @@ namespace VoxelStrateRegionPrivate
}
}
#if WITH_EDITOR
int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex)
{
return 1 + static_cast<int32>(VoxelHash::Cell(
StrateIndex, StrateIndex ^ 0x6D, static_cast<uint32>(Seed) ^ 0x524F4C4Cu) % 3u);
}
#endif
uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex)
{
@@ -4753,6 +4720,7 @@ FVoxelStrateRegionBoxProof VF_AnalyzeStrateRegionBox(
return Proof;
}
#if WITH_EDITOR
FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus,
int32 Seed, int32 Index)
{
@@ -5694,3 +5662,4 @@ bool VF_AreStrateStructureRecipesIdentical(const FVoxelOpStackRecipe& A,
}
return true;
}
#endif // WITH_EDITOR — no roller/corpus entry point is compiled into Shipping
+237 -60
View File
@@ -4,10 +4,12 @@
#include "VoxelStrateManager.h"
#include "CoreGlobals.h" // GIsAutomationTesting — the opt-in diagnostic stays quiet under tests
#include "VoxelSettings.h"
#include "VoxelSeasonAsset.h"
#include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord
#include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash
#include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo
#include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten)
#include "UObject/UObjectGlobals.h"
#if WITH_EDITOR
#include "VoxelStrateComposer.h"
@@ -58,22 +60,46 @@ static float PassageFBM(float X, float Seed)
return (MaxV > 0.0f) ? (Total / MaxV) : 0.0f;
}
void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
{
if (!Settings)
{
UE_LOG(LogTemp, Error, TEXT("[StrateManager] No settings provided!"));
return;
return false;
}
FVoxelSeasonManifest SeasonManifest;
bool bUseSeason = false;
if (!Settings->Season.IsNull())
{
UVoxelSeasonAsset* SeasonAsset = Settings->Season.LoadSynchronous();
FString SeasonReport;
if (SeasonAsset == nullptr
|| !SeasonAsset->LoadManifest(SeasonManifest, SeasonReport))
{
UE_LOG(LogTemp, Error,
TEXT("[StrateManager] Season asset is assigned but unusable: %s"),
SeasonAsset ? *SeasonReport : *Settings->Season.ToString());
return false; // Fail closed: falling back to the authored pool would diverge peers.
}
bUseSeason = true;
WorldSeed = SeasonManifest.Seed;
}
StrateLayout.Empty();
SeasonStrates.Empty();
ActiveSeasonContentHash.Reset();
#if WITH_EDITOR
// A full layout rebuild discards any temporary walk-through candidate. The world calls this
// under FScopedGenerationPause, so no worker can observe the map while it is being cleared.
ComposerOverrides.Reset();
#endif
const int32 TotalStrates = Settings->TotalStrates;
const int32 TotalStrates = bUseSeason
? SeasonManifest.Strates.Num() : Settings->TotalStrates;
const int32 LayoutGapChunks = bUseSeason
? SeasonManifest.InterStrateGapChunks
: FMath::Max(0, Settings->InterStrateGapChunks);
//=========================================================================
// STEP 1: Build shuffled pool (seed-based randomization)
@@ -84,13 +110,16 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
using FLoadedPoolEntry = TPair<FString, UVoxelStrateDefinition*>;
TArray<FLoadedPoolEntry> ShuffledPool;
for (const TSoftObjectPtr<UVoxelStrateDefinition>& SoftPtr : Settings->StratePool)
if (!bUseSeason)
{
// Load the asset (synchronous for now — could be async later)
UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous();
if (Def)
for (const TSoftObjectPtr<UVoxelStrateDefinition>& SoftPtr : Settings->StratePool)
{
ShuffledPool.Emplace(SoftPtr.ToString(), Def);
// Load the asset (synchronous for now — could be async later)
UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous();
if (Def)
{
ShuffledPool.Emplace(SoftPtr.ToString(), Def);
}
}
}
@@ -125,12 +154,15 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
// Pre-load fixed strate definitions
TMap<int32, UVoxelStrateDefinition*> LoadedFixed;
for (auto& Pair : Settings->FixedStrates)
if (!bUseSeason)
{
UVoxelStrateDefinition* Def = Pair.Value.LoadSynchronous();
if (Def)
for (auto& Pair : Settings->FixedStrates)
{
LoadedFixed.Add(Pair.Key, Def);
UVoxelStrateDefinition* Def = Pair.Value.LoadSynchronous();
if (Def)
{
LoadedFixed.Add(Pair.Key, Def);
}
}
}
@@ -142,32 +174,97 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
FStrateSlot Slot;
Slot.StrateIndex = i;
// Pick definition: fixed or from pool
UVoxelStrateDefinition** FixedDef = LoadedFixed.Find(i);
if (FixedDef && *FixedDef)
if (bUseSeason)
{
Slot.Definition = *FixedDef;
}
else if (ShuffledPool.Num() > 0)
{
// Cycle through the pool (wraps around if more strates than pool entries)
Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()].Value;
PoolCursor++;
const FVoxelSeasonStrate& SeasonStrate = SeasonManifest.Strates[i];
if (!SeasonStrate.SourceDefinitionPath.IsEmpty())
{
TSoftObjectPtr<UVoxelStrateDefinition> SourceDefinition(
FSoftObjectPath(SeasonStrate.SourceDefinitionPath));
UVoxelStrateDefinition* LoadedDefinition = SourceDefinition.LoadSynchronous();
if (LoadedDefinition == nullptr)
{
UE_LOG(LogTemp, Error,
TEXT("[StrateManager] Season slot %d could not load authored definition '%s'."),
i, *SeasonStrate.SourceDefinitionPath);
StrateLayout.Empty();
SeasonStrates.Empty();
return false;
}
// Keep the authored content/passage/visual bag without mutating the cooked asset,
// then make the manifest's density identity authoritative below.
Slot.Definition = DuplicateObject<UVoxelStrateDefinition>(LoadedDefinition, this);
}
else
{
// The current density manifest has no wider content record. Keep all consumers
// supplied with a stable definition object, but do not guess an authored theme.
Slot.Definition = NewObject<UVoxelStrateDefinition>(this, NAME_None, RF_Transient);
Slot.Definition->StrateName = FText::FromString(
FString::Printf(TEXT("Season %d Strate %d"), SeasonManifest.Season, i));
Slot.Definition->TransitionType = EVoxelStrateTransition::Hard;
// Passage configuration is not part of schema v2. Inventing the UObject default
// here would add an unreviewed tunnel to every generated boundary and make the
// runtime field differ from the field that passed composition. The origin spine
// remains the guaranteed connection until a later schema explicitly stores these.
Slot.Definition->PassageConfig.Connections = 0;
}
Slot.Definition->GeneratorType = SeasonStrate.Archetype;
Slot.Definition->bUseOperatorStack = SeasonStrate.bUsesRecipe;
Slot.Definition->StrateHeightInChunks = SeasonStrate.HeightInChunks;
Slot.Definition->GenerationParams = SeasonStrate.Params.TunnelNetworkParams;
Slot.Definition->SlabParams = SeasonStrate.Params.SlabParams;
Slot.Definition->MazeParams = SeasonStrate.Params.MazeParams;
Slot.Definition->SurfaceParams = SeasonStrate.Params.SurfaceParams;
Slot.Definition->VerticalShaftParams = SeasonStrate.Params.VerticalShaftParams;
Slot.Definition->FloatingIslandParams = SeasonStrate.Params.FloatingIslandParams;
Slot.HeightInChunks = SeasonStrate.HeightInChunks;
Slot.TopChunkZ = SeasonStrate.TopWorldZ / CHUNK_SIZE - 1;
Slot.BottomChunkZ = SeasonStrate.BottomWorldZ / CHUNK_SIZE;
if (SeasonStrate.TopWorldZ % CHUNK_SIZE != 0
|| SeasonStrate.BottomWorldZ % CHUNK_SIZE != 0
|| Slot.TopChunkZ != CurrentTopZ
|| Slot.BottomChunkZ != CurrentTopZ - (Slot.HeightInChunks - 1))
{
UE_LOG(LogTemp, Error,
TEXT("[StrateManager] Season slot %d bounds do not describe the declared stacked layout."), i);
StrateLayout.Empty();
SeasonStrates.Empty();
return false;
}
SeasonStrates.Add(SeasonStrate);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("[StrateManager] No strate definitions available for slot %d!"), i);
continue;
}
// Pick definition: fixed or from pool
UVoxelStrateDefinition** FixedDef = LoadedFixed.Find(i);
if (FixedDef && *FixedDef)
{
Slot.Definition = *FixedDef;
}
else if (ShuffledPool.Num() > 0)
{
// Cycle through the pool (wraps around if more strates than pool entries)
Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()].Value;
PoolCursor++;
}
else
{
UE_LOG(LogTemp, Warning, TEXT("[StrateManager] No strate definitions available for slot %d!"), i);
continue;
}
// Compute Z range from definition's height
Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks;
Slot.TopChunkZ = CurrentTopZ;
Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1);
// Compute Z range from definition's height
Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks;
Slot.TopChunkZ = CurrentTopZ;
Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1);
}
// Move the cursor down for the next strate, leaving a solid-bedrock gap of
// InterStrateGapChunks chunks between this strate and the next.
CurrentTopZ = Slot.BottomChunkZ - 1 - FMath::Max(0, Settings->InterStrateGapChunks);
CurrentTopZ = Slot.BottomChunkZ - 1 - LayoutGapChunks;
StrateLayout.Add(Slot);
@@ -187,13 +284,17 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
int32 NumOperatorStackDisabledCaves = 0;
for (const FStrateSlot& Slot : StrateLayout)
{
if (!Slot.Definition || Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld)
const ECaveGeneratorType SlotArchetype = bUseSeason
? SeasonStrates[Slot.StrateIndex].Archetype : Slot.Definition->GeneratorType;
if (!Slot.Definition || SlotArchetype == ECaveGeneratorType::SurfaceWorld)
{
continue;
}
++NumCaveSlots;
if (!Slot.Definition->bUseOperatorStack)
const bool bUsesStack = bUseSeason
? SeasonStrates[Slot.StrateIndex].bUsesRecipe : Slot.Definition->bUseOperatorStack;
if (!bUsesStack)
{
++NumOperatorStackDisabledCaves;
}
@@ -226,9 +327,13 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
for (const FStrateSlot& Slot : StrateLayout)
{
const bool bSeasonStack = bUseSeason
&& SeasonStrates[Slot.StrateIndex].bUsesRecipe;
const ECaveGeneratorType SlotArchetype = bUseSeason
? SeasonStrates[Slot.StrateIndex].Archetype : Slot.Definition->GeneratorType;
if (!Slot.Definition
|| Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld
|| Slot.Definition->bUseOperatorStack)
|| SlotArchetype == ECaveGeneratorType::SurfaceWorld
|| (bUseSeason ? bSeasonStack : Slot.Definition->bUseOperatorStack))
{
continue;
}
@@ -246,8 +351,13 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
CachedSeed = WorldSeed;
bOpenSurfaceEntry = Settings->bOpenSurfaceEntry;
OriginSpineRadius = Settings->OriginSpineRadius;
InterStrateGapChunks = FMath::Max(0, Settings->InterStrateGapChunks);
OriginSpineRadius = bUseSeason
? SeasonManifest.OriginSpineRadius : Settings->OriginSpineRadius;
InterStrateGapChunks = LayoutGapChunks;
if (bUseSeason)
{
ActiveSeasonContentHash = SeasonManifest.ContentHash;
}
// Passage shape/count is per-strate now (UVoxelStrateDefinition::PassageConfig).
//=========================================================================
@@ -260,6 +370,7 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
for (const FStrateSlot& Slot : StrateLayout)
{
if (!Slot.Definition) continue;
if (bUseSeason && SeasonStrates[Slot.StrateIndex].bUsesRecipe) continue;
for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations)
{
@@ -275,6 +386,7 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
// Generate passages between consecutive strates
GeneratePassages();
return bUseSeason ? IsUsingSeason() : true;
}
#if WITH_EDITOR
@@ -429,18 +541,40 @@ bool UVoxelStrateManager::GetComposerRegionOverrideForChunk(
return true;
}
bool UVoxelStrateManager::HasComposerRecipeOverride() const
#endif
bool UVoxelStrateManager::GetRecipeForChunk(
const FIntVector& ChunkCoord, int32& OutRecipeSeed,
ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams,
FVoxelOpStackRecipe& OutRecipe) const
{
for (const TPair<int32, TSharedPtr<FVoxelStrateComposerSlotOverride>>& Pair : ComposerOverrides)
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0) return false;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override =
FindComposerOverride(StrateLayout[SlotIdx].StrateIndex))
{
if (Pair.Value.IsValid() && Pair.Value->bUseRecipe)
{
return true;
}
if (!Override->bUseRecipe || Override->bUseRegions) return false;
OutRecipeSeed = Override->CandidateSeed;
OutArchetype = Override->Archetype;
OutParams = Override->Params;
OutRecipe = Override->Recipe;
return true;
}
#endif
if (SeasonStrates.IsValidIndex(SlotIdx))
{
const FVoxelSeasonStrate& Strate = SeasonStrates[SlotIdx];
if (!Strate.bUsesRecipe || Strate.bUsesRegions) return false;
OutRecipeSeed = Strate.Seed;
OutArchetype = Strate.Archetype;
OutParams = Strate.Params;
OutRecipe = Strate.Recipe;
return true;
}
return false;
}
#endif
//=============================================================================
// PASSAGE GENERATION
@@ -619,10 +753,12 @@ void UVoxelStrateManager::GeneratePassages()
const bool bUsesRoomSeed =
UpperDef->GeneratorType == ECaveGeneratorType::TunnelNetwork
|| UpperDef->GeneratorType == ECaveGeneratorType::Underwater;
const int32 UpperQuerySeed = bUsesRoomSeed
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
static_cast<uint32>(CachedSeed), Upper.StrateIndex))
: CachedSeed;
const int32 UpperQuerySeed = SeasonStrates.IsValidIndex(i)
? SeasonStrates[i].Seed
: (bUsesRoomSeed
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
static_cast<uint32>(CachedSeed), Upper.StrateIndex))
: CachedSeed);
bAimedUpperAtOpenPoint = VF_SuggestLandingPoint(
UpperDef->GeneratorType,
@@ -657,10 +793,12 @@ void UVoxelStrateManager::GeneratePassages()
const bool bUsesRoomSeed =
LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork
|| LowerDef->GeneratorType == ECaveGeneratorType::Underwater;
const int32 LowerQuerySeed = bUsesRoomSeed
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
static_cast<uint32>(CachedSeed), Lower.StrateIndex))
: CachedSeed;
const int32 LowerQuerySeed = SeasonStrates.IsValidIndex(i + 1)
? SeasonStrates[i + 1].Seed
: (bUsesRoomSeed
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
static_cast<uint32>(CachedSeed), Lower.StrateIndex))
: CachedSeed);
const float MaxLateralSnap = MaxLateralSnapFor(*LowerDef);
bAimedLowerAtOpenPoint = VF_SuggestLandingPoint(
@@ -1145,6 +1283,11 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto
}
#endif
if (SeasonStrates.IsValidIndex(SlotIdx))
{
return SeasonStrates[SlotIdx].Archetype;
}
return StrateLayout[SlotIdx].Definition->GeneratorType;
}
@@ -1153,6 +1296,13 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; }
if (SeasonStrates.IsValidIndex(SlotIdx))
{
// A recipe is an explicit stack opt-in. Native fixed entries retain the authored/switch
// route while still reading their season parameter vector.
return SeasonStrates[SlotIdx].bUsesRecipe;
}
#if WITH_EDITOR
if (FindComposerOverride(StrateLayout[SlotIdx].StrateIndex) != nullptr)
{
@@ -1240,8 +1390,12 @@ FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVecto
// Copy the designer-authored slab params from the strate definition, unless an editor
// composer candidate owns this slot.
FSlabGenerationParams Result;
if (SeasonStrates.IsValidIndex(SlotIdx))
{
Result = SeasonStrates[SlotIdx].Params.SlabParams;
}
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override =
else if (const FVoxelStrateComposerSlotOverride* Override =
FindComposerOverride(Slot.StrateIndex))
{
Result = (Override->Archetype == ECaveGeneratorType::FlatPlain
@@ -1281,7 +1435,8 @@ FMazeGenerationParams UVoxelStrateManager::GetMazeParamsForChunk(const FIntVecto
}
const FStrateSlot& Slot = StrateLayout[SlotIdx];
FMazeGenerationParams Result = Slot.Definition->MazeParams;
FMazeGenerationParams Result = SeasonStrates.IsValidIndex(SlotIdx)
? SeasonStrates[SlotIdx].Params.MazeParams : Slot.Definition->MazeParams;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{
@@ -1307,7 +1462,8 @@ FSurfaceGenerationParams UVoxelStrateManager::GetSurfaceParamsForChunk(const FIn
}
const FStrateSlot& Slot = StrateLayout[SlotIdx];
FSurfaceGenerationParams Result = Slot.Definition->SurfaceParams;
FSurfaceGenerationParams Result = SeasonStrates.IsValidIndex(SlotIdx)
? SeasonStrates[SlotIdx].Params.SurfaceParams : Slot.Definition->SurfaceParams;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{
@@ -1333,7 +1489,8 @@ FVerticalShaftParams UVoxelStrateManager::GetVerticalShaftParamsForChunk(const F
}
const FStrateSlot& Slot = StrateLayout[SlotIdx];
FVerticalShaftParams Result = Slot.Definition->VerticalShaftParams;
FVerticalShaftParams Result = SeasonStrates.IsValidIndex(SlotIdx)
? SeasonStrates[SlotIdx].Params.VerticalShaftParams : Slot.Definition->VerticalShaftParams;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{
@@ -1359,7 +1516,8 @@ FFloatingIslandParams UVoxelStrateManager::GetFloatingIslandParamsForChunk(const
}
const FStrateSlot& Slot = StrateLayout[SlotIdx];
FFloatingIslandParams Result = Slot.Definition->FloatingIslandParams;
FFloatingIslandParams Result = SeasonStrates.IsValidIndex(SlotIdx)
? SeasonStrates[SlotIdx].Params.FloatingIslandParams : Slot.Definition->FloatingIslandParams;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{
@@ -1440,11 +1598,20 @@ float UVoxelStrateManager::GetWaterLevelWorldZForChunk(const FIntVector& ChunkCo
// Pull the relative level from whichever archetype owns water.
float Rel = 0.0f;
switch (Def->GeneratorType)
const ECaveGeneratorType Archetype = SeasonStrates.IsValidIndex(SlotIdx)
? SeasonStrates[SlotIdx].Archetype : Def->GeneratorType;
switch (Archetype)
{
case ECaveGeneratorType::SurfaceWorld: Rel = Def->SurfaceParams.WaterLevelRelative; break;
case ECaveGeneratorType::Underwater: Rel = Def->GenerationParams.WaterLevelRelative; break;
default: Rel = Def->GenerationParams.WaterLevelRelative; break;
case ECaveGeneratorType::SurfaceWorld:
Rel = SeasonStrates.IsValidIndex(SlotIdx)
? SeasonStrates[SlotIdx].Params.SurfaceParams.WaterLevelRelative
: Def->SurfaceParams.WaterLevelRelative;
break;
default:
Rel = SeasonStrates.IsValidIndex(SlotIdx)
? SeasonStrates[SlotIdx].Params.TunnelNetworkParams.WaterLevelRelative
: Def->GenerationParams.WaterLevelRelative;
break;
}
if (Rel <= 0.0f) return -FLT_MAX;
@@ -1483,6 +1650,16 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto
}
#endif
if (SeasonStrates.IsValidIndex(SlotIdx))
{
FStrateGenerationParams Result = SeasonStrates[SlotIdx].Params.TunnelNetworkParams;
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE;
// Season vectors are already final, measured slot records. Vertical structural posts own
// their boundary; blending them with another selected recipe would describe neither one.
return Result;
}
FStrateGenerationParams BaseParams = BuildParamsFromDefinition(Slot.Definition);
//=========================================================================
@@ -2,6 +2,8 @@
#include "VoxelStrateMeasure.h"
#if WITH_EDITOR
#include "VoxelGenerator.h"
#include "VoxelStrateManager.h"
#include "VoxelTypes.h"
@@ -1579,3 +1581,5 @@ FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler(
Result.bStartSnapped, Result.bGoalSnapped, &Result);
return Result;
}
#endif // WITH_EDITOR — measurement never ships
@@ -0,0 +1,38 @@
#include "VoxelStrateComposer.h"
bool FVoxelStrateMeasuredMetrics::IsUsable() const
{
return bValid
&& NumSampled > 0
&& NumAir > 0
&& NumSolid > 0
&& NumSampled == NumAir + NumSolid
&& NumAirComponents > 0
&& LargestComponentCells > 0
&& AirComponentCells.Num() == NumAirComponents
&& FMath::IsFinite(AirFraction)
&& FMath::IsFinite(LargestComponentShare)
&& FMath::IsFinite(WalkableFraction)
&& FMath::IsFinite(WalkableFloorAreaFraction)
&& FMath::IsFinite(LargestWalkableSurfaceShare)
&& FMath::IsFinite(MedianFeatureScale)
&& FMath::IsFinite(LargestComponentPoint.X)
&& FMath::IsFinite(LargestComponentPoint.Y)
&& FMath::IsFinite(LargestComponentPoint.Z);
}
const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork");
case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain");
case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber");
case ECaveGeneratorType::Maze: return TEXT("Maze");
case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld");
case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts");
case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands");
case ECaveGeneratorType::Underwater: return TEXT("Underwater");
}
return TEXT("Unknown");
}
+37 -8
View File
@@ -423,6 +423,7 @@ void AVoxelWorld::RegenerateAllChunks()
void AVoxelWorld::RebuildStrates()
{
bool bLayoutRebuilt = true;
{
FScopedGenerationPause Guard(this);
if (!Guard.Acquired())
@@ -434,12 +435,20 @@ void AVoxelWorld::RebuildStrates()
if (StrateManager && Settings)
{
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
StrateManager->Initialize(Settings, Settings->Seed);
bLayoutRebuilt = StrateManager->Initialize(
Settings, Settings->GetEffectiveWorldSeed());
}
if (AtmosphereManager) AtmosphereManager->Reset();
if (ContentManager) ContentManager->ClearAll();
}
if (!bLayoutRebuilt)
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] RebuildStrates refused an invalid season; loaded tiles were not regenerated."));
return;
}
// Reload all chunks against the rebuilt strate data.
RegenerateAllChunks();
@@ -831,7 +840,7 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
if (StrateManager)
{
StrateManager->Initialize(Settings, Settings->Seed);
StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed());
}
if (Generator)
{
@@ -959,11 +968,19 @@ void AVoxelWorld::BeginPlay()
Mesher->SkirtCells = Settings->SkirtCells;
Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off
// Système de strates — piloté par le pool et les fixed entries dans Settings.
if (Settings->StratePool.Num() > 0)
// Système de strates — a cooked season takes precedence; otherwise the authored pool path is
// exactly the legacy one.
if (!Settings->Season.IsNull() || Settings->StratePool.Num() > 0)
{
StrateManager = NewObject<UVoxelStrateManager>(this);
StrateManager->Initialize(Settings, Settings->Seed);
if (!StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed()))
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] World generation disabled because its assigned season is invalid or incomplete."));
bShuttingDown.store(true, std::memory_order_release);
SetActorTickEnabled(false);
return;
}
Generator->SetStrateManager(StrateManager);
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Strate system initialized with %d strates"),
StrateManager->GetNumStrates());
@@ -977,7 +994,8 @@ void AVoxelWorld::BeginPlay()
// Content manager — distance-based decoration grid (no LOD pop) + level-0 water planes.
ContentManager = NewObject<UVoxelContentManager>(this);
ContentManager->Initialize(this, StrateManager, Generator, Settings, Settings->Seed);
ContentManager->Initialize(
this, StrateManager, Generator, Settings, Settings->GetEffectiveWorldSeed());
// Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers.
if (bManageAtmosphere && StrateManager)
@@ -2641,6 +2659,12 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed failed — no Settings assigned"));
return;
}
if (!Settings->Season.IsNull())
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] ChangeSeed rejected: a cooked Season owns the seed. Assign another season asset instead."));
return;
}
const int32 OldSeed = Settings->Seed;
const int32 OldSeason = Settings->CurrentSeason;
@@ -2702,12 +2726,17 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
int32 AVoxelWorld::GetCurrentSeed() const
{
return Settings ? Settings->Seed : 0;
return Settings ? Settings->GetEffectiveWorldSeed() : 0;
}
int32 AVoxelWorld::GetCurrentSeason() const
{
return Settings ? Settings->CurrentSeason : 0;
return Settings ? Settings->GetEffectiveSeasonNumber() : 0;
}
FString AVoxelWorld::GetCurrentSeasonContentHash() const
{
return StrateManager ? StrateManager->GetSeasonContentHash() : FString();
}
//=============================================================================
@@ -481,6 +481,7 @@ VOXELFORGE_API bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe,
bool bAppendStructuralPosts = true);
/** Build the region combiner stack. Creative cores are independent; structural posts are global. */
#if WITH_EDITOR
VOXELFORGE_API bool VF_BuildStrateRegionStack(
const FVoxelStrateRegionManifest& Manifest,
float SpineRadius,
@@ -488,3 +489,4 @@ VOXELFORGE_API bool VF_BuildStrateRegionStack(
FVoxelOpStack& OutStack,
FVoxelOpContext& OutContext,
FString* OutError = nullptr);
#endif
@@ -0,0 +1,72 @@
// Cookable runtime carrier for one reviewed season manifest.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "VoxelSeasonAsset.generated.h"
struct FVoxelSeasonManifest;
class UVoxelStrateDefinition;
/**
* The JSON remains the reviewed/diffable source artifact. This asset embeds that exact text so
* Unreal's cooker can carry it into a packaged build, together with an independently stored digest
* used to detect an accidentally stale or edited asset.
*/
UCLASS(BlueprintType)
class VOXELFORGE_API UVoxelSeasonAsset : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
virtual FPrimaryAssetId GetPrimaryAssetId() const override;
/** Parse and validate the embedded manifest, including both content-hash copies. */
bool LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const;
/** Runtime-safe import core used by the editor button and automation. */
bool SetManifestJson(const FString& InManifestJson, FString& OutReport);
int32 GetSeasonNumber() const { return SeasonNumber; }
int32 GetSeasonSeed() const { return SeasonSeed; }
float GetOriginSpineRadius() const { return OriginSpineRadius; }
float GetWorldRadiusVoxels() const { return WorldRadiusVoxels; }
const FString& GetManifestContentHash() const { return ManifestContentHash; }
/** Create the asset in Content Browser, choose a JSON file, then press this button. */
UFUNCTION(CallInEditor, Category = "Season")
void ImportSeasonManifestJson();
#if WITH_EDITORONLY_DATA
UPROPERTY(EditAnywhere, Category = "Season|Import", meta = (FilePathFilter = "json"))
FFilePath SourceManifestJson;
#endif
private:
UPROPERTY(VisibleAnywhere, Category = "Season")
int32 SchemaVersion = 0;
UPROPERTY(VisibleAnywhere, Category = "Season")
int32 SeasonNumber = 0;
UPROPERTY(VisibleAnywhere, Category = "Season")
int32 SeasonSeed = 0;
UPROPERTY(VisibleAnywhere, Category = "Season")
float OriginSpineRadius = 0.0f;
UPROPERTY(VisibleAnywhere, Category = "Season")
float WorldRadiusVoxels = 0.0f;
UPROPERTY(VisibleAnywhere, Category = "Season")
FString ManifestContentHash;
UPROPERTY(VisibleAnywhere, Category = "Season", meta = (MultiLine = true))
FString ManifestJson;
// Import-derived cook references for fixed/authored content bags named by the JSON. Generated
// strates legitimately have no source definition and use deterministic C++ defaults at runtime.
UPROPERTY(VisibleAnywhere, Category = "Season")
TArray<TSoftObjectPtr<UVoxelStrateDefinition>> ReferencedDefinitions;
};
+18 -9
View File
@@ -1,8 +1,4 @@
// Offline Tier 4c season composition and the diffable Tier A manifest.
//
// This header deliberately describes an editor/build-box artifact. The normal runtime path still
// receives its existing settings and builds density exactly as before; no runtime code consumes a
// season manifest as a hidden generation fallback.
// Offline Tier 4c composition plus the runtime-readable, diffable Tier A manifest.
#pragma once
@@ -195,7 +191,7 @@ struct VOXELFORGE_API FVoxelSeasonStrate
*/
struct VOXELFORGE_API FVoxelSeasonManifest
{
static constexpr int32 CurrentSchemaVersion = 1;
static constexpr int32 CurrentSchemaVersion = 2;
bool bValid = false;
int32 SchemaVersion = CurrentSchemaVersion;
@@ -204,6 +200,9 @@ struct VOXELFORGE_API FVoxelSeasonManifest
int32 Season = 0;
int32 Seed = 0;
uint32 InputCorpusHash = 0;
// SHA-1 of the canonical manifest JSON with this field omitted. This detects stale cooked
// season assets without requiring the editor-only input corpus on a player's machine.
FString ContentHash;
int32 CandidateCount = 0;
int32 SurvivorCount = 0;
int32 SelectedCount = 0;
@@ -230,15 +229,17 @@ struct VOXELFORGE_API FVoxelSeasonManifest
bool IsUsable() const
{
if (!bValid || !Error.IsEmpty() || WorldRadiusVoxels != 0.0f
if (!bValid || !Error.IsEmpty() || ContentHash.Len() != 40 || WorldRadiusVoxels != 0.0f
|| Strates.Num() != SelectedCount || SelectedCount <= 0)
{
return false;
}
for (const FVoxelSeasonStrate& Strate : Strates)
{
if (Strate.bUsesRegions
&& (!Strate.Regions.IsValid() || Strate.Regions.RegionCount <= 1))
// Region composition is intentionally not a shippable season format yet. Keeping
// this rejection in the portable manifest contract prevents an editor experiment
// from becoming packaged terrain merely because its density evaluator exists.
if (Strate.bUsesRegions)
{
return false;
}
@@ -252,16 +253,24 @@ VOXELFORGE_API const TCHAR* VF_GetVoxelSeasonSelectionReasonName(
EVoxelSeasonSelectionReason Reason);
/** Compose, validate, select, serialize, and (by default) write one season. Editor/build-box only. */
#if WITH_EDITOR
VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason(
int32 SeasonSeed, const FVoxelSeasonCompositionSettings& Settings);
/** Convenience overload that adapts the existing runtime settings asset. */
VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason(
int32 SeasonSeed, const UVoxelSettings* WorldSettings);
#endif
/** Serialize/read the complete Tier A manifest as stable pretty JSON. */
VOXELFORGE_API FString VF_SerializeVoxelSeasonManifest(
const FVoxelSeasonManifest& Manifest);
/** Parse an in-memory/cooked JSON manifest and verify its embedded content hash. */
VOXELFORGE_API bool VF_DeserializeVoxelSeasonManifest(
const FString& ManifestJson, FVoxelSeasonManifest& OutManifest, FString& OutReport);
/** Stable SHA-1 over the canonical manifest payload (the content_hash field itself is omitted). */
VOXELFORGE_API FString VF_ComputeVoxelSeasonManifestContentHash(
const FVoxelSeasonManifest& Manifest);
VOXELFORGE_API bool VF_SaveVoxelSeasonManifest(
const FString& ManifestPath, const FVoxelSeasonManifest& Manifest, FString& OutReport);
VOXELFORGE_API bool VF_LoadVoxelSeasonManifest(
+12
View File
@@ -13,6 +13,7 @@
// IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use.
class UMaterialInterface;
class UVoxelSeasonAsset;
UCLASS(BlueprintType)
class UVoxelSettings : public UPrimaryDataAsset
@@ -21,6 +22,12 @@ class UVoxelSettings : public UPrimaryDataAsset
public:
/** Season seed/radius replace the mutable authored values only when Season is assigned. */
int32 GetEffectiveWorldSeed() const;
float GetEffectiveOriginSpineRadius() const;
float GetEffectiveWorldRadiusVoxels() const;
int32 GetEffectiveSeasonNumber() const;
//=========================================================================
// STREAMING (distance de vue)
//=========================================================================
@@ -350,6 +357,11 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
int32 Seed = 0;
// Optional reviewed/cooked season. When set, its manifest owns layout, seed, bounds and
// density recipes; the authored StratePool/FixedStrates path remains untouched when unset.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Voxel|Strates")
TSoftObjectPtr<UVoxelSeasonAsset> Season;
// Numéro de saison (incrementé par ChangeSeed). Purement informatif côté gameplay.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates")
int32 CurrentSeason = 1;
@@ -356,7 +356,9 @@ VOXELFORGE_API void VF_SetStrateArchetypeRuntimeBounds(
FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ);
/** Stable identity helpers for the deterministic lateral partition. */
#if WITH_EDITOR
VOXELFORGE_API int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex);
#endif
VOXELFORGE_API uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex);
VOXELFORGE_API void VF_RekeyStrateRegionManifest(
FVoxelStrateRegionManifest& Manifest, int32 Seed, int32 StrateIndex);
+21 -5
View File
@@ -20,13 +20,12 @@
#include "CoreMinimal.h"
#include "VoxelStrateTypes.h"
#include "VoxelStrateDefinition.h"
#include "VoxelSeasonManifest.h"
#include "VoxelStrateManager.generated.h"
class UVoxelSettings;
#if WITH_EDITOR
struct FVoxelStrateArchetypeParams;
struct FVoxelOpStackRecipe;
struct FVoxelStrateRegionManifest;
struct FVoxelStrateComposerSlotOverride;
#endif
@@ -148,7 +147,21 @@ public:
* @param Settings - VoxelSettings with pool/fixed strate config
* @param WorldSeed - Seed for randomizing non-fixed strates
*/
void Initialize(UVoxelSettings* Settings, int32 WorldSeed);
/** Returns false without a usable layout; an assigned invalid/stale season never falls back. */
bool Initialize(UVoxelSettings* Settings, int32 WorldSeed);
/**
* Copy the immutable recipe payload for this chunk. In packaged builds this comes only from
* the cooked season; editor candidate overrides use the same read-only worker hand-off.
*/
bool GetRecipeForChunk(
const FIntVector& ChunkCoord, int32& OutRecipeSeed,
ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams,
FVoxelOpStackRecipe& OutRecipe) const;
bool IsUsingSeason() const { return !ActiveSeasonContentHash.IsEmpty(); }
const FString& GetSeasonContentHash() const { return ActiveSeasonContentHash; }
int32 GetWorldSeed() const { return CachedSeed; }
#if WITH_EDITOR
/**
@@ -172,8 +185,6 @@ public:
bool GetComposerRegionOverrideForChunk(
const FIntVector& ChunkCoord, FVoxelStrateRegionManifest& OutRegions) const;
/** Custom recipes cannot be safely classified by the native ClassifyBox proof yet. */
bool HasComposerRecipeOverride() const;
#endif
//=========================================================================
@@ -356,6 +367,11 @@ protected:
UPROPERTY()
TArray<FStrateSlot> StrateLayout;
// Parallel to StrateLayout only while a cooked season is active. Plain immutable data is
// copied into worker-local caches on the existing layout-versioned refetch path.
TArray<FVoxelSeasonStrate> SeasonStrates;
FString ActiveSeasonContentHash;
// Passages connecting consecutive strates
TArray<FVoxelPassage> Passages;
+4
View File
@@ -308,6 +308,10 @@ public:
UFUNCTION(BlueprintPure, Category = "Voxel World|Season")
int32 GetCurrentSeason() const;
/** Reviewed manifest digest to replicate/compare at join; empty on the legacy authored path. */
UFUNCTION(BlueprintPure, Category = "Voxel World|Season")
FString GetCurrentSeasonContentHash() const;
//=========================================================================
// STRATE QUERIES (gameplay integration)
//=========================================================================