acdream/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
Erik 823936ec31 fix(streaming): preserve portal destination ownership
Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
2026-07-25 08:35:12 +02:00

194 lines
6.8 KiB
C#

using AcDream.App.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Coordinates streaming-origin lifetime boundaries. Spatial ownership must
/// detach the complete old window first; a teleport then changes
/// <see cref="LiveWorldOriginState"/> before destination streaming resumes,
/// while deferred resource receipts continue under the shared frame budget.
/// A session boundary releases the controller without recentering.
/// </summary>
internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConvergence
{
private readonly record struct Request(
int DestinationX,
int DestinationY,
bool IsSealedDungeon,
bool CancelAtSessionBoundary = false);
private readonly StreamingController _streaming;
private readonly LiveWorldOriginState _origin;
private Request? _pending;
private Request? _replacement;
private bool _originCommitted;
private bool _acceptReplacement;
private bool _sourceWasSealedDungeon;
private bool _advancing;
public StreamingOriginRecenterCoordinator(
StreamingController streaming,
LiveWorldOriginState origin)
{
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
}
public bool IsPending => _pending is not null;
/// <summary>
/// Begins a new recenter transaction. Its retained preparation and
/// retirement cursors advance only from <see cref="StreamingController.Tick"/>
/// under that frame's shared work meter. A teleport replacement must call
/// <see cref="Reset"/> before supplying a different destination.
/// </summary>
public bool Begin(int destinationX, int destinationY, bool isSealedDungeon)
{
var request = new Request(destinationX, destinationY, isSealedDungeon);
if (_pending is { } pending && pending != request)
{
if (!_acceptReplacement)
{
throw new InvalidOperationException(
"A different streaming-origin recenter is already pending.");
}
_acceptReplacement = false;
if (_originCommitted)
_replacement = request;
else
_pending = request;
}
if (_pending is null)
{
_pending = request;
_acceptReplacement = false;
_sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon;
_streaming.BeginOriginRecenter();
}
return Advance();
}
/// <summary>
/// Observes old-window spatial detachment and commits the new origin once
/// every detached owner has an exact retirement receipt. Returns true only
/// after the destination streaming gate has been released.
/// </summary>
public bool Advance()
{
if (_advancing)
return false;
_advancing = true;
try
{
while (_pending is { } request)
{
if (!_originCommitted)
{
if (!_streaming.IsOriginRecenterRetirementComplete())
return false;
if (_pending is not { } current || current != request)
continue;
// A session-boundary cancellation only releases the old
// controller gate. Session identity owns resetting and
// initializing the next live origin, so an asynchronously
// converging teardown must never overwrite it.
if (!request.CancelAtSessionBoundary)
_origin.Recenter(request.DestinationX, request.DestinationY);
_originCommitted = true;
}
bool destinationCommitted = request.CancelAtSessionBoundary
? _streaming.TryCancelOriginRecenter()
: _streaming.TryCommitOriginRecenter(
request.DestinationX,
request.DestinationY,
request.IsSealedDungeon);
if (!destinationCommitted)
return false;
if (_pending is not { } committed || committed != request)
{
_originCommitted = false;
_streaming.BeginOriginRecenter();
continue;
}
_pending = null;
_originCommitted = false;
_acceptReplacement = false;
if (_replacement is not { } replacement)
return true;
_replacement = null;
_pending = replacement;
_sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon;
_streaming.BeginOriginRecenter();
}
return true;
}
finally
{
_advancing = false;
}
}
/// <summary>
/// Converts an uncommitted request into a same-origin convergence request
/// at a teleport-lifetime boundary. This cannot orphan the controller's
/// bootstrap gate: a replacement may reuse the retained old-window barrier,
/// while cancellation/session reset completes it at the current origin.
/// </summary>
public bool Reset(bool sessionEnding = false)
{
if (_pending is null)
{
if (!sessionEnding)
return true;
// Every character-session boundary invalidates geometry baked in
// the old shared coordinate frame, even when no teleport was in
// flight. Retire the complete window before session identity may
// allow the next player spawn to initialize a different origin.
_pending = new Request(
_origin.CenterX,
_origin.CenterY,
IsSealedDungeon: false,
CancelAtSessionBoundary: true);
_replacement = null;
_originCommitted = false;
_acceptReplacement = false;
_sourceWasSealedDungeon = _streaming.IsCollapsedToDungeon;
_streaming.BeginOriginRecenter();
return Advance();
}
_replacement = null;
_acceptReplacement = true;
if (!_originCommitted)
{
_pending = new Request(
_origin.CenterX,
_origin.CenterY,
IsSealedDungeon: !sessionEnding && _sourceWasSealedDungeon,
CancelAtSessionBoundary: sessionEnding);
}
else if (sessionEnding)
{
_pending = new Request(
_origin.CenterX,
_origin.CenterY,
IsSealedDungeon: false,
CancelAtSessionBoundary: true);
}
return Advance();
}
}