feat(app): observe canonical placement receipts

This commit is contained in:
Erik 2026-08-01 15:22:52 +02:00
parent 378ca95a67
commit f05ed5c3cd
14 changed files with 545 additions and 31 deletions

View file

@ -0,0 +1,102 @@
using AcDream.Runtime;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
namespace AcDream.App.Net;
/// <summary>
/// Owns the inbound route, canonical placement observer, and update-thread
/// retry lease for one exact graphical session generation.
/// </summary>
internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting
{
private readonly ILiveSessionEventRouting _events;
private readonly Func<RuntimePlacementProjectionSubscription>
_createSubscription;
private readonly Func<RuntimeGenerationToken> _generation;
private readonly RuntimePlacementProjectionRetrySlot _retries;
private RuntimePlacementProjectionSubscription? _subscription;
private IDisposable? _retryLease;
private bool _attachStarted;
private bool _eventsDisposed;
private bool _disposed;
internal GraphicalSessionEventRoute(
ILiveSessionEventRouting events,
GameRuntime runtime,
IRuntimePlacementProjectionSink placements,
RuntimePlacementProjectionRetrySlot retries)
: this(
events,
() => new RuntimePlacementProjectionSubscription(
runtime,
placements,
retryPendingOnSubscribe: false),
() => runtime.Generation,
retries)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(placements);
}
internal GraphicalSessionEventRoute(
ILiveSessionEventRouting events,
Func<RuntimePlacementProjectionSubscription> createSubscription,
Func<RuntimeGenerationToken> generation,
RuntimePlacementProjectionRetrySlot retries)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_createSubscription = createSubscription
?? throw new ArgumentNullException(nameof(createSubscription));
_generation = generation
?? throw new ArgumentNullException(nameof(generation));
_retries = retries ?? throw new ArgumentNullException(nameof(retries));
}
public void Attach()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_attachStarted)
return;
_attachStarted = true;
_events.Attach();
RuntimePlacementProjectionSubscription? subscription = null;
IDisposable? retryLease = null;
try
{
subscription = _createSubscription();
retryLease = _retries.BindOwned(
_generation(),
subscription.RetryPending);
_subscription = subscription;
_retryLease = retryLease;
_ = subscription.RetryPending();
}
catch
{
retryLease?.Dispose();
subscription?.Dispose();
throw;
}
}
public void Dispose()
{
if (_disposed)
return;
// Unpublish the frame callback before detaching the observer. A frame
// can therefore never retry a retired generation or disposed route.
Interlocked.Exchange(ref _retryLease, null)?.Dispose();
Interlocked.Exchange(ref _subscription, null)?.Dispose();
if (!_eventsDisposed)
{
_events.Dispose();
_eventsDisposed = true;
}
_disposed = true;
}
}

View file

@ -83,7 +83,9 @@ internal sealed record LiveSessionWorldRuntime(
AnimationHookFrameQueue AnimationHookFrames,
LiveEntityPresentationController Presentation,
RemoteMovementObservationTracker RemoteMovementObservations,
RenderSceneShadowRuntime? RenderSceneShadow);
RenderSceneShadowRuntime? RenderSceneShadow,
RuntimePlacementPresentationSink PlacementProjection,
RuntimePlacementProjectionRetrySlot PlacementRetries);
/// <summary>
/// Builds the exact per-generation route/reset graph for the canonical live
@ -225,7 +227,7 @@ internal sealed class LiveSessionRuntimeFactory
_player.WorldOrigin.Reset();
}
private LiveSessionEventRouter CreateEventRouter(WorldSession session)
private ILiveSessionEventRouting CreateEventRouter(WorldSession session)
{
SkillTable? skillTable = _world.Dats.Get<SkillTable>(0x0E000004u);
if (_ui.CharacterSheet is not null)
@ -235,7 +237,7 @@ internal sealed class LiveSessionRuntimeFactory
CharacterSheetProvider.LoadExperienceTable(_world.Dats, _log);
}
return new LiveSessionEventRouter(
var route = new LiveSessionEventRouter(
session,
_world.EntitySession.CreateSink(),
new LiveEnvironmentSessionSink(
@ -248,6 +250,11 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Communication.TurbineChat,
_domain.Communication.Friends,
_domain.Communication.Squelch));
return new GraphicalSessionEventRoute(
route,
_domain.Runtime,
_world.PlacementProjection,
_world.PlacementRetries);
}
private LiveInventorySessionBindings CreateInventoryBindings() => new(

View file

@ -0,0 +1,87 @@
using AcDream.Runtime;
namespace AcDream.App.Net;
internal interface IRuntimePlacementProjectionRetryPhase
{
void RetryPending();
}
/// <summary>
/// Publishes the retry callback owned by the exact active graphical session
/// route. The frame thread may only reach the binding whose Runtime
/// generation is still current; disposing an older lease cannot unbind a
/// replacement route.
/// </summary>
internal sealed class RuntimePlacementProjectionRetrySlot
: IRuntimePlacementProjectionRetryPhase
{
private sealed record Binding(
long Id,
RuntimeGenerationToken Generation,
Func<bool> Retry);
private readonly Func<RuntimeGenerationToken> _currentGeneration;
private Binding? _current;
private long _nextBindingId;
internal RuntimePlacementProjectionRetrySlot(
Func<RuntimeGenerationToken> currentGeneration)
{
_currentGeneration = currentGeneration
?? throw new ArgumentNullException(nameof(currentGeneration));
}
internal int BindingCount => _current is null ? 0 : 1;
internal IDisposable BindOwned(
RuntimeGenerationToken generation,
Func<bool> retry)
{
if (generation.Value == 0UL)
{
throw new ArgumentException(
"A placement retry route requires a live Runtime generation.",
nameof(generation));
}
ArgumentNullException.ThrowIfNull(retry);
if (_current is not null)
{
throw new InvalidOperationException(
"A graphical placement retry route is already bound.");
}
var binding = new Binding(
checked(++_nextBindingId),
generation,
retry);
_current = binding;
return new DelegateDisposable(() => Unbind(binding));
}
public void RetryPending()
{
Binding? binding = _current;
if (binding is null
|| binding.Generation != _currentGeneration())
{
return;
}
_ = binding.Retry();
}
private void Unbind(Binding binding)
{
if (ReferenceEquals(_current, binding))
_current = null;
}
private sealed class DelegateDisposable(Action dispose) : IDisposable
{
private Action? _dispose = dispose
?? throw new ArgumentNullException(nameof(dispose));
public void Dispose() => Interlocked.Exchange(ref _dispose, null)?.Invoke();
}
}