Skip to content

Caves delayed writes - #285

Merged
Zaldaryon merged 9 commits into
StratumServer:indevfrom
tehtelev:caves-clean
Aug 25, 2026
Merged

Caves delayed writes#285
Zaldaryon merged 9 commits into
StratumServer:indevfrom
tehtelev:caves-clean

Conversation

@tehtelev

@tehtelev tehtelev commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

A quick summary of what we've changed since the very first (original) code:

  1. Sparse deferred writes
    Was: Each carved block was written to the chunk immediately via the indexer (chunkBlockData[index3d] = value), acquiring a lock and doing a palette lookup per block.
    Now: Writes are buffered into per‑subchunk lists of indices and values (stratumPendIdx, stratumPendVal) and applied once per touched subchunk at the end of GeneratePartial. Untouched subchunks cost nothing. Normal worldgen flushes through the batched SetBlocksUnsafe/SetManyUnsafe path, which only serializes writers against each other — safe because those chunks are not published until generation finishes. The /dev gencaves command flushes through the regular locked indexer path instead (see point 3).

  2. Safe lazy initialisation of the blocks layer
    Was: The batched write path required an already‑initialised palette and NRE’d on a palette‑less layer; the earlier fix attempt called ClearBlocksAndPrepare(), which frees the blocks, fluids and light layers together and silently dropped unbuffered lava writes.
    Now: SetBlocksUnsafe is self‑initialising: if the blocks layer or its palette is missing, it creates the layer and fills it via PopulateWithAir(), touching only the blocks layer – fluidsLayer and lightLayer are left completely untouched. GenCaves no longer calls ClearBlocksAndPrepare() mid‑generation, so lava written earlier in the same pass survives the first blocks flush.

  3. Thread‑safe and isolated /dev gencaves command
    Was: The command called initWorldGen() on the shared instance and reassigned airBlockId on it, so any worker that lazily created its GenCaves during the command’s window would permanently generate inverted caves. It also wrote to live, player‑visible chunks through the lock‑free batched path, where a concurrent reader or the save/compression thread could observe a torn bit‑plane update.
    Now: The command builds a dedicated thread‑confined GenCaves copy under lock (cmdLock) and never touches shared state. Because it operates on published chunks, its buffered writes are flushed through the readWriteLock‑protected Set() path (stratumLockedWrites = true), giving readers and writers the same exclusion the old code had. Normal worldgen keeps the fast batched path.

  4. No per‑block allocations (GC‑friendly)
    Was: Per‑block lock acquisitions and temporary objects on the hot path.
    Now: The buffering lists are allocated once per worker and reused via Clear() between columns.

  5. Buffered block‑light updates
    Was: Every light‑emitting block placed inside a cave called ScheduleBlockLightUpdate directly, which added entries to a shared list owned by the map chunk. This list was accessed concurrently by GenCaves (from TerrainLate) and GenLight (from Vegetation), leading to lost updates and corrupted lists.
    Now: All light‑update requests are also buffered per GenCaves worker (stratumPendLightPos, stratumPendLightOld, stratumPendLightNew). At the end of GeneratePartial, these are flushed to the map chunk using a global lock (lightUpdatesLock) inside BlockAccessorWorldGen. This guarantees that the shared list is never accessed concurrently by different worldgen stages, and all light updates are correctly applied.

  6. Synchronised light‑update processing
    Was: RunScheduledBlockLightUpdates took the list from the map chunk, processed it, and cleared it – all without any locking, so two concurrent runs could process the same list twice or miss entries.
    Now: The method now uses lightUpdatesLock to atomically take the list and set it to null, then delegates actual processing to the newly introduced ProcessScheduledBlockLightUpdates in the illuminator. This prevents double‑processing and ensures thread‑safe handover between worldgen phases.

Type

  • Bug fix
  • Performance
  • New feature
  • Refactor or cleanup
  • Docs or build

Checklist

  • .\scripts\extract-patches.ps1 ran clean.
  • dotnet build VintageStory.slnx -c Release is green.
  • Every vanilla edit has a // Stratum marker.
  • No vanilla source committed.
  • Tested on a real server start, not just compilation.

Performance numbers

Tested on the generation of 31417 chunk columns (6 threads) by /stratum pregen start radius 100 16000 16000
Seed - seed 1027995113.
World setting - default.

Three runs were performed before and after the changes using the Jetbrains dotTrace program.

Before

  • GenCaves.GeneratePartial method execution time: 58826 ms; 62375 ms; 59406 ms.
  • Average: 60202.33 ms
  • Standard deviation: 1554.45 ms

After

  • GenCaves.GeneratePartial method execution time: 57970 ms; 57128 ms; 52378 ms (Fix v5)
  • Average: 55825.33 ms
  • Standard deviation: 2461.75 ms

After optimization, the code runs on average ≈ 7.3% faster (about 4377 ms per run), while the spread of results (standard deviation) has increased from ~1554 ms to ~2462 ms, indicating less predictable and less stable performance. At the same time, we closed an important hole with light updates.

@tehtelev
tehtelev marked this pull request as ready for review August 24, 2026 11:15

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by reconstructing all five changed files against their pristine baselines (three fork projects via direct git apply, the two VintagestoryLib files via --directory=baseline against the decompiled vanilla source, matching what scripts/bootstrap.sh does), then swapping the reconstruction into a bootstrapped tree and running dotnet build VintageStory.slnx -c Release: 0 errors, no new warnings.

The core mechanism is sound. The per-column buffers are genuine ThreadLocal<T> instances (StratumWorkerInstances<GenCaves>), StratumCopyStateFrom nulls the lists so a new worker never aliases the source's, every batch is flushed exactly once per GeneratePartial with no double-flush or leaked stale write, the index math is unchanged, and nothing inside a GeneratePartial reads back the blocks layer it's still buffering (fluids and heightmaps are written unbuffered on purpose, which is correct). The /dev gencaves rewrite is also a real fix for a real bug: the old command called initWorldGen() on the shared instance and reassigned airBlockId on it too, so any worker thread that lazily created its GenCaves during the command's window would permanently inherit airBlockId = <granite> and generate inverted caves for the rest of the server's life. The new isolated-instance-plus-copy approach genuinely avoids that.

Both remaining problems are concentrated in reusing that same buffered-write path for /dev gencaves specifically, against chunks that are already live and player-visible rather than still being generated.

ClearBlocksAndPrepare() also wipes the fluids and light layers, and the comment justifying it is wrong. GenCaves.cs's new lazy-init branch calls data.ClearBlocksAndPrepare() on a subchunk with no palette yet, with a comment claiming it "initializes palette/planes without destroying the fluids/light layers." It does destroy them: ClearBlocksAndPrepare() calls ClearBlocks(), which calls pool.FreeArraysAndReset(this), which nulls blocksLayer, lightLayer, and fluidsLayer together. /dev gencaves clears every subchunk's blocks layer up front (ClearChunkColumn), sets airBlockId to granite so buffered air writes are non-zero, and writes lava unbuffered for y below the lava line in geologically active regions. If subchunk 0 gets lava written before its first buffered granite/basalt flush in the same command run, that flush calls ClearBlocksAndPrepare() and the lava is silently gone, along with the light update already scheduled for it. Regular worldgen doesn't hit this in practice, since GenTerra always populates subchunk 0's palette before GenCaves runs, but the command path does.

The fix belongs in ChunkData.SetBlocksUnsafe itself, which already has the same palette-less-layer problem (SetManyUnsafe NREs on the first zero value if the layer was never initialized): create the layer and call PopulateWithAir() on it directly, without touching fluidsLayer/lightLayer at all. That also means HasBlocksLayer, the anyNonZero scan, and the ClearBlocksAndPrepare() call in GenCaves can go away entirely. One correction to how I initially read this: ClearBlocksAndPrepare() isn't only called from here, GenBlockLayersFlat also calls it, but only once at the very start of generating a chunk that has never had anything written to it, which is exactly the "clear everything, start fresh" use the method's contract implies. GenCaves is the first caller to invoke it mid-generation on a subchunk that may already carry writes from the same pass, which is the actual problem.

The buffered write path drops the reader/writer exclusion the old path had, and /dev gencaves is the first caller that reaches it on published chunks. The old chunkBlockData[index3d] = value goes through ChunkDataLayer.Set, which calls SetCore, wrapping the bit-plane update in readWriteLock.AcquireWriteLock()/ReleaseWriteLock(). The new SetBlocksUnsafe(indices, values) goes through SetManyUnsafe, which calls SetUnsafeCore, and only holds stratumWriteLock (serializes writers against each other, nothing else) and never touches readWriteLock. SetManyUnsafe already existed before this PR (added in "Read and write batching for lighting calculations"), and it was already safe, because its only caller generated chunks that weren't published yet. /dev gencaves changes that: GetChunkColumn fetches chunks through api.WorldManager.GetChunk, the same live/loaded chunks any player can be standing in, and MarkDirty broadcasts them to clients afterward. A concurrent reader, or the chunk-save/compression thread's CompressUsing (which does take readWriteLock.AcquireReadLock()), can now observe a bit-plane update mid-write and read a torn palette index, either the wrong block or a corrupted save.

This doesn't need to block the buffering optimization itself, since normal worldgen chunks aren't published until every generation stage finishes. It's specifically /dev gencaves reusing the same "unsafe" path against live chunks that's the problem. Either keep the dev command on the old locked Set() path (it's a debug command, the batching win doesn't matter there), or add real exclusion to SetManyUnsafe. The PR description's "one lock per subchunk instead of one lock per block" undersells this: it's a different lock with a different guarantee, not a coarser version of the same one.

Performance evidence is before/after screenshots only, no seed, world config, chunk count, or raw numbers in text. Per this repo's established bar, that's not enough to accept the performance claim as a completed gate; a reproducible command and the raw before/after numbers would close it.

One more worth a follow-up, not blocking: HasBlocksLayer's two "empty" implementations (NoChunkData, ProPickWorkSpace's DummyChunkData) are getter-only auto-properties that always return false, but for both of them SetBlocksUnsafe/SetBlockUnsafe are always safe to call (no-ops or a plain array respectively), so per the property's own documented meaning they should return true. Not reachable today since neither type is ever driven through GenCaves's flush path, but DummyChunkData.ClearBlocksAndPrepare() zeroes its whole backing array, so any future code that copies this PR's if (!HasBlocksLayer) ClearBlocksAndPrepare(); pattern against a DummyChunkData would silently erase a prospecting pick's rock column.

Also: git diff --check flags 20 trailing-whitespace lines this PR adds across the patch files (18 in GenCaves.cs.patch, one each in ProPickWorkSpace.cs.patch and NoChunkData.cs.patch). Worth a cleanup pass before merge.

Good work isolating the /dev gencaves state, that was a real bug and the fix is correct. The buffering mechanism itself checks out too. It's specifically pointing that command at the same unsynchronized write path used during generation that needs another look.

@tehtelev

Copy link
Copy Markdown
Contributor Author

Thanks a lot for the thorough review and for catching both the /dev gencaves state bug and the subtleties around the reader/writer exclusion — that was genuinely a different lock with a different guarantee, not a coarser one, and I undersold it in the PR description. Happy to confirm the /dev gencaves isolation fix was a real bug worth fixing.

All blocking issues have been addressed in the latest revision:

  1. Fluids/light layer wipe in ClearBlocksAndPrepare — removed. The lazy-init branch in GenCaves no longer calls it. Initialization now happens directly inside ChunkData.SetBlocksUnsafe: when the blocks layer doesn't exist, it's created and PopulateWithAir() is called on it, leaving fluidsLayer and lightLayer completely untouched. Lava written earlier in the same command run is now preserved, and the stale anyNonZero scan / HasBlocksLayer pre-check in GenCaves is gone.

  2. Reader/writer exclusion on live chunks — fixed. Added a stratumLockedWrites flag that is set to true only for the isolated /dev gencaves command instance. In that mode, buffered writes are flushed via the regular data[idx] = val indexer path, which wraps every SetCore in readWriteLock.AcquireWriteLock()/ReleaseWriteLock() exactly like the old code. The save/compression thread and concurrent readers can no longer observe a torn bit-plane update. Normal worldgen keeps the fast SetManyUnsafe batched path, since those chunks aren't published until generation finishes.

  3. HasBlocksLayer cleanup — done. Since SetBlocksUnsafe is now self-sufficient for palette initialization, I removed HasBlocksLayer entirely (from ChunkData, NoChunkData, and ProPickWorkSpace.DummyChunkData) instead of flipping its semantics, which also eliminates the latent prospecting-pick rock-column wipe risk you flagged.

  4. Performance evidence — agreed that the screenshots alone don't meet the repo's bar. I'll follow up with the seed, world config, chunk count, the exact reproducible command, and raw before/after numbers in a comment once I re-run the benchmark on a stable machine.

  5. Trailing whitespace — I've run a cleanup pass across the .patch files (sed -i 's/[ \t]*$//' on all of them) and git diff --check is now clean. If anything still slips through in CI, I'll squash it before merge.

Let me know if anything else needs another look.

@Evansch0

This comment was marked as spam.

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of be01a14e0b730d35a1520fc0dbeb0642ca56435f after Fix 285.

The earlier ClearBlocksAndPrepare() and live-chunk SetManyUnsafe() findings are fixed in this head. Two blocking problems remain.

[P1] The submitted patch set cannot bootstrap

These commands fail before compilation:

git apply --check --whitespace=nowarn patches/VintagestoryApi/Common/API/IWorldChunk.cs.patch
returns error: corrupt patch at line 7.

git apply --check --whitespace=nowarn --directory=baseline patches/VintagestoryLib/Vintagestory.Common/ChunkData.cs.patch
returns error: corrupt patch at line 10.

Both files begin with a BOM, and the first hunk contains a raw { without the unified-diff context prefix and source indentation. scripts/bootstrap.sh applies these files directly, so a clean checkout stops before the build. Please regenerate both patch files and verify bootstrap from a clean checkout.

[P1] GenCaves shares an unsynchronized worldgen accessor across workers

GenCaves.StratumCopyStateFrom() copies worldgenBlockAccessor by reference. ServerSystemLoadAndSaveGame.GetBlockAccessor(false) supplies that accessor as a singleton. BlockAccessorWorldGen.ScheduleBlockLightUpdate() appends to ServerMapChunk.ScheduledBlockLightUpdates, while RunScheduledBlockLightUpdates() processes and clears the same list without synchronization.

This PR moves caves to concurrent TerrainLate workers, so neighboring generation requests can race on a map chunk's List<Vec4i>. Updates can be lost, or concurrent List<T> access can fail. cmdLock only serializes command invocations and does not exclude worldgen workers. Use a per-worker accessor or synchronize scheduling and processing with the worker lifecycle.

Performance evidence

The PR description now lists the command, seed, world setting, and chunk count, but both before and after timing values are blank. Please add the measured values before treating the performance gate as complete.

Verification note: after temporarily repairing only the two malformed patch lines and BOMs in a disposable reconstruction, dotnet build VintageStory.slnx -c Release completed with 0 errors and 168 warnings. The submitted patch set still fails during bootstrap.

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 00e26ce0fef7fcf2c9efa35550646cd00489e287 after Fix 285 v2.

The two previous blockers are fixed. Both patch files now apply against the pristine baselines, and the performance section contains three before and three after measurements. One blocking race remains.

[P1] GenCaves and GenLight still race on the shared light-update list

The new GenCaves.accessorLock protects only GenCaves' buffered light flush and the debug command. GenLight obtains the same singleton from GetBlockAccessor(false), then calls BeginColumn() and RunScheduledBlockLightUpdates() without that lock.

The scheduler keeps separate active counts for each stage and raises TerrainLate independently, so a TerrainLate cave worker can append to a map chunk's ScheduledBlockLightUpdates while the Vegetation light pass reads, processes, and clears the same List<Vec4i>. That can lose lava light updates or race List<T> access. The lock must be shared with GenLight, or the synchronization must move into BlockAccessorWorldGen around schedule and run operations. Please add a concurrent TerrainLate plus Vegetation regression test.

Verification: all five patch files applied against the pristine reconstruction with zero failures. dotnet restore and dotnet build VintageStory.slnx -c Release --no-restore completed with 0 errors and 168 warnings. No checks are reported for the caves-clean branch.

Minor cleanup: the build still reports the unused chunksizeSq local in GenCaves.cs, and git diff --check still reports trailing whitespace in the patch files.

@tehtelev

Copy link
Copy Markdown
Contributor Author

Great catch on the shared worldgenBlockAccessor state. You were exactly right — BeginColumn() mutates the cached state inside the singleton accessor, so concurrent GeneratePartial calls on neighboring chunks were effectively cross-scheduling their block light updates into the wrong map chunk's List<Vec4i>, causing both torn list access and lost updates.

I went with the "synchronize scheduling and processing" approach you suggested, using the same deferred-write pattern we established for blocks:

  1. Buffered Light Updates: Instead of calling worldgenBlockAccessor.ScheduleBlockLightUpdate inside the hot carving loop, the updates are now buffered into per-worker lists (stratumPendLightPos, stratumPendLightOld, stratumPendLightNew) during SetBlocks.
  2. Synchronized Flush: At the very end of GeneratePartial, after the chunk geometry is finalized, the buffered light updates are flushed under a new accessorLock. BeginColumn() is called inside this lock immediately before the updates are applied, ensuring the accessor's internal cache points to the correct chunk while its List<Vec4i> is mutated.
  3. Dev Command: The same accessorLock is now used around the lighting recalculation block in CmdCaveGenTest to guarantee exclusion against any running worldgen workers.

This keeps the hot path completely lock-free while strictly serializing the shared state mutations at the column boundary.

Additionally, I ran new tests and produced a detailed report with numbers about this.
I also regenerated the patches. They should work fine.

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up after tehtelev's 15:59:50Z comment.

The review was submitted after commit 00e26ce and after the benchmark values were added to the PR description. The comment came 20 seconds later and clarifies the intended GenCaves.accessorLock design. It does not cover the remaining call site below.

GenCaves.cs flushes buffered ScheduleBlockLightUpdate() calls under accessorLock. GenLight.cs gets the same singleton from GetBlockAccessor(false), then calls BeginColumn() and RunScheduledBlockLightUpdates() without that lock. The scheduler has separate stage limits and allows TerrainLate workers to run while the Vegetation stage processes another column. A TerrainLate flush can therefore append to ServerMapChunk.ScheduledBlockLightUpdates while GenLight reads, processes, and clears the same list. That can lose updates or race the List<Vec4i>.

Please move the synchronization into BlockAccessorWorldGen so scheduling and processing share it, or make GenLight use the same shared lock. Add a concurrent TerrainLate plus Vegetation regression test.

I am keeping the change request because this remains a correctness issue in the current head.

Verification: all five patch files apply to the pristine reconstruction. The reconstructed Release build completes with 0 errors and 168 warnings. No CI checks are reported for caves-clean.

@tehtelev

Copy link
Copy Markdown
Contributor Author

I added locks to BlockAccessorWorldGen and removed them from GenCaves. Generation works fine, as does pregeneration.
I also updated the tests, and we've seen a performance loss despite initial optimizations. The overhead of locks in BlockAccessorWorldGen is making itself felt.

I'm not great at regression tests, so if you could do them, that would be great.
Also, I can't do anything about the spaces in the patches due to the specifics of UTF-8 encoding on Windows. This doesn't break the patches, so I don't consider it a problem.

@tehtelev
tehtelev requested a review from Zaldaryon August 24, 2026 17:22

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding

  • P1: map-chunk saves can race with pending light updates. BlockAccessorWorldGen now protects scheduling and detaching ScheduledBlockLightUpdates with lightUpdatesLock, but VintagestoryLib/Vintagestory.Server/ServerMapChunk.cs:329 still passes the same mutable List<Vec4i> directly to FastSerializer.Write. That serializer enumerates the list, while ServerSystemLoadAndSaveGame serializes loaded map chunks on its save thread. A worldgen List.Add can therefore race with save enumeration, causing an exception or inconsistent pending-light data. Please use the same shared lock or take an atomic snapshot under it, or provide an equivalent lifecycle guarantee. Add a concurrent regression test or equivalent evidence.

Verification

  • Bootstrap applied all patches successfully at 4c7a9c8557bd846e296e3b2ebcc69584b0bda489.
  • dotnet build VintageStory.slnx -c Release completed with 0 errors and 169 warnings.
  • make smoke reached RunGame and WorldReady with the patched assemblies embedded.
  • GitHub reports no build checks for this PR.

Correction

I am withdrawing the earlier whitespace note. It is not a repository review gate and does not represent a blocking finding.

@tehtelev

Copy link
Copy Markdown
Contributor Author

[P1 (save race vs pending light updates)] Resolved at the current head; the finding
targeted the intermediate lightUpdatesLock revision where FastSerialize still
enumerated the live ScheduledBlockLightUpdates list. The final design takes the
"equivalent lifecycle guarantee" option from your finding:

  • Runtime pending updates live in ServerMapChunk.stratumLightUpdateBatches
    (ConcurrentStack of batches). A batch is owned by its producer until Push and is
    never mutated afterwards (GenCaves nulls its reference after the handoff; Run only
    reads what it pops).
  • The legacy ScheduledBlockLightUpdates field is written exactly once, in FromBytes,
    which moves it onto the stack and nulls it; no runtime thread mutates it.
  • FastSerialize never hands a live list to the serializer: it enumerates the
    ConcurrentStack (lock-free snapshot), reads the immutable batches, folds them into
    a fresh private List and writes that copy as field 17. Hardened to copy the
    legacy field even in the (currently dead) non-null case, so a future resurrection
    of the field cannot reintroduce the race. On-disk shape is unchanged.

I don't know how to run a regression test, but I can say that generation runs smoothly. Lava generates correctly.

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the current head 79b01aa against the PR merge base. The v4 ConcurrentStack and serialization snapshot changes appear to address the earlier save-thread race, but I still need changes before approval.\n\n### [P1] Initialize the pending-light buffer on the first column\n\nStratumBeginColumn returns immediately after creating stratumPendIdx and stratumPendVal, while stratumPendLight is initialized only below that return. StratumCopyStateFrom resets the light list to null, and the first GeneratePartial can reach the lava path that calls stratumPendLight.Add(...). That causes a NullReferenceException and aborts cave generation for any first column that emits a lava light update. Move the light-list initialization before the early return and add a first-column lava regression test.\n\n### [P1] Complete the performance evidence\n\nThe PR description still leaves every After timing blank, so the claimed performance change cannot be evaluated. Please add the three raw After measurements, average, standard deviation, and reproducible output for the current head before checking the performance gate. GitHub also reports no checks for caves-clean.\n\nThe command issue is left as an inline finding below.

@tehtelev

Copy link
Copy Markdown
Contributor Author

The two issues mentioned have been fixed.
Tests have been updated.

@tehtelev
tehtelev requested a review from Zaldaryon August 25, 2026 12:49

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two v4 blockers are fixed. StratumBeginColumn now initializes stratumPendLight before both of its exits, so a worker's first column can reach the lava branch without a null reference, and CmdCaveGenTest now checks args.Caller.Player for null before touching Entity.Pos. I rebuilt the full patch set against pristine baseline (0 errors) and ran a smoke test with these files embedded; the server reaches WorldReady with no crash.

[P1] GeneratePartial and CmdCaveGenTest no longer call BeginColumn

Vanilla's GeneratePartial opens with worldgenBlockAccessor.BeginColumn(), and indev added a second call in CmdCaveGenTest right before its lighting recalculation, to reset the [ThreadStatic] chunk lookup cache in BlockAccessorWorldGen before that worker touches GetMapChunk again. This revision removes both calls. GeneratePartial now opens with StratumBeginColumn(chunks.Length) instead, which resets this file's own pending write buffers and has nothing to do with the accessor's cache.

The new ScheduleBlockLightUpdates and RunScheduledBlockLightUpdates that this PR's flush path calls both resolve their target through GetMapChunk, which returns whatever ServerMapChunk instance is cached under cachedChunkIndex2d for that column index, without checking that the cache is still current. If a worker thread's cache holds a chunk index whose ServerMapChunk object later gets replaced (a region reload, /wgen regen, or a second /dev gencaves run after an unload) and that same thread later processes that column again, the buffered light batch for that column is pushed onto the stale object instead of the live one. RunScheduledBlockLightUpdates never drains it, so every light update scheduled for that column, lava included, is silently dropped.

Please restore both BeginColumn() calls, or explain why GenCaves no longer needs the reset every other generator extending GenPartial still relies on, with a test that exercises a chunk reload or a repeated /dev gencaves run over the same coordinates.

One smaller item, not blocking: stratumIndicesBuf is written in StratumCopyStateFrom but never read anywhere in the file.

@tehtelev

tehtelev commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Here's what I've done against the pristine baseline:

  • [P1] Restored both BeginColumn() calls — one at the top of GeneratePartial, and one in CmdCaveGenTest right before the lighting recalculation (SunFloodChunkColumnForWorldGen / RunScheduledBlockLightUpdates). This resets the accessor's [ThreadStatic] chunk-lookup cache before that worker touches GetMapChunk again, so buffered light batches are pushed onto the live ServerMapChunk instead of a stale one.
  • Removed the dead stratumIndicesBuf field (and its reset in StratumCopyStateFrom) — it was written but never read.

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the BeginColumn fix directly against the [ThreadStatic] cache it targets. BlockAccessorWorldGen.GetMapChunk returns mapchunkCached whenever cachedChunkIndex2d matches the requested index and only re-resolves on a miss; only BeginColumn() (cachedChunkIndex2d = -1) forces that miss. ChunkServerThread.GetMapChunk really can hand back a different ServerMapChunk object for the same index over a thread's lifetime, so the staleness this closes is real, not theoretical. Both restored calls land correctly: GeneratePartial's call at the top of the method covers its own ScheduleBlockLightUpdates flush later in the same call, and CmdCaveGenTest's call sits directly before RunScheduledBlockLightUpdates, with nothing in between that could reseed the cache. I traced every other caller of ScheduleBlockLightUpdate(s) and RunScheduledBlockLightUpdates in the tree (GenStructures, GenDeposits, GenVegetationAndPatches, GenRivulets, GenPonds, GenSnowLayer, GenCreatures, GenTerraPostProcess, both GenLight implementations) and each already calls BeginColumn() at column start, same as vanilla. Nothing is left uncovered. stratumIndicesBuf's removal is clean too: zero remaining references anywhere, and StratumCopyStateFrom still nulls all three stratumPend* buffers the earlier NRE fix depends on. The v5 finding is fixed.

One new blocking issue surfaced while re-tracing this flush, in code that hasn't changed since an earlier round but that no prior pass caught. BlockAccessorWorldGen.RunScheduledBlockLightUpdates drains ServerMapChunk.stratumLightUpdateBatches (a ConcurrentStack<List<Vec4i>>) by popping every queued batch and merging them into the first one it pops, in place:

List<Vec4i> merged = null;
while (serverMapChunk.stratumLightUpdateBatches.TryPop(out List<Vec4i> batch))
{
    if (merged == null) merged = batch;
    else merged.AddRange(batch);
}

ServerMapChunk.FastSerialize (the save path) reads the same stack concurrently without popping, on the strength of a comment a few lines above it: "Batches on the stack are immutable once pushed (producer nulls its reference after Push; Run only reads what it pops) ... the serializer therefore never enumerates a list another thread mutates." Run does not only read what it pops. Once there is more than one batch to merge, it appends to the first one. ConcurrentStack<T>'s enumerator is a moment-in-time snapshot of the node chain, not of the values inside it, so if FastSerialize's foreach (List<Vec4i> batch in stratumLightUpdateBatches) captures a reference to a batch before RunScheduledBlockLightUpdates pops that same batch and starts appending to it, both threads hold the same List<Vec4i> instance at the same time: one growing it, one reading it through AddRange. That is an unsynchronized concurrent mutation of a plain List<T>, and it can throw on the save thread or leave persistedLightUpdates with a torn read.

The reachable path is /dev gencaves. Its flush at GenCaves.cs:214 calls RunScheduledBlockLightUpdates against an already-loaded, player-visible map chunk, since GetMapChunk resolves server.loadedMapChunks first for a chunk in that state. The periodic save system serializes those same loaded chunks through SaveStratumDirtyMapChunksBudget, which takes no generatingLock, unlike the still-generating-column save path a few lines above it that does. Normal worldgen doesn't reach this: PopulateChunk holds generatingLock for the write while its matching save path takes the read lock, so the two never overlap there. The command bypasses that lock entirely, by design, since it operates on chunks that already finished generating.

The fix is one line: merged = new List<Vec4i>(batch); on the first pop instead of aliasing the popped list, so the list RunScheduledBlockLightUpdates grows is never one FastSerialize can also be enumerating. That also makes the invariant the ServerMapChunk comment states actually true.

Also stale: the comment at GenCaves.cs:210-212 says ScheduleBlockLightUpdate and RunScheduledBlockLightUpdates are "thread-safe (protected by lightUpdatesLock inside BlockAccessorWorldGen)." That field doesn't exist anywhere in the tree. The real mechanism is the lock-free ConcurrentStack, and the drain's own comment says explicitly that nothing on this path is locked. Not blocking by itself, but it's the stated justification for /dev gencaves calling into the shared chunkIlluminatorWorldGen instance without any external locking, so it is worth correcting alongside the batch fix rather than leaving a guarantee documented that nothing actually provides.

Build and smoke test both pass on this head (cf46a4d2d757563f6687e46a99dba78b33966a37): all eight patch files apply cleanly against pristine baseline, dotnet build VintageStory.slnx -c Release completes with 0 errors, and scripts/smoke-test.sh reaches WorldReady with these files embedded.

Comment thread patches/VintagestoryLib/Vintagestory.Server/BlockAccessorWorldGen.cs.patch Outdated

@Zaldaryon Zaldaryon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues remain. The batch-mutation fix closes the race cleanly and nothing else in the eight-file diff moved. Build and smoke test both pass on this head.

@Zaldaryon
Zaldaryon merged commit 602ee22 into StratumServer:indev Aug 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants