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

@ -585,7 +585,8 @@ internal sealed class FrameRootCompositionPhase
session.LocalPlayerFrame, session.LocalPlayerFrame,
session.LiveSpatialReconciler, session.LiveSpatialReconciler,
live.WorldAvailability, live.WorldAvailability,
live.RenderSceneShadow?.LiveProjections); live.RenderSceneShadow?.LiveProjections,
session.PlacementProjectionRetry);
var cameraFrame = new CameraFrameController( var cameraFrame = new CameraFrameController(
host.CameraController, host.CameraController,
d.InputCapture, d.InputCapture,

View file

@ -96,6 +96,7 @@ internal sealed record LivePresentationResult(
GpuWorldState WorldState, GpuWorldState WorldState,
RenderSceneShadowRuntime? RenderSceneShadow, RenderSceneShadowRuntime? RenderSceneShadow,
LiveEntityRuntime LiveEntities, LiveEntityRuntime LiveEntities,
RuntimePlacementPresentationSink PlacementProjection,
ProjectileController ProjectileController, ProjectileController ProjectileController,
LiveEntityProjectionWithdrawalController ProjectionWithdrawal, LiveEntityProjectionWithdrawalController ProjectionWithdrawal,
LiveEntityLightController Lights, LiveEntityLightController Lights,
@ -451,6 +452,35 @@ internal sealed class LivePresentationCompositionPhase
liveEntities, liveEntities,
particleVisibility, particleVisibility,
"particle projection visibility"); "particle projection visibility");
var placementVisibilitySinks = new List<
Action<LiveEntityRecord, bool>>(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); Fault(LivePresentationCompositionPoint.ProjectionVisibilityBound);
var projectileController = new ProjectileController( var projectileController = new ProjectileController(
@ -619,6 +649,7 @@ internal sealed class LivePresentationCompositionPhase
renderSceneShadow, renderSceneShadow,
renderSceneShadowLease, renderSceneShadowLease,
liveEntities, liveEntities,
placementProjection,
projectileController, projectileController,
projectionWithdrawal, projectionWithdrawal,
lightsLease, lightsLease,
@ -668,6 +699,7 @@ internal sealed class LivePresentationCompositionPhase
CompositionAcquisitionScope.CompositionAcquisitionLease< CompositionAcquisitionScope.CompositionAcquisitionLease<
RenderSceneShadowRuntime>? renderSceneShadowLease, RenderSceneShadowRuntime>? renderSceneShadowLease,
LiveEntityRuntime liveEntities, LiveEntityRuntime liveEntities,
RuntimePlacementPresentationSink placementProjection,
ProjectileController projectileController, ProjectileController projectileController,
LiveEntityProjectionWithdrawalController projectionWithdrawal, LiveEntityProjectionWithdrawalController projectionWithdrawal,
CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityLightController> lightsLease, CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityLightController> lightsLease,
@ -1147,6 +1179,7 @@ internal sealed class LivePresentationCompositionPhase
worldState, worldState,
renderSceneShadow, renderSceneShadow,
liveEntities, liveEntities,
placementProjection,
projectileController, projectileController,
projectionWithdrawal, projectionWithdrawal,
lightsLease.Resource, lightsLease.Resource,

View file

@ -122,6 +122,7 @@ internal sealed record SessionPlayerResult(
PlayerModeAutoEntry PlayerModeAutoEntry, PlayerModeAutoEntry PlayerModeAutoEntry,
LocalPlayerTeleportController LocalTeleport, LocalPlayerTeleportController LocalTeleport,
LiveSessionHost SessionHost, LiveSessionHost SessionHost,
RuntimePlacementProjectionRetrySlot PlacementProjectionRetry,
CurrentGameRuntimeAdapter GameRuntime, CurrentGameRuntimeAdapter GameRuntime,
GameplayInputActionRouter? GameplayActions, GameplayInputActionRouter? GameplayActions,
SessionPlayerRuntimeBindings RuntimeBindings); SessionPlayerRuntimeBindings RuntimeBindings);
@ -832,6 +833,9 @@ internal sealed class SessionPlayerCompositionPhase
AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals = AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? vitals =
interaction.RetainedUi?.Vitals; interaction.RetainedUi?.Vitals;
var placementProjectionRetry =
new RuntimePlacementProjectionRetrySlot(
() => d.Runtime.Generation);
var sessionRuntimeFactory = new LiveSessionRuntimeFactory( var sessionRuntimeFactory = new LiveSessionRuntimeFactory(
new LiveSessionPlayerRuntime( new LiveSessionPlayerRuntime(
d.PlayerIdentity, d.PlayerIdentity,
@ -878,7 +882,9 @@ internal sealed class SessionPlayerCompositionPhase
content.AnimationHookFrames, content.AnimationHookFrames,
live.Presentation, live.Presentation,
d.RemoteMovementObservations, d.RemoteMovementObservations,
live.RenderSceneShadow), live.RenderSceneShadow,
live.PlacementProjection,
placementProjectionRetry),
liveSessionCommands, liveSessionCommands,
d.Log); d.Log);
LiveSessionHost sessionHost = sessionRuntimeFactory.Create( LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
@ -1015,6 +1021,7 @@ internal sealed class SessionPlayerCompositionPhase
playerModeAutoEntry, playerModeAutoEntry,
teleportLease.Resource, teleportLease.Resource,
sessionHost, sessionHost,
placementProjectionRetry,
gameRuntime, gameRuntime,
gameplayActionsLease?.Resource, gameplayActionsLease?.Resource,
bindings); bindings);

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

View file

@ -27,6 +27,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
private readonly ILiveSpatialReconcilePhase _spatialReconciler; private readonly ILiveSpatialReconcilePhase _spatialReconciler;
private readonly IWorldGenerationAvailability _availability; private readonly IWorldGenerationAvailability _availability;
private readonly IRenderProjectionSyncPhase? _renderProjectionSync; private readonly IRenderProjectionSyncPhase? _renderProjectionSync;
private readonly IRuntimePlacementProjectionRetryPhase?
_placementProjectionRetry;
public RetailLiveFrameCoordinator( public RetailLiveFrameCoordinator(
ILiveObjectFramePhase objects, ILiveObjectFramePhase objects,
@ -35,7 +37,8 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
IPostNetworkCommandFramePhase localPlayer, IPostNetworkCommandFramePhase localPlayer,
ILiveSpatialReconcilePhase spatialReconciler, ILiveSpatialReconcilePhase spatialReconciler,
IWorldGenerationAvailability? availability = null, IWorldGenerationAvailability? availability = null,
IRenderProjectionSyncPhase? renderProjectionSync = null) IRenderProjectionSyncPhase? renderProjectionSync = null,
IRuntimePlacementProjectionRetryPhase? placementProjectionRetry = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState)); _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
@ -45,6 +48,7 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
?? throw new ArgumentNullException(nameof(spatialReconciler)); ?? throw new ArgumentNullException(nameof(spatialReconciler));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance; _availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
_renderProjectionSync = renderProjectionSync; _renderProjectionSync = renderProjectionSync;
_placementProjectionRetry = placementProjectionRetry;
} }
public void Tick(float deltaSeconds) public void Tick(float deltaSeconds)
@ -53,7 +57,14 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
if (_availability.IsWorldAvailable) if (_availability.IsWorldAvailable)
_objects.Tick(frameDelta); _objects.Tick(frameDelta);
using (_worldState.BeginMutationBatch()) using (_worldState.BeginMutationBatch())
{
_session.Tick(); _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(); _localPlayer.RunPostNetworkCommandPhase();
if (_availability.IsWorldAvailable) if (_availability.IsWorldAvailable)
_spatialReconciler.Reconcile(); _spatialReconciler.Reconcile();

View file

@ -14,9 +14,9 @@ namespace AcDream.App.World;
/// and renderer/VFX visibility projections. Runtime physics, shadows, body /// and renderer/VFX visibility projections. Runtime physics, shadows, body
/// state, clocks, and worksets were committed before this sink is invoked. /// state, clocks, and worksets were committed before this sink is invoked.
/// ///
/// This adapter deliberately owns no subscription: production composition /// This adapter deliberately owns no subscription. The exact graphical
/// activates the shared observer only after both graphical and no-window hosts /// session route owns the shared Runtime observer and its generation-scoped
/// implement the same presentation-only contract. /// update-thread retry lease.
/// </summary> /// </summary>
internal sealed class RuntimePlacementPresentationSink internal sealed class RuntimePlacementPresentationSink
: IRuntimePlacementProjectionSink : IRuntimePlacementProjectionSink

View file

@ -34,11 +34,25 @@ public sealed class RuntimePlacementProjectionSubscription
public RuntimePlacementProjectionSubscription( public RuntimePlacementProjectionSubscription(
GameRuntime runtime, GameRuntime runtime,
IRuntimePlacementProjectionSink sink) IRuntimePlacementProjectionSink sink)
: this(runtime, sink, retryPendingOnSubscribe: true)
{
}
/// <summary>
/// Subscribes before optionally draining the pending FIFO. A session route
/// which must publish its own disposal/retry ownership first passes
/// <c>false</c>, stores those owners, then calls <see cref="RetryPending"/>.
/// </summary>
public RuntimePlacementProjectionSubscription(
GameRuntime runtime,
IRuntimePlacementProjectionSink sink,
bool retryPendingOnSubscribe)
: this( : this(
runtime?.Placements runtime?.Placements
?? throw new ArgumentNullException(nameof(runtime)), ?? throw new ArgumentNullException(nameof(runtime)),
() => runtime.Generation, () => runtime.Generation,
sink) sink,
retryPendingOnSubscribe)
{ {
} }
@ -46,13 +60,27 @@ public sealed class RuntimePlacementProjectionSubscription
RuntimePlacementProjectionChannel channel, RuntimePlacementProjectionChannel channel,
Func<RuntimeGenerationToken> generation, Func<RuntimeGenerationToken> generation,
IRuntimePlacementProjectionSink sink) IRuntimePlacementProjectionSink sink)
: this(
channel,
generation,
sink,
retryPendingOnSubscribe: true)
{
}
internal RuntimePlacementProjectionSubscription(
RuntimePlacementProjectionChannel channel,
Func<RuntimeGenerationToken> generation,
IRuntimePlacementProjectionSink sink,
bool retryPendingOnSubscribe)
{ {
_channel = channel ?? throw new ArgumentNullException(nameof(channel)); _channel = channel ?? throw new ArgumentNullException(nameof(channel));
_generation = generation _generation = generation
?? throw new ArgumentNullException(nameof(generation)); ?? throw new ArgumentNullException(nameof(generation));
_sink = sink ?? throw new ArgumentNullException(nameof(sink)); _sink = sink ?? throw new ArgumentNullException(nameof(sink));
_subscription = _channel.Subscribe(this); _subscription = _channel.Subscribe(this);
_ = RetryPending(); if (retryPendingOnSubscribe)
_ = RetryPending();
} }
public bool HasAppliedReceiptAwaitingAcknowledgement => public bool HasAppliedReceiptAwaitingAcknowledgement =>

View file

@ -162,7 +162,7 @@ public sealed class GameWindowSlice8BoundaryTests
"LiveSessionRuntimeFactory.cs")); "LiveSessionRuntimeFactory.cs"));
string sessionFactory = Slice( string sessionFactory = Slice(
sessionFactorySource, sessionFactorySource,
"private LiveSessionEventRouter CreateEventRouter(", "private ILiveSessionEventRouting CreateEventRouter(",
"private LiveInventorySessionBindings CreateInventoryBindings()"); "private LiveInventorySessionBindings CreateInventoryBindings()");
string worldPhase = File.ReadAllText(Path.Combine( string worldPhase = File.ReadAllText(Path.Combine(
FindRepoRoot(), FindRepoRoot(),

View file

@ -5,28 +5,36 @@ namespace AcDream.App.Tests.Runtime;
public sealed class RuntimePhysicsOwnershipTests public sealed class RuntimePhysicsOwnershipTests
{ {
[Fact] [Fact]
public void GraphicalPlacementProjectionRemainsDormantUntilCoordinatedCutover() public void ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel()
{ {
string root = FindRepositoryRoot(); string root = FindRepositoryRoot();
string relative = Path.Combine("src", "AcDream.App"); foreach (string relative in new[]
foreach (string file in Directory.EnumerateFiles( {
Path.Combine(root, relative), Path.Combine("src", "AcDream.App"),
"*.cs", Path.Combine("src", "AcDream.Headless"),
SearchOption.AllDirectories)) })
{ {
string source = File.ReadAllText(file); string[] sources = Directory.EnumerateFiles(
Path.Combine(root, relative),
"*.cs",
SearchOption.AllDirectories)
.Select(File.ReadAllText)
.ToArray();
Assert.DoesNotContain( Assert.DoesNotContain(
".Placements.", sources,
source, source => source.Contains(
StringComparison.Ordinal); ".Placements.",
StringComparison.Ordinal));
Assert.DoesNotContain( Assert.DoesNotContain(
"RuntimePlacementProjectionChannel", sources,
source, source => source.Contains(
StringComparison.Ordinal); "RuntimePlacementProjectionChannel",
Assert.DoesNotContain( StringComparison.Ordinal));
"RuntimePlacementProjectionSubscription", Assert.Contains(
source, sources,
StringComparison.Ordinal); source => source.Contains(
"RuntimePlacementProjectionSubscription",
StringComparison.Ordinal));
} }
} }

View file

@ -1,6 +1,7 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.App.Net;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.Core.Net; using AcDream.Core.Net;
@ -11,6 +12,7 @@ using AcDream.Core.World;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics; using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
using AcDream.Runtime.World; using AcDream.Runtime.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -409,6 +411,108 @@ public sealed class RuntimePlacementPresentationSinkTests
Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); 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( private static RuntimePlacementProjectionSnapshot Placement(
Fixture fixture, Fixture fixture,
LiveEntityRecord record, 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 private sealed class RecordingResources : ILiveEntityResourceLifecycle
{ {
public void Register(WorldEntity entity) { } public void Register(WorldEntity entity) { }

View file

@ -1,9 +1,11 @@
using System.Reflection; using System.Reflection;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Net;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.Update; using AcDream.App.Update;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session;
using AcDream.Runtime.World; using AcDream.Runtime.World;
namespace AcDream.App.Tests.World; namespace AcDream.App.Tests.World;
@ -387,6 +389,65 @@ public sealed class UpdateFrameOrchestratorTests
Assert.Equal(1, CountOccurrences(source, "_scripts.Tick(")); 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<string>();
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] [Fact]
public void GameWindow_ComposesTheLiveFrameOwnersWithoutOwningTheirBodies() public void GameWindow_ComposesTheLiveFrameOwnersWithoutOwningTheirBodies()
{ {
@ -1048,6 +1109,26 @@ public sealed class UpdateFrameOrchestratorTests
} }
} }
private sealed class RecordingLivePhases(List<string> 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<string> calls) private sealed class RecordingCommit(List<string> calls)
: IUpdateFrameCommitPhase : IUpdateFrameCommitPhase
{ {

View file

@ -173,6 +173,28 @@ public sealed class RuntimePlacementProjectionSubscriptionTests
Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); 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] [Fact]
public void SyntheticWithdrawalUsesTheSameExactSinkAndAckPath() public void SyntheticWithdrawalUsesTheSameExactSinkAndAckPath()
{ {
@ -263,10 +285,12 @@ public sealed class RuntimePlacementProjectionSubscriptionTests
internal RuntimeEntityRecord? Second { get; } internal RuntimeEntityRecord? Second { get; }
internal RuntimePlacementProjectionSubscription Subscribe( internal RuntimePlacementProjectionSubscription Subscribe(
IRuntimePlacementProjectionSink sink) => new( IRuntimePlacementProjectionSink sink,
bool retryPendingOnSubscribe = true) => new(
Lifetime.Placements, Lifetime.Placements,
() => _generation, () => _generation,
sink); sink,
retryPendingOnSubscribe);
internal RuntimeSetPositionOutcome Place( internal RuntimeSetPositionOutcome Place(
RuntimeEntityRecord record, RuntimeEntityRecord record,