Fifth and final Phase A.1 hotfix. Replaces the previous "drop on miss" semantics in GpuWorldState.AppendLiveEntity with a per-landblock pending bucket that survives the race where a CreateObject arrives before its landblock has been streamed in. Root cause: The post-login spawn flood (40+ NPCs/items) drains in a single WorldSession.Tick() call. The synchronous streamer enqueues all 25 visible-window landblocks in one shot but StreamingController.Tick was capped at MaxCompletionsPerFrame=4, so only 4 landblocks landed in GpuWorldState on the first frame. The center landblock 0xA9B4FFFF may or may not have been in those first 4 (HashSet iteration order is undefined). Spawns whose target landblock wasn't yet loaded were silently dropped by AppendLiveEntity. Re-ordering the OnUpdate (streaming first, live second) didn't fix it because the cap still limited to 4 per frame; spawns for landblocks #5+ kept dropping until the queue drained, by which point the spawn flood was over. The reordering was correct but insufficient. The cap was a relic of the original async streamer design (limit GPU upload spikes per frame). With the synchronous streamer there's no backlog to spread, so the cap was pure latency for no benefit. Setting it to int.MaxValue restores "drain everything you just enqueued" semantics. The pending-spawn list is the *correct* architecture fix that makes the system robust against any future ordering bug, not just the cap: - AppendLiveEntity for an unloaded landblock parks the entity in a per-landblock pending bucket instead of dropping it. - AddLandblock drains pending entries for its landblock and merges them into the loaded record before storing. - RemoveLandblock drops pending entries for the same landblock — if the player moved away, the spawns are no longer relevant; the server resends them via CreateObject when the player returns. Diagnostic counter PendingLiveEntityCount exposes the bucket size so future regressions are visible without spelunking. 7 new GpuWorldStateTests pin the contract: - AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately - AppendLiveEntity_LandblockNotLoaded_ParksInPending - AddLandblock_DrainsPendingEntriesForThatLandblock - AddLandblock_DoesNotDrainPendingForADifferentLandblock - RemoveLandblock_DropsPendingForThatLandblock - RemoveLandblock_LoadedThenRemoved_DropsItsEntities - IsLoaded_ReturnsTrueForLoaded_FalseForPendingOnly Also removes the diagnostic Console.WriteLine I added in the previous debugging round and the old LiveAppendsResolved/Dropped counters that were never read by anyone. 219 tests green (212 + 7 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
113 lines
4.5 KiB
C#
113 lines
4.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Streaming;
|
|
|
|
/// <summary>
|
|
/// Called once per frame from <c>GameWindow.OnUpdate</c>. Owns the
|
|
/// <see cref="StreamingRegion"/> and uses delegates into
|
|
/// <see cref="LandblockStreamer"/> so tests can inject fakes. All work
|
|
/// happens on the render thread; the streamer itself is background.
|
|
///
|
|
/// <remarks>
|
|
/// Threading: not thread-safe. All calls must happen on the render thread.
|
|
/// </remarks>
|
|
/// </summary>
|
|
public sealed class StreamingController
|
|
{
|
|
private readonly Action<uint> _enqueueLoad;
|
|
private readonly Action<uint> _enqueueUnload;
|
|
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
|
private readonly Action<LoadedLandblock> _applyTerrain;
|
|
private readonly GpuWorldState _state;
|
|
private StreamingRegion? _region;
|
|
|
|
public int Radius { get; set; }
|
|
|
|
/// <summary>
|
|
/// Cap on completions drained per <see cref="Tick"/> call. Defaults to
|
|
/// effectively unlimited because the current <c>LandblockStreamer</c>
|
|
/// is synchronous — every <c>EnqueueLoad</c> writes to the outbox on
|
|
/// the same thread, so by the time we drain there's no backlog
|
|
/// to spread, and the cap only serves to *delay* applying landblocks
|
|
/// the user is already trying to look at.
|
|
///
|
|
/// <para>
|
|
/// The original async design used a small cap (4) to limit per-frame
|
|
/// GPU upload spikes. That reasoning becomes relevant again if/when
|
|
/// the streamer moves back to async loading; lower this knob then.
|
|
/// Crucially, dropping completions to a lower frame is what was
|
|
/// silently breaking live spawns: the post-login spawn flood would
|
|
/// arrive on a frame where only 4 of the 25 visible-window landblocks
|
|
/// had been applied, the spawns for the other 21 hit
|
|
/// <c>AppendLiveEntity</c> with no matching loaded slot, and got
|
|
/// dropped (now: parked in the pending bucket).
|
|
/// </para>
|
|
/// </summary>
|
|
public int MaxCompletionsPerFrame { get; set; } = int.MaxValue;
|
|
|
|
public StreamingController(
|
|
Action<uint> enqueueLoad,
|
|
Action<uint> enqueueUnload,
|
|
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
|
Action<LoadedLandblock> applyTerrain,
|
|
GpuWorldState state,
|
|
int radius)
|
|
{
|
|
_enqueueLoad = enqueueLoad;
|
|
_enqueueUnload = enqueueUnload;
|
|
_drainCompletions = drainCompletions;
|
|
_applyTerrain = applyTerrain;
|
|
_state = state;
|
|
Radius = radius;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
|
|
/// are landblock coordinates (0..255) of the current viewer — the camera
|
|
/// in offline mode, the server-sent player position in live.
|
|
/// </summary>
|
|
public void Tick(int observerCx, int observerCy)
|
|
{
|
|
// First-tick bootstrap: no region yet, so the whole visible window
|
|
// is a load diff.
|
|
if (_region is null)
|
|
{
|
|
_region = new StreamingRegion(observerCx, observerCy, Radius);
|
|
foreach (var id in _region.Visible)
|
|
_enqueueLoad(id);
|
|
}
|
|
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
|
{
|
|
var diff = _region.RecenterTo(observerCx, observerCy);
|
|
foreach (var id in diff.ToLoad) _enqueueLoad(id);
|
|
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
|
}
|
|
|
|
// Drain up to N completions per frame so a big diff doesn't spike
|
|
// GPU upload time. Remaining completions wait for the next frame.
|
|
var drained = _drainCompletions(MaxCompletionsPerFrame);
|
|
foreach (var result in drained)
|
|
{
|
|
switch (result)
|
|
{
|
|
case LandblockStreamResult.Loaded loaded:
|
|
_applyTerrain(loaded.Landblock);
|
|
_state.AddLandblock(loaded.Landblock);
|
|
break;
|
|
case LandblockStreamResult.Unloaded unloaded:
|
|
_state.RemoveLandblock(unloaded.LandblockId);
|
|
break;
|
|
case LandblockStreamResult.Failed failed:
|
|
Console.WriteLine(
|
|
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
|
|
break;
|
|
case LandblockStreamResult.WorkerCrashed crashed:
|
|
Console.WriteLine(
|
|
$"streaming: worker CRASHED: {crashed.Error}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|