fix(world): drain workers before mutating strate layout/passages (VF-01)
Initialize does StrateLayout.Empty() and Passages.Empty()/Add() -- freeing and
reallocating both -- with no guard, while mesher workers read them through
AnyPassageNearBox, EvaluateModifierSDF and FindSlotIndexForChunkZ. The epoch is
bumped AFTER, so previous-epoch workers are live during the mutation; the epoch
rejects a finished result, it cannot make a read of a freed allocation safe.
Same class as the DiffLayer.ChunkMods carve-vs-stream AV that ModsLock fixed.
Chose the drain over the two alternatives:
- FRWLock on the arrays: correct, exact in-repo precedent, but a read lock on
the per-voxel hot path (~43k/tile) would contaminate the perf measurement
CODEX-TASK-001/002 are queued to take. Worst possible timing.
- Immutable generation snapshot (Sol's proposal): right long-term, refactors
the whole StrateManager API surface. A design conversation, not an agent's
unprompted call.
- Drain: zero hot-path cost, reuses the machinery EndPlay already proved, and
its stall lands only on human-initiated editor actions, never during play.
FScopedGenerationPause raises a new bGenerationPaused (deliberately NOT
bShuttingDown, which means teardown) and drains both reader populations: chunk
tasks via ActiveTaskCount and decoration tasks via a new
WaitForDecorationTasks, whose refactor also removed NotifyShutdown's duplicate
spin loop. On timeout it does NOT mutate -- logs an Error and leaves the world
consistent. A timeout that proceeds is what the bug already does.
RebuildStrates, OnObjectModifiedInEditor and ChangeSeed cannot reach Initialize
without the pause. BeginPlay untouched (no tasks yet). EndPlay untouched --
VF-02's 3s timeout is a separate deliberate decision.
Verified: four files, no density/mesher file, so terrain cannot move.
ShouldAbortWork replaced three existing checks, adding none. No worker path
waits on the game thread, so the drain is bounded.
Not built -- Jahni builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
|||||||
|
# Codex task 007 — VF-01: never mutate layout/passages while workers read them
|
||||||
|
|
||||||
|
**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental`
|
||||||
|
**Status:** specified, not started
|
||||||
|
**Kind:** ⚠️ **crash class (use-after-free).** Highest-severity item found on 2026-08-16.
|
||||||
|
**Origin:** Sol-High audit VF-01, **independently confirmed by reading** before this spec was written.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The defect
|
||||||
|
|
||||||
|
`UVoxelStrateManager::Initialize` does `StrateLayout.Empty()` and `Passages.Empty()` + `Passages.Add()`
|
||||||
|
— it **frees and reallocates** both arrays. There is **no lock, no barrier, no drain** in that file.
|
||||||
|
|
||||||
|
Meanwhile those same arrays are read **on mesher worker threads**:
|
||||||
|
|
||||||
|
| reader | access |
|
||||||
|
|---|---|
|
||||||
|
| `AnyPassageNearBox` (`VoxelStrateManager.cpp:460`) | range-`for` over `Passages` |
|
||||||
|
| `EvaluateModifierSDF` | indexes `Passages[...]` |
|
||||||
|
| `FindSlotIndexForChunkZ` | iterates `StrateLayout` |
|
||||||
|
|
||||||
|
all reached from `GetDensityAt` / `ClassifyTile` inside chunk tasks.
|
||||||
|
|
||||||
|
`RegenerateAllChunks()` bumps the epoch **after** `Initialize`, so previous-epoch workers are live
|
||||||
|
*during* the mutation. **The epoch rejects a finished result; it cannot make a read of a freed
|
||||||
|
allocation safe.**
|
||||||
|
|
||||||
|
**Precedent in this very codebase:** `DiffLayer.ChunkMods` is read on mesher workers and written on
|
||||||
|
the game thread, and all access now holds `ModsLock` — added after a real carve-vs-stream access
|
||||||
|
violation. `StrateLayout` / `Passages` are the same shape with no guard.
|
||||||
|
|
||||||
|
Four `Initialize` call sites:
|
||||||
|
|
||||||
|
| line | function | dangerous? |
|
||||||
|
|---|---|---|
|
||||||
|
| 145 | `RebuildStrates` | **yes** |
|
||||||
|
| 309 | `OnObjectModifiedInEditor` | **yes — fires automatically on a strate asset edit while streaming** |
|
||||||
|
| 417 | `BeginPlay` | **no** — no tasks exist yet. **Leave it alone.** |
|
||||||
|
| 2091 | `ChangeSeed` | **yes** (also writes `Generator`'s `Seed` / `OriginSpineRadius`) |
|
||||||
|
|
||||||
|
## Why THIS fix and not the other two
|
||||||
|
|
||||||
|
Rejected deliberately — do not "improve" the design into either of these:
|
||||||
|
|
||||||
|
- **An `FRWLock` around the two arrays** (the `ModsLock` shape) would put a **read lock on the
|
||||||
|
per-voxel hot path** — `EvaluateModifierSDF` and `FindSlotIndexForChunkZ` run ~43k times per tile.
|
||||||
|
There is an open, unmeasured perf regression under active investigation (`CODEX-TASK-001/002`);
|
||||||
|
adding hot-path lock traffic now would **contaminate the very measurement those tasks exist to
|
||||||
|
take.** Correct, but the worst possible timing.
|
||||||
|
- **An immutable generation snapshot** (Sol's suggestion) is the right long-term architecture and a
|
||||||
|
real refactor of `UVoxelStrateManager`'s whole API surface. Too large to improvise, and it belongs
|
||||||
|
in a design conversation.
|
||||||
|
|
||||||
|
**The drain has zero hot-path cost**, reuses machinery already proven in `EndPlay`, and its only
|
||||||
|
cost — a brief stall — lands exclusively on **human-initiated editor actions** (asset edit, rebuild,
|
||||||
|
seed change). It never occurs during play.
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
### 1. A pause flag distinct from shutdown
|
||||||
|
|
||||||
|
Add to `AVoxelWorld`: `std::atomic<bool> bGenerationPaused{false};`
|
||||||
|
|
||||||
|
⚠️ **Do NOT reuse `bShuttingDown` for this.** It would work mechanically, but it means "we are tearing
|
||||||
|
down" and a future reader would be misled about lifetime. Introduce a small helper used at the
|
||||||
|
existing gate points:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
FORCEINLINE bool ShouldAbortWork() const
|
||||||
|
{
|
||||||
|
return bShuttingDown.load(std::memory_order_relaxed)
|
||||||
|
|| bGenerationPaused.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Route the **existing** checks through it — the submission gate (`VoxelWorld.cpp:638`) and the
|
||||||
|
in-task checks (`:1467`, `:1474`). **Do not add new check points**; do not change what those sites do
|
||||||
|
when the check is true.
|
||||||
|
|
||||||
|
### 2. An RAII scoped pause, modelled on `EndPlay`'s drain
|
||||||
|
|
||||||
|
`EndPlay` (`:323–334`) already implements this exact pattern: raise the gate, then spin until
|
||||||
|
`ActiveTaskCount` reaches 0. Mirror it.
|
||||||
|
|
||||||
|
```
|
||||||
|
FScopedGenerationPause guard(this);
|
||||||
|
if (!guard.Acquired()) { /* log error, DO NOT mutate, return */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Ctor:** set `bGenerationPaused = true`, then wait for **both** `AVoxelWorld::ActiveTaskCount == 0`
|
||||||
|
**and** the decoration tasks to finish. Decoration tasks are counted by the file-static
|
||||||
|
`GActiveDecoTasks` in `VoxelContentManager.cpp` and already drained by `NotifyShutdown` (`:65–80`) —
|
||||||
|
add a small public drain/wait accessor on `UVoxelContentManager` rather than exposing the counter.
|
||||||
|
- **Dtor:** always clear `bGenerationPaused`, including on the failure path.
|
||||||
|
|
||||||
|
### 3. ⚠️ FAIL SAFE — this is the most important line in the spec
|
||||||
|
|
||||||
|
If the deadline expires with tasks still running: **DO NOT MUTATE.** Log an error naming the
|
||||||
|
function, clear the flag, and return, leaving the world in its previous consistent state. The user
|
||||||
|
can retry the edit.
|
||||||
|
|
||||||
|
**Mutating anyway is what the bug already does.** A timeout that proceeds is not a fix. The three
|
||||||
|
dangerous call sites must each be structured so the `Initialize` call is *unreachable* unless the
|
||||||
|
pause was acquired.
|
||||||
|
|
||||||
|
Use a generous deadline (≥ 5 s) and log at `Error` when it expires — a silent skip would look like
|
||||||
|
the edit simply didn't apply.
|
||||||
|
|
||||||
|
### 4. Wrap the three call sites
|
||||||
|
|
||||||
|
`RebuildStrates`, `OnObjectModifiedInEditor`, `ChangeSeed`. The pause must cover **all** the mutation,
|
||||||
|
including `ChangeSeed`'s writes to the generator's `Seed` / `OriginSpineRadius`, and it must be
|
||||||
|
released **before** `RegenerateAllChunks()` so regeneration can submit work. **`BeginPlay` is not
|
||||||
|
wrapped.**
|
||||||
|
|
||||||
|
## ⚠️ Invariants
|
||||||
|
|
||||||
|
1. **No density, mesher, or geometry code may change.** This must not move one bit of generated
|
||||||
|
terrain. If your diff touches `VoxelGenerator.cpp`, `VoxelDensityOpStack.cpp`,
|
||||||
|
`VoxelCaveMorphology.cpp` or `VoxelMarchingCubesMesher.cpp`, stop — wrong site.
|
||||||
|
2. **No deadlock.** The pause is taken on the **game thread**. Verify by reading that chunk tasks
|
||||||
|
never block on the game thread (they read the generator and `Enqueue` to an MPSC queue, which is
|
||||||
|
non-blocking) — so a drain is bounded. **State in your report that you checked this**, and if you
|
||||||
|
find any worker path that waits on the game thread, STOP and report it instead of proceeding.
|
||||||
|
3. **`ProcessQueue` stays `EQueueMode::Mpsc`.** Do not touch it.
|
||||||
|
4. **Do not change `EndPlay`.** Its 3-second timeout is a separate, deliberate decision
|
||||||
|
(audit VF-02) and is Jahni's call, not part of this task.
|
||||||
|
5. **Carry the `Epoch`** through anything you touch; do not reorder the existing epoch bump relative
|
||||||
|
to `RegenerateAllChunks`.
|
||||||
|
6. Comments are French + English; match the surrounding file.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- `stat`/gameplay unchanged; **generated terrain bit-identical** (the equivalence tests and every
|
||||||
|
box-verdict number must be untouched — this change cannot reach them).
|
||||||
|
- Editing a strate asset while the world streams: brief stall, then the edit applies. **No crash.**
|
||||||
|
- The failure path is reachable and honest: if the drain times out, an `Error` log names the function
|
||||||
|
and the world keeps its previous state.
|
||||||
|
- `git diff --stat` should list `VoxelWorld.cpp`, `VoxelWorld.h`, and `VoxelContentManager.{h,cpp}`
|
||||||
|
for the drain accessor. Nothing else.
|
||||||
|
|
||||||
|
## Notes for the reviewer (Claude)
|
||||||
|
|
||||||
|
- Confirm the three dangerous sites cannot reach `Initialize` when the pause was not acquired, and
|
||||||
|
that `BeginPlay` is untouched.
|
||||||
|
- Confirm the dtor clears the flag on **every** path including early return.
|
||||||
|
- Confirm `ShouldAbortWork` replaced the existing checks rather than adding new ones, and that
|
||||||
|
`bShuttingDown`'s own semantics are unchanged.
|
||||||
|
- Confirm the deco drain is included — chunk tasks alone are not the whole reader set.
|
||||||
@@ -3899,3 +3899,63 @@ VF-01's fix is also genuinely Jahni's call, not an agent's:
|
|||||||
|
|
||||||
Trading a possible crash for a guaranteed hitch, or for hot-path lock traffic, is a product decision.
|
Trading a possible crash for a guaranteed hitch, or for hot-path lock traffic, is a product decision.
|
||||||
It waits for him.
|
It waits for him.
|
||||||
|
|
||||||
|
## 2026-08-16 (k) — VF-01 FIXED: the drain, chosen over the lock and the snapshot
|
||||||
|
|
||||||
|
Jahni: *"well, you select it, I trust you and Sol."* `CODEX-TASK-007`.
|
||||||
|
|
||||||
|
**First, a position I updated rather than defended.** An hour earlier I argued for adding no more
|
||||||
|
code before the build, to keep the three "nothing should move" fixes attributable. That argument was
|
||||||
|
about the **geometry** numbers — and a streaming-lifecycle change cannot reach an equivalence test or
|
||||||
|
a box verdict, which are computed from params and hashes, not from *when* `Initialize` runs. The risk
|
||||||
|
I cited did not apply to this particular change. A lifecycle bug shows up as a hang or a hitch, which
|
||||||
|
is trivially attributable because nothing else in the stack touches lifecycle.
|
||||||
|
|
||||||
|
### Why the drain, and not the other two
|
||||||
|
|
||||||
|
| option | verdict |
|
||||||
|
|---|---|
|
||||||
|
| **`FRWLock` on the two arrays** (the `ModsLock` shape — smallest diff, exact in-repo precedent) | ⛔ **rejected on timing.** It puts a read lock on the **per-voxel hot path**: `EvaluateModifierSDF` and `FindSlotIndexForChunkZ` run ~43k times per tile. There is an open, unmeasured perf regression and two tasks queued to measure it — adding hot-path lock traffic now would **contaminate the exact measurement they exist to take.** Correct, worst possible timing. |
|
||||||
|
| **Immutable generation snapshot** (Sol's proposal) | ⛔ **right long-term, too large to improvise.** It refactors `UVoxelStrateManager`'s whole API surface. That is a design conversation, not an agent's unprompted call. |
|
||||||
|
| **Drain before mutating** | ✅ **chosen.** Zero hot-path cost. Reuses machinery already proven in `EndPlay`. Its only cost — a brief stall — lands exclusively on **human-initiated editor actions** (asset edit, rebuild, seed change) and never during play. |
|
||||||
|
|
||||||
|
### What landed
|
||||||
|
|
||||||
|
`FScopedGenerationPause` (RAII, in `VoxelWorld.cpp`) raises a new `bGenerationPaused` — **deliberately
|
||||||
|
not `bShuttingDown`**, which means "tearing down" and would have misled the next reader — then drains
|
||||||
|
**both** reader populations: `ActiveTaskCount` for chunk tasks and, via a new
|
||||||
|
`UVoxelContentManager::WaitForDecorationTasks`, the decoration tasks counted by `GActiveDecoTasks`.
|
||||||
|
Chunk tasks alone were never the whole reader set.
|
||||||
|
|
||||||
|
⚠️ **The line that makes it a fix rather than a narrowing: on timeout it does NOT mutate.** It logs an
|
||||||
|
`Error` naming the function and returns, leaving the world in its previous consistent state. A
|
||||||
|
timeout that proceeds anyway is exactly what the bug already does. The three dangerous sites —
|
||||||
|
`RebuildStrates`, `OnObjectModifiedInEditor`, `ChangeSeed` — are structured so `Initialize` is
|
||||||
|
*unreachable* without the pause. `BeginPlay` (the fourth site) is untouched: no tasks exist yet.
|
||||||
|
|
||||||
|
### Verified after the fact, not taken on report
|
||||||
|
|
||||||
|
- Four files only; **no density or mesher file in the diff**, so generated terrain cannot move.
|
||||||
|
- `ShouldAbortWork()` **replaced** three existing `bShuttingDown` checks — 3 replacements, 0 new
|
||||||
|
check points. `bShuttingDown`'s own semantics are unchanged.
|
||||||
|
- `EndPlay` and `BeginPlay` untouched (VF-02's 3-second timeout is a separate deliberate decision and
|
||||||
|
stays Jahni's call).
|
||||||
|
- The deadline passed to `WaitForDecorationTasks` is **absolute**, documented as such in the header —
|
||||||
|
and the refactor **removed** the duplicate spin loop from `NotifyShutdown` rather than adding a
|
||||||
|
second one.
|
||||||
|
- Deadlock check, which the spec required Codex to perform and report: chunk and decoration tasks
|
||||||
|
read the generator and `Enqueue` to MPSC queues; **no worker path waits on the game thread**, so a
|
||||||
|
game-thread drain is bounded. Raising the pause also makes in-flight tasks bail early, so the drain
|
||||||
|
gets *faster*, not slower.
|
||||||
|
|
||||||
|
### Compile-risk spots for the build
|
||||||
|
|
||||||
|
1. `WaitForDecorationTasks(double)` declaration / definition / call-site agreement.
|
||||||
|
2. `FScopedGenerationPause`'s forward declaration and `friend class` access in `VoxelWorld.h`.
|
||||||
|
3. `ShouldAbortWork()` visibility from inside the `UE::Tasks::Launch` lambda.
|
||||||
|
|
||||||
|
### What to check in the editor
|
||||||
|
|
||||||
|
Edit a strate asset while the world is streaming. Expect a **brief stall, then the edit applies, and
|
||||||
|
no crash.** If the `Error` log about a timed-out pause ever appears, the drain deadline (5 s) is too
|
||||||
|
short for the in-flight queue and that is worth knowing rather than guessing.
|
||||||
|
|||||||
@@ -68,17 +68,23 @@ void UVoxelContentManager::NotifyShutdown()
|
|||||||
|
|
||||||
// Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs.
|
// Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs.
|
||||||
const double Deadline = FPlatformTime::Seconds() + 3.0;
|
const double Deadline = FPlatformTime::Seconds() + 3.0;
|
||||||
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
|
WaitForDecorationTasks(Deadline);
|
||||||
{
|
|
||||||
if (FPlatformTime::Seconds() > Deadline) break;
|
|
||||||
FPlatformProcess::Yield();
|
|
||||||
}
|
|
||||||
|
|
||||||
DrainDecoResults();
|
DrainDecoResults();
|
||||||
ResetGridBuildState(NearGrid);
|
ResetGridBuildState(NearGrid);
|
||||||
ResetGridBuildState(FarGrid);
|
ResetGridBuildState(FarGrid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
|
||||||
|
{
|
||||||
|
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
|
||||||
|
{
|
||||||
|
if (FPlatformTime::Seconds() > Deadline) return false;
|
||||||
|
FPlatformProcess::Yield();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void UVoxelContentManager::DrainDecoResults()
|
void UVoxelContentManager::DrainDecoResults()
|
||||||
{
|
{
|
||||||
FDecoCellResult Discard;
|
FDecoCellResult Discard;
|
||||||
|
|||||||
@@ -88,6 +88,45 @@ static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FScopedGenerationPause
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit FScopedGenerationPause(AVoxelWorld* InWorld)
|
||||||
|
: World(InWorld)
|
||||||
|
{
|
||||||
|
if (!World) return;
|
||||||
|
|
||||||
|
World->bGenerationPaused.store(true, std::memory_order_release);
|
||||||
|
|
||||||
|
// The game thread owns this gate; workers only read Generator/Mesher and enqueue results.
|
||||||
|
// La barrière est prise sur le thread de jeu ; les workers ne font qu'énumérer et Enqueue.
|
||||||
|
const double Deadline = FPlatformTime::Seconds() + 5.0;
|
||||||
|
while (World->ActiveTaskCount.load(std::memory_order_relaxed) > 0)
|
||||||
|
{
|
||||||
|
if (FPlatformTime::Seconds() > Deadline) return;
|
||||||
|
FPlatformProcess::Yield();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (World->ContentManager && !World->ContentManager->WaitForDecorationTasks(Deadline)) return;
|
||||||
|
|
||||||
|
bAcquired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
~FScopedGenerationPause()
|
||||||
|
{
|
||||||
|
if (World)
|
||||||
|
{
|
||||||
|
World->bGenerationPaused.store(false, std::memory_order_release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Acquired() const { return bAcquired; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
AVoxelWorld* World = nullptr;
|
||||||
|
bool bAcquired = false;
|
||||||
|
};
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// LIVE EDIT — regenerate all chunks when params change in the Details panel
|
// LIVE EDIT — regenerate all chunks when params change in the Details panel
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -139,13 +178,22 @@ void AVoxelWorld::RegenerateAllChunks()
|
|||||||
|
|
||||||
void AVoxelWorld::RebuildStrates()
|
void AVoxelWorld::RebuildStrates()
|
||||||
{
|
{
|
||||||
if (StrateManager && Settings)
|
|
||||||
{
|
{
|
||||||
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
|
FScopedGenerationPause Guard(this);
|
||||||
StrateManager->Initialize(Settings, Settings->Seed);
|
if (!Guard.Acquired())
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] RebuildStrates: generation pause timed out; no mutation applied."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrateManager && Settings)
|
||||||
|
{
|
||||||
|
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
|
||||||
|
StrateManager->Initialize(Settings, Settings->Seed);
|
||||||
|
}
|
||||||
|
if (AtmosphereManager) AtmosphereManager->Reset();
|
||||||
|
if (ContentManager) ContentManager->ClearAll();
|
||||||
}
|
}
|
||||||
if (AtmosphereManager) AtmosphereManager->Reset();
|
|
||||||
if (ContentManager) ContentManager->ClearAll();
|
|
||||||
|
|
||||||
// Reload all chunks against the rebuilt strate data.
|
// Reload all chunks against the rebuilt strate data.
|
||||||
RegenerateAllChunks();
|
RegenerateAllChunks();
|
||||||
@@ -304,13 +352,22 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
|
|||||||
|
|
||||||
// Re-initialize the strate manager so it picks up the changed definition values,
|
// Re-initialize the strate manager so it picks up the changed definition values,
|
||||||
// then regenerate all chunks with the updated params.
|
// then regenerate all chunks with the updated params.
|
||||||
if (StrateManager)
|
|
||||||
{
|
{
|
||||||
StrateManager->Initialize(Settings, Settings->Seed);
|
FScopedGenerationPause Guard(this);
|
||||||
}
|
if (!Guard.Acquired())
|
||||||
if (Generator)
|
{
|
||||||
{
|
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] OnObjectModifiedInEditor: generation pause timed out; no mutation applied."));
|
||||||
Generator->InitializeSettings(Settings);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrateManager)
|
||||||
|
{
|
||||||
|
StrateManager->Initialize(Settings, Settings->Seed);
|
||||||
|
}
|
||||||
|
if (Generator)
|
||||||
|
{
|
||||||
|
Generator->InitializeSettings(Settings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RegenerateAllChunks();
|
RegenerateAllChunks();
|
||||||
@@ -635,7 +692,7 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
|
|||||||
// is refilled from the diff via MarkDirtyVoxelBox in RemeshDirtyChunks).
|
// is refilled from the diff via MarkDirtyVoxelBox in RemeshDirtyChunks).
|
||||||
void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile)
|
void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile)
|
||||||
{
|
{
|
||||||
if (!Generator || !Mesher || bShuttingDown.load(std::memory_order_relaxed)) return;
|
if (!Generator || !Mesher || ShouldAbortWork()) return;
|
||||||
|
|
||||||
const FIntVector OriginVoxels = Tile.OriginVoxels();
|
const FIntVector OriginVoxels = Tile.OriginVoxels();
|
||||||
const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels)
|
const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels)
|
||||||
@@ -1464,14 +1521,14 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
|
|||||||
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
|
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
|
||||||
} Guard{ActiveTaskCount};
|
} Guard{ActiveTaskCount};
|
||||||
|
|
||||||
if (bShuttingDown.load(std::memory_order_relaxed)) return;
|
if (ShouldAbortWork()) return;
|
||||||
|
|
||||||
FChunkResult Result;
|
FChunkResult Result;
|
||||||
GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
|
GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
|
||||||
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
|
||||||
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result);
|
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result);
|
||||||
|
|
||||||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
if (!ShouldAbortWork())
|
||||||
{
|
{
|
||||||
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
|
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
|
||||||
}
|
}
|
||||||
@@ -2072,42 +2129,51 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
|
|||||||
const int32 OldSeed = Settings->Seed;
|
const int32 OldSeed = Settings->Seed;
|
||||||
const int32 OldSeason = Settings->CurrentSeason;
|
const int32 OldSeason = Settings->CurrentSeason;
|
||||||
|
|
||||||
// 1. Update seed in Settings (the authoritative source)
|
|
||||||
Settings->Seed = NewSeed;
|
|
||||||
|
|
||||||
// 2. Increment season counter
|
|
||||||
Settings->CurrentSeason++;
|
|
||||||
|
|
||||||
// 3. Push new seed to Generator
|
|
||||||
if (Generator)
|
|
||||||
{
|
{
|
||||||
Generator->InitializeSettings(Settings);
|
FScopedGenerationPause Guard(this);
|
||||||
}
|
if (!Guard.Acquired())
|
||||||
|
{
|
||||||
|
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed: generation pause timed out; no mutation applied."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Rebuild strate layout with the new seed.
|
// 1. Update seed in Settings (the authoritative source)
|
||||||
// Strate assignments and passages all change.
|
Settings->Seed = NewSeed;
|
||||||
if (StrateManager)
|
|
||||||
{
|
|
||||||
StrateManager->Initialize(Settings, NewSeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Clear all player modifications — carvings from the old world are meaningless
|
// 2. Increment season counter
|
||||||
if (DiffLayer)
|
Settings->CurrentSeason++;
|
||||||
{
|
|
||||||
DiffLayer->Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5b. Update content placement seed so the new world scatters differently.
|
// 3. Push new seed to Generator
|
||||||
if (ContentManager)
|
if (Generator)
|
||||||
{
|
{
|
||||||
ContentManager->SetSeed(NewSeed);
|
Generator->InitializeSettings(Settings);
|
||||||
ContentManager->ClearAll();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 5c. Reset atmosphere — strate layout changed, re-apply on next Tick.
|
// 4. Rebuild strate layout with the new seed.
|
||||||
if (AtmosphereManager)
|
// Strate assignments and passages all change.
|
||||||
{
|
if (StrateManager)
|
||||||
AtmosphereManager->Reset();
|
{
|
||||||
|
StrateManager->Initialize(Settings, NewSeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Clear all player modifications — carvings from the old world are meaningless
|
||||||
|
if (DiffLayer)
|
||||||
|
{
|
||||||
|
DiffLayer->Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5b. Update content placement seed so the new world scatters differently.
|
||||||
|
if (ContentManager)
|
||||||
|
{
|
||||||
|
ContentManager->SetSeed(NewSeed);
|
||||||
|
ContentManager->ClearAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5c. Reset atmosphere — strate layout changed, re-apply on next Tick.
|
||||||
|
if (AtmosphereManager)
|
||||||
|
{
|
||||||
|
AtmosphereManager->Reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Unload all existing chunks and let Tick reload them with new generation
|
// 6. Unload all existing chunks and let Tick reload them with new generation
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ public:
|
|||||||
* UObject teardown (worker tasks read the Generator). */
|
* UObject teardown (worker tasks read the Generator). */
|
||||||
void NotifyShutdown();
|
void NotifyShutdown();
|
||||||
|
|
||||||
|
/** Wait until in-flight decoration march tasks drain before a generation mutation. Deadline is absolute.
|
||||||
|
* Attend la fin des tâches de décoration avant une mutation de génération ; échéance absolue. */
|
||||||
|
bool WaitForDecorationTasks(double Deadline);
|
||||||
|
|
||||||
//--- async-task plumbing (public so the worker lambda can reach them) -----
|
//--- async-task plumbing (public so the worker lambda can reach them) -----
|
||||||
/** One placement decided off-thread; spawned on the game thread from FDecoCellResult::Entries. */
|
/** One placement decided off-thread; spawned on the game thread from FDecoCellResult::Entries. */
|
||||||
struct FDecoSpawn
|
struct FDecoSpawn
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class UMaterialParameterCollection;
|
|||||||
class UVolumeTexture;
|
class UVolumeTexture;
|
||||||
class UMaterialInterface;
|
class UMaterialInterface;
|
||||||
class UMaterialInstanceDynamic;
|
class UMaterialInstanceDynamic;
|
||||||
|
class FScopedGenerationPause;
|
||||||
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
|
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -373,6 +374,8 @@ public:
|
|||||||
UVolumeTexture* GetDensityVolumeTexture(int32 Level = 0) const;
|
UVolumeTexture* GetDensityVolumeTexture(int32 Level = 0) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
friend class FScopedGenerationPause;
|
||||||
|
|
||||||
/** Get/create the shared MID wrapping a base terrain material (binds volume textures + shadow params).
|
/** Get/create the shared MID wrapping a base terrain material (binds volume textures + shadow params).
|
||||||
* Returns Base unchanged-wrapped, or nullptr if Base is null. */
|
* Returns Base unchanged-wrapped, or nullptr if Base is null. */
|
||||||
UMaterialInstanceDynamic* GetOrCreateTerrainMID(UMaterialInterface* Base);
|
UMaterialInstanceDynamic* GetOrCreateTerrainMID(UMaterialInterface* Base);
|
||||||
@@ -677,9 +680,19 @@ public:
|
|||||||
// Set to true during EndPlay — async tasks check this before accessing UObjects
|
// Set to true during EndPlay — async tasks check this before accessing UObjects
|
||||||
std::atomic<bool> bShuttingDown{false};
|
std::atomic<bool> bShuttingDown{false};
|
||||||
|
|
||||||
|
// Set during editor-driven generation mutations; distinct from teardown/shutdown semantics.
|
||||||
|
// Active pendant les mutations de génération lancées par l'éditeur, sans signifier la destruction.
|
||||||
|
std::atomic<bool> bGenerationPaused{false};
|
||||||
|
|
||||||
// Number of async tasks currently running — EndPlay waits for this to reach 0
|
// Number of async tasks currently running — EndPlay waits for this to reach 0
|
||||||
std::atomic<int32> ActiveTaskCount{0};
|
std::atomic<int32> ActiveTaskCount{0};
|
||||||
|
|
||||||
|
FORCEINLINE bool ShouldAbortWork() const
|
||||||
|
{
|
||||||
|
return bShuttingDown.load(std::memory_order_relaxed)
|
||||||
|
|| bGenerationPaused.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
// Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
|
// Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
|
||||||
FIntVector CurrentCenterChunk = FIntVector::ZeroValue;
|
FIntVector CurrentCenterChunk = FIntVector::ZeroValue;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user