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 caller-supplied
/// factory delegates (the production instance wraps
/// for loading and
/// for the terrain
/// mesh) and posting results to an outbox the render thread drains once
/// per OnUpdate.
///
///
/// Thread model (Phase A.5 T11+): spawns a
/// dedicated background worker thread. and
/// write non-blocking to the inbox
/// ; the worker drains it and posts
/// records to the outbox.
///
///
///
/// DatCollection thread safety is provided by the caller:
/// GameWindow's _datLock (Phase A.5 T10) serialises all
/// DatCollection.Get<T> calls. Both factory closures passed at
/// construction acquire that lock before reading dats. The worker never
/// touches DatCollection directly — it only calls the factories.
///
///
///
/// 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: must be called from a single
/// consumer thread (the render thread in production). All other public
/// methods are thread-safe.
///
///
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 Func _buildMeshOrNull;
private readonly Channel _inbox;
private readonly Channel _outbox;
private readonly CancellationTokenSource _cancel = new();
private Thread? _worker;
private int _disposed;
public LandblockStreamer(
Func loadLandblock,
Func? buildMeshOrNull = null)
{
_loadLandblock = loadLandblock;
// Default: no mesh build (returns null → Failed result). Production
// wires in LandblockMesh.Build via the T12 construction site.
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
_inbox = Channel.CreateUnbounded(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
_outbox = Channel.CreateUnbounded(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
}
///
/// Activate the dedicated background worker thread. Idempotent and
/// thread-safe: concurrent callers will only spawn one worker; subsequent
/// calls are no-ops. Atomic via .
///
public void Start()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
// A.5 T10-T12 follow-up: atomically install the worker so concurrent
// Start() callers don't both pass the null check and spawn duplicate
// threads. Construct the candidate; CAS it into _worker; if we lost
// the race, the candidate goes unstarted and is GCed.
var candidate = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "acdream.streaming.worker",
};
if (Interlocked.CompareExchange(ref _worker, candidate, null) == null)
candidate.Start();
// else: another caller won the race; their thread is running.
}
///
/// Non-blocking enqueue. The worker drains the inbox and posts a
/// (or
/// ) to the outbox.
///
public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
}
///
/// Non-blocking enqueue. The worker posts a
/// to the outbox.
///
public void EnqueueUnload(uint landblockId)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(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
{
// 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.
while (!_cancel.Token.IsCancellationRequested)
{
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:
// TODO(A.5 T16): route by load.Kind. LoadFar will skip
// LandBlockInfo + scenery generation; PromoteToNear will skip
// mesh build (terrain already on GPU). Today every Kind takes
// the full-load path via _loadLandblock, which matches today's
// single-tier semantics.
try
{
var lb = _loadLandblock(load.LandblockId);
if (lb is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null"));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
if (mesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null"));
break;
}
var tier = load.Kind == LandblockStreamJobKind.LoadFar
? LandblockStreamTier.Far : LandblockStreamTier.Near;
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, lb, mesh));
}
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();
}
}