refactor(streaming): complete landblock presentation cutover
Remove the legacy GameWindow apply path and make the concrete render, physics, and static publishers the only production owner graph. Serialize full-window retirement with shared-origin teleport and session boundaries so old coordinate-frame resources cannot survive into a new world or login.
This commit is contained in:
parent
801d8a189c
commit
c79d0a49da
12 changed files with 1373 additions and 402 deletions
|
|
@ -18,6 +18,21 @@ namespace AcDream.App.Streaming;
|
|||
/// </summary>
|
||||
public sealed class StreamingController
|
||||
{
|
||||
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;
|
||||
|
|
@ -29,6 +44,8 @@ public sealed class StreamingController
|
|||
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.
|
||||
|
|
@ -116,6 +133,7 @@ public sealed class StreamingController
|
|||
// 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
|
||||
|
|
@ -154,15 +172,21 @@ public sealed class StreamingController
|
|||
return true;
|
||||
}
|
||||
|
||||
// [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; }
|
||||
// [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();
|
||||
public StreamingController(
|
||||
/// <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,
|
||||
|
|
@ -245,6 +269,15 @@ public sealed class StreamingController
|
|||
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
|
||||
|
|
@ -427,6 +460,12 @@ public sealed class StreamingController
|
|||
/// </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)
|
||||
|
|
@ -762,18 +801,262 @@ public sealed class StreamingController
|
|||
}
|
||||
|
||||
/// <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 during portal travel; the rest streams in).
|
||||
/// A sealed-dungeon destination uses <see cref="PreCollapseToDungeon"/> instead.
|
||||
/// 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;
|
||||
|
|
@ -785,8 +1068,8 @@ public sealed class StreamingController
|
|||
_region = null;
|
||||
// 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
|
||||
FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
|
||||
LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
|
||||
foreach (var id in ids)
|
||||
_presentation.BeginFullRetirement(id);
|
||||
}
|
||||
|
|
@ -838,14 +1121,13 @@ public sealed class StreamingController
|
|||
}
|
||||
catch
|
||||
{
|
||||
// The presentation pipeline retains its exact committed
|
||||
// stage after the coarse presentation callback commits.
|
||||
// Preserve it and every untouched completion from this
|
||||
// already-drained chunk in original FIFO order. A
|
||||
// presentation-stage failure is deliberately fail-fast
|
||||
// until the focused publishers replace that callback;
|
||||
// its result is not retryable, but the untouched suffix
|
||||
// must still survive.
|
||||
// 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++)
|
||||
|
|
@ -890,12 +1172,12 @@ public sealed class StreamingController
|
|||
}
|
||||
catch
|
||||
{
|
||||
// A suffix-stage failure keeps its pipeline receipt and
|
||||
// must remain at this exact FIFO position. The still-
|
||||
// monolithic presentation callback deliberately removes
|
||||
// its receipt on failure because its internal committed
|
||||
// prefix is unknown; consume only that failed result so a
|
||||
// later frame cannot replay it automatically.
|
||||
// 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue