From f05ed5c3cd59dfc38c43590638560248cfb25a3b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 1 Aug 2026 15:22:52 +0200 Subject: [PATCH] feat(app): observe canonical placement receipts --- .../Composition/FrameRootComposition.cs | 3 +- .../LivePresentationComposition.cs | 33 +++++ .../Composition/SessionPlayerComposition.cs | 9 +- .../Net/GraphicalSessionEventRoute.cs | 102 ++++++++++++++ .../Net/LiveSessionRuntimeFactory.cs | 13 +- .../RuntimePlacementProjectionRetrySlot.cs | 87 ++++++++++++ .../World/RetailLiveFrameCoordinator.cs | 13 +- .../World/RuntimePlacementPresentationSink.cs | 6 +- .../RuntimePlacementProjectionSubscription.cs | 32 ++++- .../GameWindowSlice8BoundaryTests.cs | 2 +- .../Runtime/RuntimePhysicsOwnershipTests.cs | 42 +++--- .../RuntimePlacementPresentationSinkTests.cs | 125 ++++++++++++++++++ .../World/UpdateFrameOrchestratorTests.cs | 81 ++++++++++++ ...imePlacementProjectionSubscriptionTests.cs | 28 +++- 14 files changed, 545 insertions(+), 31 deletions(-) create mode 100644 src/AcDream.App/Net/GraphicalSessionEventRoute.cs create mode 100644 src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 7248f712..d661dc3a 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -585,7 +585,8 @@ internal sealed class FrameRootCompositionPhase session.LocalPlayerFrame, session.LiveSpatialReconciler, live.WorldAvailability, - live.RenderSceneShadow?.LiveProjections); + live.RenderSceneShadow?.LiveProjections, + session.PlacementProjectionRetry); var cameraFrame = new CameraFrameController( host.CameraController, d.InputCapture, diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 784b44c4..7a4be2fd 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -96,6 +96,7 @@ internal sealed record LivePresentationResult( GpuWorldState WorldState, RenderSceneShadowRuntime? RenderSceneShadow, LiveEntityRuntime LiveEntities, + RuntimePlacementPresentationSink PlacementProjection, ProjectileController ProjectileController, LiveEntityProjectionWithdrawalController ProjectionWithdrawal, LiveEntityLightController Lights, @@ -451,6 +452,35 @@ internal sealed class LivePresentationCompositionPhase liveEntities, particleVisibility, "particle projection visibility"); + var placementVisibilitySinks = new List< + Action>(3) + { + wbVisibility, + }; + if (liveRenderProjections is not null) + { + placementVisibilitySinks.Add( + liveRenderProjections.OnProjectionVisibilityChanged); + } + placementVisibilitySinks.Add(particleVisibility); + var placementProjection = new RuntimePlacementPresentationSink( + liveEntities, + worldTransit, + d.WorldGameState, + d.WorldEvents, + d.EffectPoses, + d.LocalPlayerShadow, + () => d.PlayerIdentity.ServerGuid, + guid => + { + if (d.Selection.SelectedObjectId == guid) + { + d.Selection.Clear( + SelectionChangeSource.System, + SelectionChangeReason.SelectedObjectRemoved); + } + }, + placementVisibilitySinks); Fault(LivePresentationCompositionPoint.ProjectionVisibilityBound); var projectileController = new ProjectileController( @@ -619,6 +649,7 @@ internal sealed class LivePresentationCompositionPhase renderSceneShadow, renderSceneShadowLease, liveEntities, + placementProjection, projectileController, projectionWithdrawal, lightsLease, @@ -668,6 +699,7 @@ internal sealed class LivePresentationCompositionPhase CompositionAcquisitionScope.CompositionAcquisitionLease< RenderSceneShadowRuntime>? renderSceneShadowLease, LiveEntityRuntime liveEntities, + RuntimePlacementPresentationSink placementProjection, ProjectileController projectileController, LiveEntityProjectionWithdrawalController projectionWithdrawal, CompositionAcquisitionScope.CompositionAcquisitionLease lightsLease, @@ -1147,6 +1179,7 @@ internal sealed class LivePresentationCompositionPhase worldState, renderSceneShadow, liveEntities, + placementProjection, projectileController, projectionWithdrawal, lightsLease.Resource, diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index d7ae0328..2702f7b9 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -122,6 +122,7 @@ internal sealed record SessionPlayerResult( PlayerModeAutoEntry PlayerModeAutoEntry, LocalPlayerTeleportController LocalTeleport, LiveSessionHost SessionHost, + RuntimePlacementProjectionRetrySlot PlacementProjectionRetry, CurrentGameRuntimeAdapter GameRuntime, GameplayInputActionRouter? GameplayActions, SessionPlayerRuntimeBindings RuntimeBindings); @@ -832,6 +833,9 @@ internal sealed class SessionPlayerCompositionPhase AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals = interaction.RetainedUi?.Vitals; + var placementProjectionRetry = + new RuntimePlacementProjectionRetrySlot( + () => d.Runtime.Generation); var sessionRuntimeFactory = new LiveSessionRuntimeFactory( new LiveSessionPlayerRuntime( d.PlayerIdentity, @@ -878,7 +882,9 @@ internal sealed class SessionPlayerCompositionPhase content.AnimationHookFrames, live.Presentation, d.RemoteMovementObservations, - live.RenderSceneShadow), + live.RenderSceneShadow, + live.PlacementProjection, + placementProjectionRetry), liveSessionCommands, d.Log); LiveSessionHost sessionHost = sessionRuntimeFactory.Create( @@ -1015,6 +1021,7 @@ internal sealed class SessionPlayerCompositionPhase playerModeAutoEntry, teleportLease.Resource, sessionHost, + placementProjectionRetry, gameRuntime, gameplayActionsLease?.Resource, bindings); diff --git a/src/AcDream.App/Net/GraphicalSessionEventRoute.cs b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs new file mode 100644 index 00000000..e2411320 --- /dev/null +++ b/src/AcDream.App/Net/GraphicalSessionEventRoute.cs @@ -0,0 +1,102 @@ +using AcDream.Runtime; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; + +namespace AcDream.App.Net; + +/// +/// Owns the inbound route, canonical placement observer, and update-thread +/// retry lease for one exact graphical session generation. +/// +internal sealed class GraphicalSessionEventRoute : ILiveSessionEventRouting +{ + private readonly ILiveSessionEventRouting _events; + private readonly Func + _createSubscription; + private readonly Func _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 createSubscription, + Func 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; + } +} diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index fb683ea5..a300012e 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -83,7 +83,9 @@ internal sealed record LiveSessionWorldRuntime( AnimationHookFrameQueue AnimationHookFrames, LiveEntityPresentationController Presentation, RemoteMovementObservationTracker RemoteMovementObservations, - RenderSceneShadowRuntime? RenderSceneShadow); + RenderSceneShadowRuntime? RenderSceneShadow, + RuntimePlacementPresentationSink PlacementProjection, + RuntimePlacementProjectionRetrySlot PlacementRetries); /// /// 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(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( diff --git a/src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs b/src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs new file mode 100644 index 00000000..7a2de84d --- /dev/null +++ b/src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs @@ -0,0 +1,87 @@ +using AcDream.Runtime; + +namespace AcDream.App.Net; + +internal interface IRuntimePlacementProjectionRetryPhase +{ + void RetryPending(); +} + +/// +/// 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. +/// +internal sealed class RuntimePlacementProjectionRetrySlot + : IRuntimePlacementProjectionRetryPhase +{ + private sealed record Binding( + long Id, + RuntimeGenerationToken Generation, + Func Retry); + + private readonly Func _currentGeneration; + private Binding? _current; + private long _nextBindingId; + + internal RuntimePlacementProjectionRetrySlot( + Func currentGeneration) + { + _currentGeneration = currentGeneration + ?? throw new ArgumentNullException(nameof(currentGeneration)); + } + + internal int BindingCount => _current is null ? 0 : 1; + + internal IDisposable BindOwned( + RuntimeGenerationToken generation, + Func 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(); + } +} diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs index a6b61d94..697ba41c 100644 --- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs +++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs @@ -27,6 +27,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase private readonly ILiveSpatialReconcilePhase _spatialReconciler; private readonly IWorldGenerationAvailability _availability; private readonly IRenderProjectionSyncPhase? _renderProjectionSync; + private readonly IRuntimePlacementProjectionRetryPhase? + _placementProjectionRetry; public RetailLiveFrameCoordinator( ILiveObjectFramePhase objects, @@ -35,7 +37,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase IPostNetworkCommandFramePhase localPlayer, ILiveSpatialReconcilePhase spatialReconciler, IWorldGenerationAvailability? availability = null, - IRenderProjectionSyncPhase? renderProjectionSync = null) + IRenderProjectionSyncPhase? renderProjectionSync = null, + IRuntimePlacementProjectionRetryPhase? placementProjectionRetry = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); @@ -45,6 +48,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase ?? throw new ArgumentNullException(nameof(spatialReconciler)); _availability = availability ?? AlwaysAvailableWorldGeneration.Instance; _renderProjectionSync = renderProjectionSync; + _placementProjectionRetry = placementProjectionRetry; } public void Tick(float deltaSeconds) @@ -53,7 +57,14 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase if (_availability.IsWorldAvailable) _objects.Tick(frameDelta); using (_worldState.BeginMutationBatch()) + { _session.Tick(); + // Streaming publication precedes this coordinator, while inbound + // network dispatch completes immediately above. A graphical + // receipt that previously lacked its destination backend can now + // retry against both readiness edges on the update thread. + _placementProjectionRetry?.RetryPending(); + } _localPlayer.RunPostNetworkCommandPhase(); if (_availability.IsWorldAvailable) _spatialReconciler.Reconcile(); diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs index 1bef262f..79e23e9c 100644 --- a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -14,9 +14,9 @@ namespace AcDream.App.World; /// and renderer/VFX visibility projections. Runtime physics, shadows, body /// state, clocks, and worksets were committed before this sink is invoked. /// -/// This adapter deliberately owns no subscription: production composition -/// activates the shared observer only after both graphical and no-window hosts -/// implement the same presentation-only contract. +/// This adapter deliberately owns no subscription. The exact graphical +/// session route owns the shared Runtime observer and its generation-scoped +/// update-thread retry lease. /// internal sealed class RuntimePlacementPresentationSink : IRuntimePlacementProjectionSink diff --git a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs index bc48214c..622cea4e 100644 --- a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs +++ b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs @@ -34,11 +34,25 @@ public sealed class RuntimePlacementProjectionSubscription public RuntimePlacementProjectionSubscription( GameRuntime runtime, IRuntimePlacementProjectionSink sink) + : this(runtime, sink, retryPendingOnSubscribe: true) + { + } + + /// + /// Subscribes before optionally draining the pending FIFO. A session route + /// which must publish its own disposal/retry ownership first passes + /// false, stores those owners, then calls . + /// + public RuntimePlacementProjectionSubscription( + GameRuntime runtime, + IRuntimePlacementProjectionSink sink, + bool retryPendingOnSubscribe) : this( runtime?.Placements ?? throw new ArgumentNullException(nameof(runtime)), () => runtime.Generation, - sink) + sink, + retryPendingOnSubscribe) { } @@ -46,13 +60,27 @@ public sealed class RuntimePlacementProjectionSubscription RuntimePlacementProjectionChannel channel, Func generation, IRuntimePlacementProjectionSink sink) + : this( + channel, + generation, + sink, + retryPendingOnSubscribe: true) + { + } + + internal RuntimePlacementProjectionSubscription( + RuntimePlacementProjectionChannel channel, + Func generation, + IRuntimePlacementProjectionSink sink, + bool retryPendingOnSubscribe) { _channel = channel ?? throw new ArgumentNullException(nameof(channel)); _generation = generation ?? throw new ArgumentNullException(nameof(generation)); _sink = sink ?? throw new ArgumentNullException(nameof(sink)); _subscription = _channel.Subscribe(this); - _ = RetryPending(); + if (retryPendingOnSubscribe) + _ = RetryPending(); } public bool HasAppliedReceiptAwaitingAcknowledgement => diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 9faeb3e5..0310a39f 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -162,7 +162,7 @@ public sealed class GameWindowSlice8BoundaryTests "LiveSessionRuntimeFactory.cs")); string sessionFactory = Slice( sessionFactorySource, - "private LiveSessionEventRouter CreateEventRouter(", + "private ILiveSessionEventRouting CreateEventRouter(", "private LiveInventorySessionBindings CreateInventoryBindings()"); string worldPhase = File.ReadAllText(Path.Combine( FindRepoRoot(), diff --git a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs index 5ac5862f..861a19ee 100644 --- a/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Runtime/RuntimePhysicsOwnershipTests.cs @@ -5,28 +5,36 @@ namespace AcDream.App.Tests.Runtime; public sealed class RuntimePhysicsOwnershipTests { [Fact] - public void GraphicalPlacementProjectionRemainsDormantUntilCoordinatedCutover() + public void ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel() { string root = FindRepositoryRoot(); - string relative = Path.Combine("src", "AcDream.App"); - foreach (string file in Directory.EnumerateFiles( - Path.Combine(root, relative), - "*.cs", - SearchOption.AllDirectories)) + foreach (string relative in new[] + { + Path.Combine("src", "AcDream.App"), + Path.Combine("src", "AcDream.Headless"), + }) { - string source = File.ReadAllText(file); + string[] sources = Directory.EnumerateFiles( + Path.Combine(root, relative), + "*.cs", + SearchOption.AllDirectories) + .Select(File.ReadAllText) + .ToArray(); Assert.DoesNotContain( - ".Placements.", - source, - StringComparison.Ordinal); + sources, + source => source.Contains( + ".Placements.", + StringComparison.Ordinal)); Assert.DoesNotContain( - "RuntimePlacementProjectionChannel", - source, - StringComparison.Ordinal); - Assert.DoesNotContain( - "RuntimePlacementProjectionSubscription", - source, - StringComparison.Ordinal); + sources, + source => source.Contains( + "RuntimePlacementProjectionChannel", + StringComparison.Ordinal)); + Assert.Contains( + sources, + source => source.Contains( + "RuntimePlacementProjectionSubscription", + StringComparison.Ordinal)); } } diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs index 18ac4a06..7c6e252f 100644 --- a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.App.Net; using AcDream.App.Physics; using AcDream.App.Rendering.Vfx; using AcDream.Core.Net; @@ -11,6 +12,7 @@ using AcDream.Core.World; using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using AcDream.Runtime.World; using DatReaderWriter.DBObjs; @@ -409,6 +411,108 @@ public sealed class RuntimePlacementPresentationSinkTests Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); } + [Fact] + public void GraphicalRoute_ReentrantInitialDrainTeardownLeavesHeadForReplacement() + { + using var fixture = SubscriptionFixture.Create(); + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.Record.Canonical, + publishWithdrawal: true)); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + + RuntimeGenerationToken generation = fixture.Generation; + var retries = new RuntimePlacementProjectionRetrySlot( + () => generation); + var firstEvents = new RecordingEventRoute(); + GraphicalSessionEventRoute? first = null; + int applications = 0; + var sink = new DelegatePlacementSink( + (in RuntimePlacementProjectionSnapshot projection) => + { + applications++; + bool applied = fixture.Sink.TryApply(in projection); + if (applications == 1) + first!.Dispose(); + return applied; + }); + first = new GraphicalSessionEventRoute( + firstEvents, + () => new RuntimePlacementProjectionSubscription( + fixture.Lifetime.Placements, + () => generation, + sink, + retryPendingOnSubscribe: false), + () => generation, + retries); + + first.Attach(); + + Assert.Equal(1, applications); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Equal(0, retries.BindingCount); + Assert.Equal(0, fixture.Lifetime.Events.PlacementSubscriberCount); + Assert.Equal(1, firstEvents.DisposeCount); + + var replacementEvents = new RecordingEventRoute(); + var replacement = new GraphicalSessionEventRoute( + replacementEvents, + () => new RuntimePlacementProjectionSubscription( + fixture.Lifetime.Placements, + () => generation, + sink, + retryPendingOnSubscribe: false), + () => generation, + retries); + replacement.Attach(); + + Assert.Equal(2, applications); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.Equal(1, retries.BindingCount); + Assert.Equal(1, fixture.Lifetime.Events.PlacementSubscriberCount); + + replacement.Dispose(); + + Assert.Equal(0, retries.BindingCount); + Assert.Equal(0, fixture.Lifetime.Events.PlacementSubscriberCount); + Assert.Equal(1, replacementEvents.DisposeCount); + } + + [Fact] + public void RetrySlot_InvokesOnlyExactCurrentGenerationBinding() + { + RuntimeGenerationToken generation = new(7UL); + var retries = new RuntimePlacementProjectionRetrySlot( + () => generation); + int firstCalls = 0; + IDisposable first = retries.BindOwned( + generation, + () => + { + firstCalls++; + return true; + }); + + retries.RetryPending(); + generation = new RuntimeGenerationToken(8UL); + retries.RetryPending(); + first.Dispose(); + + int replacementCalls = 0; + using IDisposable replacement = retries.BindOwned( + generation, + () => + { + replacementCalls++; + return true; + }); + first.Dispose(); + retries.RetryPending(); + + Assert.Equal(1, firstCalls); + Assert.Equal(1, replacementCalls); + Assert.Equal(1, retries.BindingCount); + } + private static RuntimePlacementProjectionSnapshot Placement( Fixture fixture, LiveEntityRecord record, @@ -805,6 +909,27 @@ public sealed class RuntimePlacementPresentationSinkTests } } + private sealed class DelegatePlacementSink( + PlacementApply apply) : IRuntimePlacementProjectionSink + { + public bool TryApply( + in RuntimePlacementProjectionSnapshot projection) => + apply(in projection); + } + + private delegate bool PlacementApply( + in RuntimePlacementProjectionSnapshot projection); + + private sealed class RecordingEventRoute : ILiveSessionEventRouting + { + public int AttachCount { get; private set; } + public int DisposeCount { get; private set; } + + public void Attach() => AttachCount++; + + public void Dispose() => DisposeCount++; + } + private sealed class RecordingResources : ILiveEntityResourceLifecycle { public void Register(WorldEntity entity) { } diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 30aeae28..4adcb828 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -1,9 +1,11 @@ using System.Reflection; using AcDream.App.Rendering; +using AcDream.App.Net; using AcDream.App.Streaming; using AcDream.App.Update; using AcDream.App.World; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.Runtime.World; namespace AcDream.App.Tests.World; @@ -387,6 +389,65 @@ public sealed class UpdateFrameOrchestratorTests Assert.Equal(1, CountOccurrences(source, "_scripts.Tick(")); } + [Fact] + public void PlacementRetryRunsAfterStreamingAndInboundBeforeCommandReconcile() + { + string root = FindRepoRoot(); + string outer = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "Update", + "UpdateFrameOrchestrator.cs")); + string live = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "World", + "RetailLiveFrameCoordinator.cs")); + + AssertAppearsInOrder( + outer, + "_streaming.Tick();", + "_liveFrame.Tick("); + AssertAppearsInOrder( + live, + "_session.Tick();", + "_placementProjectionRetry?.RetryPending();", + "_localPlayer.RunPostNetworkCommandPhase();", + "_spatialReconciler.Reconcile();"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LiveFrameRetriesPlacementAfterInboundEvenWhenWorldIsQuiesced( + bool worldAvailable) + { + var calls = new List(); + var phases = new RecordingLivePhases(calls) + { + IsWorldAvailable = worldAvailable, + }; + var frame = new RetailLiveFrameCoordinator( + phases, + new GpuWorldState(), + phases, + phases, + phases, + phases, + phases, + phases); + + frame.Tick(1f / 60f); + + Assert.Equal( + worldAvailable + ? ["objects", "network", "placement-retry", "commands", "reconcile"] + : ["network", "placement-retry", "commands", "render-sync"], + calls); + } + [Fact] public void GameWindow_ComposesTheLiveFrameOwnersWithoutOwningTheirBodies() { @@ -1048,6 +1109,26 @@ public sealed class UpdateFrameOrchestratorTests } } + private sealed class RecordingLivePhases(List calls) : + ILiveObjectFramePhase, + IRuntimeLiveSessionFramePhase, + IPostNetworkCommandFramePhase, + ILiveSpatialReconcilePhase, + IRenderProjectionSyncPhase, + IWorldGenerationAvailability, + IRuntimePlacementProjectionRetryPhase + { + public bool IsWorldAvailable { get; set; } + public long QuiescedGeneration => IsWorldAvailable ? 0L : 1L; + + public void Tick(float deltaSeconds) => calls.Add("objects"); + public void Tick() => calls.Add("network"); + public void RunPostNetworkCommandPhase() => calls.Add("commands"); + public void Reconcile() => calls.Add("reconcile"); + public void SynchronizeActiveSources() => calls.Add("render-sync"); + public void RetryPending() => calls.Add("placement-retry"); + } + private sealed class RecordingCommit(List calls) : IUpdateFrameCommitPhase { diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs index 541a0ac3..c3bcd7be 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs @@ -173,6 +173,28 @@ public sealed class RuntimePlacementProjectionSubscriptionTests Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); } + [Fact] + public void SubscribeWithoutDrainPublishesOnlyAfterExplicitRetry() + { + using var fixture = new Fixture(); + RuntimeSetPositionOutcome pending = fixture.Place( + fixture.First, + new Vector3(18.5f, 18f, 7f)); + var sink = new RecordingSink(); + + using var subscription = fixture.Subscribe( + sink, + retryPendingOnSubscribe: false); + + Assert.Empty(sink.Applied); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + + Assert.True(subscription.RetryPending()); + + Assert.Equal(pending.Projection, Assert.Single(sink.Applied).Token); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + [Fact] public void SyntheticWithdrawalUsesTheSameExactSinkAndAckPath() { @@ -263,10 +285,12 @@ public sealed class RuntimePlacementProjectionSubscriptionTests internal RuntimeEntityRecord? Second { get; } internal RuntimePlacementProjectionSubscription Subscribe( - IRuntimePlacementProjectionSink sink) => new( + IRuntimePlacementProjectionSink sink, + bool retryPendingOnSubscribe = true) => new( Lifetime.Placements, () => _generation, - sink); + sink, + retryPendingOnSubscribe); internal RuntimeSetPositionOutcome Place( RuntimeEntityRecord record,