using System; using System.Collections.Generic; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using AcDream.Core.World; namespace AcDream.App.Streaming; /// /// Services landblock load/unload requests by invoking a caller-supplied /// load delegate (the production instance wraps /// ) and posting results to an outbox /// the render thread drains once per OnUpdate. /// /// /// Currently runs synchronously on the calling thread. The original /// Phase A.1 design ran loads on a dedicated worker thread, but DatReaderWriter's /// DatCollection is not thread-safe — concurrent reads from a worker /// and the render thread (animation tick, live spawn handlers) corrupt /// internal buffer state and produce half-populated LandBlock.Height[] /// arrays which render as wildly distorted terrain. Until Phase A.3 introduces /// a thread-safe dat wrapper, loads are synchronous: /// invokes the load delegate inline and writes the result to the outbox in /// a single call. This causes a frame hitch when crossing landblock /// boundaries, but the rendering is correct. /// /// /// /// The Channel-based outbox + API is /// preserved so the move back to async loading is a single-class change /// when DatCollection thread safety lands. /// /// /// /// Unloads pass through the outbox as /// records so the render thread can release GPU state on the next drain — /// the streamer never touches GPU resources directly. /// /// /// /// Threading: synchronous mode means all methods must be called from the /// same thread (the render thread in production). /// /// public sealed class LandblockStreamer : IDisposable { /// /// Default drain batch size. Tuned to cap GPU upload work the render /// thread does per frame while still draining a moderate backlog in a /// few frames. Callers can override on a per-call basis. /// public const int DefaultDrainBatchSize = 4; private readonly Func _loadLandblock; private readonly Channel _inbox; private readonly Channel _outbox; private readonly CancellationTokenSource _cancel = new(); #pragma warning disable CS0649 // _worker stays declared for the future async path; unused in synchronous mode. private Thread? _worker; #pragma warning restore CS0649 private int _disposed; public LandblockStreamer(Func loadLandblock) { _loadLandblock = loadLandblock; _inbox = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); _outbox = Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); } /// /// No-op in synchronous mode. Preserved on the API surface so callers /// don't need to change when async loading returns in Phase A.3. /// public void Start() { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); // No worker thread in synchronous mode. } public void EnqueueLoad(uint landblockId) { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); // Synchronous mode: invoke the load delegate inline. The result lands // in the outbox and DrainCompletions picks it up later in the same // (or next) frame. HandleJob(new LandblockStreamJob.Load(landblockId)); } public void EnqueueUnload(uint landblockId) { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); HandleJob(new LandblockStreamJob.Unload(landblockId)); } /// /// Drain up to completed results. /// Non-blocking. Call from the render thread once per OnUpdate. /// /// /// Must be called from a single consumer thread. The outbox channel is /// configured with SingleReader = true and will throw on concurrent reads. /// public IReadOnlyList DrainCompletions(int maxBatchSize = DefaultDrainBatchSize) { var batch = new List(maxBatchSize); while (batch.Count < maxBatchSize && _outbox.Reader.TryRead(out var result)) batch.Add(result); return batch; } private void WorkerLoop() { try { // Synchronous read loop via .WaitToReadAsync + ReadAllAsync // would be idiomatic but requires async; the blocking reader // is simpler and the thread is dedicated anyway. while (!_cancel.Token.IsCancellationRequested) { // Safe to block: this is a dedicated worker thread with no // SynchronizationContext, so .Result/.GetResult cannot deadlock // against any captured continuation. Using the sync pattern // here keeps the loop linear; an async-enumerable alternative // would force WorkerLoop to be async Task and lose the // simple thread-start shape. if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult()) break; while (_inbox.Reader.TryRead(out var job)) { if (_cancel.Token.IsCancellationRequested) return; HandleJob(job); } } } catch (OperationCanceledException) { /* graceful shutdown */ } catch (Exception ex) { // Last-ditch: surface via outbox so the caller at least sees // something. We never retry a crashed worker. _outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString())); } finally { _outbox.Writer.TryComplete(); } } private void HandleJob(LandblockStreamJob job) { switch (job) { case LandblockStreamJob.Load load: try { var lb = _loadLandblock(load.LandblockId); if (lb is null) _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( load.LandblockId, "LandblockLoader.Load returned null")); else _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( load.LandblockId, lb)); } catch (Exception ex) { _outbox.Writer.TryWrite(new LandblockStreamResult.Failed( load.LandblockId, ex.ToString())); } break; case LandblockStreamJob.Unload unload: _outbox.Writer.TryWrite(new LandblockStreamResult.Unloaded(unload.LandblockId)); break; } } public void Dispose() { if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return; _cancel.Cancel(); _inbox.Writer.TryComplete(); _worker?.Join(TimeSpan.FromSeconds(2)); _cancel.Dispose(); } }