acdream/src/AcDream.App/Streaming/StreamingController.cs

1350 lines
57 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
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 : IStreamingFrameBackend
{
private sealed class OriginRecenterRetirement
{
public bool RadiiConverged;
public bool GenerationAdvanced;
public bool PendingLoadsCleared;
public bool DeferredApplyCleared;
public bool RegionCleared;
public List<uint>? ResidentIds;
public int RetirementCursor;
public bool PreparationCommitted;
public (int X, int Y, bool IsSealedDungeon)? Destination;
public bool DestinationConfigured;
public bool DestinationLoadEnqueued;
}
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
private readonly Action<uint, ulong> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action? _clearPendingLoads;
private readonly LandblockPresentationPipeline _presentation;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
private RadiiReconfiguration? _pendingRadiiReconfiguration;
private bool _advancingRadiiReconfiguration;
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
private OriginRecenterRetirement? _originRecenterRetirement;
private bool _advancingOriginRecenter;
// Hard streaming boundaries advance this token. Worker completions carry
// the generation captured at enqueue time, so an old overlapping load or
// unload cannot mutate the replacement window after a portal recenter.
private ulong _generation;
// 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). Runtime changes go through
/// <see cref="ReconfigureRadii"/>.
/// </summary>
public int NearRadius { get; private set; }
/// <summary>
/// Far-tier radius (LBs from observer that load terrain only).
/// </summary>
public int FarRadius { get; private set; }
/// <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 while the portal viewport replaces the world, 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;
public int PendingRetirementCount => _presentation.PendingRetirementCount;
internal bool IsCollapsedToDungeon => _collapsed;
/// <summary>
/// True once every in-bounds landblock in the requested Chebyshev ring has
/// crossed the render-thread publication barrier. Worker completion and
/// world-state registration are not sufficient: all static GfxObj and
/// EnvCell shell meshes must have completed their render-thread upload.
/// Portal-space exit uses this alongside physics residency so the world
/// cannot be revealed while its render slots are still absent.
/// </summary>
public bool IsRenderNeighborhoodResident(uint cellOrLandblockId, int radius)
{
if (radius < 0)
throw new ArgumentOutOfRangeException(nameof(radius));
// LandDefs::InboundValidCellId validates both map axes and the low-word
// class (outdoor cell, EnvCell, or canonical landblock sentinel).
if (!AcDream.Core.Physics.LandDefs.InboundValidCellId(cellOrLandblockId))
return false;
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
for (int dx = -radius; dx <= radius; dx++)
for (int dy = -radius; dy <= radius; dy++)
{
int nx = cx + dx;
int ny = cy + dy;
// Match PhysicsEngine.IsNeighborhoodTerrainResident: the outer
// 0xFF coordinate has no loadable neighbour beyond it.
if (nx < 0 || nx > 254 || ny < 0 || ny > 254)
continue;
uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical))
return false;
}
return true;
}
// [FRAME-DIAG]: full-window presentation retirements include explicit
// reloads and shared-origin recenter barriers. Both retire and later
// re-upload the complete resident window.
public int FullWindowRetirementCount { get; private set; }
public int LastFullWindowRetirementLandblockCount { 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();
/// <summary>
/// Internal compatibility seam for hermetic controller policy tests. Live
/// composition cannot supply presentation callbacks; it must use the
/// concrete pipeline constructor below.
/// </summary>
internal StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LandblockBuild, LandblockMeshData> applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null,
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
: this(
enqueueLoad,
enqueueUnload,
drainCompletions,
state,
nearRadius,
farRadius,
new LandblockPresentationPipeline(
applyTerrain,
state,
onLandblockLoaded,
ensureEnvCellMeshes,
retirementCoordinator,
removeTerrain,
demoteNearLayer),
clearPendingLoads)
{
}
/// <summary>
/// Concrete presentation path. Its pipeline is the sole presentation and
/// retirement owner, so no legacy publication delegate can be supplied or
/// silently ignored.
/// </summary>
public StreamingController(
Action<uint, LandblockStreamJobKind, ulong> enqueueLoad,
Action<uint, ulong> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
GpuWorldState state,
int nearRadius,
int farRadius,
LandblockPresentationPipeline presentationPipeline,
Action? clearPendingLoads = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_clearPendingLoads = clearPendingLoads;
_state = state;
_presentation = presentationPipeline
?? throw new ArgumentNullException(nameof(presentationPipeline));
if (!_presentation.MatchesState(_state))
{
throw new ArgumentException(
"The presentation pipeline must own the controller's world state.",
nameof(presentationPipeline));
}
NearRadius = nearRadius;
FarRadius = farRadius;
}
/// <summary>
/// Reconciles an active outdoor streaming window to new quality radii
/// without replacing the worker/controller or blindly bootstrapping data
/// already published in <see cref="GpuWorldState"/>. Pending jobs from the
/// old window are invalidated by generation; resident blocks are retained,
/// promoted, demoted, loaded, or unloaded from their actual current tier.
/// A sealed dungeon remains radius-zero until its normal exit edge while
/// remembering the new outdoor radii for that exit.
/// </summary>
public void ReconfigureRadii(int nearRadius, int farRadius)
{
if (nearRadius < 0)
throw new ArgumentOutOfRangeException(nameof(nearRadius));
if (farRadius < nearRadius)
throw new ArgumentOutOfRangeException(
nameof(farRadius),
"Far radius must be greater than or equal to near radius.");
// A radius transaction may not admit work after an origin-recenter
// snapshot has been captured. Retain only the latest requested radii;
// the first Tick after origin commit applies them to the new frame.
if (_originRecenterRetirement is not null)
{
_deferredRadiiRequest = (nearRadius, farRadius);
return;
}
// Queue callbacks are injected seams and can synchronously reenter the
// controller. Last-request-wins deferral keeps the active mutation
// cursor stable; the request is applied after the current transaction
// converges instead of recursively replaying its active step.
if (_advancingRadiiReconfiguration)
{
_deferredRadiiRequest = (nearRadius, farRadius);
return;
}
// A prior callback may have failed after this controller accepted the
// request. Resume that exact transaction before considering another
// target; replacing it would orphan already-admitted generation work.
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
// This explicit call is newer than any request deferred by a prior
// failed attempt and therefore supersedes it.
_deferredRadiiRequest = null;
if (nearRadius == NearRadius && farRadius == FarRadius)
return;
// A staged old-window publication must either finish or fail before
// the desired-tier snapshot below is computed. Otherwise a landblock
// that becomes spatially resident during convergence is absent from
// the reconfiguration mutation ledger.
ConvergePendingPublications();
if (_collapsed || _region is null)
{
NearRadius = nearRadius;
FarRadius = farRadius;
return;
}
var rebuilt = new StreamingRegion(
_region.CenterX,
_region.CenterY,
nearRadius,
farRadius);
TwoTierDiff bootstrap = rebuilt.ComputeFirstTickDiff();
var desiredNear = new HashSet<uint>(bootstrap.ToLoadNear);
var desiredFar = new HashSet<uint>(bootstrap.ToLoadFar);
var mutations = new List<RadiiMutation>();
uint[] loaded = [.. _state.LoadedLandblockIds];
for (int i = 0; i < loaded.Length; i++)
{
uint id = loaded[i];
if (desiredNear.Contains(id))
{
if (!_state.IsNearTier(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.PromoteToNear)));
}
else if (desiredFar.Contains(id))
{
if (_state.IsNearTier(id))
mutations.Add(new RadiiMutation(() => DemoteLandblock(id)));
}
else
{
mutations.Add(new RadiiMutation(() => EnqueueUnload(id)));
}
}
foreach (uint id in desiredNear)
if (!_state.IsLoaded(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.LoadNear)));
foreach (uint id in desiredFar)
if (!_state.IsLoaded(id))
mutations.Add(new RadiiMutation(
() => EnqueueLoad(id, LandblockStreamJobKind.LoadFar)));
_pendingRadiiReconfiguration = new RadiiReconfiguration(
nearRadius,
farRadius,
rebuilt,
mutations);
AdvanceRadiiReconfiguration();
}
/// <summary>
/// Compatibility constructor for deterministic tests and callers that do
/// not own an asynchronous worker. Production must use the generation-aware
/// overload above so hard-recenter results can be rejected by incarnation.
/// </summary>
internal StreamingController(
Action<uint, LandblockStreamJobKind> enqueueLoad,
Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LandblockBuild, LandblockMeshData> applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null,
Action<uint>? demoteNearLayer = null,
Action? clearPendingLoads = null,
Action<uint>? onLandblockLoaded = null,
Action<EnvCellLandblockBuild>? ensureEnvCellMeshes = null,
LandblockRetirementCoordinator? retirementCoordinator = null)
: this(
(id, kind, _) => enqueueLoad(id, kind),
(id, _) => enqueueUnload(id),
drainCompletions,
applyTerrain,
state,
nearRadius,
farRadius,
removeTerrain,
demoteNearLayer,
clearPendingLoads,
onLandblockLoaded,
ensureEnvCellMeshes,
retirementCoordinator)
{
}
private void EnqueueLoad(uint id, LandblockStreamJobKind kind) =>
_enqueueLoad(id, kind, _generation);
private void EnqueueUnload(uint id) => _enqueueUnload(id, _generation);
private void DemoteLandblock(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
_presentation.BeginNearLayerRetirement(canonical);
}
private void AdvanceGeneration()
{
ConvergePendingPublications();
_generation = unchecked(_generation + 1);
}
private void ConvergePendingPublications()
{
IReadOnlyList<LandblockStreamResult> pending =
_presentation.GetPendingPublicationResults();
if (pending.Count == 0)
return;
try
{
for (int i = 0; i < pending.Count; i++)
_presentation.ResumePublication(pending[i]);
}
finally
{
// A completed staged result can still be present in the deferred
// FIFO that originally retained it. Remove only those exact
// objects; unrelated accepted completions keep their order.
_deferredApply.RemoveAll(result =>
{
for (int i = 0; i < pending.Count; i++)
{
if (ReferenceEquals(result, pending[i]))
return !_presentation.HasPendingPublication(result);
}
return false;
});
}
}
/// <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)
{
if (_originRecenterRetirement is not null)
{
_presentation.AdvanceRetirements();
return;
}
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
else if (_deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
// Presentation-owner failures never roll back spatial state. Resume
// unfinished owner ledgers before accepting replacement publications.
_presentation.AdvanceRetirements();
// Complete an admitted publication before this frame can mutate its
// desired tier or spatial residence. A later recenter may then demote
// or retire the fully known owner set through the normal ledger.
ConvergePendingPublications();
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();
}
private void AdvanceRadiiReconfiguration()
{
if (_advancingRadiiReconfiguration)
return;
RadiiReconfiguration pending = _pendingRadiiReconfiguration
?? throw new InvalidOperationException("No radii reconfiguration is pending.");
var failures = new List<Exception>();
_advancingRadiiReconfiguration = true;
try
{
if (!pending.GenerationAdvanced)
{
AdvanceGeneration();
pending.GenerationAdvanced = true;
}
if (!pending.PendingLoadsCleared)
{
try
{
_clearPendingLoads?.Invoke();
pending.PendingLoadsCleared = true;
}
catch (Exception error)
{
if (error is StreamingMutationException { MutationCommitted: true })
pending.PendingLoadsCleared = true;
failures.Add(error);
}
}
// Do not admit new-generation work until cancellation of the old
// inbox is durable. Otherwise a successful retry of ClearPendingLoads
// would also erase mutations already marked complete by this ledger.
for (int i = 0; pending.PendingLoadsCleared && i < pending.Mutations.Count; i++)
{
RadiiMutation mutation = pending.Mutations[i];
if (mutation.Completed)
continue;
try
{
mutation.Apply();
mutation.Completed = true;
}
catch (Exception error)
{
if (error is StreamingMutationException { MutationCommitted: true })
mutation.Completed = true;
failures.Add(error);
}
}
bool converged = pending.PendingLoadsCleared
&& pending.Mutations.All(static mutation => mutation.Completed);
if (converged)
{
// Publish the new logical window only after every queue/retirement
// admission is durable. A later Tick can now trust Resident as the
// complete desired set and will never strand a hole.
pending.Region.MarkResidentFromBootstrap();
_region = pending.Region;
NearRadius = pending.NearRadius;
FarRadius = pending.FarRadius;
_deferredApply.Clear();
_pendingRadiiReconfiguration = null;
}
}
finally
{
_advancingRadiiReconfiguration = false;
}
if (failures.Count != 0)
{
throw new AggregateException(
"Streaming quality reconfiguration did not complete cleanly.",
failures);
}
if (_pendingRadiiReconfiguration is null
&& _deferredRadiiRequest is { } deferred)
{
_deferredRadiiRequest = null;
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
}
private sealed class RadiiReconfiguration
{
public RadiiReconfiguration(
int nearRadius,
int farRadius,
StreamingRegion region,
List<RadiiMutation> mutations)
{
NearRadius = nearRadius;
FarRadius = farRadius;
Region = region;
Mutations = mutations;
}
public int NearRadius { get; }
public int FarRadius { get; }
public StreamingRegion Region { get; }
public List<RadiiMutation> Mutations { get; }
public bool GenerationAdvanced { get; set; }
public bool PendingLoadsCleared { get; set; }
}
private sealed class RadiiMutation
{
public RadiiMutation(Action apply) => Apply = apply;
public Action Apply { get; }
public bool Completed { get; set; }
}
/// <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 InitializeKnownLoginCenter(int cx, int cy, bool isSealedDungeon)
{
// StreamingReadinessGate keeps the worker stopped until the player's
// real spawn supplies this center. There is therefore no guessed-center
// window to force-reload here. A sealed dungeon is the one special case:
// pin radius zero before the first Tick so no ocean-grid neighbours are
// ever enqueued.
if (isSealedDungeon)
PreCollapseToDungeon(cx, cy);
}
/// <summary>
/// Pins streaming to a sealed dungeon before the first normal Tick.
/// </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) DemoteLandblock(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)
{
bool logTransition = !_collapsed || _collapsedCenter != centerId;
if (logTransition)
Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}");
AdvanceGeneration();
_collapsed = true;
_collapsedCenter = centerId;
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
_region = null;
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);
else if (!_state.IsNearTier(centerId))
EnqueueLoad(centerId, LandblockStreamJobKind.PromoteToNear);
}
/// <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})");
AdvanceGeneration();
_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>
/// Starts a reload of the current streaming window. Logical residency is
/// detached immediately; render, physics, and static-lighting owners then
/// converge through the retained presentation-retirement ledger before
/// <see cref="NormalTick"/> bootstraps the window again.
/// Shared-origin teleports use <see cref="BeginOriginRecenter"/> instead so
/// every old-window owner retires while the old coordinate frame is active.
/// </summary>
public void ForceReloadWindow()
{
BeginFullWindowRetirement();
}
/// <summary>
/// Starts the old-window half of a shared-origin recenter. All accepted
/// worker work is invalidated and every resident landblock is detached
/// through the retryable presentation ledger while the old coordinate
/// frame is still active. <see cref="Tick"/> remains blocked until
/// <see cref="TryCommitOriginRecenter"/> succeeds.
/// </summary>
internal void BeginOriginRecenter()
{
_originRecenterRetirement ??= new OriginRecenterRetirement();
}
/// <summary>
/// Advances every retained old-window teardown and reports whether the
/// composition root may safely change the shared world origin.
/// </summary>
internal bool IsOriginRecenterRetirementComplete()
{
if (_originRecenterRetirement is null)
throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
if (!TryAdvanceOriginRecenterPreparation())
return false;
_presentation.AdvanceRetirements();
return _presentation.PendingRetirementCount == 0;
}
/// <summary>
/// Releases the streaming bootstrap gate after the composition root has
/// committed the new shared origin. No old-window presentation owner may
/// still be pending at this edge.
/// </summary>
internal bool TryCommitOriginRecenter(
int destinationX,
int destinationY,
bool isSealedDungeon)
{
if (_advancingOriginRecenter)
return false;
_advancingOriginRecenter = true;
try
{
return TryCommitOriginRecenterCore(
destinationX,
destinationY,
isSealedDungeon);
}
finally
{
_advancingOriginRecenter = false;
}
}
private bool TryCommitOriginRecenterCore(
int destinationX,
int destinationY,
bool isSealedDungeon)
{
OriginRecenterRetirement transaction = _originRecenterRetirement
?? throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
if (!transaction.PreparationCommitted)
throw new InvalidOperationException(
"Streaming-origin retirement preparation has not completed.");
if (_presentation.PendingRetirementCount != 0)
throw new InvalidOperationException(
"The streaming origin cannot change while old-window presentation retirement is pending.");
var destination = (destinationX, destinationY, isSealedDungeon);
if (transaction.Destination is { } retained && retained != destination)
{
throw new InvalidOperationException(
"A recenter transaction cannot commit two different destinations.");
}
transaction.Destination ??= destination;
if (!transaction.DestinationConfigured)
{
_collapsed = isSealedDungeon;
_collapsedCenter = isSealedDungeon
? StreamingRegion.EncodeLandblockId(destinationX, destinationY)
: 0u;
if (isSealedDungeon)
{
_region = new StreamingRegion(
destinationX,
destinationY,
nearRadius: 0,
farRadius: 0);
_region.MarkResidentFromBootstrap();
}
else
{
_region = null;
}
transaction.DestinationConfigured = true;
}
if (isSealedDungeon && !transaction.DestinationLoadEnqueued)
{
try
{
EnqueueLoad(
StreamingRegion.EncodeLandblockId(destinationX, destinationY),
LandblockStreamJobKind.LoadNear);
transaction.DestinationLoadEnqueued = true;
}
catch (StreamingMutationException error) when (error.MutationCommitted)
{
transaction.DestinationLoadEnqueued = true;
Console.WriteLine(
$"streaming: committed dungeon recenter enqueue reported failure: {error}");
return false;
}
catch (Exception error)
{
Console.WriteLine(
$"streaming: dungeon recenter enqueue will resume: {error}");
return false;
}
}
_originRecenterRetirement = null;
return true;
}
/// <summary>
/// Releases a fully retired origin transaction at a session boundary
/// without bootstrapping a destination from the ending session.
/// </summary>
internal bool TryCancelOriginRecenter()
{
if (_originRecenterRetirement is not { PreparationCommitted: true })
return false;
if (_presentation.PendingRetirementCount != 0)
return false;
_collapsed = false;
_collapsedCenter = 0u;
_region = null;
_originRecenterRetirement = null;
return true;
}
private bool TryAdvanceOriginRecenterPreparation()
{
OriginRecenterRetirement transaction = _originRecenterRetirement
?? throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
if (transaction.PreparationCommitted)
return true;
if (_advancingOriginRecenter)
return false;
_advancingOriginRecenter = true;
try
{
if (!transaction.RadiiConverged)
{
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
if (_pendingRadiiReconfiguration is not null)
return false;
transaction.RadiiConverged = true;
}
if (!transaction.GenerationAdvanced)
{
AdvanceGeneration();
transaction.GenerationAdvanced = true;
}
if (!transaction.PendingLoadsCleared)
{
try
{
_clearPendingLoads?.Invoke();
transaction.PendingLoadsCleared = true;
}
catch (StreamingMutationException error) when (error.MutationCommitted)
{
transaction.PendingLoadsCleared = true;
Console.WriteLine(
$"streaming: committed pending-load clear reported failure: {error}");
return false;
}
}
if (!transaction.DeferredApplyCleared)
{
_deferredApply.Clear();
transaction.DeferredApplyCleared = true;
}
if (!transaction.RegionCleared)
{
_collapsed = false;
_region = null;
transaction.RegionCleared = true;
}
if (transaction.ResidentIds is null)
{
transaction.ResidentIds = new List<uint>(_state.LoadedLandblockIds);
FullWindowRetirementCount++;
LastFullWindowRetirementLandblockCount = transaction.ResidentIds.Count;
}
while (transaction.RetirementCursor < transaction.ResidentIds.Count)
{
uint id = transaction.ResidentIds[transaction.RetirementCursor];
try
{
_presentation.BeginFullRetirement(id);
transaction.RetirementCursor++;
}
catch (Exception error)
{
// A delivered visibility-observer failure may surface after
// detachment has committed. Advance that exact cursor only
// when world state proves the old resident is unreachable;
// otherwise retain it for the next frame.
if (!_state.IsLoaded(id))
transaction.RetirementCursor++;
Console.WriteLine(
$"streaming: origin-recenter retirement for 0x{id:X8} " +
$"will resume: {error}");
return false;
}
}
transaction.PreparationCommitted = true;
return true;
}
catch (Exception error)
{
Console.WriteLine(
$"streaming: origin-recenter preparation will resume: {error}");
return false;
}
finally
{
_advancingOriginRecenter = false;
}
}
private void BeginFullWindowRetirement()
{
AdvanceGeneration();
_collapsed = false;
_clearPendingLoads?.Invoke();
_deferredApply.Clear();
// Commit the old region boundary before any presentation owner is
// advanced. New same-id publications are fenced by the presentation
// pipeline's retryable retirement ledger.
_region = null;
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
foreach (var id in ids)
_presentation.BeginFullRetirement(id);
}
/// <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 during portal travel) 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;
for (int chunkIndex = 0; chunkIndex < chunk.Count; chunkIndex++)
{
LandblockStreamResult result = chunk[chunkIndex];
// Reject obsolete hard-window work before it can occupy the
// metered deferred queue. ApplyResult repeats this invariant
// for direct callers and future drain paths.
if (IsStaleGeneration(result)
&& !_presentation.HasPendingPublication(result))
continue;
if (IsPublicationBlockedByRetirement(result))
_deferredApply.Add(result);
else if (result is LandblockStreamResult.Unloaded
|| IsWithinPriorityRing(ResultLandblockId(result)))
{
try
{
ApplyResult(result); // free (unload) or behind-the-fade near ring
}
catch
{
// The concrete presentation pipeline retains the exact
// unfinished stage. Preserve it and every untouched
// completion from this already-drained chunk in
// original FIFO order. Internal compatibility tests may
// use a callback pipeline that cannot retain a receipt;
// its failed result is consumed, but the untouched
// suffix must still survive.
if (_presentation.HasPendingPublication(result))
_deferredApply.Add(result);
for (int suffix = chunkIndex + 1; suffix < chunk.Count; suffix++)
_deferredApply.Add(chunk[suffix]);
throw;
}
}
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 applied = 0;
int read = 0;
int write = 0;
try
{
while (applied < budget && read < _deferredApply.Count)
{
LandblockStreamResult result = _deferredApply[read];
if (IsPublicationBlockedByRetirement(result))
{
if (write != read)
_deferredApply[write] = result;
write++;
read++;
continue;
}
// Advance read only after the apply commits. If it throws, the
// finally block removes prior committed entries but retains this
// exact result and the untouched tail for retry.
try
{
ApplyResult(result);
read++;
applied++;
}
catch
{
// A concrete suffix-stage failure keeps its pipeline
// receipt and must remain at this exact FIFO position. The
// internal compatibility callback cannot report its exact
// committed prefix and therefore removes its receipt;
// consume only that failed result so a later frame cannot
// replay it automatically.
if (!_presentation.HasPendingPublication(result))
read++;
throw;
}
}
}
finally
{
// One linear tail shift replaces budget-many RemoveAt shifts. The
// retained blocked prefix and the untouched FIFO tail keep their
// original relative order.
if (read > write)
_deferredApply.RemoveRange(write, read - write);
}
}
/// <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)
{
if (_presentation.HasPendingPublication(result))
{
_presentation.ResumePublication(result);
ReconcileCompletedPendingPublication(result.LandblockId);
return;
}
if (IsStaleGeneration(result))
{
// A worker can finish one old load/unload after a hard recenter.
// Landblock id membership cannot distinguish overlapping windows;
// generation is the logical streaming incarnation boundary.
return;
}
if (result is LandblockStreamResult.Unloaded
&& _region?.TryGetDesiredTier(result.LandblockId, out _) == true)
{
// Normal recenter does not advance the hard-window generation. A
// rapid away->back can therefore re-own an id while its same-
// generation unload is already in the worker outbox. Current
// region ownership wins; the old unload must not tear it down.
return;
}
if (result is LandblockStreamResult.Loaded
or LandblockStreamResult.Promoted)
{
if (_region is null
|| !_region.TryGetDesiredTier(result.LandblockId, out var desiredTier))
{
// A hard recenter/collapse can leave one worker job already in
// flight. Its completion belongs to the old region and must not
// resurrect a landblock the new StreamingRegion never owns.
return;
}
bool isNearCompletion = result is LandblockStreamResult.Promoted
or LandblockStreamResult.Loaded { Tier: LandblockStreamTier.Near };
if (isNearCompletion
&& _state.IsNearTier(result.LandblockId)
&& !_presentation.HasPendingPublication(result))
{
// Normal recenter can enqueue a second high-priority Near job
// while an earlier one is already in flight. The streamer
// supersedes Far/Unload work but intentionally does not dedupe
// high-priority jobs. Publication is idempotent at this owner:
// never append statics, replay defaults, or reapply the complete
// Near transaction to an already-Near landblock.
return;
}
if (desiredTier == LandblockStreamTier.Far && isNearCompletion)
{
// Recenter can demote a Near job before its first completion.
// StreamingRegion already considers that landblock Far, so it
// deliberately emits no second LoadFar job. If no terrain is
// resident yet, publish the completed heightmap/mesh as a Far
// result while discarding its entity and EnvCell layers. This
// is the same payload a fresh LoadFar would have produced and
// closes the in-flight Near -> desired Far lifecycle without a
// duplicate worker read or a permanent terrain hole.
if (!_state.IsLoaded(result.LandblockId))
{
switch (result)
{
case LandblockStreamResult.Loaded loaded:
_presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData);
break;
case LandblockStreamResult.Promoted promoted:
_presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData);
break;
}
}
return;
}
}
switch (result)
{
case LandblockStreamResult.Loaded loaded:
if (loaded.Tier == LandblockStreamTier.Far
&& _state.IsNearTier(loaded.LandblockId))
{
// This Far completion was queued before a newer Near load
// or promotion. Applying it would erase the entity/cell
// layer that now owns the landblock.
break;
}
_presentation.PublishLoaded(loaded);
break;
case LandblockStreamResult.Promoted promoted:
// PromoteToNear carries a complete build and mesh because the
// streamer deliberately lets it supersede a queued LoadFar. If
// that Far job never started, publish this as the real Near
// landblock; if the base is already resident, merge only the
// Near layer so existing live projections retain identity.
_presentation.PublishPromoted(
promoted,
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
break;
case LandblockStreamResult.Unloaded unloaded:
_presentation.BeginFullRetirement(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;
}
}
private void ReconcileCompletedPendingPublication(uint landblockId)
{
if (_region is null
|| !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
{
if (_state.IsLoaded(landblockId))
_presentation.BeginFullRetirement(landblockId);
return;
}
if (desiredTier == LandblockStreamTier.Far
&& _state.IsNearTier(landblockId))
{
_presentation.BeginNearLayerRetirement(landblockId);
}
}
private bool IsStaleGeneration(LandblockStreamResult result) =>
result is not LandblockStreamResult.WorkerCrashed
&& result.Generation != _generation;
private bool IsPublicationBlockedByRetirement(LandblockStreamResult result) =>
result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted
&& _presentation.IsRetirementPending(result.LandblockId);
/// <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;
}