diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2e3e849..332abdb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -97,13 +97,24 @@ public sealed class GameWindow : IDisposable // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); - // Phase A.1 hotfix: DatCollection is NOT thread-safe. The streaming worker - // thread and the render thread both read dats (BuildLandblockForStreaming - // on the worker; ApplyLoadedTerrain + live-spawn handlers on the render - // thread). Concurrent reads corrupt internal caches and produce - // half-populated LandBlock.Height[] arrays, which caused terrain to render - // as "a giant ball with spikes" before this lock was added. All _dats.Get - // calls that can race with the worker thread MUST acquire this lock. + // Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe. + // DatReaderWriter's DatBinReader uses a shared buffer position internally — + // concurrent _dats.Get calls from the streaming worker thread (T11+) and + // the render thread (BuildLandblockForStreaming on the worker; + // ApplyLoadedTerrain + live-spawn handlers + animation ticks on the render + // thread) corrupt that state and produce half-populated LandBlock.Height[] + // arrays, rendering as "a giant ball with spikes". All _dats.Get call + // sites that can race with the streaming worker MUST hold this lock. + // + // Worker-thread dat reads enter via the factory closures passed to + // LandblockStreamer at construction (loadLandblock + buildMeshOrNull). + // Those closures already acquire _datLock, so no additional wrapping is + // needed for reads inside BuildLandblockForStreamingLocked / + // BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming. + // Render-thread paths (ApplyLoadedTerrain, OnLiveEntitySpawned) already + // hold this lock via their outer wrappers; all remaining render-thread + // _dats.Get calls run only when no worker dat read can be in flight (during + // initialization or within the same lock scope). private readonly object _datLock = new(); // Terrain build context shared across all streamed landblocks. Stored as @@ -1572,14 +1583,18 @@ public sealed class GameWindow : IDisposable _streamingRadius = r; Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})"); - // The streamer's load delegate wraps LandblockLoader.Load + stab - // hydration. Scenery + interior will land in Task 8. + // Phase A.5 T11+: the streamer now runs on a dedicated worker thread. + // loadLandblock and buildMeshOrNull are called on the worker; both + // closures acquire _datLock (T10) before touching DatCollection. + // T12 wires the real mesh-build factory below. _streamer = new AcDream.App.Streaming.LandblockStreamer( loadLandblock: id => BuildLandblockForStreaming(id)); _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( - enqueueLoad: _streamer.EnqueueLoad, + // Use a lambda so the Action delegate matches the method + // signature (EnqueueLoad has an optional 'kind' parameter). + enqueueLoad: id => _streamer.EnqueueLoad(id, AcDream.App.Streaming.LandblockStreamJobKind.LoadNear), enqueueUnload: _streamer.EnqueueUnload, drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain,