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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 19:36:56 +02:00
commit 27b6c8bc19
10 changed files with 850 additions and 61 deletions

View file

@ -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.
- 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 2564x changed nothing →
read as producer-limited (ONE `acdream.streaming.worker` thread,
~31 ms/block serial). This commit parallelized the producer
(min(cores2, 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 013 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
**Status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same

View file

@ -425,7 +425,8 @@ internal sealed class SessionPlayerCompositionPhase
spawnClaimClassifier.IsUnhydratable,
worldQuiescence,
streaming,
revealRenderResources);
revealRenderResources,
() => live.WorldState.LoadedLandblockCount);
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
return CompleteSessionPlayer(

View file

@ -198,6 +198,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// that need to enumerate entities before the landblock is dropped
/// (e.g. unregistering dynamic lights on a RemoveLandblock).
/// </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)
{
if (_loaded.TryGetValue(landblockId, out var found))

View file

@ -16,19 +16,24 @@ namespace AcDream.App.Streaming;
/// per OnUpdate.
///
/// <para>
/// <b>Thread model (Phase A.5 T11+):</b> <see cref="Start"/> spawns a
/// dedicated background worker thread. <see cref="EnqueueLoad"/> and
/// <see cref="EnqueueUnload"/> write non-blocking to the inbox
/// <see cref="Channel{T}"/>; the worker drains it and posts
/// <see cref="LandblockStreamResult"/> records to the outbox.
/// <b>Thread model (#418):</b> <see cref="Start"/> spawns a small pool of
/// dedicated background worker threads (default
/// <see cref="DefaultWorkerCount"/>; the single-worker degenerate case is
/// the pre-#418 Phase A.5 T11+ shape). Jobs are striped across per-worker
/// 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>
/// <b>DatCollection thread safety</b> is provided by the caller:
/// GameWindow's <c>_datLock</c> (Phase A.5 T10) serialises all
/// <c>DatCollection.Get&lt;T&gt;</c> calls. Both factory closures passed at
/// construction acquire that lock before reading dats. The worker never
/// touches <c>DatCollection</c> directly — it only calls the factories.
/// construction acquire that lock before reading dats. The workers never
/// touch <c>DatCollection</c> directly — they only call the factories.
/// </para>
///
/// <para>
@ -40,7 +45,10 @@ namespace AcDream.App.Streaming;
/// <remarks>
/// Threading: <see cref="DrainCompletions"/> must be called from a single
/// 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>
/// </summary>
public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
@ -52,14 +60,23 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
/// </summary>
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 bool _supportsRequestOrigin;
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 CancellationTokenSource _cancel = new();
private readonly object _inboxGate = new();
private Thread? _worker;
private Thread[]? _workers;
private int _activeWorkers;
private Exception? _workerFailure;
private int _completionBacklog;
private int _disposed;
@ -74,17 +91,31 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
private LandblockStreamer(
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
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;
_supportsRequestOrigin = supportsRequestOrigin;
// Default: no mesh build (returns null → Failed result). Production
// wires in LandblockMesh.Build via the T12 construction site.
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
int lanes = workerCount ?? DefaultWorkerCount;
_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>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
}
/// <summary>
@ -94,8 +125,9 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
/// </summary>
public static LandblockStreamer CreateForRequests(
Func<LandblockBuildRequest, LandblockBuild?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null) =>
new(loadLandblock, buildMeshOrNull, supportsRequestOrigin: true);
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null,
int? workerCount = null) =>
new(loadLandblock, buildMeshOrNull, supportsRequestOrigin: true, workerCount);
/// <summary>
/// Compatibility constructor for build factories that predate the
@ -103,13 +135,15 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
/// </summary>
public LandblockStreamer(
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(
request => loadLandblock(request.LandblockId, request.Kind) is { } build
? build
: null,
buildMeshOrNull,
supportsRequestOrigin: false)
supportsRequestOrigin: false,
workerCount)
{
}
@ -120,13 +154,15 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
/// </summary>
public LandblockStreamer(
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(
request => loadLandblock(request.LandblockId, request.Kind) is { } landblock
? new LandblockBuild(landblock, Origin: request.Origin)
: null,
buildMeshOrNull,
supportsRequestOrigin: false)
supportsRequestOrigin: false,
workerCount)
{
}
@ -138,19 +174,40 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
/// </summary>
public LandblockStreamer(
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(
request => loadLandblock(request.LandblockId) is { } landblock
? new LandblockBuild(landblock, Origin: request.Origin)
: null,
buildMeshOrNull,
supportsRequestOrigin: false)
supportsRequestOrigin: false,
workerCount)
{
}
/// <summary>Configured pool size (also the lane count).</summary>
internal int WorkerCount => _lanes.Length;
/// <summary>
/// Activate the dedicated background worker thread. Idempotent and
/// thread-safe: concurrent callers will only spawn one worker; subsequent
/// Lane (worker) that owns every job for <paramref name="landblockId"/>.
/// 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
/// after the owning DAT lifetime has been released.
/// </summary>
@ -160,16 +217,27 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
if (_worker is not null)
if (_workers is not null)
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,
Name = "acdream.streaming.worker",
};
worker.Start();
_worker = worker;
int lane = i;
workers[i] = new Thread(() => WorkerLoop(lane))
{
IsBackground = true,
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>
/// Cancel every queued-but-not-started Load. Posts a
/// <see cref="LandblockStreamJob.ClearLoads"/> control job which the worker
/// honours at read time, dropping all pending Loads from both priority
/// queues (Unloads survive). Used on the dungeon-entry edge to abort the
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
/// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
/// complete; the StreamingController's collapsed-sweep unloads those few.
/// <see cref="LandblockStreamJob.ClearLoads"/> control job to EVERY lane
/// (one atomic broadcast under the enqueue gate, so a load enqueued
/// before this call is ordered ahead of its lane's ClearLoads copy and
/// dropped) which each worker honours at read time, dropping all pending
/// Loads from both of its priority queues (Unloads survive). Used on the
/// 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>
public void ClearPendingLoads()
{
@ -250,18 +321,28 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
private void WriteJob(LandblockStreamJob job)
{
// Serialize the writer's terminal transition with enqueue. Without
// this small lifecycle gate a worker crash could complete the channel
// Serialize the writers' terminal transition with enqueue. Without
// this small lifecycle gate a worker crash could complete a lane
// between the caller's state check and TryWrite, silently dropping a
// landblock request. Enqueues are destination-boundary events, not a
// per-frame hot path.
// landblock request. The same gate makes the ClearLoads broadcast
// atomic with respect to concurrent enqueues. Enqueues are
// destination-boundary events, not a per-frame hot path.
lock (_inboxGate)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
if (_workerFailure is { } failure)
throw new InvalidOperationException("The landblock streaming worker has terminated.", failure);
if (!_inbox.Writer.TryWrite(job))
throw new InvalidOperationException("A landblock streaming worker has terminated.", failure);
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.");
}
}
@ -308,8 +389,9 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
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 lowPriority = new Queue<LandblockStreamJob>();
@ -325,12 +407,12 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
{
if (highPriority.Count == 0 &&
lowPriority.Count == 0 &&
!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
!inbox.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
{
break;
}
while (_inbox.Reader.TryRead(out var job))
while (inbox.TryRead(out var job))
{
if (job is LandblockStreamJob.ClearLoads)
{
@ -358,17 +440,39 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
catch (Exception ex)
{
// 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)
{
_workerFailure = ex;
_inbox.Writer.TryComplete(ex);
cascade = _workerFailure is not null && ex is ChannelClosedException;
_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
{
_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
// loads everything, and PromoteToNear now carries mesh data so the
// render thread can run the full near-tier apply side effects. If a
// LoadFar is already being processed, the single worker naturally
// finishes it before the promotion is dequeued.
// LoadFar is already being processed, the owning lane's worker
// naturally finishes it before the promotion is dequeued (every
// job for one landblock id lives on one lane).
RemoveLowPriorityJobsForLandblock(
lowPriority,
high.LandblockId,
@ -540,11 +645,18 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
System.Threading.Interlocked.Exchange(ref _disposed, 1);
_cancel.Cancel();
lock (_inboxGate)
_inbox.Writer.TryComplete();
{
foreach (Channel<LandblockStreamJob> lane in _lanes)
lane.Writer.TryComplete();
}
// The owner releases the memory-mapped DAT immediately after this
// object. Join the actual worker without a grace-period timeout so
// no native read can survive into that teardown.
_worker?.Join();
// object. Join every actual worker without a grace-period timeout
// so no native read can survive into that teardown.
if (_workers is { } workers)
{
foreach (Thread worker in workers)
worker.Join();
}
_cancel.Dispose();
_disposeCompleted = true;
}

View 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}");
}

View file

@ -51,6 +51,19 @@ internal static class StreamingDiagnostics
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>
/// The floor is 1, not 0. An outdoor destination's acknowledgement must
/// carry <c>RequiredRenderRadius &gt;= 1</c> or

View file

@ -68,6 +68,7 @@ internal sealed class WorldRevealCoordinator
private readonly IWorldRevealStreamingScheduler? _streaming;
private readonly IWorldRevealRenderResourceScheduler? _renderResources;
private readonly List<HostProjection> _hostProjections = [];
private readonly RevealTimingProbe? _timing;
private bool _hostRetryActive;
private bool _hostRetryRequested;
@ -83,7 +84,8 @@ internal sealed class WorldRevealCoordinator
Func<uint, bool> isSpawnClaimUnhydratable,
WorldGenerationQuiescence? quiescence = null,
IWorldRevealStreamingScheduler? streaming = null,
IWorldRevealRenderResourceScheduler? renderResources = null)
IWorldRevealRenderResourceScheduler? renderResources = null,
Func<int>? loadedLandblockCount = null)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_readiness = new WorldRevealReadinessBarrier(
@ -98,6 +100,8 @@ internal sealed class WorldRevealCoordinator
_quiescence = quiescence;
_streaming = streaming;
_renderResources = renderResources;
if (StreamingDiagnostics.ProbeRevealTiming)
_timing = new RevealTimingProbe(loadedLandblockCount);
}
public RuntimePortalSnapshot Snapshot => _transit.Snapshot;
@ -117,6 +121,11 @@ internal sealed class WorldRevealCoordinator
_quiescence?.CaptureBegin() ?? default;
_readiness.Begin();
long generation = _transit.BeginLoginReveal(destinationCell);
_timing?.Begin(
"login",
generation,
destinationCell,
_readiness.RequiredWindow(destinationCell));
BeginHostLifetime(
generation,
destinationCell,
@ -151,6 +160,11 @@ internal sealed class WorldRevealCoordinator
}
_readiness.Begin();
_timing?.Begin(
"portal",
generation,
destinationCell,
_readiness.RequiredWindow(destinationCell));
BeginHostLifetime(
generation,
destinationCell,
@ -195,6 +209,7 @@ internal sealed class WorldRevealCoordinator
WorldRevealReadinessSnapshot snapshot = _readiness.Evaluate(destinationCell);
ReconcileDestinationReservationRadius(snapshot);
RuntimePortalSnapshot portal = _transit.Snapshot;
_timing?.Observe(snapshot, portal);
if (portal.Generation != 0)
{
_transit.AcknowledgeDestinationReadiness(

View file

@ -118,13 +118,17 @@ public sealed class LandblockBuildOriginTests
var observed = new List<LandblockBuildRequest>();
var firstOrigin = new LandblockBuildOrigin(0xA9, 0xB4);
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(
loadLandblock: request =>
{
observed.Add(request);
return EmptyBuild(request.LandblockId, request.Origin);
},
buildMeshOrNull: (_, _) => EmptyMesh());
buildMeshOrNull: (_, _) => EmptyMesh(),
workerCount: 1);
streamer.EnqueueLoad(new LandblockBuildRequest(
0xA9B4FFFFu,

View 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();
}
}
}

View file

@ -55,13 +55,19 @@ public class LandblockStreamerTests
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
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(
loadLandblock: (id, kind) =>
{
callOrder.Add((id, kind));
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
},
buildMeshOrNull: (_, _) => stubMesh);
buildMeshOrNull: (_, _) => stubMesh,
workerCount: 1);
streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar);
streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar);