From 99a3e819c4499ea1127399fa4bf220e211e79847 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 00:15:27 +0200 Subject: [PATCH] refactor(runtime): define the update-frame contract --- ...ndow-slice-6-update-frame-orchestration.md | 9 +- .../LiveEntityNetworkUpdateController.cs | 7 +- .../DatLiveEntityProjectionMaterializer.cs | 7 +- src/AcDream.App/Rendering/GameWindow.cs | 19 +- .../Update/UpdateFrameOrchestrator.cs | 178 +++++++ .../World/RetailLiveFrameCoordinator.cs | 8 +- .../World/RecallTeleportAnimationTests.cs | 9 +- .../World/UpdateFrameOrchestratorTests.cs | 435 ++++++++++++++++++ 8 files changed, 646 insertions(+), 26 deletions(-) create mode 100644 src/AcDream.App/Update/UpdateFrameOrchestrator.cs create mode 100644 tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md index 6de9b5d2..335153ac 100644 --- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md +++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md @@ -13,7 +13,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. ## Progress ledger -- [ ] A — freeze the complete production phase graph and introduce the typed +- [x] A — freeze the complete production phase graph and introduce the typed orchestration contract plus structural/order guards. - [ ] B — extract the pre-network live-object presentation phase and the non-advancing post-network spatial reconciler; remove callbacks into @@ -35,6 +35,13 @@ never hidden behind a retry, delay, suppression flag, or reordered callback. Every checked checkpoint is committed as one bisectable architectural unit. Documentation records the exact commit and accepted test count as work lands. +Checkpoint A introduced the internal `AcDream.App.Update` contract, one typed +`UpdateFrameClock`/`IPhysicsScriptTimeSource`, the twelve-phase outer-order +oracle, exact teardown and invalid-delta tests, and guards against a transitive +`GameWindow` clock capture. Production now publishes PES time once per update; +18 focused Release tests and the full 2,709-test App project pass, and all +three corrected-diff reviews are clean. + ## 1. Outcome and non-goals At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index a73c69eb..aaebdac4 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -4,6 +4,7 @@ using AcDream.App.Interaction; using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.Rendering.Vfx; +using AcDream.App.Update; using AcDream.App.World; using AcDream.Content; using AcDream.Core.Net; @@ -52,7 +53,7 @@ internal sealed class LiveEntityNetworkUpdateController private readonly LocalPlayerOutboundController _localPlayerOutbound; private readonly Func _playerHostSource; private readonly Func _playerGuid; - private readonly Func _gameTime; + private readonly IPhysicsScriptTimeSource _gameTime; private readonly Func _session; private readonly LiveEntityInboundAuthorityGate _authorityGate; private readonly Action _aimTeleportDestination; @@ -62,7 +63,7 @@ internal sealed class LiveEntityNetworkUpdateController private PlayerMovementController? _playerController => _playerControllerSource(); private EntityPhysicsHost? _playerHost => _playerHostSource(); private uint _playerServerGuid => _playerGuid(); - private double _physicsScriptGameTime => _gameTime(); + private double _physicsScriptGameTime => _gameTime.CurrentScriptTime; private IReadOnlyDictionary _entitiesByServerGuid => _liveEntities.MaterializedWorldEntities; private IReadOnlyDictionary _visibleEntitiesByServerGuid => @@ -97,7 +98,7 @@ internal sealed class LiveEntityNetworkUpdateController LocalPlayerOutboundController localPlayerOutbound, Func playerHostSource, Func playerGuid, - Func gameTime, + IPhysicsScriptTimeSource gameTime, Func session, Action publishTimestamps, Action aimTeleportDestination, diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index 79660df4..34bbed82 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -2,6 +2,7 @@ using System.Numerics; using AcDream.App.Physics; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; +using AcDream.App.Update; using AcDream.App.World; using AcDream.Content; using AcDream.Core.Meshing; @@ -44,7 +45,7 @@ internal sealed class DatLiveEntityProjectionMaterializer private readonly LiveEntityAnimationPresenter _animationPresenter; private readonly RetailStaticAnimatingObjectScheduler _staticAnimations; private readonly LiveWorldOriginState _origin; - private readonly Func _gameTime; + private readonly IPhysicsScriptTimeSource _gameTime; private int _received; private int _hydrated; @@ -76,7 +77,7 @@ internal sealed class DatLiveEntityProjectionMaterializer LiveEntityAnimationPresenter animationPresenter, RetailStaticAnimatingObjectScheduler staticAnimations, LiveWorldOriginState origin, - Func gameTime) + IPhysicsScriptTimeSource gameTime) { _options = options ?? throw new ArgumentNullException(nameof(options)); _dats = dats ?? throw new ArgumentNullException(nameof(dats)); @@ -791,7 +792,7 @@ internal sealed class DatLiveEntityProjectionMaterializer _projectiles.TryBind( expectedRecord, setup, - _gameTime(), + _gameTime.CurrentScriptTime, _origin.CenterX, _origin.CenterY); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e4a0f07e..c06fa66e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -158,6 +158,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; + private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new(); private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; // each runtime component is stored on the canonical LiveEntityRecord. @@ -295,7 +296,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner; private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader; private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects; - private double _physicsScriptGameTime; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new(); // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but @@ -2569,7 +2569,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _animationPresenter, _staticAnimationScheduler!, _liveWorldOrigin, - () => _physicsScriptGameTime); + _updateFrameClock); var originCoordinator = new AcDream.App.World.LiveEntityWorldOriginCoordinator( _liveWorldOrigin, @@ -2642,7 +2642,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _localPlayerOutbound, () => _playerHost, () => _playerServerGuid, - () => _physicsScriptGameTime, + _updateFrameClock, () => LiveSession, PublishLocalPhysicsTimestamps, AimTeleportDestination, @@ -3800,16 +3800,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Console.Error.WriteLine($"[live-entity-teardown] {error}"); } - double frameSeconds = - AcDream.App.World.RetailLiveFrameCoordinator.NormalizeDeltaSeconds(dt); - float frameDelta = (float)frameSeconds; + AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance( + new AcDream.App.Update.UpdateFrameInput(dt)); + double frameSeconds = frameTiming.SimulationDeltaSeconds; + float frameDelta = frameTiming.SimulationDeltaSecondsSingle; // Retail ScriptManager::AddScriptInternal (0x0051B310) stamps // Timer::cur_time at the packet/default-script call site. Publish this // update frame's clock before streaming and network dispatch, then // reuse the same timestamp for animation hooks and the later drain. - _physicsScriptGameTime += frameSeconds; - _scriptRunner?.PublishTime(_physicsScriptGameTime); + _scriptRunner?.PublishTime(frameTiming.ScriptTime); // Phase A.1: advance the streaming controller FIRST so the initial // landblocks are loaded into GpuWorldState before live-session @@ -5739,7 +5739,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // Use directly from Silk's key callback inverted that order and left // the client walking to a corpse whose server callback was cancelled. _selectionInteractions?.DrainOutbound(); - _scriptRunner?.PublishTime(_physicsScriptGameTime); System.Numerics.Vector3? playerPosition = _entitiesByServerGuid.TryGetValue( _playerServerGuid, @@ -5792,7 +5791,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // ParticleManager before ScriptManager. A particle created by a PES // hook therefore begins simulation on the following object frame. _particleSystem?.Tick(dt); - _scriptRunner?.Tick(_physicsScriptGameTime); + _scriptRunner?.Tick(_updateFrameClock.CurrentScriptTime); } /// diff --git a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs new file mode 100644 index 00000000..57272024 --- /dev/null +++ b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs @@ -0,0 +1,178 @@ +namespace AcDream.App.Update; + +/// +/// The host-supplied input for one update callback. +/// +internal readonly record struct UpdateFrameInput(double HostDeltaSeconds); + +/// +/// The normalized simulation time shared by every time-advancing phase in one +/// host update. +/// +internal readonly record struct UpdateFrameTiming( + double SimulationDeltaSeconds, + float SimulationDeltaSecondsSingle, + double ScriptTime); + +/// +/// Owns the normalized host delta and the monotonic PhysicsScript clock. +/// +/// +/// Retail ScriptManager::AddScriptInternal @ 0x0051B310 stamps +/// PhysicsScript work from Timer::cur_time at the call site. acdream +/// uses one update-frame timestamp for packet/default scripts, animation +/// hooks, and the later script drain. Absolute liveness time and raw-mouse +/// idle time intentionally do not enter this clock. +/// +internal sealed class UpdateFrameClock : IPhysicsScriptTimeSource +{ + public double CurrentScriptTime { get; private set; } + + public UpdateFrameTiming Advance(UpdateFrameInput input) + { + double deltaSeconds = NormalizeDeltaSeconds(input.HostDeltaSeconds); + CurrentScriptTime += deltaSeconds; + return new UpdateFrameTiming( + deltaSeconds, + (float)deltaSeconds, + CurrentScriptTime); + } + + public static double NormalizeDeltaSeconds(double deltaSeconds) => + double.IsFinite(deltaSeconds) + && deltaSeconds > 0.0 + && deltaSeconds <= float.MaxValue + ? deltaSeconds + : 0.0; +} + +internal interface IPhysicsScriptTimeSource +{ + double CurrentScriptTime { get; } +} + +internal interface IUpdateFrameTeardownPhase +{ + void RetryPendingTeardowns(); +} + +internal interface IUpdateFrameFailureSink +{ + void ReportTeardownFailure(AggregateException error); +} + +internal interface IUpdateFrameScriptClockPublisher +{ + void PublishTime(double scriptTime); +} + +internal interface IStreamingFramePhase +{ + void Tick(); +} + +internal interface IGameplayInputFramePhase +{ + void Tick(UpdateFrameTiming timing); +} + +internal interface IRetailLiveFramePhase +{ + void Tick(float deltaSeconds); +} + +internal interface ILiveEntityLivenessFramePhase +{ + void Tick(); +} + +internal interface ILocalPlayerTeleportFramePhase +{ + void Tick(float deltaSeconds); +} + +internal interface IPlayerModeAutoEntryFramePhase +{ + void TryEnter(); +} + +internal interface ICameraFramePhase +{ + void Tick(UpdateFrameTiming timing); +} + +/// +/// Owns the accepted acdream host-update phase graph. +/// +/// +/// The nested object/network/command/reconcile order remains owned by +/// . This host order is the accepted TS-53 +/// adaptation and is not claimed to be the exact retail Client::UseTime order. +/// +internal sealed class UpdateFrameOrchestrator +{ + private readonly IUpdateFrameTeardownPhase _teardown; + private readonly IUpdateFrameFailureSink _failureSink; + private readonly UpdateFrameClock _clock; + private readonly IUpdateFrameScriptClockPublisher _scriptClockPublisher; + private readonly IStreamingFramePhase _streaming; + private readonly IGameplayInputFramePhase _input; + private readonly IRetailLiveFramePhase _liveFrame; + private readonly ILiveEntityLivenessFramePhase _liveness; + private readonly ILocalPlayerTeleportFramePhase _teleport; + private readonly IPlayerModeAutoEntryFramePhase _playerModeAutoEntry; + private readonly ICameraFramePhase _camera; + + public UpdateFrameOrchestrator( + IUpdateFrameTeardownPhase teardown, + IUpdateFrameFailureSink failureSink, + UpdateFrameClock clock, + IUpdateFrameScriptClockPublisher scriptClockPublisher, + IStreamingFramePhase streaming, + IGameplayInputFramePhase input, + IRetailLiveFramePhase liveFrame, + ILiveEntityLivenessFramePhase liveness, + ILocalPlayerTeleportFramePhase teleport, + IPlayerModeAutoEntryFramePhase playerModeAutoEntry, + ICameraFramePhase camera) + { + _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); + _failureSink = failureSink ?? throw new ArgumentNullException(nameof(failureSink)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _scriptClockPublisher = scriptClockPublisher + ?? throw new ArgumentNullException(nameof(scriptClockPublisher)); + _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); + _input = input ?? throw new ArgumentNullException(nameof(input)); + _liveFrame = liveFrame ?? throw new ArgumentNullException(nameof(liveFrame)); + _liveness = liveness ?? throw new ArgumentNullException(nameof(liveness)); + _teleport = teleport ?? throw new ArgumentNullException(nameof(teleport)); + _playerModeAutoEntry = playerModeAutoEntry + ?? throw new ArgumentNullException(nameof(playerModeAutoEntry)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + } + + public void Tick(UpdateFrameInput input) + { + try + { + _teardown.RetryPendingTeardowns(); + } + catch (AggregateException error) + { + // The canonical teardown tombstone retains unfinished work for the + // next frame. Other exception types are programming failures and + // deliberately propagate. + _failureSink.ReportTeardownFailure(error); + } + + UpdateFrameTiming timing = _clock.Advance(input); + _scriptClockPublisher.PublishTime(timing.ScriptTime); + _streaming.Tick(); + _input.Tick(timing); + _liveFrame.Tick(timing.SimulationDeltaSecondsSingle); + _liveness.Tick(); + _teleport.Tick(timing.SimulationDeltaSecondsSingle); + _playerModeAutoEntry.TryEnter(); + _camera.Tick(timing); + } +} diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs index b625e18b..7ac15993 100644 --- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs +++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs @@ -1,3 +1,5 @@ +using AcDream.App.Update; + namespace AcDream.App.World; /// @@ -45,9 +47,5 @@ public sealed class RetailLiveFrameCoordinator } public static double NormalizeDeltaSeconds(double deltaSeconds) => - double.IsFinite(deltaSeconds) - && deltaSeconds > 0.0 - && deltaSeconds <= float.MaxValue - ? deltaSeconds - : 0.0; + UpdateFrameClock.NormalizeDeltaSeconds(deltaSeconds); } diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs index 7942d1ac..d1810824 100644 --- a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs +++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering; +using AcDream.App.Update; using AcDream.App.World; using AcDream.Content.Vfx; using AcDream.Core.Physics; @@ -51,10 +52,10 @@ public sealed class RecallTeleportAnimationTests Assert.Equal([0f, 0f, 0f], deltas); Assert.Equal(3, networkDispatches); - Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NaN)); - Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NegativeInfinity)); - Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.PositiveInfinity)); - Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.MaxValue)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NaN)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NegativeInfinity)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.PositiveInfinity)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.MaxValue)); } [Fact] diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs new file mode 100644 index 00000000..e22cf03c --- /dev/null +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -0,0 +1,435 @@ +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Update; +using AcDream.App.World; + +namespace AcDream.App.Tests.World; + +public sealed class UpdateFrameOrchestratorTests +{ + [Fact] + public void Tick_PreservesTheCompleteAcceptedPhaseGraph() + { + var calls = new List(); + UpdateFrameOrchestrator frame = Create(calls); + + frame.Tick(new UpdateFrameInput(1.0 / 60.0)); + + Assert.Equal( + [ + "teardown", + "clock", + "streaming", + "input", + "objects", + "network", + "commands", + "ordinary-reconcile", + "liveness", + "teleport", + "auto-entry", + "camera", + ], + calls); + } + + [Fact] + public void ConditionalReconciles_RemainAtTheirOwningEdges() + { + // Checkpoint A freezes only the outer ownership contract. Checkpoints + // E and F must replace these probes with production teleport/camera + // owner tests before the conditional edges can be claimed guarded. + var calls = new List(); + UpdateFrameOrchestrator frame = Create( + calls, + teleportPlace: true, + inboundCreatedPlayer: true); + + frame.Tick(new UpdateFrameInput(1.0 / 60.0)); + + Assert.Equal( + [ + "teardown", + "clock", + "streaming", + "input", + "objects", + "network", + "commands", + "ordinary-reconcile", + "liveness", + "teleport-place", + "teleport-reconcile", + "teleport-reveal", + "auto-entry", + "inbound-player-projection", + "inbound-player-reconcile", + "camera", + ], + calls); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.NegativeInfinity)] + [InlineData(double.PositiveInfinity)] + [InlineData(-1.0)] + [InlineData(0.0)] + [InlineData(double.MaxValue)] + public void InvalidSimulationDelta_BecomesZeroWithoutSuppressingOuterPhases( + double hostDelta) + { + var calls = new List(); + var observed = new FrameObservations(); + UpdateFrameOrchestrator frame = Create(calls, observed: observed); + + frame.Tick(new UpdateFrameInput(hostDelta)); + + Assert.Equal( + [ + "teardown", "clock", "streaming", "input", "objects", + "network", "commands", "ordinary-reconcile", "liveness", + "teleport", "auto-entry", "camera", + ], + calls); + Assert.Equal([0.0], observed.PublishedTimes); + Assert.Equal([0.0], observed.Input.Select(value => value.SimulationDeltaSeconds)); + Assert.Equal([0f], observed.LiveDeltas); + Assert.Equal([0f], observed.TeleportDeltas); + Assert.Equal([0.0], observed.Camera.Select(value => value.SimulationDeltaSeconds)); + Assert.All( + observed.Input.Concat(observed.Camera), + timing => Assert.Equal(0.0, timing.ScriptTime)); + } + + [Fact] + public void Clock_AdvancesOnceAndPublishesTheSameTimeToEveryConsumer() + { + var calls = new List(); + var observed = new FrameObservations(); + UpdateFrameOrchestrator frame = Create(calls, observed: observed); + + frame.Tick(new UpdateFrameInput(0.25)); + frame.Tick(new UpdateFrameInput(double.NaN)); + frame.Tick(new UpdateFrameInput(0.5)); + + Assert.Equal([0.25, 0.25, 0.75], observed.PublishedTimes); + Assert.Equal( + [0.25, 0.25, 0.75], + observed.Input.Select(value => value.ScriptTime)); + Assert.Equal( + [0.25, 0.25, 0.75], + observed.Camera.Select(value => value.ScriptTime)); + Assert.Equal([0.25f, 0f, 0.5f], observed.LiveDeltas); + Assert.Equal([0.25f, 0f, 0.5f], observed.TeleportDeltas); + Assert.Equal( + [0.25, 0.0, 0.5], + observed.Input.Select(value => value.SimulationDeltaSeconds)); + Assert.Equal( + [0.25, 0.0, 0.5], + observed.Camera.Select(value => value.SimulationDeltaSeconds)); + } + + [Fact] + public void Clock_RejectsFiniteDeltaAboveTheSinglePrecisionConsumerRange() + { + double tooLarge = (double)float.MaxValue * 2.0; + + Assert.True(double.IsFinite(tooLarge)); + Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(tooLarge)); + } + + [Fact] + public void AggregateTeardownFailure_IsReportedAndRetriedNextFrame() + { + var calls = new List(); + var teardown = new RecordingTeardown(calls) + { + Failure = new AggregateException(new InvalidOperationException("transient")), + }; + var failures = new RecordingFailureSink(); + UpdateFrameOrchestrator frame = Create( + calls, + teardown: teardown, + failureSink: failures); + + frame.Tick(new UpdateFrameInput(0.1)); + teardown.Failure = null; + frame.Tick(new UpdateFrameInput(0.1)); + + Assert.Equal(2, teardown.Attempts); + Assert.Single(failures.Errors); + string[] oneFrame = + [ + "teardown", "clock", "streaming", "input", "objects", "network", + "commands", "ordinary-reconcile", "liveness", "teleport", + "auto-entry", "camera", + ]; + Assert.Equal(oneFrame.Concat(oneFrame), calls); + } + + [Fact] + public void NonAggregateTeardownFailure_PropagatesBeforeTimeOrLaterPhases() + { + var calls = new List(); + var teardown = new RecordingTeardown(calls) + { + Failure = new InvalidOperationException("fatal"), + }; + UpdateFrameOrchestrator frame = Create(calls, teardown: teardown); + + InvalidOperationException error = Assert.Throws( + () => frame.Tick(new UpdateFrameInput(0.1))); + + Assert.Equal("fatal", error.Message); + Assert.Equal(["teardown"], calls); + } + + [Fact] + public void OrchestrationTypes_UseOnlyTheExplicitTypedOwnerGraph() + { + Type[] expectedFieldTypes = + [ + typeof(IUpdateFrameTeardownPhase), + typeof(IUpdateFrameFailureSink), + typeof(UpdateFrameClock), + typeof(IUpdateFrameScriptClockPublisher), + typeof(IStreamingFramePhase), + typeof(IGameplayInputFramePhase), + typeof(IRetailLiveFramePhase), + typeof(ILiveEntityLivenessFramePhase), + typeof(ILocalPlayerTeleportFramePhase), + typeof(IPlayerModeAutoEntryFramePhase), + typeof(ICameraFramePhase), + ]; + + FieldInfo[] fields = typeof(UpdateFrameOrchestrator).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.Equal( + expectedFieldTypes.OrderBy(type => type.FullName), + fields.Select(field => field.FieldType).OrderBy(type => type.FullName)); + Assert.All(fields, field => + { + Assert.NotEqual(typeof(GameWindow), field.FieldType); + Assert.False(typeof(Delegate).IsAssignableFrom(field.FieldType)); + }); + + Type[] phaseInterfaces = expectedFieldTypes.Where(type => type.IsInterface).ToArray(); + Assert.DoesNotContain( + phaseInterfaces, + phaseInterface => phaseInterface.IsAssignableFrom(typeof(GameWindow))); + + Type[] productionPhaseOwners = typeof(GameWindow).Assembly.GetTypes() + .Where(type => !type.IsAbstract && !type.IsInterface) + .Where(type => phaseInterfaces.Any(contract => contract.IsAssignableFrom(type))) + .ToArray(); + foreach (Type owner in productionPhaseOwners) + { + FieldInfo[] ownerFields = owner.GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(ownerFields, field => field.FieldType == typeof(GameWindow)); + Assert.DoesNotContain( + ownerFields, + field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + } + + Assert.DoesNotContain( + typeof(GameWindow).GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_physicsScriptGameTime"); + } + + [Fact] + public void PhysicsScriptClockConsumers_RetainTheTypedSourceNotGameWindowClosures() + { + Type[] consumers = + [ + typeof(DatLiveEntityProjectionMaterializer), + typeof(AcDream.App.Physics.LiveEntityNetworkUpdateController), + ]; + + foreach (Type consumer in consumers) + { + FieldInfo clock = Assert.Single( + consumer.GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => field.Name == "_gameTime"); + Assert.Equal(typeof(IPhysicsScriptTimeSource), clock.FieldType); + Assert.DoesNotContain( + consumer.GetFields(BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType == typeof(GameWindow)); + } + + Assert.False(typeof(IPhysicsScriptTimeSource).IsAssignableFrom(typeof(GameWindow))); + } + + [Fact] + public void ProductionFrame_PublishesPhysicsScriptTimeExactlyOnce() + { + string source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", + "AcDream.App", + "Rendering", + "GameWindow.cs")); + + Assert.Equal(1, source.Split("PublishTime(", StringSplitOptions.None).Length - 1); + } + + private static UpdateFrameOrchestrator Create( + List calls, + RecordingTeardown? teardown = null, + RecordingFailureSink? failureSink = null, + FrameObservations? observed = null, + bool teleportPlace = false, + bool inboundCreatedPlayer = false) + { + teardown ??= new RecordingTeardown(calls); + failureSink ??= new RecordingFailureSink(); + return new UpdateFrameOrchestrator( + teardown, + failureSink, + new UpdateFrameClock(), + new RecordingClockPublisher(calls, observed), + new RecordingStreaming(calls), + new RecordingInput(calls, observed), + new RecordingLiveFrame(calls, observed), + new RecordingLiveness(calls), + new RecordingTeleport(calls, observed, teleportPlace), + new RecordingAutoEntry(calls), + new RecordingCamera(calls, observed, inboundCreatedPlayer)); + } + + private sealed class RecordingTeardown(List calls) + : IUpdateFrameTeardownPhase + { + public Exception? Failure { get; set; } + public int Attempts { get; private set; } + + public void RetryPendingTeardowns() + { + calls.Add("teardown"); + Attempts++; + if (Failure is not null) + throw Failure; + } + } + + private sealed class RecordingFailureSink : IUpdateFrameFailureSink + { + public List Errors { get; } = []; + public void ReportTeardownFailure(AggregateException error) => Errors.Add(error); + } + + private sealed class RecordingClockPublisher( + List calls, + FrameObservations? observed) : IUpdateFrameScriptClockPublisher + { + public void PublishTime(double scriptTime) + { + calls.Add("clock"); + observed?.PublishedTimes.Add(scriptTime); + } + } + + private sealed class RecordingStreaming(List calls) : IStreamingFramePhase + { + public void Tick() => calls.Add("streaming"); + } + + private sealed class RecordingInput( + List calls, + FrameObservations? observed) : IGameplayInputFramePhase + { + public void Tick(UpdateFrameTiming timing) + { + calls.Add("input"); + observed?.Input.Add(timing); + } + } + + private sealed class RecordingLiveFrame( + List calls, + FrameObservations? observed) : IRetailLiveFramePhase + { + public void Tick(float deltaSeconds) + { + observed?.LiveDeltas.Add(deltaSeconds); + calls.Add("objects"); + calls.Add("network"); + calls.Add("commands"); + calls.Add("ordinary-reconcile"); + } + } + + private sealed class RecordingLiveness(List calls) + : ILiveEntityLivenessFramePhase + { + public void Tick() => calls.Add("liveness"); + } + + private sealed class RecordingTeleport( + List calls, + FrameObservations? observed, + bool place) + : ILocalPlayerTeleportFramePhase + { + public void Tick(float deltaSeconds) + { + observed?.TeleportDeltas.Add(deltaSeconds); + if (!place) + { + calls.Add("teleport"); + return; + } + + calls.Add("teleport-place"); + calls.Add("teleport-reconcile"); + calls.Add("teleport-reveal"); + } + } + + private sealed class RecordingAutoEntry(List calls) + : IPlayerModeAutoEntryFramePhase + { + public void TryEnter() => calls.Add("auto-entry"); + } + + private sealed class RecordingCamera( + List calls, + FrameObservations? observed, + bool inboundCreatedPlayer) : ICameraFramePhase + { + public void Tick(UpdateFrameTiming timing) + { + if (inboundCreatedPlayer) + { + calls.Add("inbound-player-projection"); + calls.Add("inbound-player-reconcile"); + } + + calls.Add("camera"); + observed?.Camera.Add(timing); + } + } + + private sealed class FrameObservations + { + public List PublishedTimes { get; } = []; + public List Input { get; } = []; + public List LiveDeltas { get; } = []; + public List TeleportDeltas { get; } = []; + public List Camera { get; } = []; + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +}