feat(app): observe canonical placement receipts
This commit is contained in:
parent
378ca95a67
commit
f05ed5c3cd
14 changed files with 545 additions and 31 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
|
||||
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<LiveEntityLightController> lightsLease,
|
||||
|
|
@ -1147,6 +1179,7 @@ internal sealed class LivePresentationCompositionPhase
|
|||
worldState,
|
||||
renderSceneShadow,
|
||||
liveEntities,
|
||||
placementProjection,
|
||||
projectileController,
|
||||
projectionWithdrawal,
|
||||
lightsLease.Resource,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
102
src/AcDream.App/Net/GraphicalSessionEventRoute.cs
Normal file
102
src/AcDream.App/Net/GraphicalSessionEventRoute.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
87
src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs
Normal file
87
src/AcDream.App/Net/RuntimePlacementProjectionRetrySlot.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
internal sealed class RuntimePlacementPresentationSink
|
||||
: IRuntimePlacementProjectionSink
|
||||
|
|
|
|||
|
|
@ -34,11 +34,25 @@ public sealed class RuntimePlacementProjectionSubscription
|
|||
public RuntimePlacementProjectionSubscription(
|
||||
GameRuntime runtime,
|
||||
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(
|
||||
runtime?.Placements
|
||||
?? throw new ArgumentNullException(nameof(runtime)),
|
||||
() => runtime.Generation,
|
||||
sink)
|
||||
sink,
|
||||
retryPendingOnSubscribe)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -46,13 +60,27 @@ public sealed class RuntimePlacementProjectionSubscription
|
|||
RuntimePlacementProjectionChannel channel,
|
||||
Func<RuntimeGenerationToken> generation,
|
||||
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));
|
||||
_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 =>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
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)
|
||||
: IUpdateFrameCommitPhase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue