DeferredApplyBacklog / ForceReloadCount / LastForceReloadDropCount, read by GameWindow's [FRAME-DIAG] rollup. Diagnostic scaffolding (ACDREAM_WB_DIAG=1); strip with the rest of [FRAME-DIAG] when the FPS work fully lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
474 lines
24 KiB
C#
474 lines
24 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using AcDream.Core.Terrain;
|
||
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, LandblockStreamJobKind> _enqueueLoad;
|
||
private readonly Action<uint> _enqueueUnload;
|
||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
||
private readonly Action<uint>? _removeTerrain;
|
||
private readonly Action? _clearPendingLoads;
|
||
|
||
/// <summary>
|
||
/// #138: fired after a landblock's entity layer (re)loads — both the full
|
||
/// <see cref="LandblockStreamResult.Loaded"/> path (initial / dungeon-exit
|
||
/// expand) and the <see cref="LandblockStreamResult.Promoted"/> path
|
||
/// (Far→Near). <c>GameWindow</c> wires it to re-hydrate server objects whose
|
||
/// render entities were dropped by the collapse/demote but whose parsed
|
||
/// spawns it still retains. Receives the canonical landblock id. Null in
|
||
/// tests that don't exercise re-hydration.
|
||
/// </summary>
|
||
private readonly Action<uint>? _onLandblockLoaded;
|
||
private readonly GpuWorldState _state;
|
||
private StreamingRegion? _region;
|
||
|
||
// True while streaming is collapsed to the single dungeon landblock the
|
||
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
|
||
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
|
||
// are never visible, so we stop loading the 25×25 window entirely.
|
||
private bool _collapsed;
|
||
|
||
// The dungeon landblock id we collapsed onto. Once collapsed we key the
|
||
// gate on this STABLE landblock, not the per-frame insideDungeon signal:
|
||
// CurrCell can momentarily resolve to null/outdoor mid-frame, and gating
|
||
// expand on that flicker thrashes collapse↔expand (reload storms + a light
|
||
// leak). We only expand when the observer actually moves to a different
|
||
// landblock (teleport/portal out).
|
||
private uint _collapsedCenter;
|
||
|
||
/// <summary>
|
||
/// Near-tier radius (LBs from observer that load full detail: terrain +
|
||
/// scenery + entities). Set at construction; readable thereafter.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Mutating after the first <see cref="Tick"/> has no effect — the
|
||
/// internal <see cref="StreamingRegion"/> snapshots both radii on its
|
||
/// constructor. Treat as init-only post-Tick.
|
||
/// </remarks>
|
||
public int NearRadius { get; }
|
||
|
||
/// <summary>
|
||
/// Far-tier radius (LBs from observer that load terrain only). Set at
|
||
/// construction; readable thereafter.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Mutating after the first <see cref="Tick"/> has no effect — see <see cref="NearRadius"/>.
|
||
/// </remarks>
|
||
public int FarRadius { get; }
|
||
|
||
/// <summary>
|
||
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
|
||
/// the GPU upload budget for one frame: terrain mesh + per-entity GfxObj
|
||
/// sub-mesh uploads + texture uploads for one landblock take a few ms;
|
||
/// applying 25 of them in a single frame produces a memory spike
|
||
/// (observed: out-of-memory crash on the 5×5 first-frame load).
|
||
///
|
||
/// <para>
|
||
/// 4 is the original async-streamer value; it spreads a 5×5 first-frame
|
||
/// load over ~7 frames (~116ms at 60fps), which is below the human
|
||
/// perception threshold. Spawn races that previously dropped entities
|
||
/// while landblocks were in flight are now handled by
|
||
/// <see cref="GpuWorldState"/>'s pending-spawn list, so spreading
|
||
/// completions doesn't lose any data.
|
||
/// </para>
|
||
/// </summary>
|
||
public int MaxCompletionsPerFrame { get; set; } = 4;
|
||
|
||
/// <summary>
|
||
/// #138: the teleport destination landblock id. When non-zero,
|
||
/// <see cref="DrainAndApply"/> applies this landblock's
|
||
/// <see cref="LandblockStreamResult.Loaded"/> or
|
||
/// <see cref="LandblockStreamResult.Promoted"/> completion immediately
|
||
/// — even if it sits past the <see cref="MaxCompletionsPerFrame"/>
|
||
/// position in the outbox — so the player can materialise at the
|
||
/// destination without waiting for every earlier-queued landblock to
|
||
/// drain first. Non-priority completions drained past it are buffered
|
||
/// in <see cref="_deferredApply"/> and applied over subsequent frames,
|
||
/// so no completions are lost and there is no GPU spike.
|
||
///
|
||
/// <para>Set by <c>GameWindow</c> when a teleport starts; reset to 0
|
||
/// once the destination landblock has been applied (or when the
|
||
/// teleport destination changes).</para>
|
||
/// </summary>
|
||
public uint PriorityLandblockId { get; set; }
|
||
|
||
/// <summary>
|
||
/// 2026-06-22: the radius (in landblocks, Chebyshev) around
|
||
/// <see cref="PriorityLandblockId"/> that <see cref="DrainAndApply"/> eager-applies
|
||
/// ahead of the per-frame budget. 0 (default) = only the single center landblock — the
|
||
/// original priority behaviour. A teleport sets this to the near ring so the player's
|
||
/// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not
|
||
/// just the one landblock they stand on. Without it, only the destination landblock
|
||
/// applies immediately and everything around it drains at <see cref="MaxCompletionsPerFrame"/>,
|
||
/// so the player arrives to a near-empty world (the "Fort Tethana only one landblock
|
||
/// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the
|
||
/// transient walk-through-walls). The far ring still drains at the budget. The eager
|
||
/// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden.
|
||
/// </summary>
|
||
public int PriorityRadius { get; set; }
|
||
|
||
// [FRAME-DIAG] (read by GameWindow's ACDREAM_WB_DIAG rollup): the standing
|
||
// deferred-LOAD backlog. A non-zero value during/after a teleport is the
|
||
// GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface.
|
||
public int DeferredApplyBacklog => _deferredApply.Count;
|
||
|
||
// [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops +
|
||
// re-uploads the WHOLE window) and the landblock count it dropped last time.
|
||
public int ForceReloadCount { get; private set; }
|
||
public int LastForceReloadDropCount { get; private set; }
|
||
|
||
// Completions that were drained past a priority item get buffered here
|
||
// so they still apply over subsequent frames without loss.
|
||
private readonly List<LandblockStreamResult> _deferredApply = new();
|
||
|
||
public StreamingController(
|
||
Action<uint, LandblockStreamJobKind> enqueueLoad,
|
||
Action<uint> enqueueUnload,
|
||
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
|
||
Action<LoadedLandblock, LandblockMeshData> applyTerrain,
|
||
GpuWorldState state,
|
||
int nearRadius,
|
||
int farRadius,
|
||
Action<uint>? removeTerrain = null,
|
||
Action? clearPendingLoads = null,
|
||
Action<uint>? onLandblockLoaded = null)
|
||
{
|
||
_enqueueLoad = enqueueLoad;
|
||
_enqueueUnload = enqueueUnload;
|
||
_drainCompletions = drainCompletions;
|
||
_applyTerrain = applyTerrain;
|
||
_removeTerrain = removeTerrain;
|
||
_clearPendingLoads = clearPendingLoads;
|
||
_onLandblockLoaded = onLandblockLoaded;
|
||
_state = state;
|
||
NearRadius = nearRadius;
|
||
FarRadius = farRadius;
|
||
}
|
||
|
||
/// <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.
|
||
///
|
||
/// <para>Two-tier model (Phase A.5 T13):</para>
|
||
/// <list type="bullet">
|
||
/// <item><see cref="TwoTierDiff.ToLoadFar"/> → enqueue LoadFar (terrain only, no entities)</item>
|
||
/// <item><see cref="TwoTierDiff.ToLoadNear"/> → enqueue LoadNear (terrain + entities)</item>
|
||
/// <item><see cref="TwoTierDiff.ToPromote"/> → enqueue PromoteToNear (entity layer for already-loaded terrain)</item>
|
||
/// <item><see cref="TwoTierDiff.ToDemote"/> → drop entities on render thread immediately (terrain stays)</item>
|
||
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||
{
|
||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||
|
||
if (_collapsed)
|
||
{
|
||
// Hysteresis. Cases:
|
||
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
|
||
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
|
||
// → re-collapse onto it.
|
||
// - CurrCell flickered null but the player hasn't gone anywhere: the
|
||
// observer landblock reverts to the position-derived value, which for a
|
||
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
|
||
// local Y). Hold — never expand on an adjacent flicker.
|
||
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
|
||
// from the ocean-grid dungeon block) → expand.
|
||
if (insideDungeon && centerId != _collapsedCenter)
|
||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
|
||
ExitDungeonExpand(observerCx, observerCy);
|
||
else
|
||
SweepCollapsed();
|
||
}
|
||
else if (insideDungeon)
|
||
{
|
||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||
}
|
||
else
|
||
{
|
||
NormalTick(observerCx, observerCy);
|
||
}
|
||
|
||
DrainAndApply();
|
||
}
|
||
|
||
/// <summary>
|
||
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
|
||
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
|
||
/// from the login / teleport spawn path the instant the streaming center is
|
||
/// recentered onto a SEALED dungeon landblock.
|
||
///
|
||
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
|
||
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
|
||
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
|
||
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
|
||
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
|
||
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
|
||
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
|
||
///
|
||
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
|
||
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
|
||
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
|
||
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
|
||
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
|
||
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
|
||
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
|
||
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
|
||
/// same as <see cref="Tick"/>.</para>
|
||
/// </summary>
|
||
public void PreCollapseToDungeon(int cx, int cy)
|
||
{
|
||
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
|
||
if (_collapsed && _collapsedCenter == centerId) return;
|
||
EnterDungeonCollapse(cx, cy, centerId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Outdoor / building-interior streaming — the original two-tier model.
|
||
/// </summary>
|
||
private void NormalTick(int observerCx, int observerCy)
|
||
{
|
||
if (_region is null)
|
||
{
|
||
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||
var bootstrap = _region.ComputeFirstTickDiff();
|
||
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
_region.MarkResidentFromBootstrap();
|
||
}
|
||
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
|
||
{
|
||
var diff = _region.RecenterTo(observerCx, observerCy);
|
||
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
|
||
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dungeon-entry edge: cancel the in-flight window load, unload every
|
||
/// resident neighbor, and pin streaming to the player's single dungeon
|
||
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
|
||
/// (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a dungeon);
|
||
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
|
||
/// their thousands of emitters (#133 FPS). Unloading them also tears down
|
||
/// their lights, shrinking the static-light set toward retail's ≤40.
|
||
/// </summary>
|
||
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
||
{
|
||
if (!_collapsed || _collapsedCenter != centerId)
|
||
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
|
||
_collapsed = true;
|
||
_collapsedCenter = centerId;
|
||
_clearPendingLoads?.Invoke();
|
||
|
||
foreach (var id in _state.LoadedLandblockIds)
|
||
if (id != centerId) _enqueueUnload(id);
|
||
|
||
// Pin a radius-0 region so RecenterTo never re-expands while inside,
|
||
// and so the post-exit rebuild starts from a clean, consistent state.
|
||
_region = new StreamingRegion(cx, cy, 0, 0);
|
||
_region.MarkResidentFromBootstrap();
|
||
|
||
// The dungeon landblock itself must be (or become) loaded. If a prior
|
||
// ClearPendingLoads cancelled its queued load, re-enqueue it.
|
||
if (!_state.IsLoaded(centerId))
|
||
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
|
||
}
|
||
|
||
/// <summary>
|
||
/// While collapsed, unload any landblock that finished loading after the
|
||
/// collapse edge — a Load the worker had already dequeued before the
|
||
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
|
||
/// effect. At steady state only the dungeon landblock is resident, so this
|
||
/// is a no-op.
|
||
/// </summary>
|
||
private void SweepCollapsed()
|
||
{
|
||
// Always preserve the true dungeon landblock (_collapsedCenter), never the
|
||
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
|
||
foreach (var id in _state.LoadedLandblockIds)
|
||
if (id != _collapsedCenter) _enqueueUnload(id);
|
||
}
|
||
|
||
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
|
||
private static int ChebyshevLandblocks(uint a, uint b)
|
||
{
|
||
int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu);
|
||
int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu);
|
||
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when <paramref name="id"/> is the priority center or within
|
||
/// <see cref="PriorityRadius"/> landblocks of it (Chebyshev) — i.e. inside the teleport
|
||
/// near ring that <see cref="DrainAndApply"/> eager-applies. With the default radius 0
|
||
/// this reduces to an exact match on <see cref="PriorityLandblockId"/> (the original
|
||
/// single-landblock priority behaviour).
|
||
/// </summary>
|
||
private bool IsWithinPriorityRing(uint id)
|
||
=> PriorityLandblockId != 0u
|
||
&& ChebyshevLandblocks(id, PriorityLandblockId) <= PriorityRadius;
|
||
|
||
/// <summary>
|
||
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
|
||
/// two-tier window at the new center and unload anything resident from the
|
||
/// collapsed state that falls outside it.
|
||
/// </summary>
|
||
private void ExitDungeonExpand(int observerCx, int observerCy)
|
||
{
|
||
Console.WriteLine(
|
||
$"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " +
|
||
$"(was collapsed on 0x{_collapsedCenter:X8})");
|
||
_collapsed = false;
|
||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||
|
||
foreach (var id in _state.LoadedLandblockIds)
|
||
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
|
||
|
||
var boot = rebuilt.ComputeFirstTickDiff();
|
||
foreach (var id in boot.ToLoadNear)
|
||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||
foreach (var id in boot.ToLoadFar)
|
||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||
rebuilt.MarkResidentFromBootstrap();
|
||
_region = rebuilt;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 2026-06-22: an OUTDOOR teleport moved the render origin (<c>_liveCenter</c>). Every
|
||
/// resident terrain block was baked relative to the OLD origin, so any block that survives
|
||
/// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows
|
||
/// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed
|
||
/// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192).
|
||
/// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none
|
||
/// survives into the next frame stale, and no async unload can race a re-bake, then null
|
||
/// the region so the next <see cref="NormalTick"/> re-bootstraps the WHOLE window fresh at
|
||
/// the new origin (the near ring is priority-applied behind the fade; the rest streams in).
|
||
/// A sealed-dungeon destination uses <see cref="PreCollapseToDungeon"/> instead.
|
||
/// </summary>
|
||
public void ForceReloadWindow()
|
||
{
|
||
_collapsed = false;
|
||
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
||
var ids = new List<uint>(_state.LoadedLandblockIds);
|
||
ForceReloadCount++; // [FRAME-DIAG] churn counter
|
||
LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume
|
||
foreach (var id in ids)
|
||
{
|
||
_state.RemoveLandblock(id);
|
||
_removeTerrain?.Invoke(id); // frees the render slot + physics + cell registries
|
||
}
|
||
_region = null; // NormalTick re-creates + bootstraps the full window next frame
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the
|
||
/// expensive part, so they are metered at <see cref="MaxCompletionsPerFrame"/> to avoid a
|
||
/// GPU-upload spike; the overflow buffers in <see cref="_deferredApply"/> and drains over
|
||
/// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied
|
||
/// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and
|
||
/// metering them at the load rate left the previous location's terrain resident for seconds
|
||
/// (rendering at its old world position as "floating terrain at the horizon"), and rapid
|
||
/// hops accumulated them faster than they cleared — a runaway resident count (951 observed
|
||
/// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring
|
||
/// (<see cref="PriorityRadius"/>, applied behind the fade) likewise bypass the budget so the
|
||
/// player materialises in a loaded world.
|
||
/// </summary>
|
||
private void DrainAndApply()
|
||
{
|
||
// --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring
|
||
// loads immediately; defer every other (budget-metered) load. Draining the whole
|
||
// outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush
|
||
// promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on
|
||
// freeing them. The drain cap must NOT be gated behind the per-frame load budget
|
||
// (the prior version returned once the budget hit 0, stranding the outbox).
|
||
const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame
|
||
int iter = 0;
|
||
while (iter++ < MaxDrainIterations)
|
||
{
|
||
var chunk = _drainCompletions(MaxCompletionsPerFrame);
|
||
if (chunk.Count == 0) break;
|
||
foreach (var result in chunk)
|
||
{
|
||
if (result is LandblockStreamResult.Unloaded
|
||
|| IsWithinPriorityRing(ResultLandblockId(result)))
|
||
ApplyResult(result); // free (unload) or behind-the-fade near ring
|
||
else
|
||
_deferredApply.Add(result); // a GPU-upload load — meter it in step 2
|
||
}
|
||
}
|
||
|
||
// --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so
|
||
// earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't
|
||
// spike. _deferredApply now only ever holds loads — unloads were applied in step 1.
|
||
int budget = MaxCompletionsPerFrame;
|
||
int i = 0;
|
||
while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; }
|
||
if (i > 0) _deferredApply.RemoveRange(0, i);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply a single <see cref="LandblockStreamResult"/> with the full side-
|
||
/// effects: terrain upload, GPU state, and the re-hydration callback.
|
||
/// Extracted from the inline switch in the original <c>DrainAndApply</c>
|
||
/// so both the priority-hunt path and the normal drain path share it.
|
||
/// </summary>
|
||
private void ApplyResult(LandblockStreamResult result)
|
||
{
|
||
switch (result)
|
||
{
|
||
case LandblockStreamResult.Loaded loaded:
|
||
_applyTerrain(loaded.Landblock, loaded.MeshData);
|
||
_state.AddLandblock(loaded.Landblock);
|
||
// #138: after the landblock is in _loaded (so AppendLiveEntity
|
||
// hot-paths), restore any retained server objects ACE won't
|
||
// re-send. Fired AFTER AddLandblock, never before.
|
||
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
|
||
break;
|
||
case LandblockStreamResult.Promoted promoted:
|
||
_applyTerrain(promoted.Landblock, promoted.MeshData);
|
||
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
|
||
_onLandblockLoaded?.Invoke(promoted.LandblockId);
|
||
break;
|
||
case LandblockStreamResult.Unloaded unloaded:
|
||
_state.RemoveLandblock(unloaded.LandblockId);
|
||
_removeTerrain?.Invoke(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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the landblock id associated with <paramref name="result"/>.
|
||
/// For <see cref="LandblockStreamResult.WorkerCrashed"/> this is 0 by
|
||
/// convention (not tied to a specific landblock).
|
||
/// </summary>
|
||
private static uint ResultLandblockId(LandblockStreamResult result) => result.LandblockId;
|
||
}
|