Fix MoistureGen/TemperatureGen non-determinism (FastNoiseLite race)

FastNoiseLite lazily populates its internal _perm[512] table on the first
GetNoise call via EnsurePerm(). When called concurrently from a Parallel.For
loop, threads race on this initialization and may read a partially-populated
table, producing different moisture/temperature values per row across runs.

Empirical: a 10-run worldgen-hash sweep on seed 12345 produced 4+ distinct
moisture hashes and 3+ distinct temperature hashes. All other channels
(elevation, biomes, settlements, polylines) remained stable; biomes only
because their bucket thresholds happened to absorb the upstream float noise.

The fix is the same one ElevationGenStage:125-130 and BorderDistortionGenStage:
102-104 already apply: call GetNoise once on the main thread before the
Parallel.For so _perm is fully initialized when worker threads start reading.
MoistureGenStage and TemperatureGenStage were missing this; now they have it.

WorldgenDeterminismTests didn't catch this because xUnit's WorldCache fixture
runs both pipeline variants in the same process, where consecutive runs hit
the same JIT/thread-pool state and produce the same corrupted output. The
Godot port surfaced it by invoking Core from a fresh process with different
threading.

Verified: post-fix 10-run sweep produces stable hashes on all six channels
(0xA8F99BB9795D8CF8 moisture, 0xAA05F3FB1523F6C3 temperature, seed 12345).
708/708 tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-04-30 21:37:55 -07:00
parent 59e86af7a2
commit b3da447673
2 changed files with 8 additions and 0 deletions
@@ -28,6 +28,10 @@ public sealed class MoistureGenStage : IWorldGenStage
int W = C.WORLD_WIDTH_TILES; int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES; int H = C.WORLD_HEIGHT_TILES;
// FastNoiseLite.EnsurePerm() is not thread-safe; calling GetNoise once here
// populates _perm in the main thread before the parallel loop reads it.
_ = noise.GetNoise01(0f, 0f);
Parallel.For(0, H, ty => Parallel.For(0, H, ty =>
{ {
for (int tx = 0; tx < W; tx++) for (int tx = 0; tx < W; tx++)
@@ -29,6 +29,10 @@ public sealed class TemperatureGenStage : IWorldGenStage
int W = C.WORLD_WIDTH_TILES; int W = C.WORLD_WIDTH_TILES;
int H = C.WORLD_HEIGHT_TILES; int H = C.WORLD_HEIGHT_TILES;
// FastNoiseLite.EnsurePerm() is not thread-safe; calling GetNoise once here
// populates _perm in the main thread before the parallel loop reads it.
_ = noise.GetNoise(0f, 0f);
Parallel.For(0, H, ty => Parallel.For(0, H, ty =>
{ {
// Latitude: 0 at north (cold), 1 at south (warm) // Latitude: 0 at north (cold), 1 at south (warm)