refactor(runtime): define the update-frame contract

This commit is contained in:
Erik 2026-07-22 00:15:27 +02:00
parent a36a7015c4
commit 99a3e819c4
8 changed files with 646 additions and 26 deletions

View file

@ -13,7 +13,7 @@ never hidden behind a retry, delay, suppression flag, or reordered callback.
## Progress ledger ## 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. orchestration contract plus structural/order guards.
- [ ] B — extract the pre-network live-object presentation phase and the - [ ] B — extract the pre-network live-object presentation phase and the
non-advancing post-network spatial reconciler; remove callbacks into 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. Every checked checkpoint is committed as one bisectable architectural unit.
Documentation records the exact commit and accepted test count as work lands. 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 ## 1. Outcome and non-goals
At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one

View file

@ -4,6 +4,7 @@ using AcDream.App.Interaction;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Update;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Net; using AcDream.Core.Net;
@ -52,7 +53,7 @@ internal sealed class LiveEntityNetworkUpdateController
private readonly LocalPlayerOutboundController _localPlayerOutbound; private readonly LocalPlayerOutboundController _localPlayerOutbound;
private readonly Func<EntityPhysicsHost?> _playerHostSource; private readonly Func<EntityPhysicsHost?> _playerHostSource;
private readonly Func<uint> _playerGuid; private readonly Func<uint> _playerGuid;
private readonly Func<double> _gameTime; private readonly IPhysicsScriptTimeSource _gameTime;
private readonly Func<WorldSession?> _session; private readonly Func<WorldSession?> _session;
private readonly LiveEntityInboundAuthorityGate _authorityGate; private readonly LiveEntityInboundAuthorityGate _authorityGate;
private readonly Action<WorldSession.EntityPositionUpdate> _aimTeleportDestination; private readonly Action<WorldSession.EntityPositionUpdate> _aimTeleportDestination;
@ -62,7 +63,7 @@ internal sealed class LiveEntityNetworkUpdateController
private PlayerMovementController? _playerController => _playerControllerSource(); private PlayerMovementController? _playerController => _playerControllerSource();
private EntityPhysicsHost? _playerHost => _playerHostSource(); private EntityPhysicsHost? _playerHost => _playerHostSource();
private uint _playerServerGuid => _playerGuid(); private uint _playerServerGuid => _playerGuid();
private double _physicsScriptGameTime => _gameTime(); private double _physicsScriptGameTime => _gameTime.CurrentScriptTime;
private IReadOnlyDictionary<uint, WorldEntity> _entitiesByServerGuid => private IReadOnlyDictionary<uint, WorldEntity> _entitiesByServerGuid =>
_liveEntities.MaterializedWorldEntities; _liveEntities.MaterializedWorldEntities;
private IReadOnlyDictionary<uint, WorldEntity> _visibleEntitiesByServerGuid => private IReadOnlyDictionary<uint, WorldEntity> _visibleEntitiesByServerGuid =>
@ -97,7 +98,7 @@ internal sealed class LiveEntityNetworkUpdateController
LocalPlayerOutboundController localPlayerOutbound, LocalPlayerOutboundController localPlayerOutbound,
Func<EntityPhysicsHost?> playerHostSource, Func<EntityPhysicsHost?> playerHostSource,
Func<uint> playerGuid, Func<uint> playerGuid,
Func<double> gameTime, IPhysicsScriptTimeSource gameTime,
Func<WorldSession?> session, Func<WorldSession?> session,
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps, Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
Action<WorldSession.EntityPositionUpdate> aimTeleportDestination, Action<WorldSession.EntityPositionUpdate> aimTeleportDestination,

View file

@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Update;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
@ -44,7 +45,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
private readonly LiveEntityAnimationPresenter _animationPresenter; private readonly LiveEntityAnimationPresenter _animationPresenter;
private readonly RetailStaticAnimatingObjectScheduler _staticAnimations; private readonly RetailStaticAnimatingObjectScheduler _staticAnimations;
private readonly LiveWorldOriginState _origin; private readonly LiveWorldOriginState _origin;
private readonly Func<double> _gameTime; private readonly IPhysicsScriptTimeSource _gameTime;
private int _received; private int _received;
private int _hydrated; private int _hydrated;
@ -76,7 +77,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
LiveEntityAnimationPresenter animationPresenter, LiveEntityAnimationPresenter animationPresenter,
RetailStaticAnimatingObjectScheduler staticAnimations, RetailStaticAnimatingObjectScheduler staticAnimations,
LiveWorldOriginState origin, LiveWorldOriginState origin,
Func<double> gameTime) IPhysicsScriptTimeSource gameTime)
{ {
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
@ -791,7 +792,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
_projectiles.TryBind( _projectiles.TryBind(
expectedRecord, expectedRecord,
setup, setup,
_gameTime(), _gameTime.CurrentScriptTime,
_origin.CenterX, _origin.CenterX,
_origin.CenterY); _origin.CenterY);

View file

@ -158,6 +158,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map; // Step 7 projectile presentation. The controller owns no identity map;
// each runtime component is stored on the canonical LiveEntityRecord. // 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.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader; private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects; private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private double _physicsScriptGameTime;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new(); private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new();
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
@ -2569,7 +2569,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_animationPresenter, _animationPresenter,
_staticAnimationScheduler!, _staticAnimationScheduler!,
_liveWorldOrigin, _liveWorldOrigin,
() => _physicsScriptGameTime); _updateFrameClock);
var originCoordinator = var originCoordinator =
new AcDream.App.World.LiveEntityWorldOriginCoordinator( new AcDream.App.World.LiveEntityWorldOriginCoordinator(
_liveWorldOrigin, _liveWorldOrigin,
@ -2642,7 +2642,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_localPlayerOutbound, _localPlayerOutbound,
() => _playerHost, () => _playerHost,
() => _playerServerGuid, () => _playerServerGuid,
() => _physicsScriptGameTime, _updateFrameClock,
() => LiveSession, () => LiveSession,
PublishLocalPhysicsTimestamps, PublishLocalPhysicsTimestamps,
AimTeleportDestination, AimTeleportDestination,
@ -3800,16 +3800,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Console.Error.WriteLine($"[live-entity-teardown] {error}"); Console.Error.WriteLine($"[live-entity-teardown] {error}");
} }
double frameSeconds = AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance(
AcDream.App.World.RetailLiveFrameCoordinator.NormalizeDeltaSeconds(dt); new AcDream.App.Update.UpdateFrameInput(dt));
float frameDelta = (float)frameSeconds; double frameSeconds = frameTiming.SimulationDeltaSeconds;
float frameDelta = frameTiming.SimulationDeltaSecondsSingle;
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps // Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
// Timer::cur_time at the packet/default-script call site. Publish this // Timer::cur_time at the packet/default-script call site. Publish this
// update frame's clock before streaming and network dispatch, then // update frame's clock before streaming and network dispatch, then
// reuse the same timestamp for animation hooks and the later drain. // reuse the same timestamp for animation hooks and the later drain.
_physicsScriptGameTime += frameSeconds; _scriptRunner?.PublishTime(frameTiming.ScriptTime);
_scriptRunner?.PublishTime(_physicsScriptGameTime);
// Phase A.1: advance the streaming controller FIRST so the initial // Phase A.1: advance the streaming controller FIRST so the initial
// landblocks are loaded into GpuWorldState before live-session // 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 // Use directly from Silk's key callback inverted that order and left
// the client walking to a corpse whose server callback was cancelled. // the client walking to a corpse whose server callback was cancelled.
_selectionInteractions?.DrainOutbound(); _selectionInteractions?.DrainOutbound();
_scriptRunner?.PublishTime(_physicsScriptGameTime);
System.Numerics.Vector3? playerPosition = System.Numerics.Vector3? playerPosition =
_entitiesByServerGuid.TryGetValue( _entitiesByServerGuid.TryGetValue(
_playerServerGuid, _playerServerGuid,
@ -5792,7 +5791,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// ParticleManager before ScriptManager. A particle created by a PES // ParticleManager before ScriptManager. A particle created by a PES
// hook therefore begins simulation on the following object frame. // hook therefore begins simulation on the following object frame.
_particleSystem?.Tick(dt); _particleSystem?.Tick(dt);
_scriptRunner?.Tick(_physicsScriptGameTime); _scriptRunner?.Tick(_updateFrameClock.CurrentScriptTime);
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,178 @@
namespace AcDream.App.Update;
/// <summary>
/// The host-supplied input for one update callback.
/// </summary>
internal readonly record struct UpdateFrameInput(double HostDeltaSeconds);
/// <summary>
/// The normalized simulation time shared by every time-advancing phase in one
/// host update.
/// </summary>
internal readonly record struct UpdateFrameTiming(
double SimulationDeltaSeconds,
float SimulationDeltaSecondsSingle,
double ScriptTime);
/// <summary>
/// Owns the normalized host delta and the monotonic PhysicsScript clock.
/// </summary>
/// <remarks>
/// Retail <c>ScriptManager::AddScriptInternal @ 0x0051B310</c> stamps
/// PhysicsScript work from <c>Timer::cur_time</c> 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.
/// </remarks>
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);
}
/// <summary>
/// Owns the accepted acdream host-update phase graph.
/// </summary>
/// <remarks>
/// The nested object/network/command/reconcile order remains owned by
/// <see cref="IRetailLiveFramePhase"/>. This host order is the accepted TS-53
/// adaptation and is not claimed to be the exact retail Client::UseTime order.
/// </remarks>
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);
}
}

View file

@ -1,3 +1,5 @@
using AcDream.App.Update;
namespace AcDream.App.World; namespace AcDream.App.World;
/// <summary> /// <summary>
@ -45,9 +47,5 @@ public sealed class RetailLiveFrameCoordinator
} }
public static double NormalizeDeltaSeconds(double deltaSeconds) => public static double NormalizeDeltaSeconds(double deltaSeconds) =>
double.IsFinite(deltaSeconds) UpdateFrameClock.NormalizeDeltaSeconds(deltaSeconds);
&& deltaSeconds > 0.0
&& deltaSeconds <= float.MaxValue
? deltaSeconds
: 0.0;
} }

View file

@ -1,5 +1,6 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Update;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content.Vfx; using AcDream.Content.Vfx;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -51,10 +52,10 @@ public sealed class RecallTeleportAnimationTests
Assert.Equal([0f, 0f, 0f], deltas); Assert.Equal([0f, 0f, 0f], deltas);
Assert.Equal(3, networkDispatches); Assert.Equal(3, networkDispatches);
Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NaN)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NaN));
Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NegativeInfinity)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.NegativeInfinity));
Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.PositiveInfinity)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.PositiveInfinity));
Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.MaxValue)); Assert.Equal(0.0, UpdateFrameClock.NormalizeDeltaSeconds(double.MaxValue));
} }
[Fact] [Fact]

View file

@ -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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
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<string>();
var teardown = new RecordingTeardown(calls)
{
Failure = new InvalidOperationException("fatal"),
};
UpdateFrameOrchestrator frame = Create(calls, teardown: teardown);
InvalidOperationException error = Assert.Throws<InvalidOperationException>(
() => 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<string> 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<string> 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<AggregateException> Errors { get; } = [];
public void ReportTeardownFailure(AggregateException error) => Errors.Add(error);
}
private sealed class RecordingClockPublisher(
List<string> calls,
FrameObservations? observed) : IUpdateFrameScriptClockPublisher
{
public void PublishTime(double scriptTime)
{
calls.Add("clock");
observed?.PublishedTimes.Add(scriptTime);
}
}
private sealed class RecordingStreaming(List<string> calls) : IStreamingFramePhase
{
public void Tick() => calls.Add("streaming");
}
private sealed class RecordingInput(
List<string> calls,
FrameObservations? observed) : IGameplayInputFramePhase
{
public void Tick(UpdateFrameTiming timing)
{
calls.Add("input");
observed?.Input.Add(timing);
}
}
private sealed class RecordingLiveFrame(
List<string> 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<string> calls)
: ILiveEntityLivenessFramePhase
{
public void Tick() => calls.Add("liveness");
}
private sealed class RecordingTeleport(
List<string> 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<string> calls)
: IPlayerModeAutoEntryFramePhase
{
public void TryEnter() => calls.Add("auto-entry");
}
private sealed class RecordingCamera(
List<string> 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<double> PublishedTimes { get; } = [];
public List<UpdateFrameTiming> Input { get; } = [];
public List<float> LiveDeltas { get; } = [];
public List<float> TeleportDeltas { get; } = [];
public List<UpdateFrameTiming> 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.");
}
}