From b3da447673be8c5a770505273340ec99b5bc642d Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Thu, 30 Apr 2026 21:37:55 -0700 Subject: [PATCH] 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 --- Theriapolis.Core/World/Generation/Stages/MoistureGenStage.cs | 4 ++++ .../World/Generation/Stages/TemperatureGenStage.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Theriapolis.Core/World/Generation/Stages/MoistureGenStage.cs b/Theriapolis.Core/World/Generation/Stages/MoistureGenStage.cs index 2caae04..aa16c53 100644 --- a/Theriapolis.Core/World/Generation/Stages/MoistureGenStage.cs +++ b/Theriapolis.Core/World/Generation/Stages/MoistureGenStage.cs @@ -28,6 +28,10 @@ public sealed class MoistureGenStage : IWorldGenStage int W = C.WORLD_WIDTH_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 => { for (int tx = 0; tx < W; tx++) diff --git a/Theriapolis.Core/World/Generation/Stages/TemperatureGenStage.cs b/Theriapolis.Core/World/Generation/Stages/TemperatureGenStage.cs index e11d149..4e3f686 100644 --- a/Theriapolis.Core/World/Generation/Stages/TemperatureGenStage.cs +++ b/Theriapolis.Core/World/Generation/Stages/TemperatureGenStage.cs @@ -29,6 +29,10 @@ public sealed class TemperatureGenStage : IWorldGenStage int W = C.WORLD_WIDTH_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 => { // Latitude: 0 at north (cold), 1 at south (warm)