Merge campaign-hover-ui-round: #417 logout audio fix, reveal-timing probe, #418 landblock build pool (login speedup in progress)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
27b6c8bc19
10 changed files with 850 additions and 61 deletions
|
|
@ -24,6 +24,54 @@ What does NOT go here:
|
||||||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||||
|
|
||||||
|
## #418 — Login world load takes ~27 s: publication advances at a flat 32 blocks/s
|
||||||
|
|
||||||
|
**Status:** IN-PROGRESS 2026-08-17 — producer half landed (this commit's
|
||||||
|
striped `LandblockStreamer` worker pool); the pacer measurably remains on the
|
||||||
|
consumer side. **Symptom:** login holds the portal tunnel ~27 s while the
|
||||||
|
25×25 window (625 landblocks) drips in at exactly 32 blocks/s
|
||||||
|
(`ACDREAM_PROBE_REVEAL_TIMING=1`, probe `695a27b4`; baseline
|
||||||
|
totalMs=26728), then render/composites/collision/gate/materialization all
|
||||||
|
flip ready in the same millisecond. **Evidence chain:** an A/B with every
|
||||||
|
`StreamingWorkBudgetOptions` env ceiling cranked 25–64x changed nothing →
|
||||||
|
read as producer-limited (ONE `acdream.streaming.worker` thread,
|
||||||
|
~31 ms/block serial). This commit parallelized the producer
|
||||||
|
(min(cores−2, 8) striped workers, per-landblock ordering preserved) and
|
||||||
|
**disproved that reading**: with 8 workers ALL 625 builds complete in
|
||||||
|
~203 ms of wall clock (`ACDREAM_PROBE_TELEPORT=1` BUILD lines
|
||||||
|
t=3475390→3475593, dat-lock waited ≤ 12 ms, held 0–13 ms), yet `loaded=`
|
||||||
|
still advances at exactly +32/1000 ms and totalMs measured 27395 / 27503
|
||||||
|
across two runs of the new binary. **Hypothesis:** the 32/s cadence lives in
|
||||||
|
the update-thread admission/publication path (`StreamingController` meter →
|
||||||
|
`LandblockPresentationPipeline`), is frame-quantized (an exactly-integer
|
||||||
|
per-second rate held for 14+ consecutive seconds — N update ticks per
|
||||||
|
landblock at a stable tick rate, e.g. 2 ticks × 64 Hz), and is NOT governed
|
||||||
|
by the budget env ceilings (the original A/B and this change now agree on
|
||||||
|
that). **Next step:** instrument per-frame meter operations/yields + the
|
||||||
|
tunnel frame rate, find which operation stage eats the ensured-progress
|
||||||
|
floor, then lift the actual limiter. The producer pool stays: it takes the
|
||||||
|
builds off the critical path (23 s → 0.2 s) and is quality-neutral
|
||||||
|
(per-landblock ordering, ClearLoads, priority, and disposal semantics
|
||||||
|
preserved; a pool of 1 reproduces the old serial behavior,
|
||||||
|
regression-tested in `LandblockStreamerPoolTests`).
|
||||||
|
|
||||||
|
**Lead-review refinement (2026-08-17, same day):** the identical ~31 ms
|
||||||
|
period across BOTH configurations is unlikely to be a coincidence of two
|
||||||
|
different limiters — the sharper hypothesis is that ONE ADMITTED
|
||||||
|
LANDBLOCK'S update-thread publication itself costs ~31 ms (GPU upload +
|
||||||
|
registration + typed-budget accounting as one indivisible admission).
|
||||||
|
The 2 ms `MaxUpdateMilliseconds` floor guarantees exactly one admission
|
||||||
|
per frame; that admission stretches the frame to ~31 ms; the hold
|
||||||
|
therefore runs at ~32 fps and 1 admission/frame × 32 fps = the measured
|
||||||
|
flat 32/s — in the OLD binary the serial builder happened to produce at
|
||||||
|
the same ~31 ms/block, which masked the admission cost entirely. This
|
||||||
|
predicts: (a) tunnel frame time during the hold is ~31 ms (measurable
|
||||||
|
from the existing frame profiler), and (b) the fix is splitting or
|
||||||
|
off-threading the per-landblock publication cost — NOT raising budgets
|
||||||
|
(already disproved twice). `MaxCompletionsPerFrame = 4` (the quality
|
||||||
|
line) is a profile SCALE of the same env-tunable options, so it is also
|
||||||
|
already exonerated. Verify (a) first next session.
|
||||||
|
|
||||||
## #417 — World ambience keeps playing (and re-firing) on the character-select screen after the in-world logoff
|
## #417 — World ambience keeps playing (and re-firing) on the character-select screen after the in-world logoff
|
||||||
|
|
||||||
**Status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same
|
**Status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same
|
||||||
|
|
|
||||||
|
|
@ -425,7 +425,8 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
spawnClaimClassifier.IsUnhydratable,
|
spawnClaimClassifier.IsUnhydratable,
|
||||||
worldQuiescence,
|
worldQuiescence,
|
||||||
streaming,
|
streaming,
|
||||||
revealRenderResources);
|
revealRenderResources,
|
||||||
|
() => live.WorldState.LoadedLandblockCount);
|
||||||
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
|
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
|
||||||
|
|
||||||
return CompleteSessionPlayer(
|
return CompleteSessionPlayer(
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
/// that need to enumerate entities before the landblock is dropped
|
/// that need to enumerate entities before the landblock is dropped
|
||||||
/// (e.g. unregistering dynamic lights on a RemoveLandblock).
|
/// (e.g. unregistering dynamic lights on a RemoveLandblock).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>Resident landblock count — reveal-timing probe progress
|
||||||
|
/// lines only (<see cref="RevealTimingProbe"/>).</summary>
|
||||||
|
public int LoadedLandblockCount => _loaded.Count;
|
||||||
|
|
||||||
public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb)
|
public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb)
|
||||||
{
|
{
|
||||||
if (_loaded.TryGetValue(landblockId, out var found))
|
if (_loaded.TryGetValue(landblockId, out var found))
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,24 @@ namespace AcDream.App.Streaming;
|
||||||
/// per OnUpdate.
|
/// per OnUpdate.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Thread model (Phase A.5 T11+):</b> <see cref="Start"/> spawns a
|
/// <b>Thread model (#418):</b> <see cref="Start"/> spawns a small pool of
|
||||||
/// dedicated background worker thread. <see cref="EnqueueLoad"/> and
|
/// dedicated background worker threads (default
|
||||||
/// <see cref="EnqueueUnload"/> write non-blocking to the inbox
|
/// <see cref="DefaultWorkerCount"/>; the single-worker degenerate case is
|
||||||
/// <see cref="Channel{T}"/>; the worker drains it and posts
|
/// the pre-#418 Phase A.5 T11+ shape). Jobs are striped across per-worker
|
||||||
/// <see cref="LandblockStreamResult"/> records to the outbox.
|
/// lanes by landblock id, so every job for one landblock id executes on
|
||||||
|
/// one lane in enqueue order — a Load and Unload for the same id can never
|
||||||
|
/// race on two workers. <see cref="EnqueueLoad"/> and
|
||||||
|
/// <see cref="EnqueueUnload"/> write non-blocking to the owning lane's
|
||||||
|
/// inbox <see cref="Channel{T}"/>; each worker drains its lane and posts
|
||||||
|
/// <see cref="LandblockStreamResult"/> records to the shared outbox.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>DatCollection thread safety</b> is provided by the caller:
|
/// <b>DatCollection thread safety</b> is provided by the caller:
|
||||||
/// GameWindow's <c>_datLock</c> (Phase A.5 T10) serialises all
|
/// GameWindow's <c>_datLock</c> (Phase A.5 T10) serialises all
|
||||||
/// <c>DatCollection.Get<T></c> calls. Both factory closures passed at
|
/// <c>DatCollection.Get<T></c> calls. Both factory closures passed at
|
||||||
/// construction acquire that lock before reading dats. The worker never
|
/// construction acquire that lock before reading dats. The workers never
|
||||||
/// touches <c>DatCollection</c> directly — it only calls the factories.
|
/// touch <c>DatCollection</c> directly — they only call the factories.
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -40,7 +45,10 @@ namespace AcDream.App.Streaming;
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Threading: <see cref="DrainCompletions"/> must be called from a single
|
/// Threading: <see cref="DrainCompletions"/> must be called from a single
|
||||||
/// consumer thread (the render thread in production). All other public
|
/// consumer thread (the render thread in production). All other public
|
||||||
/// methods are thread-safe.
|
/// methods are thread-safe. Completion arrival order is preserved per
|
||||||
|
/// landblock id (per lane); across different landblocks it is arbitrary,
|
||||||
|
/// which the single consumer already tolerates — the StreamingController's
|
||||||
|
/// admission classifies each result independently into per-priority FIFOs.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
|
|
@ -52,14 +60,23 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int DefaultDrainBatchSize = 4;
|
public const int DefaultDrainBatchSize = 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default build worker pool size: leave two cores for the render and
|
||||||
|
/// update threads, cap at 8 (the login window's ~625 builds saturate
|
||||||
|
/// well before that), floor 1 (the pre-#418 single-worker shape).
|
||||||
|
/// </summary>
|
||||||
|
public static int DefaultWorkerCount =>
|
||||||
|
Math.Max(1, Math.Min(Environment.ProcessorCount - 2, 8));
|
||||||
|
|
||||||
private readonly Func<LandblockBuildRequest, LandblockBuild?> _loadLandblock;
|
private readonly Func<LandblockBuildRequest, LandblockBuild?> _loadLandblock;
|
||||||
private readonly bool _supportsRequestOrigin;
|
private readonly bool _supportsRequestOrigin;
|
||||||
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
|
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
|
||||||
private readonly Channel<LandblockStreamJob> _inbox;
|
private readonly Channel<LandblockStreamJob>[] _lanes;
|
||||||
private readonly Channel<LandblockStreamResult> _outbox;
|
private readonly Channel<LandblockStreamResult> _outbox;
|
||||||
private readonly CancellationTokenSource _cancel = new();
|
private readonly CancellationTokenSource _cancel = new();
|
||||||
private readonly object _inboxGate = new();
|
private readonly object _inboxGate = new();
|
||||||
private Thread? _worker;
|
private Thread[]? _workers;
|
||||||
|
private int _activeWorkers;
|
||||||
private Exception? _workerFailure;
|
private Exception? _workerFailure;
|
||||||
private int _completionBacklog;
|
private int _completionBacklog;
|
||||||
private int _disposed;
|
private int _disposed;
|
||||||
|
|
@ -74,17 +91,31 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
private LandblockStreamer(
|
private LandblockStreamer(
|
||||||
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
|
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
|
||||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull,
|
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull,
|
||||||
bool supportsRequestOrigin)
|
bool supportsRequestOrigin,
|
||||||
|
int? workerCount)
|
||||||
{
|
{
|
||||||
|
if (workerCount is < 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(workerCount),
|
||||||
|
workerCount,
|
||||||
|
"The landblock build pool needs at least one worker.");
|
||||||
|
}
|
||||||
_loadLandblock = loadLandblock;
|
_loadLandblock = loadLandblock;
|
||||||
_supportsRequestOrigin = supportsRequestOrigin;
|
_supportsRequestOrigin = supportsRequestOrigin;
|
||||||
// Default: no mesh build (returns null → Failed result). Production
|
// Default: no mesh build (returns null → Failed result). Production
|
||||||
// wires in LandblockMesh.Build via the T12 construction site.
|
// wires in LandblockMesh.Build via the T12 construction site.
|
||||||
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
|
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
|
||||||
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
|
int lanes = workerCount ?? DefaultWorkerCount;
|
||||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
_lanes = new Channel<LandblockStreamJob>[lanes];
|
||||||
|
for (int i = 0; i < lanes; i++)
|
||||||
|
{
|
||||||
|
_lanes[i] = Channel.CreateUnbounded<LandblockStreamJob>(
|
||||||
|
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||||
|
}
|
||||||
|
// SingleWriter = false: every pool worker posts completions.
|
||||||
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
|
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
|
||||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -94,8 +125,9 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static LandblockStreamer CreateForRequests(
|
public static LandblockStreamer CreateForRequests(
|
||||||
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
|
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
|
||||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null) =>
|
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null,
|
||||||
new(loadLandblock, buildMeshOrNull, supportsRequestOrigin: true);
|
int? workerCount = null) =>
|
||||||
|
new(loadLandblock, buildMeshOrNull, supportsRequestOrigin: true, workerCount);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compatibility constructor for build factories that predate the
|
/// Compatibility constructor for build factories that predate the
|
||||||
|
|
@ -103,13 +135,15 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LandblockStreamer(
|
public LandblockStreamer(
|
||||||
Func<uint, LandblockStreamJobKind, LandblockBuild?> loadLandblock,
|
Func<uint, LandblockStreamJobKind, LandblockBuild?> loadLandblock,
|
||||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null,
|
||||||
|
int? workerCount = null)
|
||||||
: this(
|
: this(
|
||||||
request => loadLandblock(request.LandblockId, request.Kind) is { } build
|
request => loadLandblock(request.LandblockId, request.Kind) is { } build
|
||||||
? build
|
? build
|
||||||
: null,
|
: null,
|
||||||
buildMeshOrNull,
|
buildMeshOrNull,
|
||||||
supportsRequestOrigin: false)
|
supportsRequestOrigin: false,
|
||||||
|
workerCount)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,13 +154,15 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LandblockStreamer(
|
public LandblockStreamer(
|
||||||
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
|
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
|
||||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null,
|
||||||
|
int? workerCount = null)
|
||||||
: this(
|
: this(
|
||||||
request => loadLandblock(request.LandblockId, request.Kind) is { } landblock
|
request => loadLandblock(request.LandblockId, request.Kind) is { } landblock
|
||||||
? new LandblockBuild(landblock, Origin: request.Origin)
|
? new LandblockBuild(landblock, Origin: request.Origin)
|
||||||
: null,
|
: null,
|
||||||
buildMeshOrNull,
|
buildMeshOrNull,
|
||||||
supportsRequestOrigin: false)
|
supportsRequestOrigin: false,
|
||||||
|
workerCount)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,19 +174,40 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LandblockStreamer(
|
public LandblockStreamer(
|
||||||
Func<uint, LoadedLandblock?> loadLandblock,
|
Func<uint, LoadedLandblock?> loadLandblock,
|
||||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null,
|
||||||
|
int? workerCount = null)
|
||||||
: this(
|
: this(
|
||||||
request => loadLandblock(request.LandblockId) is { } landblock
|
request => loadLandblock(request.LandblockId) is { } landblock
|
||||||
? new LandblockBuild(landblock, Origin: request.Origin)
|
? new LandblockBuild(landblock, Origin: request.Origin)
|
||||||
: null,
|
: null,
|
||||||
buildMeshOrNull,
|
buildMeshOrNull,
|
||||||
supportsRequestOrigin: false)
|
supportsRequestOrigin: false,
|
||||||
|
workerCount)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Configured pool size (also the lane count).</summary>
|
||||||
|
internal int WorkerCount => _lanes.Length;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Activate the dedicated background worker thread. Idempotent and
|
/// Lane (worker) that owns every job for <paramref name="landblockId"/>.
|
||||||
/// thread-safe: concurrent callers will only spawn one worker; subsequent
|
/// Landblock ids carry their identity in the high 16 bits (0xXXYYFFFF —
|
||||||
|
/// the low word is constant across ids), so the id is mixed with a
|
||||||
|
/// Knuth multiplicative hash before reduction; a bare modulo would map
|
||||||
|
/// every id to one lane. Internal so tests can construct deterministic
|
||||||
|
/// per-lane contention.
|
||||||
|
/// </summary>
|
||||||
|
internal int LaneFor(uint landblockId)
|
||||||
|
{
|
||||||
|
if (_lanes.Length == 1)
|
||||||
|
return 0;
|
||||||
|
uint mixed = (landblockId >> 16) * 2654435761u;
|
||||||
|
return (int)(mixed % (uint)_lanes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Activate the dedicated background worker threads. Idempotent and
|
||||||
|
/// thread-safe: concurrent callers will only spawn one pool; subsequent
|
||||||
/// calls are no-ops. Serialized with disposal so a worker can never start
|
/// calls are no-ops. Serialized with disposal so a worker can never start
|
||||||
/// after the owning DAT lifetime has been released.
|
/// after the owning DAT lifetime has been released.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -160,16 +217,27 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
{
|
{
|
||||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||||
if (_worker is not null)
|
if (_workers is not null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var worker = new Thread(WorkerLoop)
|
var workers = new Thread[_lanes.Length];
|
||||||
|
// Armed before any worker can run so the last worker to exit —
|
||||||
|
// however fast — is the one that completes the outbox.
|
||||||
|
System.Threading.Volatile.Write(ref _activeWorkers, workers.Length);
|
||||||
|
for (int i = 0; i < workers.Length; i++)
|
||||||
{
|
{
|
||||||
IsBackground = true,
|
int lane = i;
|
||||||
Name = "acdream.streaming.worker",
|
workers[i] = new Thread(() => WorkerLoop(lane))
|
||||||
};
|
{
|
||||||
worker.Start();
|
IsBackground = true,
|
||||||
_worker = worker;
|
Name = workers.Length == 1
|
||||||
|
? "acdream.streaming.worker"
|
||||||
|
: $"acdream.streaming.worker.{lane}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
foreach (Thread worker in workers)
|
||||||
|
worker.Start();
|
||||||
|
_workers = workers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,12 +304,15 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancel every queued-but-not-started Load. Posts a
|
/// Cancel every queued-but-not-started Load. Posts a
|
||||||
/// <see cref="LandblockStreamJob.ClearLoads"/> control job which the worker
|
/// <see cref="LandblockStreamJob.ClearLoads"/> control job to EVERY lane
|
||||||
/// honours at read time, dropping all pending Loads from both priority
|
/// (one atomic broadcast under the enqueue gate, so a load enqueued
|
||||||
/// queues (Unloads survive). Used on the dungeon-entry edge to abort the
|
/// before this call is ordered ahead of its lane's ClearLoads copy and
|
||||||
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
|
/// dropped) which each worker honours at read time, dropping all pending
|
||||||
/// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
|
/// Loads from both of its priority queues (Unloads survive). Used on the
|
||||||
/// complete; the StreamingController's collapsed-sweep unloads those few.
|
/// dungeon-entry edge to abort the in-flight 25×25 neighbor window so the
|
||||||
|
/// ~129 ocean-grid dungeons never finish loading (#133 FPS). Loads a
|
||||||
|
/// worker has ALREADY dequeued still complete (up to one per worker);
|
||||||
|
/// the StreamingController's collapsed-sweep unloads those few.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ClearPendingLoads()
|
public void ClearPendingLoads()
|
||||||
{
|
{
|
||||||
|
|
@ -250,18 +321,28 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
|
|
||||||
private void WriteJob(LandblockStreamJob job)
|
private void WriteJob(LandblockStreamJob job)
|
||||||
{
|
{
|
||||||
// Serialize the writer's terminal transition with enqueue. Without
|
// Serialize the writers' terminal transition with enqueue. Without
|
||||||
// this small lifecycle gate a worker crash could complete the channel
|
// this small lifecycle gate a worker crash could complete a lane
|
||||||
// between the caller's state check and TryWrite, silently dropping a
|
// between the caller's state check and TryWrite, silently dropping a
|
||||||
// landblock request. Enqueues are destination-boundary events, not a
|
// landblock request. The same gate makes the ClearLoads broadcast
|
||||||
// per-frame hot path.
|
// atomic with respect to concurrent enqueues. Enqueues are
|
||||||
|
// destination-boundary events, not a per-frame hot path.
|
||||||
lock (_inboxGate)
|
lock (_inboxGate)
|
||||||
{
|
{
|
||||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||||
if (_workerFailure is { } failure)
|
if (_workerFailure is { } failure)
|
||||||
throw new InvalidOperationException("The landblock streaming worker has terminated.", failure);
|
throw new InvalidOperationException("A landblock streaming worker has terminated.", failure);
|
||||||
if (!_inbox.Writer.TryWrite(job))
|
if (job is LandblockStreamJob.ClearLoads)
|
||||||
|
{
|
||||||
|
foreach (Channel<LandblockStreamJob> lane in _lanes)
|
||||||
|
{
|
||||||
|
if (!lane.Writer.TryWrite(job))
|
||||||
|
throw new InvalidOperationException("The landblock streaming inbox is no longer accepting work.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_lanes[LaneFor(job.LandblockId)].Writer.TryWrite(job))
|
||||||
throw new InvalidOperationException("The landblock streaming inbox is no longer accepting work.");
|
throw new InvalidOperationException("The landblock streaming inbox is no longer accepting work.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -308,8 +389,9 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
System.Threading.Interlocked.Increment(ref _completionBacklog);
|
System.Threading.Interlocked.Increment(ref _completionBacklog);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WorkerLoop()
|
private void WorkerLoop(int laneIndex)
|
||||||
{
|
{
|
||||||
|
ChannelReader<LandblockStreamJob> inbox = _lanes[laneIndex].Reader;
|
||||||
var highPriority = new Queue<LandblockStreamJob>();
|
var highPriority = new Queue<LandblockStreamJob>();
|
||||||
var lowPriority = new Queue<LandblockStreamJob>();
|
var lowPriority = new Queue<LandblockStreamJob>();
|
||||||
|
|
||||||
|
|
@ -325,12 +407,12 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
{
|
{
|
||||||
if (highPriority.Count == 0 &&
|
if (highPriority.Count == 0 &&
|
||||||
lowPriority.Count == 0 &&
|
lowPriority.Count == 0 &&
|
||||||
!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
|
!inbox.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (_inbox.Reader.TryRead(out var job))
|
while (inbox.TryRead(out var job))
|
||||||
{
|
{
|
||||||
if (job is LandblockStreamJob.ClearLoads)
|
if (job is LandblockStreamJob.ClearLoads)
|
||||||
{
|
{
|
||||||
|
|
@ -358,17 +440,39 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Last-ditch: surface via outbox so the caller at least sees
|
// Last-ditch: surface via outbox so the caller at least sees
|
||||||
// something. We never retry a crashed worker.
|
// something. We never retry a crashed worker; any worker crash
|
||||||
|
// terminates the pool (matching the single-worker contract that
|
||||||
|
// a crash ends all job processing). A sibling that merely
|
||||||
|
// observed the crashed worker's lane completion (the wrapped
|
||||||
|
// ChannelClosedException) exits quietly instead of reporting a
|
||||||
|
// second, spurious crash.
|
||||||
|
bool cascade;
|
||||||
lock (_inboxGate)
|
lock (_inboxGate)
|
||||||
{
|
{
|
||||||
_workerFailure = ex;
|
cascade = _workerFailure is not null && ex is ChannelClosedException;
|
||||||
_inbox.Writer.TryComplete(ex);
|
_workerFailure ??= ex;
|
||||||
|
foreach (Channel<LandblockStreamJob> lane in _lanes)
|
||||||
|
lane.Writer.TryComplete(ex);
|
||||||
|
}
|
||||||
|
if (!cascade)
|
||||||
|
{
|
||||||
|
PublishResult(new LandblockStreamResult.WorkerCrashed(
|
||||||
|
_lanes.Length == 1
|
||||||
|
? ex.ToString()
|
||||||
|
: $"worker {laneIndex}: {ex}"));
|
||||||
|
// Stop the sibling workers. Safe against Dispose: its
|
||||||
|
// CTS disposal only happens after every worker (this one
|
||||||
|
// included) has been joined.
|
||||||
|
_cancel.Cancel();
|
||||||
}
|
}
|
||||||
PublishResult(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_outbox.Writer.TryComplete();
|
// The outbox has N writers; only the last worker out may
|
||||||
|
// complete it, or a crashed worker would silently drop the
|
||||||
|
// still-running workers' completions.
|
||||||
|
if (Interlocked.Decrement(ref _activeWorkers) == 0)
|
||||||
|
_outbox.Writer.TryComplete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,8 +490,9 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
// older queued LoadFar for the same landblock: LoadNear obviously
|
// older queued LoadFar for the same landblock: LoadNear obviously
|
||||||
// loads everything, and PromoteToNear now carries mesh data so the
|
// loads everything, and PromoteToNear now carries mesh data so the
|
||||||
// render thread can run the full near-tier apply side effects. If a
|
// render thread can run the full near-tier apply side effects. If a
|
||||||
// LoadFar is already being processed, the single worker naturally
|
// LoadFar is already being processed, the owning lane's worker
|
||||||
// finishes it before the promotion is dequeued.
|
// naturally finishes it before the promotion is dequeued (every
|
||||||
|
// job for one landblock id lives on one lane).
|
||||||
RemoveLowPriorityJobsForLandblock(
|
RemoveLowPriorityJobsForLandblock(
|
||||||
lowPriority,
|
lowPriority,
|
||||||
high.LandblockId,
|
high.LandblockId,
|
||||||
|
|
@ -540,11 +645,18 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
|
||||||
System.Threading.Interlocked.Exchange(ref _disposed, 1);
|
System.Threading.Interlocked.Exchange(ref _disposed, 1);
|
||||||
_cancel.Cancel();
|
_cancel.Cancel();
|
||||||
lock (_inboxGate)
|
lock (_inboxGate)
|
||||||
_inbox.Writer.TryComplete();
|
{
|
||||||
|
foreach (Channel<LandblockStreamJob> lane in _lanes)
|
||||||
|
lane.Writer.TryComplete();
|
||||||
|
}
|
||||||
// The owner releases the memory-mapped DAT immediately after this
|
// The owner releases the memory-mapped DAT immediately after this
|
||||||
// object. Join the actual worker without a grace-period timeout so
|
// object. Join every actual worker without a grace-period timeout
|
||||||
// no native read can survive into that teardown.
|
// so no native read can survive into that teardown.
|
||||||
_worker?.Join();
|
if (_workers is { } workers)
|
||||||
|
{
|
||||||
|
foreach (Thread worker in workers)
|
||||||
|
worker.Join();
|
||||||
|
}
|
||||||
_cancel.Dispose();
|
_cancel.Dispose();
|
||||||
_disposeCompleted = true;
|
_disposeCompleted = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
155
src/AcDream.App/Streaming/RevealTimingProbe.cs
Normal file
155
src/AcDream.App/Streaming/RevealTimingProbe.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
|
||||||
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ACDREAM_PROBE_REVEAL_TIMING=1</c> (see
|
||||||
|
/// <see cref="StreamingDiagnostics.ProbeRevealTiming"/>): wall-clock
|
||||||
|
/// attribution of one login/portal hold. Emits <c>[reveal-timing]</c> lines:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>event=begin</c> — the hold starts, with the required window and
|
||||||
|
/// its landblock count.</item>
|
||||||
|
/// <item><c>event=render-ready / composites-ready / collision-ready /
|
||||||
|
/// gate-ready / materialized</c> — first-true edges with elapsed ms. The
|
||||||
|
/// readiness barrier OBSERVES the dimensions serially (composites are only
|
||||||
|
/// evaluated once render is ready, collision once both are), so each edge's
|
||||||
|
/// delta over the previous one is that dimension's observed TAIL, not its
|
||||||
|
/// total concurrent cost — the progress lines carry the concurrency
|
||||||
|
/// shape.</item>
|
||||||
|
/// <item>1 Hz progress — elapsed, the three flags, and the resident
|
||||||
|
/// landblock count, so a budget-paced linear drip is visually obvious in the
|
||||||
|
/// log.</item>
|
||||||
|
/// <item><c>SUMMARY</c> once at the viewport reveal — the per-edge
|
||||||
|
/// timeline on one line.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Diagnostic-only: never constructed unless the probe env is set, changes
|
||||||
|
/// no behavior, and costs one branch per <c>Evaluate</c> poll otherwise.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RevealTimingProbe
|
||||||
|
{
|
||||||
|
private readonly Func<int>? _loadedLandblockCount;
|
||||||
|
private readonly Stopwatch _clock = new();
|
||||||
|
private long _generation;
|
||||||
|
private string _kind = "";
|
||||||
|
private int _windowLandblocks;
|
||||||
|
private bool _render;
|
||||||
|
private bool _composites;
|
||||||
|
private bool _collision;
|
||||||
|
private bool _gateReady;
|
||||||
|
private bool _materialized;
|
||||||
|
private bool _summarized;
|
||||||
|
private long _renderMs = -1;
|
||||||
|
private long _compositesMs = -1;
|
||||||
|
private long _collisionMs = -1;
|
||||||
|
private long _gateReadyMs = -1;
|
||||||
|
private long _materializedMs = -1;
|
||||||
|
private long _lastProgressMs;
|
||||||
|
|
||||||
|
public RevealTimingProbe(Func<int>? loadedLandblockCount) =>
|
||||||
|
_loadedLandblockCount = loadedLandblockCount;
|
||||||
|
|
||||||
|
public void Begin(
|
||||||
|
string kind,
|
||||||
|
long generation,
|
||||||
|
uint destinationCell,
|
||||||
|
in StreamingRevealWindow window)
|
||||||
|
{
|
||||||
|
_generation = generation;
|
||||||
|
_kind = kind;
|
||||||
|
int side = window.FarRadius * 2 + 1;
|
||||||
|
_windowLandblocks = side * side;
|
||||||
|
_render = false;
|
||||||
|
_composites = false;
|
||||||
|
_collision = false;
|
||||||
|
_gateReady = false;
|
||||||
|
_materialized = false;
|
||||||
|
_summarized = false;
|
||||||
|
_renderMs = -1;
|
||||||
|
_compositesMs = -1;
|
||||||
|
_collisionMs = -1;
|
||||||
|
_gateReadyMs = -1;
|
||||||
|
_materializedMs = -1;
|
||||||
|
_lastProgressMs = 0;
|
||||||
|
_clock.Restart();
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[reveal-timing] event=begin kind={kind} gen={generation} "
|
||||||
|
+ $"cell=0x{destinationCell:X8} window={window.NearRadius}/"
|
||||||
|
+ $"{window.FarRadius} landblocks={_windowLandblocks} "
|
||||||
|
+ $"loaded={_loadedLandblockCount?.Invoke() ?? -1}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Observe(
|
||||||
|
in WorldRevealReadinessSnapshot readiness,
|
||||||
|
in RuntimePortalSnapshot portal)
|
||||||
|
{
|
||||||
|
if (_generation == 0 || portal.Generation != _generation)
|
||||||
|
return;
|
||||||
|
|
||||||
|
long elapsed = _clock.ElapsedMilliseconds;
|
||||||
|
if (!_render && readiness.IsRenderNeighborhoodReady)
|
||||||
|
{
|
||||||
|
_render = true;
|
||||||
|
_renderMs = elapsed;
|
||||||
|
Edge("render-ready", elapsed);
|
||||||
|
}
|
||||||
|
if (!_composites && readiness.AreCompositeTexturesReady)
|
||||||
|
{
|
||||||
|
_composites = true;
|
||||||
|
_compositesMs = elapsed;
|
||||||
|
Edge("composites-ready", elapsed);
|
||||||
|
}
|
||||||
|
if (!_collision && readiness.IsCollisionReady)
|
||||||
|
{
|
||||||
|
_collision = true;
|
||||||
|
_collisionMs = elapsed;
|
||||||
|
Edge("collision-ready", elapsed);
|
||||||
|
}
|
||||||
|
if (!_gateReady && readiness.IsReady)
|
||||||
|
{
|
||||||
|
_gateReady = true;
|
||||||
|
_gateReadyMs = elapsed;
|
||||||
|
Edge("gate-ready", elapsed);
|
||||||
|
}
|
||||||
|
if (!_materialized && portal.Materialized)
|
||||||
|
{
|
||||||
|
_materialized = true;
|
||||||
|
_materializedMs = elapsed;
|
||||||
|
Edge("materialized", elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_summarized && portal.WorldViewportObserved)
|
||||||
|
{
|
||||||
|
_summarized = true;
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[reveal-timing] SUMMARY kind={_kind} gen={_generation} "
|
||||||
|
+ $"totalMs={elapsed} renderMs={_renderMs} "
|
||||||
|
+ $"compositesMs={_compositesMs} collisionMs={_collisionMs} "
|
||||||
|
+ $"gateReadyMs={_gateReadyMs} "
|
||||||
|
+ $"materializedMs={_materializedMs} "
|
||||||
|
+ $"landblocks={_windowLandblocks}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_summarized && elapsed - _lastProgressMs >= 1000)
|
||||||
|
{
|
||||||
|
_lastProgressMs = elapsed;
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[reveal-timing] elapsedMs={elapsed} "
|
||||||
|
+ $"render={(_render ? 1 : 0)} "
|
||||||
|
+ $"composites={(_composites ? 1 : 0)} "
|
||||||
|
+ $"collision={(_collision ? 1 : 0)} "
|
||||||
|
+ $"loaded={_loadedLandblockCount?.Invoke() ?? -1}"
|
||||||
|
+ $"/{_windowLandblocks}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Edge(string name, long elapsed) =>
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[reveal-timing] event={name} kind={_kind} gen={_generation} "
|
||||||
|
+ $"elapsedMs={elapsed} "
|
||||||
|
+ $"loaded={_loadedLandblockCount?.Invoke() ?? -1}"
|
||||||
|
+ $"/{_windowLandblocks}");
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,19 @@ internal static class StreamingDiagnostics
|
||||||
far);
|
far);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Login-load measurement probe (2026-08-17): when set, the reveal
|
||||||
|
/// coordinator emits a <c>[reveal-timing]</c> wall-clock timeline for
|
||||||
|
/// every login/portal hold — per-dimension first-ready edges
|
||||||
|
/// (render neighborhood, composite textures, collision), 1 Hz progress
|
||||||
|
/// with the resident-landblock count, and one summary line at the
|
||||||
|
/// viewport reveal — so optimization targets the measured dominant
|
||||||
|
/// phase instead of a guess. Diagnostic-only: not a user setting, not
|
||||||
|
/// persisted, no behavior change.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ProbeRevealTiming { get; } =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL_TIMING") == "1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The floor is 1, not 0. An outdoor destination's acknowledgement must
|
/// The floor is 1, not 0. An outdoor destination's acknowledgement must
|
||||||
/// carry <c>RequiredRenderRadius >= 1</c> or
|
/// carry <c>RequiredRenderRadius >= 1</c> or
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ internal sealed class WorldRevealCoordinator
|
||||||
private readonly IWorldRevealStreamingScheduler? _streaming;
|
private readonly IWorldRevealStreamingScheduler? _streaming;
|
||||||
private readonly IWorldRevealRenderResourceScheduler? _renderResources;
|
private readonly IWorldRevealRenderResourceScheduler? _renderResources;
|
||||||
private readonly List<HostProjection> _hostProjections = [];
|
private readonly List<HostProjection> _hostProjections = [];
|
||||||
|
private readonly RevealTimingProbe? _timing;
|
||||||
private bool _hostRetryActive;
|
private bool _hostRetryActive;
|
||||||
private bool _hostRetryRequested;
|
private bool _hostRetryRequested;
|
||||||
|
|
||||||
|
|
@ -83,7 +84,8 @@ internal sealed class WorldRevealCoordinator
|
||||||
Func<uint, bool> isSpawnClaimUnhydratable,
|
Func<uint, bool> isSpawnClaimUnhydratable,
|
||||||
WorldGenerationQuiescence? quiescence = null,
|
WorldGenerationQuiescence? quiescence = null,
|
||||||
IWorldRevealStreamingScheduler? streaming = null,
|
IWorldRevealStreamingScheduler? streaming = null,
|
||||||
IWorldRevealRenderResourceScheduler? renderResources = null)
|
IWorldRevealRenderResourceScheduler? renderResources = null,
|
||||||
|
Func<int>? loadedLandblockCount = null)
|
||||||
{
|
{
|
||||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||||
_readiness = new WorldRevealReadinessBarrier(
|
_readiness = new WorldRevealReadinessBarrier(
|
||||||
|
|
@ -98,6 +100,8 @@ internal sealed class WorldRevealCoordinator
|
||||||
_quiescence = quiescence;
|
_quiescence = quiescence;
|
||||||
_streaming = streaming;
|
_streaming = streaming;
|
||||||
_renderResources = renderResources;
|
_renderResources = renderResources;
|
||||||
|
if (StreamingDiagnostics.ProbeRevealTiming)
|
||||||
|
_timing = new RevealTimingProbe(loadedLandblockCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimePortalSnapshot Snapshot => _transit.Snapshot;
|
public RuntimePortalSnapshot Snapshot => _transit.Snapshot;
|
||||||
|
|
@ -117,6 +121,11 @@ internal sealed class WorldRevealCoordinator
|
||||||
_quiescence?.CaptureBegin() ?? default;
|
_quiescence?.CaptureBegin() ?? default;
|
||||||
_readiness.Begin();
|
_readiness.Begin();
|
||||||
long generation = _transit.BeginLoginReveal(destinationCell);
|
long generation = _transit.BeginLoginReveal(destinationCell);
|
||||||
|
_timing?.Begin(
|
||||||
|
"login",
|
||||||
|
generation,
|
||||||
|
destinationCell,
|
||||||
|
_readiness.RequiredWindow(destinationCell));
|
||||||
BeginHostLifetime(
|
BeginHostLifetime(
|
||||||
generation,
|
generation,
|
||||||
destinationCell,
|
destinationCell,
|
||||||
|
|
@ -151,6 +160,11 @@ internal sealed class WorldRevealCoordinator
|
||||||
}
|
}
|
||||||
|
|
||||||
_readiness.Begin();
|
_readiness.Begin();
|
||||||
|
_timing?.Begin(
|
||||||
|
"portal",
|
||||||
|
generation,
|
||||||
|
destinationCell,
|
||||||
|
_readiness.RequiredWindow(destinationCell));
|
||||||
BeginHostLifetime(
|
BeginHostLifetime(
|
||||||
generation,
|
generation,
|
||||||
destinationCell,
|
destinationCell,
|
||||||
|
|
@ -195,6 +209,7 @@ internal sealed class WorldRevealCoordinator
|
||||||
WorldRevealReadinessSnapshot snapshot = _readiness.Evaluate(destinationCell);
|
WorldRevealReadinessSnapshot snapshot = _readiness.Evaluate(destinationCell);
|
||||||
ReconcileDestinationReservationRadius(snapshot);
|
ReconcileDestinationReservationRadius(snapshot);
|
||||||
RuntimePortalSnapshot portal = _transit.Snapshot;
|
RuntimePortalSnapshot portal = _transit.Snapshot;
|
||||||
|
_timing?.Observe(snapshot, portal);
|
||||||
if (portal.Generation != 0)
|
if (portal.Generation != 0)
|
||||||
{
|
{
|
||||||
_transit.AcknowledgeDestinationReadiness(
|
_transit.AcknowledgeDestinationReadiness(
|
||||||
|
|
|
||||||
|
|
@ -118,13 +118,17 @@ public sealed class LandblockBuildOriginTests
|
||||||
var observed = new List<LandblockBuildRequest>();
|
var observed = new List<LandblockBuildRequest>();
|
||||||
var firstOrigin = new LandblockBuildOrigin(0xA9, 0xB4);
|
var firstOrigin = new LandblockBuildOrigin(0xA9, 0xB4);
|
||||||
var secondOrigin = new LandblockBuildOrigin(0x71, 0xEC);
|
var secondOrigin = new LandblockBuildOrigin(0x71, 0xEC);
|
||||||
|
// workerCount: 1 — the ordered `observed`/completion asserts span two
|
||||||
|
// DIFFERENT landblock ids; only the serial degenerate pool (#418)
|
||||||
|
// guarantees that global order (per-id order is the pool contract).
|
||||||
using var streamer = LandblockStreamer.CreateForRequests(
|
using var streamer = LandblockStreamer.CreateForRequests(
|
||||||
loadLandblock: request =>
|
loadLandblock: request =>
|
||||||
{
|
{
|
||||||
observed.Add(request);
|
observed.Add(request);
|
||||||
return EmptyBuild(request.LandblockId, request.Origin);
|
return EmptyBuild(request.LandblockId, request.Origin);
|
||||||
},
|
},
|
||||||
buildMeshOrNull: (_, _) => EmptyMesh());
|
buildMeshOrNull: (_, _) => EmptyMesh(),
|
||||||
|
workerCount: 1);
|
||||||
|
|
||||||
streamer.EnqueueLoad(new LandblockBuildRequest(
|
streamer.EnqueueLoad(new LandblockBuildRequest(
|
||||||
0xA9B4FFFFu,
|
0xA9B4FFFFu,
|
||||||
|
|
|
||||||
431
tests/AcDream.App.Tests/Streaming/LandblockStreamerPoolTests.cs
Normal file
431
tests/AcDream.App.Tests/Streaming/LandblockStreamerPoolTests.cs
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #418 build-worker-pool contract tests. The pool stripes jobs across
|
||||||
|
/// per-worker lanes by landblock id, so the contract is: per-landblock
|
||||||
|
/// enqueue order is execution (and completion-arrival) order, ClearLoads
|
||||||
|
/// supersedes every load queued before it on every lane, Near-tier jobs run
|
||||||
|
/// before Far-tier ones within a lane, a pool of one reproduces the serial
|
||||||
|
/// pre-#418 behavior, and disposal joins every worker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LandblockStreamerPoolTests
|
||||||
|
{
|
||||||
|
private const int SpinTimeoutMs = 10_000;
|
||||||
|
private const int SpinStepMs = 10;
|
||||||
|
|
||||||
|
private static AcDream.Core.Terrain.LandblockMeshData StubMesh() =>
|
||||||
|
new(
|
||||||
|
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||||
|
Array.Empty<uint>());
|
||||||
|
|
||||||
|
private static LoadedLandblock StubLandblock(uint id) =>
|
||||||
|
new(id, new LandBlock(), Array.Empty<WorldEntity>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produce a landblock id (retail 0xXXYYFFFF shape) owned by
|
||||||
|
/// <paramref name="lane"/>, distinct from every id in
|
||||||
|
/// <paramref name="taken"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static uint IdInLane(LandblockStreamer streamer, int lane, HashSet<uint> taken)
|
||||||
|
{
|
||||||
|
for (uint x = 0; x < 256; x++)
|
||||||
|
{
|
||||||
|
for (uint y = 0; y < 256; y++)
|
||||||
|
{
|
||||||
|
uint id = (x << 24) | (y << 16) | 0xFFFFu;
|
||||||
|
if (streamer.LaneFor(id) == lane && taken.Add(id))
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException($"No landblock id maps to lane {lane}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<LandblockStreamResult>> DrainCountAsync(
|
||||||
|
LandblockStreamer streamer,
|
||||||
|
int count,
|
||||||
|
int timeoutMs = SpinTimeoutMs)
|
||||||
|
{
|
||||||
|
var results = new List<LandblockStreamResult>(count);
|
||||||
|
for (int i = 0; i < timeoutMs / SpinStepMs && results.Count < count; i++)
|
||||||
|
{
|
||||||
|
results.AddRange(streamer.DrainCompletions(count - results.Count));
|
||||||
|
if (results.Count < count)
|
||||||
|
await Task.Delay(SpinStepMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(count, results.Count);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkerCount_DefaultsBoundedFloorsAtOneAndRejectsZero()
|
||||||
|
{
|
||||||
|
Assert.InRange(LandblockStreamer.DefaultWorkerCount, 1, 8);
|
||||||
|
|
||||||
|
using var defaulted = new LandblockStreamer(loadLandblock: _ => null);
|
||||||
|
Assert.Equal(LandblockStreamer.DefaultWorkerCount, defaulted.WorkerCount);
|
||||||
|
|
||||||
|
using var explicitThree = new LandblockStreamer(
|
||||||
|
loadLandblock: _ => null,
|
||||||
|
buildMeshOrNull: null,
|
||||||
|
workerCount: 3);
|
||||||
|
Assert.Equal(3, explicitThree.WorkerCount);
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => new LandblockStreamer(
|
||||||
|
loadLandblock: _ => null,
|
||||||
|
buildMeshOrNull: null,
|
||||||
|
workerCount: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LaneAssignment_SpreadsRealWindowIdsAcrossLanes()
|
||||||
|
{
|
||||||
|
// Landblock ids carry their identity in the high word (0xXXYYFFFF);
|
||||||
|
// a bare modulo of the raw id would put EVERY id in one lane. Guard
|
||||||
|
// the mixed hash: a realistic 25x25 login window must engage more
|
||||||
|
// than one lane of an 8-lane pool.
|
||||||
|
using var streamer = new LandblockStreamer(
|
||||||
|
loadLandblock: _ => null,
|
||||||
|
buildMeshOrNull: null,
|
||||||
|
workerCount: 8);
|
||||||
|
|
||||||
|
var lanes = new HashSet<int>();
|
||||||
|
for (uint x = 0xA0; x < 0xA0 + 25; x++)
|
||||||
|
for (uint y = 0xB0; y < 0xB0 + 25; y++)
|
||||||
|
lanes.Add(streamer.LaneFor((x << 24) | (y << 16) | 0xFFFFu));
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
lanes.Count > 1,
|
||||||
|
$"25x25 window ids collapsed into {lanes.Count} lane(s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PerLandblockJobs_ExecuteInEnqueueOrder_UnderPoolContention()
|
||||||
|
{
|
||||||
|
const int idCount = 12;
|
||||||
|
const int jobsPerId = 8; // alternating LoadFar / Unload
|
||||||
|
int loaderCalls = 0;
|
||||||
|
|
||||||
|
using var streamer = new LandblockStreamer(
|
||||||
|
loadLandblock: (uint id, LandblockStreamJobKind _) =>
|
||||||
|
{
|
||||||
|
// Shake worker scheduling so a per-landblock ordering bug
|
||||||
|
// would actually interleave.
|
||||||
|
if (Interlocked.Increment(ref loaderCalls) % 3 == 0)
|
||||||
|
Thread.Sleep(1);
|
||||||
|
return StubLandblock(id);
|
||||||
|
},
|
||||||
|
buildMeshOrNull: (_, _) => StubMesh(),
|
||||||
|
workerCount: 4);
|
||||||
|
streamer.Start();
|
||||||
|
|
||||||
|
var ids = new uint[idCount];
|
||||||
|
for (uint i = 0; i < idCount; i++)
|
||||||
|
ids[i] = ((0x30u + i) << 24) | ((0x40u + i) << 16) | 0xFFFFu;
|
||||||
|
|
||||||
|
// Round-robin across ids while the pool is already running, so jobs
|
||||||
|
// for the same id repeatedly queue behind and race with other lanes.
|
||||||
|
ulong generation = 0;
|
||||||
|
for (int job = 0; job < jobsPerId; job++)
|
||||||
|
{
|
||||||
|
foreach (uint id in ids)
|
||||||
|
{
|
||||||
|
generation++;
|
||||||
|
if (job % 2 == 0)
|
||||||
|
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar, generation);
|
||||||
|
else
|
||||||
|
streamer.EnqueueUnload(id, generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LandblockStreamResult> results =
|
||||||
|
await DrainCountAsync(streamer, idCount * jobsPerId);
|
||||||
|
|
||||||
|
foreach (uint id in ids)
|
||||||
|
{
|
||||||
|
var perId = results.Where(result => result.LandblockId == id).ToList();
|
||||||
|
Assert.Equal(jobsPerId, perId.Count);
|
||||||
|
// Completion arrival order per landblock id must be the enqueue
|
||||||
|
// order: alternating Loaded/Unloaded with strictly increasing
|
||||||
|
// generations.
|
||||||
|
for (int i = 0; i < perId.Count; i++)
|
||||||
|
{
|
||||||
|
if (i % 2 == 0)
|
||||||
|
Assert.IsType<LandblockStreamResult.Loaded>(perId[i]);
|
||||||
|
else
|
||||||
|
Assert.IsType<LandblockStreamResult.Unloaded>(perId[i]);
|
||||||
|
if (i > 0)
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
perId[i].Generation > perId[i - 1].Generation,
|
||||||
|
$"LB 0x{id:X8}: result {i} (gen {perId[i].Generation}) arrived " +
|
||||||
|
$"after gen {perId[i - 1].Generation} out of enqueue order.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearPendingLoads_DropsQueuedLoadsAcrossAllLanes()
|
||||||
|
{
|
||||||
|
const int workerCount = 4;
|
||||||
|
using var release = new ManualResetEventSlim();
|
||||||
|
using var entered = new CountdownEvent(workerCount);
|
||||||
|
var loadedIds = new ConcurrentBag<uint>();
|
||||||
|
var blockerIds = new HashSet<uint>();
|
||||||
|
|
||||||
|
using var streamer = new LandblockStreamer(
|
||||||
|
loadLandblock: (uint id, LandblockStreamJobKind _) =>
|
||||||
|
{
|
||||||
|
loadedIds.Add(id);
|
||||||
|
bool isBlocker;
|
||||||
|
lock (blockerIds)
|
||||||
|
isBlocker = blockerIds.Contains(id);
|
||||||
|
if (isBlocker)
|
||||||
|
{
|
||||||
|
entered.Signal();
|
||||||
|
release.Wait();
|
||||||
|
}
|
||||||
|
return StubLandblock(id);
|
||||||
|
},
|
||||||
|
buildMeshOrNull: (_, _) => StubMesh(),
|
||||||
|
workerCount: workerCount);
|
||||||
|
|
||||||
|
var taken = new HashSet<uint>();
|
||||||
|
var victimIds = new List<uint>();
|
||||||
|
var unloadIds = new List<uint>();
|
||||||
|
var survivorIds = new List<uint>();
|
||||||
|
lock (blockerIds)
|
||||||
|
{
|
||||||
|
for (int lane = 0; lane < workerCount; lane++)
|
||||||
|
{
|
||||||
|
blockerIds.Add(IdInLane(streamer, lane, taken));
|
||||||
|
victimIds.Add(IdInLane(streamer, lane, taken));
|
||||||
|
victimIds.Add(IdInLane(streamer, lane, taken));
|
||||||
|
unloadIds.Add(IdInLane(streamer, lane, taken));
|
||||||
|
survivorIds.Add(IdInLane(streamer, lane, taken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamer.Start();
|
||||||
|
lock (blockerIds)
|
||||||
|
{
|
||||||
|
foreach (uint id in blockerIds)
|
||||||
|
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
|
}
|
||||||
|
// Every worker is now inside its blocker build; everything below
|
||||||
|
// queues behind them, one lane each.
|
||||||
|
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
|
||||||
|
foreach (uint id in victimIds)
|
||||||
|
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
|
foreach (uint id in unloadIds)
|
||||||
|
streamer.EnqueueUnload(id);
|
||||||
|
|
||||||
|
// Must supersede every load queued above on EVERY lane, while the
|
||||||
|
// queued unloads survive.
|
||||||
|
streamer.ClearPendingLoads();
|
||||||
|
|
||||||
|
foreach (uint id in survivorIds)
|
||||||
|
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
|
|
||||||
|
release.Set();
|
||||||
|
|
||||||
|
// Expected completions: 4 blocker Loaded + 4 Unloaded + 4 survivor
|
||||||
|
// Loaded. The victims produce nothing.
|
||||||
|
List<LandblockStreamResult> results =
|
||||||
|
await DrainCountAsync(streamer, workerCount * 3);
|
||||||
|
|
||||||
|
var loadedResultIds = results
|
||||||
|
.OfType<LandblockStreamResult.Loaded>()
|
||||||
|
.Select(result => result.LandblockId)
|
||||||
|
.ToHashSet();
|
||||||
|
var unloadedResultIds = results
|
||||||
|
.OfType<LandblockStreamResult.Unloaded>()
|
||||||
|
.Select(result => result.LandblockId)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
lock (blockerIds)
|
||||||
|
{
|
||||||
|
foreach (uint id in blockerIds)
|
||||||
|
Assert.Contains(id, loadedResultIds);
|
||||||
|
}
|
||||||
|
foreach (uint id in survivorIds)
|
||||||
|
Assert.Contains(id, loadedResultIds);
|
||||||
|
foreach (uint id in unloadIds)
|
||||||
|
Assert.Contains(id, unloadedResultIds);
|
||||||
|
foreach (uint id in victimIds)
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(id, loadedResultIds);
|
||||||
|
Assert.DoesNotContain(id, loadedIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NearTierJobs_RunBeforeQueuedFarJobs_WithinEachLane()
|
||||||
|
{
|
||||||
|
const int workerCount = 3;
|
||||||
|
using var release = new ManualResetEventSlim();
|
||||||
|
using var entered = new CountdownEvent(workerCount);
|
||||||
|
var executionOrder = new ConcurrentQueue<uint>();
|
||||||
|
var blockerIds = new HashSet<uint>();
|
||||||
|
|
||||||
|
using var streamer = new LandblockStreamer(
|
||||||
|
loadLandblock: (uint id, LandblockStreamJobKind _) =>
|
||||||
|
{
|
||||||
|
bool isBlocker;
|
||||||
|
lock (blockerIds)
|
||||||
|
isBlocker = blockerIds.Contains(id);
|
||||||
|
if (isBlocker)
|
||||||
|
{
|
||||||
|
entered.Signal();
|
||||||
|
release.Wait();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
executionOrder.Enqueue(id);
|
||||||
|
}
|
||||||
|
return StubLandblock(id);
|
||||||
|
},
|
||||||
|
buildMeshOrNull: (_, _) => StubMesh(),
|
||||||
|
workerCount: workerCount);
|
||||||
|
|
||||||
|
var taken = new HashSet<uint>();
|
||||||
|
var farIds = new uint[workerCount];
|
||||||
|
var nearIds = new uint[workerCount];
|
||||||
|
lock (blockerIds)
|
||||||
|
{
|
||||||
|
for (int lane = 0; lane < workerCount; lane++)
|
||||||
|
{
|
||||||
|
blockerIds.Add(IdInLane(streamer, lane, taken));
|
||||||
|
farIds[lane] = IdInLane(streamer, lane, taken);
|
||||||
|
nearIds[lane] = IdInLane(streamer, lane, taken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamer.Start();
|
||||||
|
lock (blockerIds)
|
||||||
|
{
|
||||||
|
foreach (uint id in blockerIds)
|
||||||
|
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
|
}
|
||||||
|
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
|
||||||
|
// Far first, near second — the near job must still run first once
|
||||||
|
// the lane's blocker completes.
|
||||||
|
for (int lane = 0; lane < workerCount; lane++)
|
||||||
|
{
|
||||||
|
streamer.EnqueueLoad(farIds[lane], LandblockStreamJobKind.LoadFar);
|
||||||
|
streamer.EnqueueLoad(nearIds[lane], LandblockStreamJobKind.LoadNear);
|
||||||
|
}
|
||||||
|
|
||||||
|
release.Set();
|
||||||
|
|
||||||
|
await DrainCountAsync(streamer, workerCount * 3);
|
||||||
|
|
||||||
|
var observed = executionOrder.ToList();
|
||||||
|
for (int lane = 0; lane < workerCount; lane++)
|
||||||
|
{
|
||||||
|
int nearIndex = observed.IndexOf(nearIds[lane]);
|
||||||
|
int farIndex = observed.IndexOf(farIds[lane]);
|
||||||
|
Assert.True(nearIndex >= 0 && farIndex >= 0);
|
||||||
|
Assert.True(
|
||||||
|
nearIndex < farIndex,
|
||||||
|
$"lane {lane}: near 0x{nearIds[lane]:X8} (index {nearIndex}) ran " +
|
||||||
|
$"after far 0x{farIds[lane]:X8} (index {farIndex}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SingleWorkerPool_ReproducesSerialGlobalOrdering()
|
||||||
|
{
|
||||||
|
// The degenerate pool of one must be today's (pre-#418) behavior
|
||||||
|
// exactly: one worker thread, global near-first execution order
|
||||||
|
// across DIFFERENT landblock ids.
|
||||||
|
var callOrder = new List<uint>();
|
||||||
|
var loaderThreads = new HashSet<int>();
|
||||||
|
|
||||||
|
using var streamer = new LandblockStreamer(
|
||||||
|
loadLandblock: (uint id, LandblockStreamJobKind _) =>
|
||||||
|
{
|
||||||
|
callOrder.Add(id);
|
||||||
|
loaderThreads.Add(System.Environment.CurrentManagedThreadId);
|
||||||
|
return StubLandblock(id);
|
||||||
|
},
|
||||||
|
buildMeshOrNull: (_, _) => StubMesh(),
|
||||||
|
workerCount: 1);
|
||||||
|
Assert.Equal(1, streamer.WorkerCount);
|
||||||
|
|
||||||
|
streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar);
|
||||||
|
streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar);
|
||||||
|
streamer.EnqueueLoad(0xCCCCFFFFu, LandblockStreamJobKind.LoadNear);
|
||||||
|
streamer.Start();
|
||||||
|
|
||||||
|
List<LandblockStreamResult> results = await DrainCountAsync(streamer, 3);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { 0xCCCCFFFFu, 0xAAAAFFFFu, 0xBBBBFFFFu },
|
||||||
|
callOrder);
|
||||||
|
Assert.Single(loaderThreads);
|
||||||
|
var first = Assert.IsType<LandblockStreamResult.Loaded>(results[0]);
|
||||||
|
Assert.Equal(0xCCCCFFFFu, first.LandblockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_JoinsEveryWorkerInThePool()
|
||||||
|
{
|
||||||
|
const int workerCount = 3;
|
||||||
|
using var release = new ManualResetEventSlim();
|
||||||
|
using var entered = new CountdownEvent(workerCount);
|
||||||
|
var loaderThreads = new ConcurrentDictionary<int, byte>();
|
||||||
|
var blockerIds = new HashSet<uint>();
|
||||||
|
|
||||||
|
var streamer = new LandblockStreamer(
|
||||||
|
loadLandblock: (uint id, LandblockStreamJobKind _) =>
|
||||||
|
{
|
||||||
|
loaderThreads.TryAdd(System.Environment.CurrentManagedThreadId, 0);
|
||||||
|
entered.Signal();
|
||||||
|
release.Wait();
|
||||||
|
return StubLandblock(id);
|
||||||
|
},
|
||||||
|
buildMeshOrNull: (_, _) => StubMesh(),
|
||||||
|
workerCount: workerCount);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var taken = new HashSet<uint>();
|
||||||
|
for (int lane = 0; lane < workerCount; lane++)
|
||||||
|
blockerIds.Add(IdInLane(streamer, lane, taken));
|
||||||
|
|
||||||
|
streamer.Start();
|
||||||
|
foreach (uint id in blockerIds)
|
||||||
|
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||||
|
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
Assert.Equal(workerCount, loaderThreads.Count);
|
||||||
|
|
||||||
|
Task dispose = Task.Run(streamer.Dispose);
|
||||||
|
await Task.Delay(100);
|
||||||
|
// Dispose must be blocked on the still-building workers.
|
||||||
|
Assert.False(dispose.IsCompleted);
|
||||||
|
|
||||||
|
release.Set();
|
||||||
|
await dispose.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.Throws<ObjectDisposedException>(
|
||||||
|
() => streamer.EnqueueLoad(0x1234FFFFu, LandblockStreamJobKind.LoadFar));
|
||||||
|
Assert.Throws<ObjectDisposedException>(
|
||||||
|
() => streamer.EnqueueUnload(0x1234FFFFu));
|
||||||
|
Assert.Throws<ObjectDisposedException>(streamer.ClearPendingLoads);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
release.Set();
|
||||||
|
streamer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,13 +55,19 @@ public class LandblockStreamerTests
|
||||||
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||||
System.Array.Empty<uint>());
|
System.Array.Empty<uint>());
|
||||||
|
|
||||||
|
// workerCount: 1 — this test asserts a GLOBAL execution order across
|
||||||
|
// four DIFFERENT landblock ids, which only the serial degenerate pool
|
||||||
|
// guarantees (#418). The pool contract orders jobs per landblock id
|
||||||
|
// and prefers Near-tier per lane; the cross-lane variant lives in
|
||||||
|
// AcDream.App.Tests LandblockStreamerPoolTests.
|
||||||
using var streamer = new LandblockStreamer(
|
using var streamer = new LandblockStreamer(
|
||||||
loadLandblock: (id, kind) =>
|
loadLandblock: (id, kind) =>
|
||||||
{
|
{
|
||||||
callOrder.Add((id, kind));
|
callOrder.Add((id, kind));
|
||||||
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
|
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
|
||||||
},
|
},
|
||||||
buildMeshOrNull: (_, _) => stubMesh);
|
buildMeshOrNull: (_, _) => stubMesh,
|
||||||
|
workerCount: 1);
|
||||||
|
|
||||||
streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar);
|
streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar);
|
||||||
streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar);
|
streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue