fix: AUDIT C2 — per-chunk caches now key on the strate layout version

Five caches under the density path were keyed on ChunkCoord (or an XY box)
alone. After RebuildStrates or an editor live edit, StrateManager rebuilds the
layout and bumps PassagesVersion, but a pooled worker whose cache is still warm
for the chunk it is asked to regenerate skips the refetch and generates with the
OLD params. RegenerateAllChunks reloads the same tile coords, often on the same
workers, so this is likely rather than exotic. Symptom: "I tweaked the strate
asset, regenerated, and one patch kept the old shape."

Fixed:
  - CP_* in GetDensityAt          (the archetype params + biome context)
  - OC_* in GetSurfaceHeightAt    (the height oracle)
  - BM_* in GetBiomeMaterialAt    (per-vertex palette)
  - TC_BiomeCache in ClassifyTile (survives across calls)
  - GSurfColCache boxes           (see below)

Two things beyond what the audit listed:

1. GSurfColCache. Its key is (XY box, StrateKey, Seed) where StrateKey is
   round(StrateBottomWorldZ). A live edit that changes terrain params WITHOUT
   moving the strate — noise frequency, mountain strength, a biome — leaves that
   key identical and serves stale columns down the whole vertical stack. This is
   the most visible form of the bug, so LayoutVersion joins the box key.

2. FChunkBiomeCache validity is a world-XY box, which says nothing about the
   FBiomeContext its cells were classified against. Refetching the context
   without invalidating the grid would leave the fix half-done, so the four
   caches call the new FChunkBiomeCache::Invalidate() on a version change.

No behavioural change at a static layout: the version only moves on Initialize.

UNVERIFIED: not compiled, not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 01:58:20 +02:00
parent d41d34ecd3
commit 73f6b26f4d
2 changed files with 68 additions and 13 deletions
+50 -7
View File
@@ -50,6 +50,13 @@ struct FSurfaceColumnBox
static constexpr int32 Dim = 2 * Halo + 1; static constexpr int32 Dim = 2 * Halo + 1;
int32 BaseX = 0, BaseY = 0; // box origin (voxel coords) int32 BaseX = 0, BaseY = 0; // box origin (voxel coords)
int32 StrateKey = MIN_int32, Seed = MIN_int32; // key: same strate ⇒ identical heightfield params int32 StrateKey = MIN_int32, Seed = MIN_int32; // key: same strate ⇒ identical heightfield params
// AUDIT C2, étendu : StrateKey vaut round(StrateBottomWorldZ), donc une édition à chaud qui
// change les params de terrain SANS déplacer la strate (fréquence de bruit, hauteur de
// montagne, un biome…) laisse la clé identique et sert des colonnes périmées. C'est la forme
// la plus visible du bug : « j'ai retouché le terrain et une zone a gardé l'ancienne forme ».
// StrateKey is round(StrateBottomWorldZ), so a live edit that changes terrain params WITHOUT
// moving the strate leaves the key unchanged and serves stale columns.
uint32 LayoutVersion = 0xFFFFFFFFu;
uint32 LastUse = 0; // LRU stamp uint32 LastUse = 0; // LRU stamp
bool bValid = false; bool bValid = false;
FSurfaceColumn Cols[Dim * Dim]; FSurfaceColumn Cols[Dim * Dim];
@@ -64,13 +71,15 @@ struct FSurfaceColumnCache
FSurfaceColumnBox Boxes[NumBoxes]; FSurfaceColumnBox Boxes[NumBoxes];
uint32 Clock = 0; uint32 Clock = 0;
// Return the box covering (IX,IY) for this (StrateKey,Seed); allocate by evicting the LRU box on miss. // Return the box covering (IX,IY) for this (StrateKey,Seed,LayoutVersion); allocate by
FSurfaceColumnBox& Acquire(int32 IX, int32 IY, int32 InStrateKey, int32 InSeed) // evicting the LRU box on miss.
FSurfaceColumnBox& Acquire(int32 IX, int32 IY, int32 InStrateKey, int32 InSeed, uint32 InLayoutVersion)
{ {
++Clock; ++Clock;
for (FSurfaceColumnBox& B : Boxes) for (FSurfaceColumnBox& B : Boxes)
{ {
if (B.bValid && B.StrateKey == InStrateKey && B.Seed == InSeed if (B.bValid && B.StrateKey == InStrateKey && B.Seed == InSeed
&& B.LayoutVersion == InLayoutVersion
&& IX >= B.BaseX && IX < B.BaseX + FSurfaceColumnBox::Dim && IX >= B.BaseX && IX < B.BaseX + FSurfaceColumnBox::Dim
&& IY >= B.BaseY && IY < B.BaseY + FSurfaceColumnBox::Dim) && IY >= B.BaseY && IY < B.BaseY + FSurfaceColumnBox::Dim)
{ {
@@ -85,6 +94,7 @@ struct FSurfaceColumnCache
Victim->BaseY = IY - FSurfaceColumnBox::Halo; Victim->BaseY = IY - FSurfaceColumnBox::Halo;
Victim->StrateKey = InStrateKey; Victim->StrateKey = InStrateKey;
Victim->Seed = InSeed; Victim->Seed = InSeed;
Victim->LayoutVersion = InLayoutVersion;
Victim->bValid = true; Victim->bValid = true;
Victim->LastUse = Clock; Victim->LastUse = Clock;
FMemory::Memzero(Victim->Computed, sizeof(Victim->Computed)); FMemory::Memzero(Victim->Computed, sizeof(Victim->Computed));
@@ -520,9 +530,23 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// are shareable across the whole vertical chunk stack. Taken from the params themselves // are shareable across the whole vertical chunk stack. Taken from the params themselves
// (StrateBottomWorldZ is unique per stacked strate) so the key can never disagree with CP_Surface. // (StrateBottomWorldZ is unique per stacked strate) so the key can never disagree with CP_Surface.
thread_local int32 CP_StrateKey = MIN_int32; thread_local int32 CP_StrateKey = MIN_int32;
// AUDIT C2 — la clé DOIT contenir la version de layout, pas seulement le chunk. Après un
// RebuildStrates / une édition à chaud, StrateManager reconstruit le layout et bumpe la
// version ; un worker dont CP_Chunk vaut encore ce chunk sauterait le refetch et
// générerait avec les ANCIENS params. Comme RegenerateAllChunks recharge les MÊMES coords
// de tuile, souvent sur les MÊMES workers, le cas est probable plutôt qu'exotique.
// The key MUST include the layout version, not just the chunk coord. Symptom without it:
// "I tweaked the strate asset, regenerated, and one patch kept the old shape."
thread_local uint32 CP_Version = 0xFFFFFFFFu;
if (ChunkCoord != CP_Chunk) const uint32 LayoutVersion = StrateManager->GetLayoutVersion();
if (ChunkCoord != CP_Chunk || LayoutVersion != CP_Version)
{ {
// La grille de biome est validée par une BOÎTE XY, qui ne dit rien du FBiomeContext
// ayant servi à classer ses cellules : sur un changement de version elle est périmée
// même si la boîte couvre encore la requête.
if (LayoutVersion != CP_Version) { CP_BiomeCache.Invalidate(); }
CP_Version = LayoutVersion;
CP_Chunk = ChunkCoord; CP_Chunk = ChunkCoord;
CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
switch (CP_GenType) switch (CP_GenType)
@@ -563,7 +587,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
const int32 IX = (int32)WorldX, IY = (int32)WorldY; const int32 IX = (int32)WorldX, IY = (int32)WorldY;
// XY-keyed LRU box (shared down the whole vertical strate stack). Acquire centres a box on // XY-keyed LRU box (shared down the whole vertical strate stack). Acquire centres a box on
// the first sample so the rest of the chunk's queries — incl. the ±Step margin ring — hit. // the first sample so the rest of the chunk's queries — incl. the ±Step margin ring — hit.
FSurfaceColumnBox& Box = GSurfColCache.Acquire(IX, IY, CP_StrateKey, Seed); FSurfaceColumnBox& Box = GSurfColCache.Acquire(IX, IY, CP_StrateKey, Seed, LayoutVersion);
const int32 CI = (IY - Box.BaseY) * FSurfaceColumnBox::Dim + (IX - Box.BaseX); const int32 CI = (IY - Box.BaseY) * FSurfaceColumnBox::Dim + (IX - Box.BaseX);
if (!Box.Computed[CI]) if (!Box.Computed[CI])
{ {
@@ -2422,8 +2446,13 @@ bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 Chunk
thread_local FBiomeContext OC_BiomeCtx; thread_local FBiomeContext OC_BiomeCtx;
thread_local TArray<FSurfaceGenerationParams> OC_BiomeParams; thread_local TArray<FSurfaceGenerationParams> OC_BiomeParams;
thread_local FChunkBiomeCache OC_BiomeCache; thread_local FChunkBiomeCache OC_BiomeCache;
if (ChunkCoord != OC_Chunk) thread_local uint32 OC_Version = 0xFFFFFFFFu; // AUDIT C2
const uint32 OC_LayoutVersion = StrateManager->GetLayoutVersion();
if (ChunkCoord != OC_Chunk || OC_LayoutVersion != OC_Version)
{ {
if (OC_LayoutVersion != OC_Version) { OC_BiomeCache.Invalidate(); }
OC_Version = OC_LayoutVersion;
OC_Chunk = ChunkCoord; OC_Chunk = ChunkCoord;
ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams); ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams);
} }
@@ -2509,6 +2538,15 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// thread_local : les TArray gardent leur capacité d'un appel à l'autre (zéro malloc/tuile). // thread_local : les TArray gardent leur capacité d'un appel à l'autre (zéro malloc/tuile).
static thread_local FSurfSlot Slots[2]; static thread_local FSurfSlot Slots[2];
static thread_local FChunkBiomeCache TC_BiomeCache; // biome grid du classifieur (valeurs ≡ CP_BiomeCache) static thread_local FChunkBiomeCache TC_BiomeCache; // biome grid du classifieur (valeurs ≡ CP_BiomeCache)
// AUDIT C2 — même discipline que le chemin densité : la grille de biome du classifieur survit
// d'un appel à l'autre et sa boîte de validité ne dit rien du contexte qui l'a produite.
static thread_local uint32 TC_SeenVersion = 0xFFFFFFFFu;
const uint32 TC_LayoutVersion = StrateManager->GetLayoutVersion();
if (TC_LayoutVersion != TC_SeenVersion)
{
TC_SeenVersion = TC_LayoutVersion;
TC_BiomeCache.Invalidate();
}
int32 NumSlots = 0; int32 NumSlots = 0;
int32 MemoChunkZ = INT32_MAX; int32 MemoChunkZ = INT32_MAX;
@@ -2607,7 +2645,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
{ {
FSurfSlot& S = Slots[s]; FSurfSlot& S = Slots[s];
if (S.InteriorZ.Num() == 0) continue; if (S.InteriorZ.Num() == 0) continue;
FSurfaceColumnBox& Box = GSurfColCache.Acquire(Xi, Yi, S.StrateKey, Seed); FSurfaceColumnBox& Box = GSurfColCache.Acquire(Xi, Yi, S.StrateKey, Seed, TC_LayoutVersion);
const int32 CI = (Yi - Box.BaseY) * FSurfaceColumnBox::Dim + (Xi - Box.BaseX); const int32 CI = (Yi - Box.BaseY) * FSurfaceColumnBox::Dim + (Xi - Box.BaseX);
if (!Box.Computed[CI]) if (!Box.Computed[CI])
{ {
@@ -3045,8 +3083,13 @@ void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float World
thread_local FIntVector BM_Chunk(INT32_MAX, INT32_MAX, INT32_MAX); thread_local FIntVector BM_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local FBiomeContext BM_Ctx; thread_local FBiomeContext BM_Ctx;
thread_local FChunkBiomeCache BM_Cache; thread_local FChunkBiomeCache BM_Cache;
if (ChunkCoord != BM_Chunk) thread_local uint32 BM_Version = 0xFFFFFFFFu; // AUDIT C2
const uint32 BM_LayoutVersion = StrateManager->GetLayoutVersion();
if (ChunkCoord != BM_Chunk || BM_LayoutVersion != BM_Version)
{ {
if (BM_LayoutVersion != BM_Version) { BM_Cache.Invalidate(); }
BM_Version = BM_LayoutVersion;
BM_Chunk = ChunkCoord; BM_Chunk = ChunkCoord;
BM_Ctx = StrateManager->GetBiomeContextForChunk(ChunkCoord); BM_Ctx = StrateManager->GetBiomeContextForChunk(ChunkCoord);
} }
@@ -243,4 +243,16 @@ struct FChunkBiomeCache
&& X >= ValidMinX && X <= ValidMaxX && X >= ValidMinX && X <= ValidMaxX
&& Y >= ValidMinY && Y <= ValidMaxY; && Y >= ValidMinY && Y <= ValidMaxY;
} }
/** AUDIT C2 — force a rebuild on the next query. The validity box says nothing about the
* FBiomeContext the cells were classified AGAINST, so when the strate layout is rebuilt
* (RebuildStrates / an editor live edit) the grid is stale even though the box still
* covers the query. Callers that key on GetLayoutVersion() call this on a version change.
* Le box de validité ne dit rien du contexte de biome ayant servi à classer les cellules :
* après un rebuild de layout, la grille est périmée alors que la boîte couvre encore. */
void Invalidate()
{
ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere
ChunkZ = MIN_int32;
}
}; };