Found while testing mod compatibility against Stratum (a Vintage Story server fork), which is adding optional same-stage concurrency for the Terrain pass. Right now every server, vanilla or Stratum, only ever runs one column through the Terrain pass at a time, so this has never been reachable. It becomes reachable the moment anything lets two columns generate Terrain concurrently, whether that is Stratum's own concurrency work or a future engine change.
NewGenTerra.cs
columnResults, layerFullySolid, and layerFullyEmpty are plain instance fields (lines 75-77), allocated once and reused for every column this generator processes:
public ColumnResult[] columnResults = null!;
public bool[] layerFullySolid = null!;
public bool[] layerFullyEmpty = null!;
Generate() (line 254) resets and writes into these same arrays every call, including inside the Parallel.For at line 322. The comment on layerFullySolid ("thread safe even when this is parallel") is accurate for a single column's own Parallel.For, since each thread there touches a distinct index. It says nothing about two different columns running Generate() at the same time on different threads: both would read and write the same columnResults/layerFullySolid/layerFullyEmpty arrays, and whichever column finishes last wins, silently overwriting the other's terrain data. No exception, no crash, just wrong terrain for one or both columns, which makes it a nasty one to track down after the fact.
GeneratePartialFeatures.cs / WorldGenPartial.cs
chunkRand (declared public LCGRandom chunkRand in WorldGenPartial.cs line 12) is a second instance of the same shape. ChunkColumnGeneration (registered at EnumWorldGenPass.Vegetation) calls chunkRand.InitPositionSeed(...) per column and then draws from it (NextFloat, NextInt) for feature placement. Reseeding per column does not make this safe under concurrency: if two columns run this handler at the same time, one's InitPositionSeed call can land between another's reseed and its own reads, handing out numbers from the wrong sequence to both. We hit the identical bug in Stratum's own vanilla-derived code this session (GenCreatures, GenPonds, GenDungeons all had a shared LCGRandom reseeded per column) and the fix was the same each time.
Suggested fix
Same pattern for both: move the per-column state to [ThreadStatic] fields if NewGenTerra/WorldGenPartial are singleton mod systems with no second live instance (worth double-checking WorldGenPartial's subclasses for that), or to a small per-column object rented from a pool if the state needs to survive a single column's own internal Parallel.For (that's what columnResults/layerFullySolid/layerFullyEmpty need, since a Parallel.For call already spans several threads for one column, so [ThreadStatic] would not keep two concurrent columns' data apart). Happy to share the exact diff Stratum applied to vanilla's own GenTerra for the second case if useful, since the shape of the fix is identical.
None of this is reachable today on any server. It only matters once same-stage concurrency for Terrain or Vegetation ships anywhere, but it's the kind of bug that's much cheaper to fix ahead of time than to debug from a corrupted-world report months later.
Found while testing mod compatibility against Stratum (a Vintage Story server fork), which is adding optional same-stage concurrency for the Terrain pass. Right now every server, vanilla or Stratum, only ever runs one column through the Terrain pass at a time, so this has never been reachable. It becomes reachable the moment anything lets two columns generate Terrain concurrently, whether that is Stratum's own concurrency work or a future engine change.
NewGenTerra.cs
columnResults,layerFullySolid, andlayerFullyEmptyare plain instance fields (lines 75-77), allocated once and reused for every column this generator processes:Generate()(line 254) resets and writes into these same arrays every call, including inside theParallel.Forat line 322. The comment onlayerFullySolid("thread safe even when this is parallel") is accurate for a single column's ownParallel.For, since each thread there touches a distinct index. It says nothing about two different columns runningGenerate()at the same time on different threads: both would read and write the samecolumnResults/layerFullySolid/layerFullyEmptyarrays, and whichever column finishes last wins, silently overwriting the other's terrain data. No exception, no crash, just wrong terrain for one or both columns, which makes it a nasty one to track down after the fact.GeneratePartialFeatures.cs / WorldGenPartial.cs
chunkRand(declaredpublic LCGRandom chunkRandinWorldGenPartial.csline 12) is a second instance of the same shape.ChunkColumnGeneration(registered atEnumWorldGenPass.Vegetation) callschunkRand.InitPositionSeed(...)per column and then draws from it (NextFloat,NextInt) for feature placement. Reseeding per column does not make this safe under concurrency: if two columns run this handler at the same time, one'sInitPositionSeedcall can land between another's reseed and its own reads, handing out numbers from the wrong sequence to both. We hit the identical bug in Stratum's own vanilla-derived code this session (GenCreatures,GenPonds,GenDungeonsall had a sharedLCGRandomreseeded per column) and the fix was the same each time.Suggested fix
Same pattern for both: move the per-column state to
[ThreadStatic]fields ifNewGenTerra/WorldGenPartialare singleton mod systems with no second live instance (worth double-checkingWorldGenPartial's subclasses for that), or to a small per-column object rented from a pool if the state needs to survive a single column's own internalParallel.For(that's whatcolumnResults/layerFullySolid/layerFullyEmptyneed, since aParallel.Forcall already spans several threads for one column, so[ThreadStatic]would not keep two concurrent columns' data apart). Happy to share the exact diff Stratum applied to vanilla's ownGenTerrafor the second case if useful, since the shape of the fix is identical.None of this is reachable today on any server. It only matters once same-stage concurrency for Terrain or Vegetation ships anywhere, but it's the kind of bug that's much cheaper to fix ahead of time than to debug from a corrupted-world report months later.