Lost Cities world-generation thread safety
This document describes the concurrency model used by Lost Cities world generation. It is intended for developers changing generation code or writing companion features that call into Lost Cities. The iteration history later in the document records why the current boundaries exist.
Current architecture
LostCityFeature.runWithDimensionInfo() is the required entry point for normal
and companion world-generation features. It provides two independent safety
boundaries:
- A lifecycle read lock covers the complete generation callback. Cache and registry cleanup takes the corresponding write lock and therefore cannot run while any chunk is generating.
- Ordered striped locks cover the active chunk's 3x3 neighbourhood. This lets distant chunks in the same dimension generate concurrently while preventing edge-touching work, currently vine post-processing, from racing with a neighbouring chunk. Locks are always acquired in numeric order and released in reverse order.
The old monitor which serialized an entire dimension no longer exists. Different dimensions and non-overlapping neighbourhoods in one dimension can run at the same time.
Per-call generation state
GenerationContext.open() installs state for exactly one chunk on the current
worker thread and restores the previous context with try/finally. It owns:
- the active
WorldGenLevel; - the
ChunkDriver, primer/region binding, positions, and section cache; - deterministic palette variation and the terrain
RandomSource; - the current street palette character; and
- reusable rubble, leaves, ruins, and floating-bottom noise buffers.
The context reuses buffers and driver state on a worker thread to avoid adding
per-chunk allocation pressure. Nested contexts receive independent scratch
state. Code which needs the current driver, world, or generation random source
must use this context instead of adding mutable fields to
LostCityTerrainFeature or rebinding DefaultDimensionInfo.
Shared state
Shared caches use concurrent maps or TimedCache. Expensive recursive
BuildingInfo calculations use one reentrant memoization lock per dimension;
this preserves recursive neighbour discovery without globally blocking other
dimensions. Lazy asset data is constructed completely before volatile
publication. Cleanup never replaces stable calculation locks while older
cached objects may still reference them.
Inter-city highway hub decisions also have an overworld SavedData backing
cache. Its dimension maps and dirty-state transitions are synchronized, while
the expensive terrain and biome calculation happens outside that monitor. The
planner publishes only the resulting immutable HighwayHub or empty-cell
decision. Never hold the persistence monitor while calculating a hub: one cold
planning query can sample thousands of remote terrain columns.
Scattered generation has an additional immutable area plan keyed by dimension, world seed, and scattered-area coordinates. The plan contains the chosen asset, footprint, complete-footprint validity, common height, highway-facing rotation, and building choice. Highway-aligned candidate enumeration is deterministic and is completed before the plan is published. Individual chunks only read it and generate their own piece. Never replace this with a decision based on whichever chunk happens to generate first, and never eagerly generate the other chunks in a footprint.
Rules for future changes
- Do not retain an
IDimensionInfobeyond arunWithDimensionInfo()callback in companion generation code. - Do not use a shared reseeded
Random,RandomSource,ChunkDriver, mutable block position, or noise buffer. Put per-chunk state inGenerationContextor use a coordinate-seeded local value. - A cached value must be immutable after publication, safely synchronized, or
explicitly concurrent.
getfollowed byputis not atomic construction. - Do not clear or replace stable lock objects while stale cached values can still exist.
- Any generator which writes outside its own chunk must remain inside the neighbourhood boundary, or introduce an equally explicit ordered ownership scheme before widening that range.
- Deterministic decisions may depend on seed, dimension, coordinates, profile, and immutable assets. They must not depend on query order, cache warmth, thread identity, or generation timing.
- Keep cleanup under the lifecycle write lock. Datapack/asset reload must not overlap generation that reads those assets.
Recommended verification
For concurrency-sensitive changes, generate the same seed repeatedly with different starting positions and compare the result. Include colored scattered multibuildings, normal cities, damage, rubble, vegetation, railways, and, when the companion setup is available, sphere or space generation. A useful stress test requests distant chunks in the same dimension concurrently and confirms that sequential and parallel output is identical.
Implementation history
The implementation was introduced incrementally so every checkpoint remained runnable and retained a conservative synchronization boundary around state that had not yet been isolated. The statuses below describe those implementation checkpoints; the current design is documented above.
Current correctness baseline
LostCityFeature.runWithDimensionInfo() holds a lifecycle read lock and ordered striped locks for the active chunk's 3x3 neighbourhood. LostCitySphereFeature uses that same entry point. Cleanup takes the lifecycle write lock, so it cannot overlap generation. Different dimensions and non-overlapping chunks in the same dimension may generate concurrently.
The local neighbourhood boundary remains because vine post-processing can touch blocks across chunk edges. All broader per-dimension generation serialization has been removed.
Iteration 1: harden the serialized baseline
Status: implemented.
- Synchronize
LostCityFeature.place(). - Synchronize dimension-info lookup and cleanup.
- Make the global dirty counter visible across threads.
- Make sphere generation use the same monitor as normal Lost Cities generation.
- Keep scattered placement based on immutable raw city calculation.
Verification:
- Compile successfully.
- Generate several fresh worlds and confirm scattered multibuildings are either complete or absent.
- Exercise a sphere profile as well as the default profile.
- Check logs for chunk-generation exceptions.
Iteration 2: harden hot shared utilities
Status: implemented.
- Make
TimedCacheconcurrent without adding one coarse monitor to every lookup. - Make global todo maps and queues safe for concurrent producers and server-thread draining.
- Replace shared reseeded coordinate and vegetation RNGs with reusable thread-local RNGs while preserving their existing Java
Randomsequences. - Keep the serialized generation baseline active.
Verification:
- Compile and run the complete test task.
- Generate fresh default and sphere worlds under the existing correctness lock.
- Confirm todo processing neither loses newly queued work nor throws during concurrent additions.
Iteration 3: replace the global lock with safe per-dimension locking
Status: implementation complete; runtime concurrency verification pending. Stable per-dimension locks, lifecycle coordination, and the shared companion-generation API are active. Different dimensions may generate concurrently, while chunks within one dimension remain serialized.
Completed prerequisites:
- Scope legacy highway noise by dimension and make its result caches concurrent.
- Safely publish immutable predefined-city and occupied-chunk lookup maps.
- Make city rarity caching concurrent.
- Serialize one-time asset loading and safely publish registry assets and inherited city styles.
- Add a lifecycle read/write boundary so cleanup cannot overlap generation once the global fallback is removed.
- Replace the global city-sphere cache monitor with stable per-dimension cache locks.
- Make public Lost Cities information caching concurrent.
- Make palette-entry deduplication safe during concurrent datapack decoding or reload.
- Remove the global generation monitor after auditing shared cross-dimension state.
Introduce stable generation locks keyed by dimension. Protect dimension-info creation and lifecycle cleanup separately so two threads can never create different DefaultDimensionInfo instances for the same dimension.
Required properties:
- Normal and sphere generation for the same dimension use the same lock.
- Different dimensions may generate concurrently.
- Cleanup cannot clear registries or cached dimension information while any dimension is generating.
- Companion entry points have one documented generation-lock API instead of duplicating synchronization.
The global monitor was kept active while per-dimension locking, lifecycle coordination, and shared-cache protection were introduced. It has now been removed after the shared cross-dimension state audit; runtime concurrent-dimension verification remains required before Iteration 3 is considered fully verified.
Verification:
- Repeat all Iteration 1 and Iteration 2 checks.
- Generate two Lost Cities dimensions concurrently.
- Add a stress test that requests the same dimension from multiple threads and asserts that only one dimension-info instance is created.
Iteration 4: isolate per-chunk generation state
Status: implementation complete; runtime verification pending. The per-dimension generation lock remains active.
Completed:
- Add a scoped thread-local
GenerationContextinstalled for one terrain chunk withtry/finallysemantics. - Seed palette, leaf, and rubble block variation deterministically from world seed, dimension, and chunk coordinates.
- Remove the shared generation-order-dependent
gSeedsequence. - Restore the previous chunk-driver binding even when generation throws.
- Keep non-worldgen palette callers functional with a thread-local fallback sequence.
- Move the current street palette character into the per-call context.
- Move rubble, leaves, ruins, and floating-bottom noise buffers into reusable thread-local scratch storage.
- Reuse scratch arrays between chunks on the same worker thread to avoid per-chunk allocation pressure.
- Move the terrain
RandomSourceinto reusable per-worker context state and reset it deterministically for every chunk. - Preserve the existing explicit random reseeds for stuff placement and explosion/debris generation.
- Move
ChunkDriver, including its active region, primer, section cache, and mutable positions, into reusable per-worker context state. - Route normal terrain helpers and the separate sphere feature through the scoped context.
- Restore sphere and normal driver bindings with
try/finallysemantics. - Safely publish immutable lazy block-state lookup sets and variation arrays.
- Make the block-entity-type lookup cache concurrent.
- Initialize base and liquid block states as immutable constructor state.
The per-call/thread-local generation context now owns:
ChunkDriverand its active region, primer, section cache, and mutable positions.- Per-chunk random source.
- Current street palette character.
- Noise output arrays and small scratch buffers.
- Other values that are reseeded or overwritten for each chunk.
The per-dimension lock remains in place. Parallel same-dimension chunk generation must only be enabled in a test configuration until output is deterministic and the later cache audits are complete.
Verification:
- Compare generated chunk output between sequential and randomized parallel generation for identical seeds and coordinates.
- Repeat scattered multibuilding tests with deliberately colored pieces.
- Confirm normal cities, damage, rubble, vegetation, railways, and spheres remain deterministic.
Iteration 5: make remaining shared helpers and caches concurrency-safe
Status: implementation complete; runtime verification pending. The per-dimension generation lock remains active.
Completed first slice:
- Make rolling generation statistics safe for concurrent generation updates and command reads. The only write-side synchronization occurs once per generated chunk.
- Atomically compute and publish immutable biome cache entries.
- Remove the global
MultiChunk.getOrCreate()class monitor now thatTimedCache.computeIfAbsent()provides per-key atomic construction. - Replace the railway synchronized-map wrapper and its compound
containsKey/getaccess with a concurrent map andputIfAbsentpublication. - Preserve railway removal precedence when calculation and collision suppression overlap.
Completed second slice:
- Safely publish cached
BuildingInfoneighbours, monorail decisions, and ocean classification. - Fully construct compiled palettes and damage areas before publishing them, with lock-free cached reads.
- Coordinate recursive legacy bridge and stair memoization with one stable lock per dimension instead of per-object locks that could deadlock in opposing neighbour order.
- Keep memoization locks stable across cache cleanup so stale and newly created
BuildingInforeferences cannot use different locks for the same dimension.
Completed final slice:
- Replace global synchronized
BuildingInfocreation, chunk-characteristic, and city-level calculation with one reentrant lock per dimension. - Preserve recursive same-dimension calculation behavior while allowing unrelated dimensions to calculate concurrently.
- Coordinate terrain-correction height memoization through the same dimension lock.
- Build vertical building-part slices completely before volatile publication.
- Safely resolve referenced building and building-part palettes once, while keeping cached reads lock-free.
- Audit world-generation random sources; remaining coordinate and vegetation reseeding uses thread-local instances, while other generation random sources are locally constructed or context-owned.
Audit and fix shared mutable helpers before removing the per-dimension lock:
- Replace static reseeded
Randominstances with local coordinate-seeded random sources. - Verify that no shared generation-order-dependent random sequence remains.
- Make
TimedCacheand other shared maps safe for concurrent access. - Continue auditing lazy initialization outside
LostCityTerrainFeature; its generation lookup state is already safely published. - Review mutable bridge calculations and
BuildingInfomemoization. - Make statistics and diagnostic counters concurrency-safe.
Verification:
- Run focused cache tests under concurrent access.
- Run the sequential-versus-parallel chunk comparison again.
- Run generation repeatedly with cache cleanup occurring between batches.
Iteration 6: cache an immutable scattered-area plan
Status: implementation complete; runtime verification pending. The per-dimension generation lock remains active.
Completed:
- Cache one immutable plan per dimension, world seed, and scattered-area coordinate.
- Select the scattered asset from the area's canonical origin instead of whichever footprint chunk happens to query first.
- Store the selected asset, footprint, complete-footprint validity, common generation height, and building choice in the plan.
- Replay the deterministic area-random call sequence independently for each participating chunk instead of sharing mutable random state.
- Keep block placement chunk-local; calculating a plan never generates or loads distant chunks eagerly.
- Clear plans during server lifecycle cleanup and client preview refresh.
Represent the decision for one scatter area as an immutable value containing the selected asset, footprint, validity, and common generation height. Compute it once with a thread-safe computeIfAbsent keyed by dimension, seed, and scatter-area coordinates.
Chunks must only read this plan; generating one chunk must not mutate the decision observed by another chunk. Do not generate distant chunks eagerly from the anchor chunk because that would violate normal world-generation boundaries.
Verification:
- Query every chunk in an area in forward, reverse, and randomized parallel order and assert an identical plan.
- Confirm invalid structures generate no pieces and valid multibuildings generate every piece once their chunks reach full generation.
Iteration 7: remove the per-dimension generation lock
Status: implementation complete; runtime parallel-generation verification pending.
Completed:
- Remove the stable per-dimension generation monitor.
- Retain the lifecycle read/write boundary so cleanup cannot overlap active generation.
- Allow non-overlapping chunks in the same dimension to generate concurrently.
- Protect the 3x3 neighbourhood around each active chunk with ordered striped locks because vine post-processing can touch blocks across chunk edges.
- Store the active
WorldGenLevelin the scopedGenerationContextinstead of rebinding shared dimension state for every chunk. - Keep immutable seed and dimension identity in
DefaultDimensionInfo, with only a volatile fallback world for non-generation callers. - Replace shared dimension and world random use in damage and vine generation with deterministic context-owned random state.
- Return a thread-local compatibility
RandomfromDefaultDimensionInfo.getRandom()for companion callers.
Remove or relax the lock only after all per-call state is isolated and all shared caches used by generation are concurrency-safe.
Final verification:
- Sequential and parallel generation produce identical chunk data for the same seed.
- Repeated fresh-world tests produce no partial scattered multibuildings.
- Default, sphere, floating, cavern, and space profiles complete without generation exceptions.
- Companion mods can use the documented entry point without bypassing required lifecycle protection.
If any verification fails, restore the most recent conservative lock before continuing. No iteration should be handed off with a known timing-dependent generation path.